もりけん塾 課題21 ユーザーテーブルにソート機能をつける

もりけん塾 課題21 ユーザーテーブルにソート機能をつける

前回、出力したこちらの表に、IDによるソート機能をつけます。

課題の仕様

  1. こちらのようなテーブルを画面遷移してから3秒後に解決されるPromiseが返すオブジェクトを元に作り、 idがソートできる機能を作ってください
  2. ソートは通常時はidが適当でもよく
  3. ソートが昇順の場合は上矢印がアクティブ、下矢印がdisabled、1,2,3,4,5の順番で表示され、降順の場合はその逆、

通常時の矢印クリック(クリッカブル領域は2つの矢印です。上下別々のクリッカブル領域でではなく)を押すと画像のように変化します。

実装方法

動きはこちらのように、なります。IDのソートボタンをクリックすると、ランダム→昇順→降順に変化します。

下記のような順番で実装していきます。

  • ソートボタンを作成
  • IDのカラムにソートボタンを追加
  • ボタンの表示を切り替える関数を作る
  • 並び替えたデータでテーブルを再描画する関数を作る
  • データをソートする関数を作る
  • クリックイベントを発火させる

ソートボタンの作成

ソートボタンを作成します。BOTHボタン、ASCボタン、DESCボタンを作ります。

const sortButtons = [
  { dataOrder: "asc", src: "../img/asc.svg", alt: "asc-image" },
  { dataOrder: "desc", src: "../img/desc.svg", alt: "desc-image" },
  { dataOrder: "both", src: "../img/both.svg", alt: "both-image" }
];

