今回はクリックで編集状態を切り替える方法をご紹介します。使用技術はReact, Shadcn/ui, React-hook-form, Zodになります。既往歴をクリックすると詳細ダイアログが表示され、そのダイアログの編集したいfieldをクリックすると編集可能となります。

クリックで編集状態を切り替えます。

Dialog

Shadcnのdialogを使います。まずDocumentを確認してみましょう。

Dialog – shadcn/ui

ダイアログを開くためのボタンなどをDialogTriggerで囲えばいいみたいです。今回はtableのrowをクリックするとDialogが表示されるように実装しています。

<Dialog>
  <DialogTrigger asChild>
    <TableRow>
      {/* ここにコンテンツを入力してください */}
    </TableRow>
  </DialogTrigger>
</Dialog>

以下のコードは既往歴(PMH)を表示するtableの、各行のcomponentになります。recordはダイアログで表示させるための個別の情報を保持しています(diseaseName: 疾患名, diagnosisDate: 診断日, primaryCareProvider: かかりつけ病院, notes, idなど)。TableRowをクリックすると、PMHRecordDialog componentが開くようになっています。今回なぜ論理積演算子でレンダリングしているかというと({ isDialogOpen && (...) }の部分)、開閉ボタン実装やform送信後にdialogを閉じるなど、開閉を自由に操作したかったからです。純粋に閉じるボタンの実装だけであれば、DialogContent component内で、DialogClose componentを使用すれば閉じるボタンを実装できます。
※PMH: Past Medical History (既往歴)の略です。

ちなみにTailwind CSSにて className="hidden lg:table-cell" とすることで、mobile deviceならそのcellを隠し、large deviceならそのcellを表示させることができます。

import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { TableCell, TableRow } from "@/components/ui/table";
import { useState } from "react";
import { PMHRecordDialog } from "./pmh-record-dialog";

type PMHResponseType {
  diseaseName: string;                 //診断名
  diagnosisDate: string | null;        //診断日
  primaryCareProvider: string | null;  //かかりつけ病院
  notes: string | null;                //メモ
  id: string;                          //recordのid. DBは今回のプロジェクトではFirestoreを使用。
}

export const PMHTableRow = ({ record }: { record: PMHResponseType }) => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const handleCloseDialog = () => {
    setIsDialogOpen(false);
  };
  return (
    <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
      <DialogTrigger asChild>
        <TableRow>
          <TableCell>{record.diseaseName}</TableCell>
          <TableCell className="hidden lg:table-cell">
            {record.diagnosisDate}
          </TableCell>
          <TableCell className="hidden lg:table-cell">
            {record.primaryCareProvider}
          </TableCell>
        </TableRow>
      </DialogTrigger>
      {isDialogOpen && (
        <PMHRecordDialog record={record} onClose={handleCloseDialog} />
      )}
    </Dialog>
  );
};

DialogContent

DialogContent部分である、PMHRecordDialog componentを見ていきましょう。まず大まかなロジックとしてはtextObjectによってfieldの値と編集中かどうか、そしてこのDialogが一度でも変更されたかどうか(hasEdited)を管理しています。

Types

//Props type
interface PMHRecordDialogProps {
  record: PMHResponseType;
  onClose: () => void; // ダイアログを閉じる関数
}

//recordのfield名です。
type PMHFieldNames =
  | "diseaseName"
  | "diagnosisDate"
  | "primaryCareProvider"
  | "notes";

//useStateで管理するobjectです。それぞれのfield(diseaseName, diagnosisDate, primaryCareProvider, notes)の値と編集状態を管理し、一度でも編集が行われたかどうかをcheckします。
type TextObjectType = {
  [key in PMHFieldNames]: {
    value: string;
    isEditing: boolean;
  };
} & {
  hasEdited: boolean;
};

textObjectの生成

recordの値からtextObjectのdefault値を生成します。こちらは適宜書き換えてください。

const initializeTextObject = ( record: PMHResponseType ): TextObjectType => {
  const defaultValue = {
    diseaseName: record.diseaseName,
    diagnosisDate: record.diagnosisDate ?? "",
    primaryCareProvider: record.primaryCareProvider ?? "",
    notes: record.notes ?? "",
  };
  const mapped = Object.entries(defaultValue).map(([key, value]) => {
    return [
      key,
      {
        value: value,
        isEditing: false,
      },
    ];
  });

  const formattedEntries = Object.fromEntries(mapped);
  const defaultTextState = {
    ...formattedEntries,
    hasEdited: false, 
  };

  return defaultTextState;
};

const defaultTextState = initializeTextObject(record);
/* defaultTextStateの例
{
  diseaseName: {value: '統合失調症', isEditing: false},
  diagnosisDate: {value: '2025-07-05', isEditing: false},
  primaryCareProvider: {value: 'XX大学病院', isEditing: false},
  notes: {value: 'オランザピンで使用中', isEditing: false},
  hasEdited: false,
}
*/

useState, useEffect

