1
Help us understand the problem. What are the problem?

posted at

Reactで時間割アプリを作ってみた

はじめに

この記事はOUCC AdventCalendar2021の23日目の記事です。
前回の記事はようつべrさん大嫌いさんのOUCC Advent Calendar DAY21 Unityでゲーム作るRTA+おまけの謎解きでした。

OUCCでは今年中に部室を引き払うことになり、12月は部室の片付けでバタバタでした。なんでこの時期にAdventCalendarをやるんだろう。

中間テストも重なって忙しかったですが、折角の機会なので新しい事にチャレンジしようと、Reactに手を出してみました。
初心者が手探りで書いたものなので書き方は参考にせず、「こんなことできるんだー」くらいで見てください笑

作るもの

今回時間割を組みながら同時に単位計算をしてくれるアプリを作ってみました。
時間割に履修科目を登録するとその分の単位を計算してくれています。
これで来期の履修登録時に単位を計算しながら時間割を考える必要はなさそうです。
image.png

さっそく作ってみよう!!

まずは授業ごとの単位クラスです。JavaScriptは型がゆるゆるなのでTypeScriptを用いました。
private変数をコンストラクタで自動的に定義してくれるのはいいですね。
time変数は授業がどの時間にあるにあるのかを配列で管理しています。複数コマの授業があるためです。

export class Subject {
    constructor(private name:string, private time:number[], private color:string, private degree:number, private category:ECategory) {};

    // プロパティ
    get SubjectName() :string
    {
        return this.name;
    }
    get Time() :number[] {
        return this.time;
    }

    get Color() :string {
        return this.color;
    } 

    get Degree() :number {
        return this.degree;
    }

    get Category() :ECategory {
        return this.category;
    }

    // データの変更
    ChangeData(name:string, color:string, degree:number, category:ECategory): void {
        this.name = name;
        this.degree = degree;
        this.color = color;
        this.category = category;
    }

    // コマの増減
    ReduceTime(time: number){
        this.time = this.time.filter(item => item !== time);
    }

    AddTime(time :number) {
        if(this.time.every(num => num !== time)) this.time.push(time);
    }
};

// 単位の種類
export enum ECategory {
    None,
    A,
    B,
    C,
    D,
    E,
    F,
    G,
    H,
    "英語",
    "基盤教養",
    "専門基礎",
    "その他",
}

時間割のコマ

時間割のそれぞれのコマです。科目が登録されている場合は科目名を表示し、登録されていない場合はただの白マスになります。
IPropsは引数の型を指定できます。今はSubjectnullです。
興味深いのはreturn部分ですね。JSXと言って、スクリプトとhtmlのタグを共存させることが出来ます。便利ですね。

interface IProps {
    subject : Subject | null
};

const TableCell: React.VFC<IProps>= (props :IProps)=> {
    const style = {
        background: props.subject === undefined ?"white" : props.subject?.Color,
        height :150
    };
    return (
        <div className="Cell"  style={style}>
            {props.subject === undefined ? "" : props.subject?.SubjectName}
        </div>
    );
}

時間割テーブル

次は先ほどのマスを合わせて時間割をつくります。コードが長くなってしまったので描画部分だけ見せます。
時間割の情報を保持しているのはTimeTable変数ですね。これはReact Hooksの機能であるuseStateで生成したものです。setTimeTable関数でTimeTableの内容が変更されると、ブラウザ側で自動的に再描画されます。

