🔍

Remix 簡易的な検索機能の実装

2023/12/24に公開

以下の記事でご紹介したIndeed風の求人サイトに、キーワードでの検索や給与順での並び替えなどを行える機能を追加します。
https://zenn.dev/jinku/articles/5219ca81cd7d4f

▼完成イメージ

今回はRemixの学習をメインで行っていたので、データベースはdata.tsに架空のものを用意しています。

大まかな手順

  1. 架空のデータベースを用意
  2. 検索クエリに基づいて求人をフィルタリングする関数を用意
  3. 検索フォームを実装
  4. loader関数でフィルタリングに基づくデータを取得する

架空のデータベースを用意

app/data.tsにいくつかのデータを用意しておきます。

const sampleJobPostings = [
  {
    id: "1",
    title: "未経験大歓迎!年間休日120日以上の事務スタッフ募集!",
    Description: "当社では、事務経験がなくても大歓迎です!主に書類整理、データ入力~~~"
    CompanyName: "クリエイティブデザイン・アイランド株式会社",
    WorkLocation: "札幌市中央区",
    JobType: "デザイナー",
    Salary: 250000,
    EmploymentType: "正社員",
    createdAt: "2023-04-01T17:00:00.000Z",
  },
  {
    id: "2",
    ...
  }
  ...
  }
];

type JobPostingMutation = {
  id?: string;
  title?: string;
  Description?: string;
  CompanyName?: string;
  WorkLocation?: string;
  JobType?: string;
  Salary?: number;
  EmploymentType?: string;
};
const fakeJobPostings = {
  records: {} as Record<string, JobPostingRecord>,

  async getAll(): Promise<JobPostingRecord[]> {
    return Object.values(fakeJobPostings.records)
      .sort(sortBy("-createdAt"));
  },
};

sampleJobPostings.forEach(posting => {
  fakeJobPostings.records[posting.id] = posting;
});

fakeJobPostingsオブジェクトを定義し、空のrecodsを作成します。これが架空のデータベースとなります。

async getAll(): Promise<JobPostingRecord[]> は、fakeJobPostings.recordsに保存されているすべての求人情報を取得し、それらを配列として返します。

先に作成した求人情報を持つsampleJobPostings配列を繰り返し処理し、idプロパティをキーとして、fakeJobPostings.recordsオブジェクトにposting自体を値として設定しています。

つまり、各求人のidをキー、求人データのオブジェクトを値として持つ,
以下のようなfakeJobPostings.recordsオブジェクトをもとに、

{
  '1': { id: '1', title: 'Developer', ... },
  '2': { id: '2', title: 'Designer', ... }
}

Object.valuesで配列を作成すると、次のような配列が得られます。

[
  { id: '1', title: 'Developer', ... },
  { id: '2', title: 'Designer', ... }
]

検索クエリに基づいて求人をフィルタリングする関数を用意

検索クエリに基づいて求人情報をフィルタリングするgetJobPostings関数を実装します。
この関数は、まずすべての検索クエリが空かどうかをチェックします。
空であれば全てのデータを、空でなければ条件に合うデータをfilteredJobPostingsに格納します。

ソートパラメータがある場合は、条件に従って並び替えを行い、filteredJobPostingsをreturnします。

export async function getJobPostings(sortBy = null, searchQuery = {}) {
  const jobPostings = await fakeJobPostings.getAll();

   // すべての検索クエリが空かどうかをチェック
   const isAllQueriesEmpty = Object.values(searchQuery).every(value => value === '');

   // フィルタリングロジック
   const filteredJobPostings = isAllQueriesEmpty ? jobPostings : jobPostings.filter(posting => {
     return (!searchQuery.title || posting.title.toLowerCase().includes(searchQuery.title.toLowerCase())) &&
            (!searchQuery.companyName || posting.CompanyName.toLowerCase().includes(searchQuery.companyName.toLowerCase())) &&
            (!searchQuery.workLocation || posting.WorkLocation.toLowerCase().includes(searchQuery.workLocation.toLowerCase())) && 
            (!searchQuery.jobType || posting.JobType.toLowerCase().includes(searchQuery.jobType.toLowerCase())); 
});

  // ソートロジック
  if (sortBy === "Salary") {
    filteredJobPostings.sort((a, b) => b.Salary - a.Salary);
  }

  return filteredJobPostings;
}

isAllQueriesEmptyは、全ての検索クエリが空の場合はtrue、そうでない場合はfalseになります。
三項演算子を使用し、isAllQueriesEmptytrueの場合は全てのデータを持つjobPostingsをそのままfilteredJobPostingsに代入します。そうでない場合は、jobPostings配列はfilterメソッドを使ってフィルタリングされます。

!searchQuery.title || posting.title.toLowerCase().includes(searchQuery.title.toLowerCase())これらの各条件式は、「渡された検索クエリが空」か「postingがその検索クエリの文字列を含んでいる」場合にtrueを返します。

この各条件式を全て&&でつなぐことで、各検索クエリの結果が全てtrueになるposting(求人データ)のみがfilterメソッドによって新しい配列に追加されます。

ソートロジックでは、sortByクエリの値によって、並び替えを設定しています。
今回は給与額の高い順に並び替えるロジックを一つだけ設定しています。

検索フォームを実装

ここからはフロント側で検索フォームを実装します。

<Form
 onSubmit={handleFilterChange}
>
 <div className="bg-white w-2/3 mx-auto flex flex-col">
  <div className="flex">
   <div className="bg-clip-border border-b border-b-white w-1/5 flex items-center text-end border-gray-100  bg-gray-100">
    <p className="text-sm pr-4 flex-1">
      求人タイトル
    </p>
  </div>
  <div className="w-4/5 pl-7 border border-r-0 bg-white text-sm">
   <input
      type="search"
      name="title"
      defaultValue={query.title || ""}
      placeholder="求人タイトルで検索"
      className="p-2 w-4/5  border rounded-sm m-2 my-3"
   />
  </div>
 </div>