useEffectでcomponent rendering時にtextObjectを初期化しています。unmount時にcleanup関数でtextObjectを変更する方法もあるかもしれませんが、このdialogの結果がsaveやcancelなどいくつかあったので、rendering時の処理としています。ここの記述は自信はありません。より良い方法があれば教えてください。

import { useGetUser } from "@/features/auth/hooks/use-get-user"; //userを取得する関数を適宜準備してください。

//Component関数内
const { user } = useGetUser();

const [textObject, setTextObject] = useState<TextObjectType>(defaultTextState);

useEffect(() => {
  //componentがrenderされたときにtextObjectを初期化する。
  //これがないと、dialogを閉じた後、再度開いた際に前回のtextObjectが保持されている。
  setTextObject(defaultTextState);
}, []);

const handleClose  //キャンセル時のロジックを適宜記述してください。
const handleSave   //保存時のロジックを適宜記述してください。今回はこの関数をsubmit関数にします。
const handleDelete //Delete時のロジックを適宜記述してください。

React-hook-form, Zod

React hook form, Get Started
まずzodでform validationのためのschemaを作成しましょう。そしてschemaから対応した型を生成します。

import { z } from "zod";

//Component関数内
const pmhSchema = z.object({
  diseaseName: z
    .string()
    .min(1, "Disease name is required")
    .max(100, "Disease name must be less than 100 characters"),
  diagnosisDate: z
    .string() //簡略化のために文字列としてますが、多分date型の方が適切です。
    .max(100, "Diagnosis date must be less than 100 characters"), 
  primaryCareProvider: z
    .string()
    .max(100, "Primary care provider name must be less than 100 characters"),
  notes: z.string().max(1024, "Notes must be less than 1024 characters"),
});

type PMHSchemaType = z.infer<typeof pmhSchema>

このschemaを元にreact-hook-formのform objectを生成します。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

//Component関数内
const defaultValue =
  {
    diseaseName: record.diseaseName,
    diagnosisDate: record.diagnosisDate ?? "",
    primaryCareProvider: record.primaryCareProvider ?? "",
    notes: record.notes ?? "",
  };

const form = useForm<PMHSchemaType>({
  resolver: zodResolver(pmhSchema),  //このresolverで、formの構造を決定します。
  defaultValues: defaultValue,
  mode: "onChange", //dialogのfieldを変更するたびにvalidationが走ります。defaultは"onSubmit"で、こちらはsubmit時にvalidationが走ります。
});

こちらのform objectには以下のようなpropertyがあります。

  • register
    • 引数にnameを受け取りobjectとしてname, onChange, onBlur, refなどを返却する関数。まさしくinput要素をRHFのformに登録する役割。
  • control
    • registerと同じようにinputをRHFに登録する。Shadcn/uiのForm componentを使う場合は、registerではなくこちらのcontrolを使ってinputとRHFを紐付けます。
  • handleSubmit
    • Submit関数のラッパー関数
    • 入力値を resolver(例:Zod)でバリデーション。submit関数はonSubmitとして説明します。
    • エラーがなければ → onSubmit(data) を呼ぶ
    • エラーがあれば → formState.errors にerror messageを入れて再レンダリング(onSubmit は呼ばれない)

Form

Shadcn/uiのForm componentを使用します。
React Hook Form – shadcn/ui

<Form {...form}>で、RHFのform objectを分割代入します。このForm componentで、<form onSubmit={form.handleSubmit(onSubmit)}>TSXのform (HTMLのform相当)をラップします。

次にFormFieldでinputをRHFに紐付けながらrenderingします。一つのinputにつき一つのFormFieldを使います。

//Shadcn/uiのForm使用例。usernameを入力するためのformです。
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"

//Component関数内
return (
  <Form {...form}>
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      <FormField
        control={form.control} //form objectのcontrolを渡してRHFと紐付けます。
        name="username" //zodのSchemaのpropertyにある名前を入力します。
        render={({ field }) => (
          <FormItem>
            <FormLabel>Username</FormLabel>
            <FormControl>
              <Input placeholder="shadcn" {...field} />
            </FormControl>
            <FormDescription>
              This is your public display name.
            </FormDescription>
            <FormMessage /> //Errorが生じた場合、FormMessageで表示されます。
          </FormItem>
        )}
      />
      <Button type="submit">Submit</Button>
    </form>
  </Form>
)

TSX

以上を踏まえてTSX部分を実装していきます。

FormField部分

こちらはFormFieldの各々のfieldをrenderingするcomponentです。formNameでfieldを特定しています。formNameはこの例ではdiseaseNameやdiagnosisDateなどです。

まずformNameが編集中でない時、textObjectのformNameのvalueがpタグで表示されます。クリックされた時にhandleEditMode関数で編集状態を切り替えます

{ textObject[formName].isEditing ? (
  {/* ここに編集中に表示するcomponentを記述 */}
) : (
  <p onClick={() => handleEditMode()}>
    {textObject[formName].value ? textObject[formName].value : <br />}
  </p>
)}