return (
    <div className="TableContainer">   
        <table className="TimeTable">
            <thead>
                <tr >
                    {headers.map((head, idx) => {
                        return <th key={idx}>{head}</th>
                    })}
                </tr>
            </thead>
            <tbody>
            {times.map((time, idx1) =>{                      // 時間ごとに
                return<tr key={idx1}>
                    <th key={0}>{time}</th>
                    {headers.map((data, idx2) => {           // 曜日ごとに
                        if(idx2 === headers.length-1 ) return null; 
                        return <th  data-id={idx1*10+idx2} onClick={setDataForm} key={idx2+1}>
                            <TableCell subject={TimeTable[idx1*10+idx2]} />    // 時間割のコマを生成
                        </th>
                    })}
                </tr>
            })}
            </tbody>
        </table>
    </div>

フォーム部分

この辺はコードが長くなったので割愛。

データの保存

データはローカルストレージにJSON形式で保存します。
(安全性は今は考えない。動けばいいのだッ)
時間割は二次元配列で保持しています。1次元目がセメスター、二次元目がコマです。
これを`JSONに変換していきます。

const STORAGE_NAME = "subjects";

export default function SaveSubjects (tableList: Subject[][]) {
    let json = JSON.stringify(      // jsonに変換
        tableList.map(table => {    // セメスターごとに
            let isRegistered = new Array<boolean>(table.length);
            return table.map((subject, id)=>{     // コマごとに
                if(!(subject === undefined) && !isRegistered[id] && subject.SubjectName !== "") {
                    subject.Time.forEach(time => {isRegistered[time] = true});
                    return {
                        "time" : subject.Time,
                        "name" : subject.SubjectName,
                        "color": subject.Color,
                        "degree": subject.Degree,
                        "category": subject.Category,
                    }
                }
            }).filter(subject => subject != null);
        }).filter(subject => subject != null)
    );
    localStorage.setItem(STORAGE_NAME, json);    // ストレージに保存
}

保存データは次のように型にparseできます。便利。

   type parseSubject = {
        time : number[],
        name : string,
        color : string,
        degree :number,
        category : ECategory
    }
    var json:string = localStorage.getItem(STORAGE_NAME) ?? "";   // ストレージからとりだす
    var list :parseSubject[][] = JSON.parse(json) as parseSubject[][];   // パース

カウンタ

単位のカウンタです。単一責任の原則が守れてない等の批判は受けつけません。
「今セメスター」、「取得済み(それより前のセメスター)」、「合計」の3状態で表示できるようにしています。

面白いのは<label>htmlFor属性で、指定することでラベルがクリックされた際に対応するidの<input>が選択されるようになっています。これによってタグを再現しているのですね。
また、ついでに状態ごとにカウンタの色を変えてます。
(document.documentElement.style.setPropertyでcssの値を変数のように変えられます。)

const Counter : React.VFC<IProps>  = (props :IProps) =>{
    const [displayType, SetType] = React.useState("all");
    let counts = manager.CountDegree(props.semester, displayType);

    // 変更イベント
    const onChanged= (e :(React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLSelectElement>))=>{
        SetType(e.target.name);
    };

    switch (displayType) {
        case "all":
            document.documentElement.style.setProperty("--counter-color", "#B384FF");  // 色を変える
            break;
        case "semester" :
            document.documentElement.style.setProperty("--counter-color", "#FF82B2");  // 色を変える
            break;
        case "gotten":
            document.documentElement.style.setProperty("--counter-color", "#5BFF7F");  // 色を変える
            break;
    }

    return <div className="tabs">
        <div>
            <input className="tab" type="radio" name="all" id="all" checked={displayType === "all"} onChange={onChanged}/>
                <label htmlFor="all" className="tab_item" >合計</label> 
            <input className="tab" id="semester" type="radio" name="semester" checked={displayType === "semester"} onChange={onChanged}/>
                <label htmlFor="semester" className="tab_item" >今学期</label> 
            <input className="tab"  id="gotten" type="radio" name="gotten" checked={displayType === "gotten"} onChange={onChanged}/>
                <label htmlFor="gotten" className="tab_item" >取得済み</label> 
        </div>

        <div className="tab_content">
                <table className="DegreeCounter">
                    <thead>
                        <tr>
                            {counts.map((count, idx) => idx > 0 ? <th key={idx}>{idx !== counts.length-1 ? ECategory[idx] : "合計"}</th> : null)}
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            {counts.map((count, idx) => idx > 0 ? <th key={idx}>{count}</th> : null) }
                        </tr>
                    </tbody>
                </table>
        </div>
    </div>;
}; 

最後に全体

それぞれのコンポーネントを組み合わせていきます。
Providorの説明は割愛しますが、useContextの機能の1つです。

  return (
    <div className="App">
      <formContext.Provider value={formValue}>
      <semesterTabContext.Provider value={{Semester, setSemester}} >

        <div className='TableContainer' >
            <SemesterTab />
          <TimeTables semester={Semester}/>
        </div>
        <div className="FormContainer">
          <EditForm semester={Semester}/>
          <Counter subject={TimeTable} semester={Semester}/>
        </div>
      </semesterTabContext.Provider>
      </formContext.Provider>
    </div>
  );
}

感想

今回は書きながら学ぶという方策を取ったので仕方ないですが、改善点が結構あります。

  • コンポーネントが若干肥大化しているので分割する必要がある。
  • セキュリティ対策を全くしていない。
  • 動作確認が不十分(てか完成したのが今日)
  • 専門科目のみでなく英語や基礎科目、集中講義にも対応させたい。
  • 正しくない書き方があるかも(特にreact hooksあたり) 今後の課題ですね。

Reactくん、想像以上に簡単で楽しかったです。
特にuseStateuseContextあたりはすごく便利でした。
今回使った機能をほんとはしっかり紹介したかったけれど、締切が許さなさそうです。
不満としてはJavaScriptの型が緩すぎますね。TypeScriptで多少固くはなりますが、不便だなと感じることがたまにありました。

春休みには公開できたらいいなーとおもってます。
そのときにはもうちょっと詳しく紹介したいですね。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
1
Help us understand the problem. What are the problem?