diff --git a/biome.json b/biome.json index ff3fe524..3ff85b17 100644 --- a/biome.json +++ b/biome.json @@ -2,7 +2,8 @@ "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", "formatter": { "indentStyle": "space", - "indentWidth": 2 + "indentWidth": 2, + "lineWidth": 80 }, "linter": { "rules": { diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index bb9cffcf..00000000 Binary files a/bun.lockb and /dev/null differ diff --git a/common/types.ts b/common/types.ts index dc705e6c..51cb39e5 100644 --- a/common/types.ts +++ b/common/types.ts @@ -14,6 +14,7 @@ export type { RelationshipID, Relationship, CourseID, + InterestSubjectID, Slot, Course, Enrollment, diff --git a/common/zod/schemas.ts b/common/zod/schemas.ts index 432ab50d..7a25805c 100644 --- a/common/zod/schemas.ts +++ b/common/zod/schemas.ts @@ -32,17 +32,6 @@ export const IntroLongSchema = z // .min(2, { message: "自己紹介文は2文字以上です" }) .max(225, { message: "自己紹介文は225文字以下です" }); -export const InterestSubjectSchema = z.object({ - id: z.number(), - name: z.string(), - group: z.string(), -}); - -export const InterestSchema = z.object({ - userId: UserIDSchema, - subjectId: z.number(), -}); - export const UserSchema = z.object({ id: UserIDSchema, guid: GUIDSchema, @@ -76,6 +65,8 @@ export const RelationshipSchema = z.object({ export const CourseIDSchema = z.string(); +export const InterestSubjectIDSchema = z.number(); + export const DaySchema = z.enum([ "mon", "tue", @@ -108,6 +99,17 @@ export const EnrollmentSchema = z.object({ courseId: CourseIDSchema, }); +export const InterestSubjectSchema = z.object({ + id: InterestSubjectIDSchema, + name: z.string(), + group: z.string(), +}); + +export const InterestSchema = z.object({ + userId: UserIDSchema, + subjectId: InterestSubjectIDSchema, +}); + export const UserWithCoursesAndSubjectsSchema = UserSchema.extend({ courses: CourseSchema.array(), interestSubjects: InterestSubjectSchema.array(), diff --git a/common/zod/types.ts b/common/zod/types.ts index 68714005..cb195e8b 100644 --- a/common/zod/types.ts +++ b/common/zod/types.ts @@ -15,6 +15,7 @@ import type { InitSharedRoomSchema, InitUserSchema, InterestSchema, + InterestSubjectIDSchema, InterestSubjectSchema, IntroLongSchema, IntroShortSchema, @@ -57,6 +58,7 @@ export type Step1User = z.infer; export type RelationshipID = z.infer; export type Relationship = z.infer; export type CourseID = z.infer; +export type InterestSubjectID = z.infer; export type Slot = z.infer; export type Course = z.infer; export type Enrollment = z.infer; diff --git a/flake.nix b/flake.nix index 50ed1c3d..85e717d4 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,8 @@ export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"; export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"; export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"; + + export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib ''; }; in diff --git a/package.json b/package.json index ff559c83..32793096 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,16 @@ "license": "ISC", "devDependencies": { "@biomejs/biome": "^1.9.1", + "husky": "^9.1.4", + "lint-staged": "^15.2.10" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "react-hook-form": "^7.53.2", "@types/bun": "^1.1.10", "cspell": "^8.14.4", "typescript": "^5.6.2", - "lefthook": "^1.8.2" - }, - "dependencies": { + "lefthook": "^1.8.2", "zod": "^3.23.8" }, "trustedDependencies": ["@biomejs/biome", "lefthook"] diff --git a/server/src/database/interest.ts b/server/src/database/interest.ts index 6b87edea..e1e15b0d 100644 --- a/server/src/database/interest.ts +++ b/server/src/database/interest.ts @@ -9,6 +9,21 @@ export async function get(id: number): Promise { return await prisma.interestSubject.findUnique({ where: { id } }); } +export async function create(name: string): Promise { + const existingTag = await prisma.interestSubject.findMany({ + where: { name }, + }); + if (existingTag.length > 0) { + throw new Error("同名のタグがすでに存在します"); + } + return await prisma.interestSubject.create({ + data: { + name, + group: "", // TODO: 運用次第 + }, + }); +} + export async function of(userId: UserID): Promise { return await prisma.interest .findMany({ @@ -37,3 +52,23 @@ export async function remove(userId: UserID, subjectId: number) { }, }); } + +export async function updateMultipleWithTransaction( + userId: UserID, + subjectIds: number[], +) { + return await prisma.$transaction(async (prisma) => { + await prisma.interest.deleteMany({ + where: { + userId, + }, + }); + + await prisma.interest.createMany({ + data: subjectIds.map((subjectId) => ({ + userId, + subjectId, + })), + }); + }); +} diff --git a/server/src/index.ts b/server/src/index.ts index c7b7878a..0643cf3e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,6 +9,7 @@ import coursesRoutes from "./router/courses"; import matchesRoutes from "./router/matches"; import pictureRoutes from "./router/picture"; import requestsRoutes from "./router/requests"; +import subjectsRoutes from "./router/subjects"; import usersRoutes from "./router/users"; const app = express(); @@ -52,6 +53,7 @@ app.get("/", (_, res) => { app.use("/picture", pictureRoutes); app.use("/users", usersRoutes); app.use("/courses", coursesRoutes); +app.use("/subjects", subjectsRoutes); app.use("/requests", requestsRoutes); app.use("/matches", matchesRoutes); app.use("/chat", chatRoutes); diff --git a/server/src/router/subjects.ts b/server/src/router/subjects.ts new file mode 100644 index 00000000..6a7eef0c --- /dev/null +++ b/server/src/router/subjects.ts @@ -0,0 +1,131 @@ +import express, { type Request, type Response } from "express"; +import * as interest from "../database/interest"; +import { safeGetUserId } from "../firebase/auth/db"; + +const router = express.Router(); + +router.get("/userId/:userId", async (req: Request, res: Response) => { + const userId = Number.parseInt(req.params.userId); + if (Number.isNaN(userId)) { + return res.status(400).json({ error: "Invalid userId" }); + } + try { + const subjects = await interest.of(userId); + res.status(200).json(subjects); + } catch (error) { + console.error("Error fetching subjects by userId:", error); + res.status(500).json({ error: "Failed to fetch subjects by userId" }); + } +}); + +router.get("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + try { + const subjects = await interest.of(userId.value); + res.status(200).json(subjects); + } catch (error) { + console.error("Error fetching subjects:", error); + res.status(500).json({ error: "Failed to fetch subjects" }); + } +}); + +router.post("/", async (req: Request, res: Response) => { + const { name } = req.body; + if (typeof name !== "string") { + return res.status(400).json({ error: "name must be a string" }); + } + try { + const newSubject = await interest.create(name); + res.status(201).json(newSubject); + } catch (error) { + console.error("Error creating subject:", error); + res.status(500).json({ error: "Failed to create subject" }); + } +}); + +router.patch("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + const { subjectId } = req.body; + try { + const newSubject = await interest.get(subjectId); + if (!newSubject) { + return res.status(404).json({ error: "Subject not found" }); + } + } catch (err) { + console.error("Error fetching subject:", err); + res.status(500).json({ error: "Failed to fetch subject" }); + } + try { + const updatedSubjects = await interest.add(userId.value, subjectId); + res.status(200).json(updatedSubjects); + } catch (error) { + console.error("Error updating subjects:", error); + res.status(500).json({ error: "Failed to update subjects" }); + } +}); + +router.delete("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + const { subjectId } = req.body; + try { + const newSubject = await interest.get(subjectId); + if (!newSubject) { + return res.status(404).json({ error: "Subject not found" }); + } + } catch (err) { + console.error("Error fetching subject:", err); + res.status(500).json({ error: "Failed to fetch subject" }); + } + try { + const updatedSubjects = await interest.remove(userId.value, subjectId); + res.status(200).json(updatedSubjects); + } catch (error) { + console.error("Error deleting subjects:", error); + res.status(500).json({ error: "Failed to delete subjects" }); + } +}); + +router.put("/mine", async (req: Request, res: Response) => { + const userId = await safeGetUserId(req); + if (!userId.ok) return res.status(401).send("auth error"); + const { subjectIds } = req.body; + if (!Array.isArray(subjectIds)) { + return res.status(400).json({ error: "subjectIds must be an array" }); + } + try { + const newSubjects = await Promise.all( + subjectIds.map((id) => interest.get(id)), + ); + if (newSubjects.some((s) => !s)) { + return res.status(404).json({ error: "Subject not found" }); + } + } catch (err) { + console.error("Error fetching subjects:", err); + res.status(500).json({ error: "Failed to fetch subjects" }); + } + try { + const updatedSubjects = await interest.updateMultipleWithTransaction( + userId.value, + subjectIds, + ); + res.status(200).json(updatedSubjects); + } catch (error) { + console.error("Error updating subjects:", error); + res.status(500).json({ error: "Failed to update subjects" }); + } +}); + +router.get("/all", async (req: Request, res: Response) => { + try { + const subjects = await interest.all(); + res.status(200).json(subjects); + } catch (error) { + console.error("Error fetching subjects:", error); + res.status(500).json({ error: "Failed to fetch subjects" }); + } +}); + +export default router; diff --git a/server/src/seeds/seed-test.ts b/server/src/seeds/seed-test.ts index 23b54a51..ac5b96f9 100644 --- a/server/src/seeds/seed-test.ts +++ b/server/src/seeds/seed-test.ts @@ -11,11 +11,12 @@ import { async function main() { await Promise.all( subjects.map(async ({ group, subjects }) => { - for (const [id, name] of subjects) { - await prisma.interestSubject.upsert({ - where: { id }, - update: { name, group }, - create: { id, name, group }, + for (const [name] of subjects) { + await prisma.interestSubject.create({ + data: { + name, + group, + }, }); } }), diff --git a/server/src/seeds/test-data/data.ts b/server/src/seeds/test-data/data.ts index 55dd14f1..9a97ef9b 100644 --- a/server/src/seeds/test-data/data.ts +++ b/server/src/seeds/test-data/data.ts @@ -2,20 +2,20 @@ import type { Day } from "common/types"; export const subjects: Array<{ group: string; - subjects: Array<[number, string]>; + subjects: Array<[string]>; }> = [ { group: "Computer Science", subjects: [ - [1, "型システム"], - [2, "機械学習"], - [3, "CPU アーキテクチャ"], - [4, "分散処理"], + ["型システム"], + ["機械学習"], + ["CPU アーキテクチャ"], + ["分散処理"], ] as const, }, { group: "Math", - subjects: [[5, "Lean4"]], + subjects: [["Lean4"]], }, ]; export const interest = [ diff --git a/web/api/internal/endpoints.ts b/web/api/internal/endpoints.ts index d10d611e..30c94764 100644 --- a/web/api/internal/endpoints.ts +++ b/web/api/internal/endpoints.ts @@ -236,6 +236,73 @@ export const coursesDayPeriod = (day: Day, period: number) => { return `${API_ENDPOINT}/courses/day-period?day=${day}&period=${period}`; }; +/** + * [v] 実装済み + * POST -> create a new subject. + * - statuses: + * - 200: ok. + * - body: InterestSubject + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjects = `${API_ENDPOINT}/subjects`; + +/** + * [v] 実装済み + * GET -> get subjects the user is interested in. + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjectsUserId = (userId: UserID) => { + return `${API_ENDPOINT}/subjects/userId/${userId}`; +}; + +/** + * [v] 実装済み + * GET -> get my subjects. + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + * PATCH -> update my subjects. + * - request body: SubjectId + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + * DELETE -> delete my subjects. + * - request body: SubjectId + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + * PUT → replace my subjects. + * - request body: SubjectId[] + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjectsMine = `${API_ENDPOINT}/subjects/mine`; + +/** + * [v] 実装済み + * GET -> get all subjects. + * - statuses: + * - 200: ok. + * - body: InterestSubject[] + * - 401: unauthorized. + * - 500: internal error. + */ +export const subjectsAll = `${API_ENDPOINT}/subjects/all`; + /** * [v] 実装済み * PUT -> create request. @@ -400,6 +467,10 @@ export default { cancelRequest, coursesUserId, coursesDayPeriod, + subjects, + subjectsUserId, + subjectsMine, + subjectsAll, roomOverview, dmTo, dmWith, diff --git a/web/api/subject.ts b/web/api/subject.ts new file mode 100644 index 00000000..8b49764f --- /dev/null +++ b/web/api/subject.ts @@ -0,0 +1,49 @@ +import type { + InterestSubject, + InterestSubjectID, + UserID, +} from "common/types.ts"; +import { InterestSubjectSchema } from "common/zod/schemas.ts"; +import { z } from "zod"; +import { credFetch } from "../firebase/auth/lib.ts"; +import { type Hook, useCustomizedSWR } from "../hooks/useCustomizedSWR.ts"; +import endpoints from "./internal/endpoints.ts"; + +const InterestSubjectListSchema = z.array(InterestSubjectSchema); + +// 興味分野を作成する +export async function create(name: string) { + return await credFetch("POST", endpoints.subjects, { name }); +} + +// 自身の興味分野を取得する +export function useMyInterests(): Hook { + return useCustomizedSWR( + "interests::mine", + getMySubjects, + InterestSubjectListSchema, + ); +} + +async function getMySubjects(): Promise { + const res = await credFetch("GET", endpoints.subjectsMine); + return res.json(); +} + +// 自身の興味分野を更新する +export async function update(newSubjectIds: InterestSubjectID[]) { + const url = endpoints.subjectsMine; + return await credFetch("PUT", url, { subjectIds: newSubjectIds }); +} + +// 指定した userId のユーザの興味分野を取得 +export async function get(id: UserID): Promise { + const res = await credFetch("GET", endpoints.subjectsUserId(id)); + return await res.json(); +} + +// すべての興味分野を取得 +export async function getAll(): Promise { + const res = await credFetch("GET", endpoints.subjectsAll); + return await res.json(); +} diff --git a/web/app/edit/interests/page.tsx b/web/app/edit/interests/page.tsx new file mode 100644 index 00000000..4e1f967b --- /dev/null +++ b/web/app/edit/interests/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import type { InterestSubject } from "common/types"; +import { useRouter } from "next/navigation"; +import { enqueueSnackbar } from "notistack"; +import { useEffect, useState } from "react"; +import { MdAdd, MdClose } from "react-icons/md"; +import FullScreenCircularProgress from "~/components/common/FullScreenCircularProgress"; +import { useAlert } from "~/components/common/alert/AlertProvider"; +import * as subject from "../../../api/subject"; + +export default function EditInterest() { + const { state } = subject.useMyInterests(); + const data = state.data; + const error = state.current === "error" ? state.error : null; + const loading = state.current === "loading"; + + const router = useRouter(); + const { showAlert } = useAlert(); + + const [allSubjects, setAllSubjects] = useState([]); + const [filteredSubjects, setFilteredSubjects] = useState( + [], + ); + const [draftSubjects, setDraftSubjects] = useState( + data ?? [], + ); + const [isOpen, setIsOpen] = useState(false); + const [newSubjectName, setNewSubjectName] = useState(""); + + useEffect(() => { + getSubjects(); + }, []); + + useEffect(() => { + setDraftSubjects(data ?? []); + }, [data]); + + async function getSubjects() { + const subjects = await subject.getAll(); + setAllSubjects(subjects); + setFilteredSubjects(subjects); + } + + async function updateInterests(data: { + interestSubjects: InterestSubject[]; + }) { + const ids = data.interestSubjects.map((d) => d.id); + const result = await subject.update(ids); + if (!result.ok) { + enqueueSnackbar("興味分野の保存に失敗しました", { variant: "error" }); + } else { + enqueueSnackbar("興味分野を保存しました", { variant: "success" }); + } + } + + async function createSubject(name: string) { + const result = await subject.create(name); + if (!result.ok) { + enqueueSnackbar("興味分野の作成に失敗しました", { variant: "error" }); + } else { + enqueueSnackbar("興味分野を作成しました", { variant: "success" }); + } + } + + function handleBack() { + // TODO: 差分がないときは確認なしで戻る + showAlert({ + AlertMessage: "変更がある場合は、破棄されます。", + subAlertMessage: "本当にページを移動しますか?", + yesMessage: "移動", + clickYes: () => { + router.push("/settings/profile"); + }, + }); + } + + return loading ? ( + + ) : error ? ( +

Error: {error.message}

+ ) : !data ? ( +

データがありません。

+ ) : ( + <> +
+
+
+
+ {draftSubjects.map((subject, index) => ( + + #{subject.name} + + + ))} +
+
+ { + const newFilteredSubjects = allSubjects.filter((subject) => + subject.name.includes(e.target.value.trim()), + ); + setFilteredSubjects(newFilteredSubjects); + }} + placeholder="興味分野タグを絞り込み" + className="input input-bordered w-full" + /> +
+
    + {filteredSubjects.length !== 0 ? ( + filteredSubjects + .filter( + (subject) => + !draftSubjects.some((draft) => draft.id === subject.id), + ) + .map((subject) => ( +
  • + +
  • + )) + ) : ( +
  • + 検索結果がありません +
  • + )} +
  • + +
  • +
+
+
+ + +
+
+
+ {isOpen && ( + setIsOpen(false)} + > +
+

興味分野タグの作成

+ setNewSubjectName(e.target.value)} + placeholder="タグ名を入力" + /> + {newSubjectName && ( +

+ 興味分野タグ{" "} + #{newSubjectName}{" "} + を作成します +

+ )} +
+
+
+ + +
+
+
+
+
+ )} + + ); +} diff --git a/web/app/edit/layout.tsx b/web/app/edit/layout.tsx index be75caf1..93035146 100644 --- a/web/app/edit/layout.tsx +++ b/web/app/edit/layout.tsx @@ -10,7 +10,7 @@ export default function EditPageLayout({
-
{children}
+
{children}
); diff --git a/web/app/edit/profile/page.tsx b/web/app/edit/profile/page.tsx index b811c00b..59851bd1 100644 --- a/web/app/edit/profile/page.tsx +++ b/web/app/edit/profile/page.tsx @@ -1,22 +1,12 @@ "use client"; -import EditIcon from "@mui/icons-material/Edit"; -import { - Box, - FormControl, - IconButton, - InputLabel, - MenuItem, - Select, - TextField, - Typography, -} from "@mui/material"; -import type { SelectChangeEvent } from "@mui/material"; -import type { UpdateUser } from "common/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { User } from "common/types"; import { UpdateUserSchema } from "common/zod/schemas"; import { useRouter } from "next/navigation"; import { enqueueSnackbar } from "notistack"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; import { update, useAboutMe } from "~/api/user"; import { facultiesAndDepartments } from "~/app/signup/data"; import FullScreenCircularProgress from "~/components/common/FullScreenCircularProgress"; @@ -25,100 +15,58 @@ import PhotoModal from "~/components/config/PhotoModal"; import { PhotoPreviewButton } from "~/components/config/PhotoPreview"; import UserAvatar from "~/components/human/avatar"; -export default function EditProfile() { - const router = useRouter(); - const { showAlert } = useAlert(); +const faculties = Object.keys(facultiesAndDepartments); + +export default function App() { const { state } = useAboutMe(); const data = state.data; const error = state.current === "error" ? state.error : null; const loading = state.current === "loading"; + return loading ? ( + + ) : error ? ( +

Error: {error.message}

+ ) : data ? ( + + ) : ( +

データがありません。

+ ); +} - const [name, setName] = useState(""); - const [gender, setGender] = useState(""); - const [grade, setGrade] = useState(""); - const [faculty, setFaculty] = useState(""); - const [department, setDepartment] = useState(""); - const [intro, setIntro] = useState(""); - const [pictureUrl, setPictureUrl] = useState(""); - const [tmpName, setTmpName] = useState(""); - const [tmpGender, setTmpGender] = useState(""); - const [tmpGrade, setTmpGrade] = useState(""); - const [tmpFaculty, setTmpFaculty] = useState(""); - const [tmpDepartment, setTmpDepartment] = useState(""); - const [tmpIntro, setTmpIntro] = useState(""); - - const [isEditingName, setIsEditingName] = useState(false); - const [isEditingGender, setIsEditingGender] = useState(false); - const [isEditingGrade, setIsEditingGrade] = useState(false); - const [isEditingFaculty, setIsEditingFaculty] = useState(false); - const [isEditingDepartment, setIsEditingDepartment] = useState(false); - const [isEditingIntro, setIsEditingIntro] = useState(false); - - const [errorMessage, setErrorMessage] = useState(""); - - const [nameError, setNameError] = useState(""); - const [genderError, setGenderError] = useState(""); - const [gradeError, setGradeError] = useState(""); - const [facultyError, setFacultyError] = useState(""); - const [departmentError, setDepartmentError] = useState(""); - const [introError, setIntroError] = useState(""); - - useEffect(() => { - if (data) { - setName(data.name); - setGender(data.gender); - setGrade(data.grade); - setFaculty(data.faculty); - setDepartment(data.department); - setIntro(data.intro); - setPictureUrl(data.pictureUrl); - setTmpName(data.name); - setTmpGender(data.gender); - setTmpGrade(data.grade); - setTmpFaculty(data.faculty); - setTmpDepartment(data.department); - setTmpIntro(data.intro); - } - }, [data]); +function EditProfile({ defaultValues }: { defaultValues: User }) { + const router = useRouter(); + const { showAlert } = useAlert(); + const { + register, + handleSubmit, + getValues, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: defaultValues, + reValidateMode: "onChange", + resolver: zodResolver(UpdateUserSchema), + }); + async function submit(data: User) { + await update(data); + } function afterPhotoUpload(result: string) { try { - setPictureUrl(result); - handleSave({ pictureUrl: result }); + setValue("pictureUrl", result); } catch (err) { console.error(err); // probably a network error - onPhotoError(new Error("画像の更新に失敗しました")); + enqueueSnackbar({ + message: "画像の更新に失敗しました", + }); } } - function onPhotoError(err: Error) { - enqueueSnackbar({ - message: err?.message ?? "画像の更新に失敗しました", - }); - } const [open, setOpen] = useState(false); - function hasUnsavedChangesOrErrors() { - return ( - isEditingName || - isEditingGender || - isEditingGrade || - isEditingFaculty || - isEditingDepartment || - isEditingIntro || - errorMessage || - nameError || - genderError || - gradeError || - facultyError || - departmentError || - introError - ); - } - function handleGoToCourses() { - if (hasUnsavedChangesOrErrors()) { + if (Math.random() < 1 /* TODO: has errors or unsaved */) { showAlert({ AlertMessage: "まだ編集中のフィールド、もしくはエラーがあります", subAlertMessage: "本当にページを移動しますか?変更は破棄されます", @@ -133,7 +81,7 @@ export default function EditProfile() { } function handleBack() { - if (hasUnsavedChangesOrErrors()) { + if (Math.random() < 1 /* todo: has errors on unsaved */) { showAlert({ AlertMessage: "編集中のフィールド、もしくはエラーがあります。", subAlertMessage: "本当にページを移動しますか?変更は破棄されます", @@ -147,394 +95,149 @@ export default function EditProfile() { } } - async function handleSave(input: Partial) { - setErrorMessage(""); - setNameError(""); - setGenderError(""); - setGradeError(""); - setFacultyError(""); - setDepartmentError(""); - setIntroError(""); - const data: UpdateUser = { - name: (input.name ?? name).trim(), - gender: input.gender ?? gender, - grade: input.grade ?? grade, - faculty: input.faculty ?? faculty, - department: input.department ?? department, - intro: (input.intro ?? intro).trim(), - pictureUrl: input.pictureUrl ?? pictureUrl, - }; - const result = UpdateUserSchema.safeParse(data); - if (!result.success) { - result.error.errors.map((err) => { - switch (err.path[0]) { - case "name": - setNameError(err.message); - break; - case "gender": - setGenderError(err.message); - break; - case "grade": - setGradeError(err.message); - break; - case "faculty": - setFacultyError(err.message); - break; - case "department": - setDepartmentError(err.message); - break; - case "intro": - setIntroError(err.message); - break; - default: - setErrorMessage("入力に誤りがあります"); - } - }); - return; - } - await update(data); - } - - function handleEdit(setter: React.Dispatch>) { - setTmpName(name); - setTmpGender(gender); - setTmpGrade(grade); - setTmpFaculty(faculty); - setTmpDepartment(department); - setTmpIntro(intro); - setIsEditingName(false); - setIsEditingGender(false); - setIsEditingGrade(false); - setIsEditingFaculty(false); - setIsEditingDepartment(false); - setIsEditingIntro(false); - setter(true); - } - - const handleFacultyChange = (event: SelectChangeEvent) => { - setTmpFaculty(event.target.value); - }; - - const handleDepartmentChange = (event: SelectChangeEvent) => { - setTmpDepartment(event.target.value); - }; - + const values = getValues(); return ( - - {loading ? ( - - ) : error ? ( -

Error: {error.message}

- ) : data ? ( - - - プロフィール編集 - - - - setTmpName(e.target.value)} - label="名前" - disabled={!isEditingName} - fullWidth - error={!!nameError} - helperText={nameError} - autoComplete="off" - /> - { - if (isEditingName) { - setName(tmpName); - handleSave({ name: tmpName }); - setIsEditingName(false); - } else { - handleEdit(setIsEditingName); - } - }} - > - {isEditingName ? ( - 保存 - ) : ( - - )} - - - - - - - 性別 - - {genderError && ( - - {genderError} - - )} - { - if (isEditingGender) { - setGender(tmpGender); - handleSave({ gender: tmpGender }); - setIsEditingGender(false); - } else { - handleEdit(setIsEditingGender); - } - }} - > - {isEditingGender ? ( - 保存 - ) : ( - - )} - - - - - - - 学年 - - {gradeError && ( - - {gradeError} - - )} - { - if (isEditingGrade) { - setGrade(tmpGrade); - handleSave({ grade: tmpGrade }); - setIsEditingGrade(false); - } else { - handleEdit(setIsEditingGrade); - } - }} - > - {isEditingGrade ? ( - 保存 - ) : ( - - )} - - - - - - - 学部 - - {facultyError && ( - - {facultyError} - - )} - - { - if (isEditingFaculty) { - setDepartment(""); - setFaculty(tmpFaculty); - handleSave({ faculty: tmpFaculty, department: "" }); - setIsEditingFaculty(false); - } else { - handleEdit(setIsEditingFaculty); - } - }} - > - {isEditingFaculty ? ( - 保存 - ) : ( - - )} - - - - - - - 学科 - - {departmentError && ( - - {departmentError} - - )} - - { - if (isEditingDepartment) { - setDepartment(tmpDepartment); - handleSave({ department: tmpDepartment }); - setIsEditingDepartment(false); - } else { - handleEdit(setIsEditingDepartment); - } - }} - > - {isEditingDepartment ? ( - 保存 - ) : ( - - )} - - - - - - - setTmpIntro(e.target.value)} - label="自己紹介" - disabled={!isEditingIntro} - fullWidth - autoComplete="off" - error={!!introError} // エラースタイル適用 - helperText={introError} // エラーメッセージを表示 - /> - - { - if (isEditingIntro) { - setIntro(tmpIntro); - handleSave({ intro: tmpIntro }); - setIsEditingIntro(false); - } else { - handleEdit(setIsEditingIntro); - } - }} - > - {isEditingIntro ? ( - 保存 - ) : ( - - )} - - - - {errorMessage && ( - - {errorMessage} - - )} - -
-
- - プロフィール画像 - -
-
+
+
+
+ + +
+
+ + + {errors.gender?.message} +
+
+ + + {errors.grade?.message} +
+
+ + + + {errors.faculty?.message} + +
+
+ + + + {errors.department?.message} + +
+
+ +