Closed6

Remix+CloudflareでWebサイトを作る 38(Selectで未選択状態にする、Prismaでランダムに取得、URLのクエリに関するコンテクスト作成、Biomeの導入時にエラー)

saneatsusaneatsu

【2024-10-20】shadcnの<Select > コンポーネントで、未選択状態にしたい

https://ui.shadcn.com/docs/components/select

UIのパターン

  • パターン1: どこかにキャンセルボタンを用意する
  • パターン2: 選択中のItemをもう一度クリックしたらキャンセルされる
  • パターン3: そもそも選択肢の中に未選択状態にする選択肢を用意する
    • 選択肢をいじるだけなので一番楽そう

パターン1(うまく動かず)

fakerで適当な会社名を入れている。
会社が選択状態になると、選択しているItemの右側にバツ印のアイコンが表示されるようにした。

以下は元のnpx shadcn@latest add select をしてからの変更点。
ざっくり、{children} を囲んであとは呼び出し元で<SelectTrigger cancell> とすればOKでは?と思ったけど、この場合<Button>をクリックしてもonClickが発火せずに選択肢が開く状態になってしまうのでダメだった。

import * as React from "react";

import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";

+ import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";

const Select = SelectPrimitive.Root;

const SelectGroup = SelectPrimitive.Group;

const SelectValue = SelectPrimitive.Value;

const SelectTrigger = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & { cancell?: boolean }
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Trigger
    ref={ref}
    className={cn(
      "flex h-10 w-full items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
      className
    )}
    {...props}
  >
+ <div className="flex items-center">
    {children}
+     {props.cancell && (
+       <Button
+         variant="ghost"
+         className="h-6 w-6 ml-2 hover:bg-none"
+         size="icon"
+       >
+         <X className="h-4 w-4 opacity-50" />
+       </Button>
+     )}
+   </div>
    <SelectPrimitive.Icon asChild>
      <ChevronDown className="ml-1 h-4 w-4 opacity-50" />
    </SelectPrimitive.Icon>
  </SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;

const SelectScrollUpButton = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.ScrollUpButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1",
      className
    )}
    {...props}
  >
    <ChevronUp className="h-4 w-4" />
  </SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;

const SelectScrollDownButton = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.ScrollDownButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1",
      className
    )}
    {...props}
  >
    <ChevronDown className="h-4 w-4" />
  </SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
  SelectPrimitive.ScrollDownButton.displayName;

const SelectContent = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
  <SelectPrimitive.Portal>
    <SelectPrimitive.Content
      ref={ref}
      className={cn(
        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        position === "popper" &&
          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
        className
      )}
      position={position}
      {...props}
    >
      <SelectScrollUpButton />
      <SelectPrimitive.Viewport
        className={cn(
          "p-1",
          position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
        )}
      >
        {children}
      </SelectPrimitive.Viewport>
      <SelectScrollDownButton />
    </SelectPrimitive.Content>
  </SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;

const SelectLabel = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Label>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.Label
    ref={ref}
    className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
    {...props}
  />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;

const SelectItem = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Item
    ref={ref}
    className={cn(
      "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
      className
    )}
    {...props}
  >
    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
      <SelectPrimitive.ItemIndicator>
        <Check className="h-4 w-4" />
      </SelectPrimitive.ItemIndicator>
    </span>

    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
  </SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

const SelectSeparator = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Separator>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.Separator
    ref={ref}
    className={cn("-mx-1 my-1 h-px bg-muted", className)}
    {...props}
  />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;

export {
  Select,
  SelectGroup,
  SelectValue,
  SelectTrigger,
  SelectContent,
  SelectLabel,
  SelectItem,
  SelectSeparator,
  SelectScrollUpButton,
  SelectScrollDownButton,
};

結論

一旦パターン3の選択肢に未選択のものを入れておくので回避しておく。

パターン2が一番気持ち良いんだけどな。今後やりたいものとしてバックログに入れておこう。

saneatsusaneatsu

【2024-10-23】Prismaでランダムに複数の要素を取得する方法

https://github.com/prisma/prisma/discussions/5886#discussioncomment-1518446

ここを参考に書いた。

function randomPick<T>(array: T[]): T {
  const randomIndex = Math.floor(Math.random() * array.length);
  return array[randomIndex];
}

export async function getRandomUsers() {  
  const count = await client.user.count({
    where: {
      isArchived: false,      
    },
  });

  const take = 3;
  const skip = Math.max(0, Math.floor(Math.random() * count) - take); // 0以上、count-take 以下のランダムな数を取得
  const orderBy = randomPick(["id", "name", "email"]);
  const orderDir = randomPick(["asc", "desc"]);

  const users = await client.user.findMany({
    where: {      
      isArchived: false,      
    },
    take,
    skip,
    orderBy: { [orderBy]: orderDir },
    select: {
      id: true,
      name: true,
      email: true,      
    },
  });

  return users;
}
saneatsusaneatsu

【2024-10-25】DatTableのフィルタリングによってURLのクエリを更新してデータを取ってきたいのでコンテクストを作る

背景

DataTableでテキストを入力したり、Dropdownから選択したりすることで色々とフィルタリングをしたい。
shadcnで作っているけどUIのイメージはPrimeVueを参考にしている。

