Commit 19c610e8 authored by k1350's avatar k1350
Browse files

[update] ブログ編集画面を react hook form に変更

parent 314d7508
......@@ -10,6 +10,7 @@
"dependencies": {
"@chakra-ui/icons": "^2.0.2",
"@chakra-ui/react": "^2.1.2",
"@chakra-ui/skip-nav": "^2.0.2",
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@fontsource/noto-sans-jp": "^4.5.10",
......@@ -23,6 +24,7 @@
"graphql": "^15.8.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-hook-form": "^7.33.0",
"react-query": "^3.39.1"
},
"devDependencies": {
......@@ -1470,6 +1472,29 @@
"react": ">=18"
}
},
"node_modules/@chakra-ui/skip-nav": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@chakra-ui/skip-nav/-/skip-nav-2.0.2.tgz",
"integrity": "sha512-MbcRNcs0CYD4EUb+0xZkW8mpjGA7/z3m520LhpjdMUz2own5v9PymeaUaBp6u3uoK36X+vMp1KYlXhy71PSePg==",
"dependencies": {
"@chakra-ui/utils": "2.0.2"
},
"peerDependencies": {
"@chakra-ui/system": ">=2.0.0-next.0",
"react": ">=18"
}
},
"node_modules/@chakra-ui/skip-nav/node_modules/@chakra-ui/utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@chakra-ui/utils/-/utils-2.0.2.tgz",
"integrity": "sha512-9AC/ir9zm0shgFG7kdzOKUH2Wx5VB71M3uRMEsMZf75YlhhiU7AvBNtWXnJu+CBiTi41rKa5A+2ImMOsuPfGbA==",
"dependencies": {
"@types/lodash.mergewith": "4.6.6",
"css-box-model": "1.2.1",
"framesync": "5.3.0",
"lodash.mergewith": "4.6.2"
}
},
"node_modules/@chakra-ui/slider": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@chakra-ui/slider/-/slider-2.0.1.tgz",
......@@ -6582,6 +6607,21 @@
}
}
},
"node_modules/react-hook-form": {
"version": "7.33.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.33.0.tgz",
"integrity": "sha512-h8XoeUHQs1Snx1s/sSvM+eVTSKkWQt8TcrbL+3/Rt5gugxpy4ueL5ZZkubffyNpUyyTz0qM0kwOi2c+JgGTjLA==",
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
......@@ -8826,6 +8866,27 @@
"@chakra-ui/utils": "2.0.1"
}
},
"@chakra-ui/skip-nav": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@chakra-ui/skip-nav/-/skip-nav-2.0.2.tgz",
"integrity": "sha512-MbcRNcs0CYD4EUb+0xZkW8mpjGA7/z3m520LhpjdMUz2own5v9PymeaUaBp6u3uoK36X+vMp1KYlXhy71PSePg==",
"requires": {
"@chakra-ui/utils": "2.0.2"
},
"dependencies": {
"@chakra-ui/utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@chakra-ui/utils/-/utils-2.0.2.tgz",
"integrity": "sha512-9AC/ir9zm0shgFG7kdzOKUH2Wx5VB71M3uRMEsMZf75YlhhiU7AvBNtWXnJu+CBiTi41rKa5A+2ImMOsuPfGbA==",
"requires": {
"@types/lodash.mergewith": "4.6.6",
"css-box-model": "1.2.1",
"framesync": "5.3.0",
"lodash.mergewith": "4.6.2"
}
}
}
},
"@chakra-ui/slider": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@chakra-ui/slider/-/slider-2.0.1.tgz",
......@@ -12608,6 +12669,12 @@
"use-sidecar": "^1.1.2"
}
},
"react-hook-form": {
"version": "7.33.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.33.0.tgz",
"integrity": "sha512-h8XoeUHQs1Snx1s/sSvM+eVTSKkWQt8TcrbL+3/Rt5gugxpy4ueL5ZZkubffyNpUyyTz0qM0kwOi2c+JgGTjLA==",
"requires": {}
},
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
......
......@@ -11,6 +11,7 @@
"dependencies": {
"@chakra-ui/icons": "^2.0.2",
"@chakra-ui/react": "^2.1.2",
"@chakra-ui/skip-nav": "^2.0.2",
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@fontsource/noto-sans-jp": "^4.5.10",
......@@ -24,6 +25,7 @@
"graphql": "^15.8.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-hook-form": "^7.33.0",
"react-query": "^3.39.1"
},
"devDependencies": {
......
export const CONSTANT = {
PUBLIC_STATUS: {
PUBLIC: "1",
PASSWORD: "2",
PRIVATE: "99",
},
MAX_LINKS: 3,
ERROR_MESSAGE: {
REQUIRED: "必須入力です",
MIN_LENGTH: (min: number) => {
return `${min} 文字以上で入力してください`;
},
MAX_LENGTH: (max: number) => {
return `${max} 文字以内で入力してください`;
},
PASSWORD: "使用できない文字が含まれています",
LINK_URL_REQUIRED: "リンクタイトルを入力した場合は必須入力です",
LINK_NAME_REQUIRED: "URL を入力した場合は必須入力です",
},
};
......@@ -9,6 +9,7 @@ import {
Grid,
Button,
Center,
useMediaQuery,
} from "@chakra-ui/react";
import { useGetBlogQuery } from "../api/generated";
import dayjs from "dayjs";
......@@ -16,6 +17,7 @@ import { LinkText } from "../ui/LinkText";
import { CloseIcon, EditIcon } from "@chakra-ui/icons";
export function BlogDetail() {
const [isLargerThan600] = useMediaQuery("(min-width: 600px)");
const { blogId } = useMatch().params;
const { status, data, error, isFetching } = useGetBlogQuery({ id: blogId });
......@@ -35,8 +37,14 @@ export function BlogDetail() {
<Spinner size="xl" />
) : (
<Box>
<Grid templateColumns="max-content 1fr" gap={1} mb={2}>
<Text mr={2}>URL:</Text>
<Grid
templateColumns={isLargerThan600 ? "max-content 1fr" : "1fr"}
gap={1}
mb={2}
>
<Text mr={2} fontWeight="bold">
URL
</Text>
<Text>
<Link
href={
......@@ -54,15 +62,25 @@ export function BlogDetail() {
/>
</Link>
</Text>
<Text mr={2}>著者名:</Text>
<Text mr={2} fontWeight="bold">
著者名
</Text>
<Text>{data?.blogById?.author}</Text>
<Text mr={2}>ブログ名:</Text>
<Text mr={2} fontWeight="bold">
ブログ名
</Text>
<Text>{data?.blogById?.name}</Text>
<Text mr={2}>ブログ説明:</Text>
<Text mr={2} fontWeight="bold">
ブログ説明
</Text>
<Text>{data?.blogById?.description}</Text>
<Text mr={2}>公開状態:</Text>
<Text mr={2} fontWeight="bold">
公開状態
</Text>
<Text>{data?.blogById?.publishOption}</Text>
<Text mr={2}>リンク: </Text>
<Text mr={2} fontWeight="bold">
リンク
</Text>
<Grid gap={2}>
{data?.blogById?.links?.map((link) => (
<Grid
......@@ -70,16 +88,22 @@ export function BlogDetail() {
templateColumns="max-content 1fr"
gap={1}
>
<Text mr={2}>リンクタイトル: </Text>
<Text mr={2} fontWeight="bold">
リンクタイトル
</Text>
<Text>{link?.name}</Text>
<Text mr={2}>URL: </Text>
<Text mr={2} fontWeight="bold">
URL
</Text>
<Link href={link?.url}>
<LinkText text={link?.url ?? ""} isExternal={true} />
</Link>
</Grid>
))}
</Grid>
<Text mr={2}>作成日時:</Text>
<Text mr={2} fontWeight="bold">
作成日時
</Text>
<Text>
<time dateTime={data?.blogById?.createdAt}>
{dayjs(data?.blogById?.createdAt).format(
......@@ -87,7 +111,9 @@ export function BlogDetail() {
)}
</time>
</Text>
<Text mr={2}>更新日時:</Text>
<Text mr={2} fontWeight="bold">
更新日時
</Text>
<Text>
<time dateTime={data?.blogById?.updatedAt}>
{dayjs(data?.blogById?.updatedAt).format(
......
import { useState, useEffect } from "react";
import { useMatch } from "@tanstack/react-location";
import { useForm } from "react-hook-form";
import {
FormErrorMessage,
FormLabel,
FormControl,
Heading,
Spinner,
ButtonGroup,
Link,
Box,
Text,
Grid,
......@@ -12,107 +15,206 @@ import {
Center,
Input,
Textarea,
Radio,
RadioGroup,
Stack,
FormHelperText,
Flex,
Select,
InputGroup,
InputRightElement,
Divider,
Checkbox,
useMediaQuery,
} from "@chakra-ui/react";
import { useGetBlogQuery } from "../api/generated";
import { LinkText } from "../ui/LinkText";
import { CloseIcon, CheckIcon } from "@chakra-ui/icons";
import { CheckIcon, CloseIcon, ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { CONSTANT } from "../constants";
export function BlogEdit() {
const [isLargerThan600] = useMediaQuery("(min-width: 600px)");
const gridTemplate = "7em 1fr";
const { blogId } = useMatch().params;
const { status, data, error, isFetching } = useGetBlogQuery({ id: blogId });
const [author, setAuthor] = useState("");
const handleAuthorChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setAuthor(event.target.value);
const [name, setName] = useState("");
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setName(event.target.value);
const [description, setDescription] = useState("");
const handleDescriptionChange = (
event: React.ChangeEvent<HTMLTextAreaElement>
) => setDescription(event.target.value);
const [publishOption, setPublishOption] = useState("");
const handlePublishOptionChange = (nextValue: string) =>
setPublishOption(nextValue);
const [links, setLinks] = useState(
new Map<number, { name: string; url: string }>()
);
const handleLinkChange = (
event: React.ChangeEvent<HTMLInputElement>,
id: number,
isName: boolean
) => {
if (isName) {
updateLinks(id, event.target.value);
return;
}
updateLinks(id, undefined, event.target.value);
};
const [isInitialized, setIsInitialized] = useState(false);
const {
handleSubmit,
register,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm({ mode: "all" });
const watchPublishOption = watch("publishOption");
const [showPasswordInput, setShowPasswordInput] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleClick = () => setShowPassword(!showPassword);
const [hidePassword, setHidePassword] = useState(true);
const handleHidePasswordCheck = (value: boolean) => setHidePassword(value);
const updateLinks = (id: number, name?: string, url?: string) => {
const prev = links.get(id);
if (prev) {
if (name !== undefined) {
links.set(id, { ...prev, name });
} else if (url !== undefined) {
links.set(id, { ...prev, url });
}
} else {
links.set(id, { name: name ?? "", url: url ?? "" });
}
setLinks(new Map<number, { name: string; url: string }>(links));
const onSubmit = (data: any) => {
console.log(data);
};
useEffect(() => {
if (status !== "success") {
return;
}
if (data?.blogById) {
setAuthor(data.blogById.author);
setName(data.blogById.name);
setDescription(data.blogById.description);
if (!isInitialized && data?.blogById) {
setValue("author", data.blogById.author);
setValue("name", data.blogById.name);
setValue("description", data.blogById.description);
switch (data.blogById.publishOption) {
case "PUBLIC":
setPublishOption("1");
setValue("publishOption", CONSTANT.PUBLIC_STATUS.PUBLIC);
break;
case "PASSWORD":
setPublishOption("2");
setValue("publishOption", CONSTANT.PUBLIC_STATUS.PASSWORD);
break;
default:
setPublishOption("99");
setValue("publishOption", CONSTANT.PUBLIC_STATUS.PRIVATE);
break;
}
for (let i = 0; i < 3; i++) {
updateLinks(
i,
data.blogById.links?.[i]?.name ?? "",
data.blogById.links?.[i]?.url ?? ""
);
for (let i = 0; i < CONSTANT.MAX_LINKS; i++) {
setValue(`links${i}_name`, data.blogById.links?.[i]?.name ?? "");
setValue(`links${i}_url`, data.blogById.links?.[i]?.url ?? "");
}
}
}, [status]);
if (!isInitialized) {
setIsInitialized(true);
}
if (watchPublishOption === CONSTANT.PUBLIC_STATUS.PASSWORD) {
setShowPasswordInput(true);
} else {
setShowPasswordInput(false);
}
}, [status, isInitialized, watchPublishOption]);
const passwordInput = () => {
if (showPasswordInput) {
return (
<Grid
templateColumns={isLargerThan600 ? gridTemplate : "1fr"}
gap={1}
mb={4}
>
<span></span>
<Checkbox
defaultChecked
onChange={(event) => handleHidePasswordCheck(event.target.checked)}
>
現在のパスワードと同じ
</Checkbox>
{hidePassword ? (
<></>
) : (
<>
<span></span>
<FormControl isInvalid={Boolean(errors.password)} mb={2}>
<FormLabel htmlFor="password">
<Flex>
<Text fontWeight="bold">パスワード</Text>
<Text color="red.600">*</Text>
</Flex>
</FormLabel>
<InputGroup size="md">
<Input
id="password"
pr="4.5rem"
type={showPassword ? "text" : "password"}
{...register("password", {
required: CONSTANT.ERROR_MESSAGE.REQUIRED,
pattern: {
value: /^[a-zA-Z\d!@#%\^&\*]+$/i,
message: CONSTANT.ERROR_MESSAGE.PASSWORD,
},
minLength: {
value: 8,
message: CONSTANT.ERROR_MESSAGE.MIN_LENGTH(8),
},
maxLength: {
value: 255,
message: CONSTANT.ERROR_MESSAGE.MAX_LENGTH(255),
},
})}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={handleClick}>
{showPassword ? <ViewOffIcon /> : <ViewIcon />}
</Button>
</InputRightElement>
</InputGroup>
<FormErrorMessage>
{errors.password && errors.password.message?.toString()}
</FormErrorMessage>
<FormHelperText ml={4}>
<ul>
<li>8 文字以上 255 文字以下</li>
<li>半角英数字と記号が使用できます</li>
<li>使用できる記号は!@#%\^&\*</li>
</ul>
</FormHelperText>
</FormControl>
</>
)}
</Grid>
);
}
return (
<>
<span></span>
<FormControl mb={2}></FormControl>
</>
);
};
const linksInput = (links: Map<number, { name: string; url: string }>) => {
const result: JSX.Element[] = [];
for (let i = 0; i < 3; i++) {
result.push(
<Grid key={`new-${i}`} templateColumns="max-content 1fr" gap={1}>
<Text mr={2}>リンクタイトル: </Text>
const linksInput = (i: number) => {
const { [`links${i}_name`]: nameError } = errors;
const { [`links${i}_url`]: urlError } = errors;
return (
<div key={`new-${i}`}>
<FormControl isInvalid={Boolean(nameError)} mb={2}>
<FormLabel htmlFor={`link-name-${i}`} mr={2}>
<Text fontWeight="bold">リンクタイトル</Text>
</FormLabel>
<Input
value={links.get(i)?.name ?? ""}
onChange={(e) => handleLinkChange(e, i, true)}
id={`link-name-${i}`}
{...register(`links${i}_name`, {
maxLength: {
value: 255,
message: CONSTANT.ERROR_MESSAGE.MAX_LENGTH(255),
},
})}
></Input>
<Text mr={2}>URL: </Text>
{nameError ? (
<FormErrorMessage>{nameError.message?.toString()}</FormErrorMessage>
) : (
<></>
)}
</FormControl>
<FormControl isInvalid={Boolean(urlError)} mb={4}>
<FormLabel htmlFor={`link-url-${i}`} mr={2}>
<Text fontWeight="bold">URL</Text>
</FormLabel>
<Input
value={links.get(i)?.url ?? ""}
onChange={(e) => handleLinkChange(e, i, false)}
id={`link-url-${i}`}
{...register(`links${i}_url`, {
maxLength: {
value: 3000,
message: CONSTANT.ERROR_MESSAGE.MAX_LENGTH(3000),
},
validate: (value: string) => {
if (watch(`links${i}_name`) !== "" && value === "") {
return CONSTANT.ERROR_MESSAGE.LINK_URL_REQUIRED;
}
},
})}
></Input>
</Grid>
);
}
return result;
{urlError ? (
<FormErrorMessage>{urlError.message?.toString()}</FormErrorMessage>
) : (
<></>
)}
</FormControl>
{i !== CONSTANT.MAX_LINKS - 1 ? <Divider mb={2} /> : <></>}
</div>
);
};
return (
......@@ -130,35 +232,185 @@ export function BlogEdit() {
{isFetching ? (
<Spinner size="xl" />
) : (
<Box>
<Grid templateColumns="max-content 1fr" gap={1} mb={2}>
<Text mr={2}>著者名:</Text>
<Input value={author} onChange={handleAuthorChange} />
<Text mr={2}>ブログ名:</Text>
<Input value={name} onChange={handleNameChange} />
<Text mr={2}>ブログ説明:</Text>
<Textarea
value={description}
onChange={handleDescriptionChange}
/>
<Text mr={2}>公開状態:</Text>
<RadioGroup
onChange={handlePublishOptionChange}
value={publishOption}
<form onSubmit={handleSubmit(onSubmit)}>
<Text color="red.600" mb={2}>
* は必須入力
</Text>
<FormControl isInvalid={Boolean(errors.author)} mb={4}>
<Grid
templateColumns={isLargerThan600 ? gridTemplate : "1fr"}
gap={1}
mb={2}
>
<FormLabel htmlFor="author">
<Flex>
<Text fontWeight="bold">著者名</Text>
<Text color="red.600">*</Text>
</Flex>
</FormLabel>
<Input
id="author"
{...register("author", {
required: CONSTANT.ERROR_MESSAGE.REQUIRED,
maxLength: {
value: 50,
message: CONSTANT.ERROR_MESSAGE.MAX_LENGTH(50),
},
})}
/>
{errors.author ? (
<>
<span></span>
<FormErrorMessage>
{errors.author.message?.toString()}
</FormErrorMessage>
</>
) : (
<>
<span></span>
<FormHelperText>最大 50 文字</FormHelperText>
</>
)}
</Grid>
</FormControl>
<FormControl isInvalid={Boolean(errors.name)} mb={4}>
<Grid
templateColumns={isLargerThan600 ? gridTemplate : "1fr"}
gap={1}
mb={2}
>