次にFormControl部分です。onBlurにhandleEditMode関数を渡すことでFocusが外れた時に、編集状態を切り替えます。またInputもしくはTextAreaにautoFocusを設定することで、編集状態が切り替わった時に自動でFocusされるようにします。

<FormControl>
  {type === "text" ? (
    <Input
      {...field}
      onBlur={() => handleEditMode()}
      autoFocus
      onChange={(e) => handleValueChange(e.target.value)}
      value={textObject[formName].value}
    />
  ) : (
    <Textarea
      {...field}
      onBlur={() => handleEditMode()}
      autoFocus
      onChange={(e) => handleValueChange(e.target.value)}
      value={textObject[formName].value}
    />
  )}
</FormControl>

DialogFormField componentの全体です。

import { FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@ui/input";
import { Textarea } from "@ui/textarea";

type DialogFormFieldProps = {
  form: UseFormReturn<PMHSchemaType>;
  formName: keyof PMHSchemaType;
  textObject: TextObjectType;
  setTextObject: React.Dispatch<React.SetStateAction<TextObjectType>>;
  type?: "text" | "textarea"; //もしdate型も扱う場合は"date"を追加してください
}

// TextObjectTypeは上のTypes参照。

export const DialogFormField = ({
  form,
  formName,
  textObject,
  setTextObject,
  type = "text",
}: DialogFormFieldProps) => {

  const handleEditMode = () => {
    setTextObject((prev: TextObjectType) => ({
      ...prev,
      [formName]: {
        ...prev[formName],
        isEditing: !prev[formName].isEditing,
      },
    }));
  };

  const handleValueChange = (formValue: string) => {
    setTextObject((prev: TextObjectType) => ({
      ...prev,
      [formName]: {
        ...prev[formName],
        value: formValue,
      },
      hasEdited: true, //この部分で一度でも編集を行なったかどうかcheck。
    }));
    form.setValue(formName, formValue);
  };

  return (
    <FormField
      control={form.control}
      name={formName}
      render={({ field }) => (
        <FormItem>
          {textObject[formName].isEditing ? (
            <FormControl>
              {type === "text" ? (
                <Input
                  {...field}
                  onBlur={() => handleEditMode()}
                  autoFocus
                  onChange={(e) => handleValueChange(e.target.value)}
                  value={textObject[formName].value}
                />
              ) : (
                <Textarea
                  {...field}
                  onBlur={() => handleEditMode()}
                  autoFocus
                  onChange={(e) => handleValueChange(e.target.value)}
                  value={textObject[formName].value}
                />
              )}
            </FormControl>
          ) : (
            <p onClick={() => handleEditMode()}>
              {textObject[formName].value ? textObject[formName].value : <br />}
            </p>
          )}
          <FormMessage />
        </FormItem>
      )}
    />
  );
};

DialogContent部分

上記のDialogFormFieldを用いてDialogContent部分記述していきます。hasEditedがfalseの間はsave buttonをdisableに設定しています。

import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Button } from "@/components/ui/button";
import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { DialogFormField } from "./dialog-form-field";

//Component内
return (
  <DialogContent showCloseButton={false}> {/* close buttonを隠しています。defaultでは右上に表示されます。 */}
    {/* DialogContent内にはaccessibilityのためにDialogTitleが必要です。radix-uiのVisuallyHiddenでラップすることで、DialogTitleを隠しています。 */}
    <VisuallyHidden>
      <DialogTitle>Past Medical History Record</DialogTitle>
    </VisuallyHidden>

    <Form {...form}>
      <form className="space-y-4" onSubmit={form.handleSubmit(handleSave)}>

        <div>
          <h3>Disease Name</h3>
          <DialogFormField
            form={form}
            formName="diseaseName"
            textObject={textObject}
            setTextObject={setTextObject}
          />
        </div>

        <div>
          <h3>Diagnosis Date</h3>
          <DialogFormField
            form={form}
            formName="diagnosisDate"
            textObject={textObject}
            setTextObject={setTextObject}
          />
        </div>

        <div>
          <h3>Primary Care Provider</h3>
          <DialogFormField
            form={form}
            formName="primaryCareProvider"
            textObject={textObject}
            setTextObject={setTextObject}
          />
        </div>

        <div>
          <h3>Notes</h3>
          <DialogFormField
            form={form}
            formName="notes"
            textObject={textObject}
            setTextObject={setTextObject}
            type="textarea"
          />
        </div>

        <div className="flex items-center justify-between">
          <Button
            className="w-30 bg-blue-500 hover:bg-blue-400"
            disabled={!textObject.hasEdited}
          >
            Save
          </Button>
          <Button
            className="w-30 bg-yellow-400 hover:bg-yellow-300 text-black"
            type="button"
            onClick={handleClose}
          >
            Close
          </Button>
        </div>

      </form>
    </Form>
    <Button type="button" onClick={handleDelete} className="w-full">
      Delete
    </Button>
  </DialogContent>
);

投稿者 Hiro

医師免許を持ってるシステムエンジニアです。卒後3年目。 Python (flask, tensorflow), JS・TS (React, Next.js), PHP, SQLなど使用。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です