以前使ったことがあるけどカラムごとにフィルタリングするコンポーネントがあるのが好み。

https://primevue.org/datatable/#filter

以下は、shadcnのDataTableを拡張したexampleだけど、こんな感じでフィルタリング箇所が全部まとまってるUIもあるけど、カラムごとにあるのが直感的な気がして好きなんだよな。

https://table.sadmn.com/

やりたいこと

  1. 上のPrimeVueの例でいうとNameカラムのinputや、Agentカラムのselectをいじる
  2. URLのクエリパラメーターが更新される
  3. loaderが走る
  4. クエリパラメーターを元にフィルタリング
  5. 取得したデータを表示

実装

2で更新するときに、いろいろなコンポーネントで現在のクエリパラメータを更新する処理をconst navigate = useNavigate();で書くのがしんどかったのでコンテクストを使った。

import { useLocation, useNavigate } from "@remix-run/react";
import React, { createContext, useContext, useEffect, useState } from "react";

export type Query = {
  key: string;
  value: string;
};

type QueryContextType = {
  query: URLSearchParams;
  upsertQuery: (newQuery: Query) => void;
  deleteQuery: (key: string) => void;
};

const QueryContext = createContext<QueryContextType>({
  query: new URLSearchParams(),
  upsertQuery: () => {},
  deleteQuery: () => {},
});

export const useQuery = () => {
  return useContext(QueryContext);
};

function QueryProvider({ children }: { children: React.ReactNode }) {
  const location = useLocation();
  const navigate = useNavigate();
  const [query, setQueryState] = useState(new URLSearchParams(location.search));

  useEffect(() => {
    setQueryState(new URLSearchParams(location.search));
  }, [location.search]);

  /**
   * クエリを追加・更新する
   */
  const upsertQuery = ({ key, value }: Query) => {
    const newQuery = new URLSearchParams(query);
    newQuery.set(key, String(value));
    setQueryState(newQuery);
    navigate(`${location.pathname}?${newQuery.toString()}`, { replace: true });
  };

  /**
   * クエリを削除する
   */
  const deleteQuery = (key: string) => {
    const newQuery = new URLSearchParams(query);
    if (newQuery.has(key)) {
      newQuery.delete(key);
      setQueryState(newQuery);
      navigate(`${location.pathname}?${newQuery.toString()}`, {
        replace: true,
      });
    }
  };

  return (
    <QueryContext.Provider value={{ query, upsertQuery, deleteQuery }}>
      {children}
    </QueryContext.Provider>
  );
}

export default QueryProvider;

呼び出し側では以下の様な感じで使うとURLが http://localhost:5173/hoge?name=<YOUR_INPUT> という感じになる。

const { upsertQuery, deleteQuery } = useQuery();
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  const value = event.target.value;  
  if (value) {
    upsertQuery({ key: "name", value }); // クエリを追加 or 更新
  } else {
    deleteQuery("name"); // なにもない場合はクエリから削除
  }
};
saneatsusaneatsu

【2024-10-25】Biome導入してeslintのパッケージ消したらThe package "punycode" wasn't found on the file system but is built into node.と怒られた。

やったこと

以下を全部消した。

package.json
"@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^8.7.0",
"eslint": "^8.57.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^4.6.2",

エラー内容

デプロイ時に以下のエラーが発生。
"markdown-it": "^13.0.2", でエラーが発生している。

markdown-it パッケージが punycode という Node.js のネイティブモジュールに依存しているため発生しています。Cloudflare Workers はブラウザライクな環境のため、ネイティブ Node.js モジュールを直接使用できません。

  🚀 Running Wrangler Commands

  ✘ [ERROR] Build failed with 1 error:
  
    ✘ [ERROR] Could not resolve "punycode"
    
        ../node_modules/markdown-it/lib/index.js:14:27:
          14 │ var punycode     = require('punycode');
             ╵                            ~~~~~~~~~~
    
      The package "punycode" wasn't found on the file system but is built into node. Are you trying to bundle for node? You can use "platform: 'node'" to do that, which will remove this error.    

原因探る

1つずつ消していってどこでエラーが発生するのか探ってみると、eslint-plugin-react-hooks がpackage.jsonに残っていれば発生しなかった。

理由がわからん...。

package.json
"@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^8.7.0",
"eslint": "^8.57.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^4.6.2", // これがあればこのエラーが発生しない

その他

https://zenn.dev/lincwell_inc/articles/format-by-biome

biome は Prettier と違う点としてデフォルトがインデントタブです。
インデントにタブを使うことはアクセシビリティ上の利点があるようです。

知らなかった。
とはいえGitHubでdiff見る時スペース4つ分になるのが気に食わないのでデフォルトの設定は使わずにスペースに変えます。

lintを実行したら、Found 984 errors....。
明日は少しずつ対象となるディレクトリ分けてlintで書き換え実施していこう。

てか実行速度が早すぎる。
強めのこだわりが無いのでインデントがスペースなこと以外はBiome様の仰せのままに行く。

見た記事

https://tech.bitbank.cc/biome-js/
https://zenn.dev/ako/articles/b8a686843f6b83
https://zenn.dev/voluntas/scraps/31de0e6155b43e

このスクラップは27日前にクローズされました