const createSortButtons = (columnKey,th) => {
    const sortButtonsBox = createAttributedElements ({
        tag:"div",
        valuesByAttributes:{
            id:`js-${columnKey}Buttons-Box`,
            class: "w-10 inline-block align-middle"
        }
    })

    const fragment = document.createDocumentFragment();

    for(const button of sortButtons){
        const sortButton = createAttributedElements({
            tag:"button",
            valuesByAttributes:{
                class: `js-${columnKey}SortButton`,
                'data-order':button.dataOrder
            }
        });

        button.dataOrder != "both" && sortButton.classList.add("hidden");

        const sortButtonImage = createAttributedElements({
            tag:"img",
            valuesByAttributes:{
                class:"pointer-events-none",
                src:button.src,
                alt:button.alt
            }
        })

        fragment.appendChild(sortButton).appendChild(sortButtonImage);
    }

こんな感じのボタンが作成されます。

ボタンは、 z-indexで重ねるか悩みましたが、BOTHボタン以外はhiddenクラスを付与して、非表示にすることにしました。

button.dataOrder != "both" && sortButton.classList.add("hidden");

デフォルトは、BOTHボタンのみ、表示されます。

ソートボタンを追加する

ソートボタンを追加するカラム用のオブジェクトを作り、条件に一致する要素にボタンを追加する関数を作成します。sortByUsersTableColumnのvalueに一致する要素があったときに、ボタンを追加します。

今回はIDと一致する要素があったら、ボタンを追加するようにしています。

const
 = {
    "id" : "ID"
}

const addSortButtons = (userKey,userValue,th) => {
    for(const value of Object.values(sortByUsersTableColumn)){
        userValue === value && createSortButtons(userKey,th) ;
    }
}

sortByUsersTableColumnは、オブジェクトにしなくても、配列で可能というレビューを頂き、配列に変更しました。それに伴い、ソートボタンを追加する処理も変更しました。かなりコンパクトな処理になりました。

const sortCategories = ["id"];

if (sortCategories.includes(userKey)) {
   th.appendChild(createSortButtons(userKey));
}

ボタンを追加する関数を、テーブルのヘッダーを作成するときの関数の中で、実行します。

const renderTableHeader = () => {
    const thead = document.createElement("thead");
    const tr = document.createElement("tr");
    thead.className = "bg-slate-500";

    for (const [userKey, userValue] of Object.entries(usersTableColumn)) {
        const th = createAttributedElements({
            tag:"th",
            valuesByAttributes:{
                class:"text-sm text-white px-6 py-4",
            },
            str:userValue
        });

        tr.appendChild(th);

        if (sortCategories.includes(userKey)) {
            th.appendChild(createSortButtons(userKey));
        }
    };

    table.appendChild(thead).appendChild(tr);
}

ひとまず、見た目ができました。

ボタンの表示を切り替える関数を作る

ボタンの表示を切り替える関数を作り、ボタンをクリックしたら切り替わるようにしました。

const toggleHiddenClassSortButton = (sortButtons,index) => {
    sortButtons[index].classList.add("hidden");
    (index === sortButtons.length -1) ? index = 0 : index++;
    sortButtons[index].classList.remove("hidden");
}

const sortTableBody = usersData => {
    for(const key of Object.keys(sortByUsersTableColumn)){
        const sortButtons = [...document.getElementsByClassName(`js-${key}SortButton`)];
        const sortButtonsBox = document.getElementById(`js-${key}Buttons-Box`);

        let dataOrder;

        sortButtonsBox.addEventListener("click", (e) => {
            const index = sortButtons.indexOf(e.target);
            dataOrder = e.target.getAttribute("data-order");

            if(e.currentTarget === e.target){
                return;
            }

            toggleHiddenClassSortButton(sortButtons,index);
            sortUsersData(key, usersData, dataOrder);
        });
    }
} 

indexで切り替える方法はあまりボタンの配列の順番に依存するため、良い方法ではないとレビューを受けて、最終的には、ボタンの属性値をみるようにして、下記のように修正しました。

下記の方法だと、配列の順番に依存しないので、とてもスマートです。

const toggleHiddenClassForSortButton = (currentButton, nextButton) => {
  currentButton.classList.add("hidden");
  nextButton.classList.remove("hidden");
};

const sortTableBody = (usersData) => {
  sortCategories.forEach((category) => {
    const sortButtonsBox = document.getElementById(`js-${category}Buttons-Box`);

    sortButtonsBox.addEventListener("click", (e) => {
      if (e.currentTarget === e.target) return;

      const currentState = e.target.getAttribute("data-state");
      const nextState = changeState(currentState);
      const nextButton = document.querySelector(`[data-state=${nextState}]`);

      toggleHiddenClassForSortButton(e.target, nextButton);
      sortUsersData(category, usersData, nextState);
    });
  });
};

並び替えたデータでテーブルを再描画する関数を作る

const upDateTableBody = usersData =>  { 
   document.getElementById("js-usersTableBody").remove();
    renderTableBody(usersData);
};

データをソートする関数を作る

データを、ランダム・昇順・降順にする関数を作ります。

最初に元のデータとなるデータをコピーします。こうすると、別々のデータとして扱うことができるので、元のデータを壊さずにすみます。

const cloneUsersData = [...usersData];

データのソートは、オブジェクトで管理する方法にしました。Switch文にしなかったのは、breakをつける必要があることと、処理したいものが増えたときにコードが長くなるためです。

const sortUsersData = (key, usersData, dataOrder) => {
    const cloneUsersData = [...usersData];

    const sortButtonsFunc = {
        "both": () => {
            cloneUsersData.sort((a,b) => a[key] - b[key]);
            upDateTableBody(cloneUsersData);
        },
        "asc" : () => {
            cloneUsersData.sort((a,b) => b[key] - a[key]);
            upDateTableBody(cloneUsersData);
        },
        "desc": () => {
            upDateTableBody(usersData);
        }
    }

    Object.keys(sortButtonsFunc).forEach((button) => dataOrder === button && sortButtonsFunc[button]() );
}

オブジェクトのメソッド名と処理の内容が噛み合っていない点について、レビューでご指摘を受けました。たしかに、混乱を避けるために、メソッド名と処理内容を合わせるべきです。

しかし、そうすると、仕様と異なる動きになってしまうため、どうすれば良いか相談して、助けていただきました。

いったん、メソッド名と中身が一致する関数を作成してから、明示的に切り替える方法を提案いただきました。また、Switch文にすることをすすめて頂きました。

処理する項目がふえたときに、breakのつけ忘れや、コードが冗長になることをさけたくて、オブジェクトのメソッドにしていました。

今回は処理する項目が3つだけなので、Switch文に変更することにしました。

const sortUsersData = (category, usersData, state) => {
    const cloneUsersData = [...usersData];

    switch (state) {
        case "asc":
            cloneUsersData.sort((a, b) => a[category] - b[category]);
            updateTableBody(cloneUsersData);
            break;
        case "desc":
            cloneUsersData.sort((a, b) => b[category] - a[category]);
            updateTableBody(cloneUsersData);
            break;
        case "both":
            updateTableBody(usersData);
            break;

        default:
   }
}

その後、明示的にSwitch文で、切り替えます。このようにすることで、ascボタンは、desc処理を実行、descボタンは、both処理の実行、デフォルトのbothボタンはasc処理を実行させることが可能になりました。

const changeState = (currentState) => {
  switch (currentState) {
    case "asc":
      return "desc";
    case "desc":
      return "both";
    default:
      return "asc";
  }
};

クリックイベントの処理を作成する

const sortTableBody = usersData => {
    sortCategories.forEach((category) => {
        const sortButtonsBox = document.getElementById(`js-${category}Buttons-Box`);

        sortButtonsBox.addEventListener("click", (e) => {
            if(e.currentTarget === e.target) return;

            const currentState = e.target.getAttribute("data-state");
            const nextState  = changeState(currentState);
            const nextButton = document.querySelector(`[data-state=${nextState}]`);

            toggleHiddenClassForSortButton(e.target,nextButton);
            sortUsersData(category, usersData, nextState);
        });
    });
} 

最終的なコード

import '../css/style.css'
import * as loading from "./module/loading";
import { createAttributedElements} from "./utils/createAttributedElements";

const endPointURL = {
    usersData: "https://myjson.dit.upm.es/api/bins/i55y"
}

const renderErrorMessage = (error,element) => {
    const errorMessage = createAttributedElements({
        tag:"p",
        valuesByAttributes:{
            class:"error-message"
        },
        str:error
    });
    element.appendChild(errorMessage);
}

const fetchData = async (endpointURL,element) => {
    const response = await fetch(endpointURL);
    if(!response.ok){
        console.error(`${response.status}:${response.statusText}`);
        renderErrorMessage("Communication with the server is broken.",element);
        return;
    }
    return await response.json();
}

const fetchContentsData = (endPointURL,element,ms) => new Promise(resolve => setTimeout(() => resolve(fetchData(endPointURL,element)),ms));

const table = document.getElementById("js-table");

const initUsersData = async () => {
    loading.showLoading(table);
    try{
        const json = await fetchContentsData(endPointURL.usersData,table,500);
        const usersData = json.data;

        if (!usersData.length) {
            renderErrorMessage("No user.",table);
            return;
        }
        renderTableElements(usersData);
        sortTableBody(usersData);
    }catch(error){
        console.error(error);
    }finally{
       loading.removeLoading(table);
    }
}

const renderTableElements = usersData => {
    renderTableHeader();
    renderTableBody(usersData);
}

const usersTableColumn = {
    "id"     : "ID",
    "name"   : "名前",
    "gender" : "性別",
    "age"    : "年齢"
}

const renderTableHeader = () => {
    const thead = document.createElement("thead");
    const tr = document.createElement("tr");
    thead.className = "bg-slate-500";

    for (const [userKey, userValue] of Object.entries(usersTableColumn)) {
        const th = createAttributedElements({
            tag:"th",
            valuesByAttributes:{
                class:"text-sm text-white px-6 py-4",
            },
            str:userValue
        });

        tr.appendChild(th);

        if (sortCategories.includes(userKey)) {
            th.appendChild(createSortButtons(userKey));
        }
    };

    table.appendChild(thead).appendChild(tr);
}

const renderTableBody = usersData => {
    const tbody = document.createElement("tbody");
    tbody.id = "js-usersTableBody";

    for(const user of usersData){
        const fragment = document.createDocumentFragment();
        const tr = document.createElement("tr");

        Object.keys(usersTableColumn).forEach((column) => {
            const td = createAttributedElements({
                tag:"td",
                valuesByAttributes:{
                    class:"border border-gray-300 px-4 py-2"
                },
                str:user[column]
            })
            fragment.appendChild(tr).appendChild(td);
        });

        table.appendChild(tbody).appendChild(fragment);
    }
}

const sortButtonAttributes = [
    {state:"asc",src:"../img/asc.svg", alt:"asc-image"},
    {state:"desc",src:"../img/desc.svg",alt:"desc-image"},
    {state:"both",src:"../img/both.svg",alt:"both-image"}
]

const sortCategories = ["id"];

const createSortButtons = (columnKey) => {
    const sortButtonsBox = createAttributedElements ({
        tag:"div",
        valuesByAttributes:{
            id:`js-${columnKey}Buttons-Box`,
            class: "w-10 inline-block align-middle"
        }
    })

    const fragment = document.createDocumentFragment();

    for(const button of sortButtonAttributes){
        const sortButton = createAttributedElements({
            tag:"button",
            valuesByAttributes:{
                class: `js-${columnKey}SortButton`,
                'data-state':button.state
            }
        });

        button.state !== "both" && sortButton.classList.add("hidden");

        const sortButtonImage = createAttributedElements({
            tag:"img",
            valuesByAttributes:{
                class:"pointer-events-none",
                src:button.src,
                alt:button.alt
            }
        })

        fragment.appendChild(sortButton).appendChild(sortButtonImage);
    }

    sortButtonsBox.appendChild(fragment);
    return sortButtonsBox;
}

const toggleHiddenClassForSortButton = (currentButton,nextButton) => {
    currentButton.classList.add("hidden");
    nextButton.classList.remove("hidden");
}

const updateTableBody = usersData =>  {
    document.getElementById("js-usersTableBody").remove();
    renderTableBody(usersData);
};

const sortUsersData = (category, usersData, state) => {
    const cloneUsersData = [...usersData];

    switch (state) {
        case "asc":
            cloneUsersData.sort((a, b) => a[category] - b[category]);
            updateTableBody(cloneUsersData);
            break;
        case "desc":
            cloneUsersData.sort((a, b) => b[category] - a[category]);
            updateTableBody(cloneUsersData);
            break;
        case "both":
            updateTableBody(usersData);
            break;

        default:
    }
}

const changeState = (currentState) => {
    switch (currentState) {
      case "asc":
        return "desc";
      case "desc":
        return "both";
      default:
        return "asc";
    }
}

const sortTableBody = usersData => {
    sortCategories.forEach((category) => {
        const sortButtonsBox = document.getElementById(`js-${category}Buttons-Box`);
        
        sortButtonsBox.addEventListener("click", (e) => {
            if(e.currentTarget === e.target) return;

            const currentState = e.target.getAttribute("data-state");
            const nextState  = changeState(currentState);
            const nextButton = document.querySelector(`[data-state=${nextState}]`);

            toggleHiddenClassForSortButton(e.target,nextButton);
            sortUsersData(category, usersData, nextState);
        });
    });
} 

initUsersData();

今回学んだこと

  • Switch分の使い方
  • オブジェクトのkey,valueの取得
  • data属性の取得方法
まい

フロントエンドエンジニア目指して、勉強中です。

その他カテゴリの最新記事