From a2b3f3433b1f97968b1c3c66eb00bd1ff8c87c39 Mon Sep 17 00:00:00 2001 From: OKAMOTO Shigehiro Date: Mon, 22 Apr 2024 04:29:39 +0900 Subject: [PATCH] feat: implement form detail and edit --- schema.yml | 4 +- src/app/forms/[form_id]/Form.tsx | 178 ++++++++++++++++ src/app/forms/[form_id]/FormItems.tsx | 15 +- src/app/forms/[form_id]/page.tsx | 199 ++++++------------ src/common_components/FileView.tsx | 48 +++-- .../formFields/CheckboxField.tsx | 26 ++- src/common_components/formFields/Files.tsx | 77 ++++--- .../formFields/_components/File.tsx | 35 +++ src/common_components/formFields/styles.ts | 9 + src/lib/file.ts | 1 + src/lib/postFile.ts | 39 ++-- src/schema.d.ts | 2 +- 12 files changed, 416 insertions(+), 217 deletions(-) create mode 100644 src/app/forms/[form_id]/Form.tsx create mode 100644 src/common_components/formFields/_components/File.tsx create mode 100644 src/lib/file.ts diff --git a/schema.yml b/schema.yml index b76ed76b..18ce265e 100644 --- a/schema.yml +++ b/schema.yml @@ -1485,9 +1485,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/FormAnswer' + $ref: '#/components/schemas/FormAnswer' '401': description: Unauthorized content: diff --git a/src/app/forms/[form_id]/Form.tsx b/src/app/forms/[form_id]/Form.tsx new file mode 100644 index 00000000..df76a1a2 --- /dev/null +++ b/src/app/forms/[form_id]/Form.tsx @@ -0,0 +1,178 @@ +import { SubmitHandler, useForm } from "react-hook-form"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { css } from "@styled-system/css"; +import toast from "react-hot-toast"; + +import { client } from "@/lib/openapi"; +import { deleteAllUploadedFiles, postFiles } from "@/lib/postFile"; + +import { components } from "@/schema"; +import { FormItems, FileErrorsType, FormFieldsType, FilesFormType } from "./FormItems"; + +import { buttonStyle } from "@/recipes/button"; +import { sosFileType } from "@/lib/file"; + +interface Props { + form: components["schemas"]["Form"]; + answerId: string; + answerItems: FormFieldsType | undefined; + editable: boolean; +} + +export const Form = ({ form, answerId, answerItems, editable }: Props) => { + const router = useRouter(); + const onSubmit: SubmitHandler = async (data) => { + if (Array.from(fileErrors).some((v) => v[1])) { + toast.error(`正しいファイルをアップロードしてください`); + return; + } + + if (!form) { + toast.error("申請の読み込みが終わってから送信してください"); + return; + } + + const fileIds = await postFiles("private", files); + if (!fileIds) { + toast.error("ファイルのアップロード中にエラーが発生しました"); + return; + } + + type formAnswerItems = components["schemas"]["CreateFormAnswer"]["items"]; + const items: formAnswerItems = form.items.flatMap((item): formAnswerItems[number] | [] => { + const value = (() => { + switch (item.type) { + case "string": + return data[item.id] || null; + case "int": + const datum = data[item.id]; + return datum ? parseInt(datum ?? "") : null; + case "file": + return fileIds[item.id] || null; + case "choose_many": + const options = JSON.parse(String(data[item.id] ?? "[]")) as string[]; + return options.length ? options : null; + default: + return data[item.id] || null; + } + })(); + + return value + ? { + item_id: item.id, + type: item.type, + value: value, + } + : []; + }); + + if (answerItems) { + client + .PUT("/form-answers/{form_answer_id}", { + params: { + path: { + form_answer_id: answerId, + }, + }, + body: { + form_id: form.id, + items: items, + }, + }) + .then(async ({ error }) => { + if (error) { + toast.error(`申請の修正に失敗しました`); + await deleteAllUploadedFiles(fileIds); + return; + } + toast.success("申請の修正に成功しました"); + router.push("/forms"); + }) + .catch(async () => { + toast.error(`申請の修正内容の送信中にエラーが発生しました`); + await deleteAllUploadedFiles(fileIds); + }); + } else { + client + .POST("/form-answers", { + body: { + form_id: form.id, + items: items, + }, + }) + .then(async ({ error }) => { + if (error) { + toast.error(`申請の送信に失敗しました`); + await deleteAllUploadedFiles(fileIds); + return; + } + toast.success("申請の送信に成功しました"); + router.push("/forms"); + }) + .catch(async () => { + toast.error(`申請の送信中にエラーが発生しました`); + await deleteAllUploadedFiles(fileIds); + }); + } + }; + + const fileFormErrors: FileErrorsType = new Map( + form?.items + .filter((item) => { + item.type === "file"; + }) + .map((item) => [item.id, null]), + ); + const [fileErrors, setFileErrors] = useState(fileFormErrors); + + const filesState: FilesFormType = new Map( + form?.items + .filter((item) => item.type === "file") + .map((item) => { + if (answerItems && answerItems[item.id]) { + const dataTransfer = new DataTransfer(); + (answerItems[item.id] as unknown as string[]).forEach((fileId) => { + const file = new File([], fileId, { type: sosFileType }); + dataTransfer.items.add(file); + }); + return [item.id, dataTransfer.files]; + } else { + return [item.id, null]; + } + }), + ); + const [files, setFiles] = useState(filesState); + const { + register, + getValues, + setValue, + handleSubmit, + formState: { errors }, + } = useForm({ mode: "onBlur", defaultValues: answerItems }); + + return ( +
+ + {(editable || !answerItems) && ( + + )} + + ); +}; diff --git a/src/app/forms/[form_id]/FormItems.tsx b/src/app/forms/[form_id]/FormItems.tsx index 7591f7bf..d095d134 100644 --- a/src/app/forms/[form_id]/FormItems.tsx +++ b/src/app/forms/[form_id]/FormItems.tsx @@ -31,6 +31,7 @@ type Props = { getValues: UseFormGetValues; setValue: UseFormSetValue; register: UseFormRegister; + disabled?: boolean; errors: FieldErrors; files: FilesFormType; setFiles: Dispatch>; @@ -42,6 +43,7 @@ export const FormItems: FC = ({ getValues, setValue, register, + disabled, errors, files, setFiles, @@ -66,6 +68,7 @@ export const FormItems: FC = ({ required: { value: item.required, message: "数字を入力してください" }, max: item.max ? { value: item.max, message: `${item.max}以下の数字を入力してください` } : undefined, min: item.min ? { value: item.min, message: `${item.min}以上の数字を入力してください` } : undefined, + disabled, })} error={errors[item.id]?.message} /> @@ -86,6 +89,7 @@ export const FormItems: FC = ({ minLength: item.min_length ? { value: item.min_length, message: `${item.min_length}文字以上で入力してください` } : undefined, + disabled, })} error={errors[item.id]?.message} /> @@ -100,6 +104,7 @@ export const FormItems: FC = ({ options={item.options ?? []} register={register(item.id, { required: { value: item.required, message: "項目を選択してください。" }, + disabled, })} error={errors[item.id]?.message} /> @@ -112,7 +117,10 @@ export const FormItems: FC = ({ description={item.description} required={item.required ?? true} options={item.options ?? []} - register={register(item.id)} + register={register(item.id, { + disabled, + })} + disabled={disabled} getValues={getValues} setValue={setValue} error={errors[item.id]?.message} @@ -127,7 +135,10 @@ export const FormItems: FC = ({ required={item.required ?? true} extensions={item.extensions ?? []} limit={item.limit ?? null} - register={register(item.id)} + register={register(item.id, { + disabled, + })} + disabled={disabled} files={files} setFiles={setFiles} setErrorState={setFileErrors} diff --git a/src/app/forms/[form_id]/page.tsx b/src/app/forms/[form_id]/page.tsx index b96855c8..908f7da2 100644 --- a/src/app/forms/[form_id]/page.tsx +++ b/src/app/forms/[form_id]/page.tsx @@ -1,133 +1,57 @@ "use client"; import useSWR from "swr"; -import { SubmitHandler, useForm } from "react-hook-form"; import { FC, useState } from "react"; -import { useRouter } from "next/navigation"; -import { assignType, client } from "@/lib/openapi"; +import { assignType } from "@/lib/openapi"; import { css } from "@styled-system/css"; import dayjs from "dayjs"; -import toast from "react-hot-toast"; -import { FormItems, FileErrorsType, FormFieldsType, FilesFormType } from "./FormItems"; +import { FormFieldsType } from "./FormItems"; import { getTimeLeftText, getSubmitStatusFromDate } from "@/lib/formHelpers"; import { type SubmitStatus, SubmitStatusBadge } from "@/common_components/SubmitStatus"; import { Loading } from "@/common_components/Loading"; -import { Button } from "@/common_components/Button"; -import { deleteAllUploadedFiles, postFiles } from "@/lib/postFile"; -import { components } from "@/schema"; import { FileView } from "@/common_components/FileView"; +import { Separator } from "@/common_components/Separator"; +import { buttonStyle } from "@/recipes/button"; +import { Form } from "./Form"; export const runtime = "edge"; const FormDetailPage = ({ params }: { params: { form_id: string } }) => { - const router = useRouter(); const id = params.form_id; - const { data: projectRes, error: projectError, isLoading: projectLoading } = useSWR("/projects/me"); - const project = assignType("/projects/me", projectRes); - - const projectId = project?.id; - const { data: formRes, error: formError, isLoading: formLoading } = useSWR(`/forms/${id}`); - const form = assignType("/forms/{form_id}", formRes); + const form = formRes ? assignType("/forms/{form_id}", formRes) : undefined; const { - data: answersRes, - error: answersError, - isLoading: answersLoading, - } = useSWR(`/form-answers?project_id=${projectId}`); - const _answers = assignType("/form-answers", answersRes); + data: answerRes, + error: answerError, + isLoading: answerLoading, + } = useSWR(form?.answer_id ? `/form-answers/${form.answer_id}` : null); + const answer = assignType("/form-answers/{form_answer_id}", answerRes); + const answerItems: FormFieldsType | undefined = + !answerLoading && answer + ? Object.fromEntries( + answer.items.map((item) => { + switch (item.type) { + case "string": + return [item.item_id, item.value ? (item.value as string) : null]; + case "int": + return [item.item_id, item.value ? String(item.value) : null]; + case "choose_one": + return [item.item_id, item.value ? (item.value as string) : null]; + case "choose_many": + return [item.item_id, item.value ? JSON.stringify(item.value) : null]; + case "file": + return [item.item_id, item.value ? (item.value as string[]) : null]; + } + }), + ) + : undefined; const status: SubmitStatus = getSubmitStatusFromDate(form?.ends_at, form?.answered_at); - const onSubmit: SubmitHandler = async (data) => { - if (Array.from(fileErrors).some((v) => v[1])) { - toast.error(`正しいファイルをアップロードしてください`); - return; - } - - const fileIds = await postFiles("private", files); - if (!fileIds) { - toast.error("ファイルのアップロード中にエラーが発生しました"); - return; - } - - type formAnswerItems = components["schemas"]["CreateFormAnswer"]["items"]; - const items: formAnswerItems = form.items.flatMap((item): formAnswerItems[number] | [] => { - const value = (() => { - switch (item.type) { - case "string": - return data[item.id] || null; - case "int": - const datum = data[item.id]; - return datum ? parseInt(datum ?? "") : null; - case "file": - return fileIds[item.id] || null; - case "choose_many": - const options = JSON.parse(String(data[item.id] ?? "[]")) as string[]; - return options.length ? options : null; - default: - return data[item.id] || null; - } - })(); - - return value - ? { - item_id: item.id, - type: item.type, - value: value, - } - : []; - }); - - client - .POST("/form-answers", { - body: { - form_id: form.id, - items: items, - }, - }) - .then(async ({ error }) => { - if (error) { - toast.error(`申請の送信に失敗しました`); - await deleteAllUploadedFiles(fileIds); - return; - } - toast.success("申請の送信に成功しました"); - router.push("/forms"); - }) - .catch(async () => { - toast.error(`申請の送信中にエラーが発生しました`); - await deleteAllUploadedFiles(fileIds); - }); - }; - - const { - register, - getValues, - setValue, - handleSubmit, - formState: { errors }, - } = useForm({ mode: "onBlur" }); - - const fileFormErrors: FileErrorsType = new Map( - form?.items - .filter((item) => { - item.type === "file"; - }) - .map((item) => [item.id, null]), - ); - const [fileErrors, setFileErrors] = useState(fileFormErrors); - - const filesState: FilesFormType = new Map( - form?.items - .filter((item) => { - item.type === "file"; - }) - .map((item) => [item.id, null]), - ); - const [files, setFiles] = useState(filesState); + const [editable, setEdiatable] = useState(false); return ( <> @@ -140,25 +64,39 @@ const FormDetailPage = ({ params }: { params: { form_id: string } }) => { maxWidth: "2xl", marginInline: "auto", })}> - {projectLoading || formLoading || answersLoading ? ( + {formLoading || answerLoading ? ( - ) : projectError || formError || answersError ? ( + ) : formError || answerError || !form ? (

申請の取得中にエラーが発生しました( - {(projectError ? `Project: ${projectError.message} ` : "") + - (formError ? `Forms: ${formError.message} ` : "") + - (answersError ? `Answers: ${answersError.message}` : "")} + {[ + formError ? `Forms: ${formError.message}` : "", + answerError ? `Answers: ${answerError.message}` : "", + ].join(" ")} )

) : ( <> -

{form.title}

-

- - {dayjs(form.ends_at).format("YYYY/MM/DD")} ({getTimeLeftText(dayjs(), dayjs(form.ends_at), status)}) - - -

+
+

+ + {form && dayjs(form.ends_at).format("YYYY/MM/DD")} ( + {form && getTimeLeftText(dayjs(), dayjs(form.ends_at), status)}) + + +

+ {answerItems && dayjs().isBefore(dayjs(form.ends_at)) && !editable && ( + + )} +
+

{form?.title}

{ ))} )} -

- - - + + +
)} diff --git a/src/common_components/FileView.tsx b/src/common_components/FileView.tsx index 1dc71f5a..f207f699 100644 --- a/src/common_components/FileView.tsx +++ b/src/common_components/FileView.tsx @@ -40,36 +40,38 @@ export const FileView = (props: Props) => { return (
{props.name} - {props.link !== undefined && } - {props.delete !== undefined && ( - - )} + height: 6, + width: 6, + cursor: "pointer", + })}> + 削除 + + )} +
); }; type DownloadButtonProps = { link: string }; const DownloadBuutton: FC = ({ link }) => ( - + ダウンロード; setValue: UseFormSetValue; @@ -44,6 +45,8 @@ export const CheckboxField: FC = (props: Props) => { value={option} id={`${props.id}-${index}`} className={checkboxFormStyle} + checked={props.disabled ? JSON.parse(props.getValues(props.id) ?? "[]").includes(option) : undefined} + disabled={props.disabled} onChange={(e) => { let checks = JSON.parse(String(props.getValues(props.id) ?? "[]")) as string[]; if (checks.includes(option)) { @@ -58,12 +61,21 @@ export const CheckboxField: FC = (props: Props) => { /> diff --git a/src/common_components/formFields/Files.tsx b/src/common_components/formFields/Files.tsx index 0589fe58..d8cf9919 100644 --- a/src/common_components/formFields/Files.tsx +++ b/src/common_components/formFields/Files.tsx @@ -7,13 +7,14 @@ import { basicFieldProps } from "./_components/types"; import { basicErrorMessageStyle, basicFormLabelStyle } from "./styles"; import { RequiredBadge } from "./_components/RequiredBadge"; -import { FileView } from "@/common_components/FileView"; import clickIcon from "@/assets/Click.svg?url"; import driveIcon from "@/assets/Drive.svg?url"; import { FileErrorsType, FilesFormType } from "@/app/forms/[form_id]/FormItems"; +import { File } from "./_components/File"; interface Props extends basicFieldProps { + disabled?: boolean; extensions?: string[]; limit?: number | null; files?: FilesFormType; @@ -29,7 +30,9 @@ export const FilesField = (props: Props) => { const filesDOM = useRef(null); const [isDragged, setIsDragged] = useState(false); - //const files = props.files + if (filesDOM.current) { + filesDOM.current.files = props.files?.get(props.id) ?? null; + } const setFiles = props.setFiles; const [errorMessage, setErrorMessage] = useState(null); @@ -70,7 +73,7 @@ export const FilesField = (props: Props) => { setFiles((prev) => prev.set(props.id, newFile)); } }; - const [updateFile, setUpdateFile] = useState(false); + const [_, setUpdateFile] = useState(false); const addFile = (oldFiles: FileList, newFiles: FileList) => { setFileIds(fileIds.concat([...Array(newFiles.length)].map((_, i) => i + maxFiles))); @@ -117,6 +120,11 @@ export const FilesField = (props: Props) => { backgroundColor: "gray.300", }, }, + isDisabled: { + true: { + display: "none", + }, + }, }, }); @@ -148,7 +156,7 @@ export const FilesField = (props: Props) => { getFiles(e); validateFiles(); }} - className={dropAreaStyle({ isDragged })}> + className={dropAreaStyle({ isDragged, isDisabled: props.disabled })}>