<div className="flex">
 // そのほかの要素
<div className="flex">
  <div className="bg-clip-border w-1/5 flex items-center text-end border-gray-100  bg-gray-100">
    <p className="text-sm pr-4 flex-1">
      並び替え
    </p>
  </div>
  <div className="w-4/5 pl-7 border border-t-0 border-r-0 bg-white text-sm">
    <select 
      name="sortBy" 
      defaultValue={sortBy || ''}
      className="text-sm p-2 w-4/5  border rounded-sm m-2 my-3"
    >
      <option value="">新着順</option>
      <option value="Salary">給与順</option>
    </select>
  </div>
  </div>
 </div>
 <div className="text-center mt-5">
  <button
  type="submit"
  className="p-2 mx-auto text-gray-800 border border-gray-400 rounded-md m-1 hover:border-gray-600"
>
  この条件で絞り込む
   </button>
  </div>
 </Form>

今回は、求人タイトル・会社名・勤務地・職種名のすべてをキーワード検索できるようにし、並び替えはセレクトボックスで用意します。
「この条件で絞り込む」ボタンをクリック数と、<Form onSubmit={handleFilterChange}>に設定しているhandleFilterChange関数でフォームの内容を処理します。

export default function JobList() {
  let {jobPostings, query, sortBy} = useLoaderData<typeof loader>();
  const [isFiltersOpen, setIsFiltersOpen] = useState(false);
  const submit = useSubmit();

  const handleFilterChange = (event) => {
    const formData = new FormData(event.currentTarget);
    const newQuery = {
      title: formData.get('title') || '',
      companyName: formData.get('companyName') || '',
      workLocation: formData.get('workLocation') || '',
      jobType: formData.get('jobType') || '',
      sortBy: formData.get('sortBy') || ''
    };
  
    // 更新されたクエリでリクエストを送信
    submit(newQuery, { method: "get", action: "/joblist" });
    setIsFiltersOpen(false);
  };

const formData = new FormData(event.currentTarget)は、送信されたフォームのデータを取得し、formDataに代入します。
event.currentTargetは、イベントが発生した要素(ここでは検索フォーム)を参照しています。

formData.get('title')のようにして、特定のフィールドのデータを取得します。
|| ''は、もし何も入力されていない場合は空文字列をデフォルト値として使うことを意味します。

submit(newQuery, { method: "get", action: "/joblist" })は、現在のルート(app/routes/joblist.tsx)にgetメソッドでリクエストを送信することで、このルートのloader関数を実行します。

loader関数でフィルタリングに基づくデータを取得する

loader関数はページの読み込み時にも実行されますが、getでリクエストを送ることでも実行します。

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const searchParams = url.searchParams;
  const query = {
    title: searchParams.get("title"),
    companyName: searchParams.get("companyName"),
    workLocation: searchParams.get("workLocation"),
    jobType: searchParams.get("jobType")
  };
  
  const sortBy = searchParams.get("sortBy");
  const jobPostings: JobPostingRecord[] = await getJobPostings(sortBy, query);
  
  return json({ jobPostings, query, sortBy });
};

const url = new URL(request.url)では、リクエストされたURLをJavaScriptのURLオブジェクトに変換し、urlに代入しています。
そのurlsearchParamsプロパティの値を取得すると、例えば以下のような値が確認できます。

URLSearchParams {
  'title' => '未経験',
  'companyName' => '',
  'workLocation' => '金沢市',
  'jobType' => 'エンジニア',
  'sortBy' => '' }

これら一つひとつの検索クエリを取得したいため、title: searchParams.get("title")のように、.getメソッドを使用して文字列を取得します。
取得した文字列を各プロパティの値として指定し、一つのオブジェクトとして保持します。

そしてバックエンドで作成したgetJobPostings関数(以下再掲)に検索クエリとソートパラメータを引数として渡すことで、条件に合致する求人データの配列がjobPostingsに代入されます。

export async function getJobPostings(sortBy = null, searchQuery = {}) {
  const jobPostings = await fakeJobPostings.getAll();

   // すべての検索クエリが空かどうかをチェック
   const isAllQueriesEmpty = Object.values(searchQuery).every(value => value === '');

   // フィルタリングロジック
   const filteredJobPostings = isAllQueriesEmpty ? jobPostings : jobPostings.filter(posting => {
     return (!searchQuery.title || posting.title.toLowerCase().includes(searchQuery.title.toLowerCase())) &&
            (!searchQuery.companyName || posting.CompanyName.toLowerCase().includes(searchQuery.companyName.toLowerCase())) &&
            (!searchQuery.workLocation || posting.WorkLocation.toLowerCase().includes(searchQuery.workLocation.toLowerCase())) && 
            (!searchQuery.jobType || posting.JobType.toLowerCase().includes(searchQuery.jobType.toLowerCase())); 
});

  // ソートロジック
  if (sortBy === "Salary") {
    filteredJobPostings.sort((a, b) => b.Salary - a.Salary);
  }

  return filteredJobPostings;
}

最後にreturn json({ jobPostings, query, sortBy });で取得した求人情報と検索クエリ、ソートパラメータをJSON形式で返します。


これで、簡単な検索機能が実装できました。

実際のデータベースやエラー処理など、本格的なアプリケーションに必要なものは他にたくさんありますが、

  • Remixでのフォーム送信
  • URLからのクエリパラメータ読み取り
  • loader関数の再実行によるデータの取得・更新

など、Remixの基礎を理解するのにちょうど良い実装だったと思います!

Discussion