diff --git a/targets/frontend/src/components/contributions/answers/Answer.tsx b/targets/frontend/src/components/contributions/answers/Answer.tsx index aaf1c478a..ba14cab34 100644 --- a/targets/frontend/src/components/contributions/answers/Answer.tsx +++ b/targets/frontend/src/components/contributions/answers/Answer.tsx @@ -1,71 +1,36 @@ import { AlertColor, Box, - Button, - FormControl, Stack, + Tooltip, + TooltipProps, Typography, + styled, + tooltipClasses, } from "@mui/material"; -import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import React, { useState } from "react"; import { useUser } from "src/hooks/useUser"; -import { FormEditionField, FormRadioGroup, FormTextField } from "../../forms"; import { StatusContainer } from "../status"; import { Answer, Status } from "../type"; import { useContributionAnswerUpdateMutation } from "./answer.mutation"; import { useContributionAnswerQuery } from "./answer.query"; import { Comments } from "./Comments"; -import { - CdtnReferenceInput, - KaliReferenceInput, - LegiReferenceInput, - OtherReferenceInput, -} from "./references"; import { statusesMapping } from "../status/data"; -import { getNextStatus, getPrimaryButtonLabel } from "../status/utils"; import { SnackBar } from "../../utils/SnackBar"; import { Breadcrumb, BreadcrumbLink } from "src/components/utils"; -import { FicheSpDocumentInput } from "./references/FicheSpDocumentInput"; +import { AnswerForm } from "./AnswerForm"; +import { fr } from "@codegouvfr/react-dsfr"; export type ContributionsAnswerProps = { id: string; }; -const isNotEditable = (answer: Answer | undefined) => - answer?.status.status !== "REDACTING" && - answer?.status.status !== "TODO" && - answer?.status.status !== "VALIDATING"; - -const isCodeDuTravail = (answer: Answer): boolean => - answer.agreement.id === "0000"; - export const ContributionsAnswer = ({ id, }: ContributionsAnswerProps): JSX.Element => { const answer = useContributionAnswerQuery({ id }); const { user } = useUser() as any; - const [status, setStatus] = useState("TODO"); - useEffect(() => { - if (answer?.status) { - setStatus(answer.status.status); - } - }, [answer]); - const { control, getValues, trigger } = useForm({ - values: answer, - defaultValues: { - content: "", - contentType: "ANSWER", - status: { - status: "TODO", - }, - legiReferences: [], - kaliReferences: [], - otherReferences: [], - cdtnReferences: [], - contentFichesSpDocument: answer?.contentFichesSpDocument ? {} : undefined, - }, - }); const updateAnswer = useContributionAnswerUpdateMutation(); const [snack, setSnack] = useState<{ open: boolean; @@ -75,19 +40,7 @@ export const ContributionsAnswer = ({ open: false, }); - const onSubmit = async (newStatus: Status) => { - const isValid = await trigger(); - if (!isValid) { - return setSnack({ - open: true, - severity: "error", - message: "Formulaire invalide", - }); - } - - setStatus(newStatus); - const data = getValues(); - + const onSubmit = async (newStatus: Status, data: Answer) => { try { if (!answer || !answer.id) { throw new Error("Id non définit"); @@ -114,38 +67,12 @@ export const ContributionsAnswer = ({ setSnack({ open: true, severity: "error", message: e.message }); } }; - - const agreementResponseOptions = [ - { - label: "La convention collective ne prévoit rien", - value: "NOTHING", - }, - { - label: "La convention collective renvoie au Code du Travail", - value: "CDT", - }, - { - label: - "La convention collective intégralement moins favorable que le CDT", - value: "UNFAVOURABLE", - }, - { - label: "Nous n'avons pas la réponse", - value: "UNKNOWN", - }, - ]; - const genericResponseOptions = [ - { - label: "Utiliser la fiche service public", - value: "SP", - }, - ]; return ( <> <> - {answer?.agreement?.id} + + + + {answer?.agreement?.name} + + + } + > + {answer?.agreement?.id} + + {answer?.status && ( -
+
)} - + -
{ - // This is a hack to prevent the form from being submitted by the tiptap editor. - // The details extension is not working properly and submit the form when click on the arrow. - // See https://github.com/ueberdosis/tiptap/issues/4384 - e.preventDefault(); - }} - > - - - - - - - - {answer && ( - - )} - {answer && isCodeDuTravail(answer) && ( - - )} - {answer && !isCodeDuTravail(answer) && ( - - )} - - - - - - - - - - - -
+ {answer && ( + + )}
- {answer && ( + {answer && answer.statuses && ( )} -
+ + + ); }; + +const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: fr.colors.decisions.background.default.grey.hover, + color: fr.colors.decisions.text.default.grey.default, + maxWidth: 220, + fontSize: theme.typography.pxToRem(12), + border: `1px solid ${fr.colors.decisions.border.default.grey.default}`, + }, +})); diff --git a/targets/frontend/src/components/contributions/answers/AnswerForm.tsx b/targets/frontend/src/components/contributions/answers/AnswerForm.tsx new file mode 100644 index 000000000..08d078216 --- /dev/null +++ b/targets/frontend/src/components/contributions/answers/AnswerForm.tsx @@ -0,0 +1,204 @@ +import { Button, FormControl, Stack } from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { FormEditionField, FormRadioGroup, FormTextField } from "../../forms"; +import { Answer, Status, answerFormSchema } from "../type"; +import { AnswerWithStatus } from "./answer.query"; +import { + CdtnReferenceInput, + KaliReferenceInput, + LegiReferenceInput, + OtherReferenceInput, +} from "./references"; +import { getNextStatus, getPrimaryButtonLabel } from "../status/utils"; +import { FicheSpDocumentInput } from "./references/FicheSpDocumentInput"; + +export type ContributionsAnswerProps = { + answer: AnswerWithStatus; + onSubmit: (status: Status, data: Answer) => void; +}; + +const isNotEditable = (answer: Answer | undefined) => + answer?.status?.status !== "REDACTING" && + answer?.status?.status !== "TODO" && + answer?.status?.status !== "VALIDATING"; + +const isCodeDuTravail = (answer: Answer): boolean => + answer?.agreement?.id === "0000"; + +export const AnswerForm = ({ + answer, + onSubmit, +}: ContributionsAnswerProps): JSX.Element => { + const [status, setStatus] = useState("TODO"); + useEffect(() => { + if (answer?.status) { + setStatus(answer.status.status); + } + }, [answer]); + const { control, getValues, trigger } = useForm({ + resolver: zodResolver(answerFormSchema), + shouldFocusError: true, + defaultValues: { + content: "", + contentType: "ANSWER", + status: { + status: "TODO", + }, + legiReferences: [], + kaliReferences: [], + otherReferences: [], + cdtnReferences: [], + contentFichesSpDocument: answer?.contentFichesSpDocument ? {} : undefined, + }, + }); + + const submit = async (newStatus: Status) => { + const isValid = await trigger(); + + if (isValid) { + setStatus(newStatus); + const data = getValues(); + onSubmit(newStatus, data); + } + }; + + const agreementResponseOptions = [ + { + label: "La convention collective ne prévoit rien", + value: "NOTHING", + }, + { + label: "La convention collective renvoie au Code du Travail", + value: "CDT", + }, + { + label: + "La convention collective intégralement moins favorable que le CDT", + value: "UNFAVOURABLE", + }, + { + label: "Nous n'avons pas la réponse", + value: "UNKNOWN", + }, + ]; + const genericResponseOptions = [ + { + label: "Utiliser la fiche service public", + value: "SP", + }, + ]; + return ( + <> +
{ + // This is a hack to prevent the form from being submitted by the tiptap editor. + // The details extension is not working properly and submit the form when click on the arrow. + // See https://github.com/ueberdosis/tiptap/issues/4384 + e.preventDefault(); + }} + > + + + + + + + + {answer && ( + + )} + {answer && isCodeDuTravail(answer) && ( + + )} + {answer?.agreement && !isCodeDuTravail(answer) && ( + + )} + + + + + + + + + +
+ + ); +}; diff --git a/targets/frontend/src/components/contributions/answers/Comment.tsx b/targets/frontend/src/components/contributions/answers/Comment.tsx index 609179c3a..f440c2c1a 100644 --- a/targets/frontend/src/components/contributions/answers/Comment.tsx +++ b/targets/frontend/src/components/contributions/answers/Comment.tsx @@ -39,7 +39,7 @@ export const Comment = ({ comment }: Props) => { }} > - {comment.user.name} + {comment?.user?.name} { overflow: "auto", }} > - {notifications.map((comment) => ( - + {notifications.map((comment, index) => ( + ))} diff --git a/targets/frontend/src/components/contributions/answers/__tests__/AnswerForm.test.tsx b/targets/frontend/src/components/contributions/answers/__tests__/AnswerForm.test.tsx new file mode 100644 index 000000000..05333003e --- /dev/null +++ b/targets/frontend/src/components/contributions/answers/__tests__/AnswerForm.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import { AnswerForm } from "../AnswerForm"; +import { AnswerWithStatus } from "../answer.query"; + +const answerBase: AnswerWithStatus = { + id: "369336d2-994f-48b1-b6ac-fec78cff240e", + questionId: "2c820037-62bd-4c0e-a1a8-ca80b97b5958", + agreementId: "0000", + content: "", + contentType: "ANSWER", + updatedAt: "2023-09-29T14:09:52.01401+00:00", + contentServicePublicCdtnId: null, + question: { + id: "2c820037-62bd-4c0e-a1a8-ca80b97b5958", + content: "Quelle est la durée maximale de la période d’essai ?", + order: 5, + }, + agreement: { + id: "0000", + name: "Convention collective nationale des transports routiers et activités auxiliaires du transport", + kaliId: "KALICONT000005635624", + }, + answerComments: [], + statuses: [], + kaliReferences: [], + legiReferences: [], + otherReferences: [], + cdtnReferences: [], + contentFichesSpDocument: null, + status: { + status: "TODO", + createdAt: new Date().toISOString(), + }, + updateDate: "29/09/2023", +}; + +describe("Given a component AnswerForm and a basic default generic answer", () => { + beforeEach(() => { + render( {}} />); + }); + test("Check options are displayed", () => { + expect(screen.queryByText("Afficher la réponse")).toBeInTheDocument(); + expect( + screen.queryByText("Utiliser la fiche service public") + ).toBeInTheDocument(); + expect( + screen.queryByText("La convention collective ne prévoit rien") + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Nous n'avons pas la réponse") + ).not.toBeInTheDocument(); + }); +}); + +describe("Given a component AnswerForm and a basic default CC answer", () => { + beforeEach(() => { + const answer = { + ...answerBase, + agreementId: "0016", + agreement: { + ...answerBase.agreement, + id: "0016", + name: "0016", + kaliId: "0016", + }, + }; + render( {}} />); + }); + test("Check options are displayed", () => { + expect(screen.queryByText("Afficher la réponse")).toBeInTheDocument(); + expect( + screen.queryByText("Utiliser la fiche service public") + ).not.toBeInTheDocument(); + expect( + screen.queryByText("La convention collective ne prévoit rien") + ).toBeInTheDocument(); + expect( + screen.queryByText("Nous n'avons pas la réponse") + ).toBeInTheDocument(); + }); +}); diff --git a/targets/frontend/src/components/contributions/answers/answer.query.ts b/targets/frontend/src/components/contributions/answers/answer.query.ts index 5d61f7236..9abaa4e6f 100644 --- a/targets/frontend/src/components/contributions/answers/answer.query.ts +++ b/targets/frontend/src/components/contributions/answers/answer.query.ts @@ -83,7 +83,10 @@ type QueryProps = { id: string; }; -type AnswerWithStatus = Answer & { status: AnswerStatus; updateDate: string }; +export type AnswerWithStatus = Answer & { + status: AnswerStatus; + updateDate?: string; +}; type QueryResult = { contribution_answers: AnswerWithStatus[]; @@ -116,6 +119,8 @@ export const useContributionAnswerQuery = ({ return { ...answer, status: initStatus(answer), - updateDate: format(parseISO(answer.updatedAt), "dd/MM/yyyy"), + updateDate: answer?.updatedAt + ? format(parseISO(answer?.updatedAt), "dd/MM/yyyy") + : undefined, }; }; diff --git a/targets/frontend/src/components/contributions/answers/comments.mutation.ts b/targets/frontend/src/components/contributions/answers/comments.mutation.ts index 04d6bcd8a..5121bd575 100644 --- a/targets/frontend/src/components/contributions/answers/comments.mutation.ts +++ b/targets/frontend/src/components/contributions/answers/comments.mutation.ts @@ -1,4 +1,4 @@ -import { OperationResult, useMutation } from "urql"; +import { useMutation } from "urql"; import { Comments } from "../type"; diff --git a/targets/frontend/src/components/contributions/answers/references/CdtnReferenceInput.tsx b/targets/frontend/src/components/contributions/answers/references/CdtnReferenceInput.tsx index a9149cbdb..437e1a650 100644 --- a/targets/frontend/src/components/contributions/answers/references/CdtnReferenceInput.tsx +++ b/targets/frontend/src/components/contributions/answers/references/CdtnReferenceInput.tsx @@ -1,4 +1,4 @@ -import { getRouteBySource } from "@socialgouv/cdtn-sources"; +import { SourceRoute, getRouteBySource } from "@socialgouv/cdtn-sources"; import { Control } from "react-hook-form"; import { CdtnReference } from "../../type"; import { useContributionSearchCdtnReferencesQuery } from "./cdtnReferencesSearch.query"; @@ -8,12 +8,14 @@ type Props = { name: string; control: Control; disabled?: boolean; + idcc?: string; }; export const CdtnReferenceInput = ({ name, control, disabled = false, + idcc, }: Props): React.ReactElement => ( isMultiple={true} @@ -23,18 +25,19 @@ export const CdtnReferenceInput = ({ disabled={disabled} control={control} fetcher={useContributionSearchCdtnReferencesQuery} + idcc={idcc} isEqual={(option, value) => value.document.cdtnId === option.document.cdtnId } getLabel={(item) => - `${getRouteBySource(item.document.source)} > ${item.document.title} (${ - item.document.slug - })` + `${getRouteBySource(item.document.source as SourceRoute)} > ${ + item.document.title + } (${item.document.slug})` } onClick={(item) => { const newWindow = window.open( `https://code.travail.gouv.fr/${getRouteBySource( - item.document.source + item.document.source as SourceRoute )}/${item.document.slug}`, "_blank", "noopener,noreferrer" diff --git a/targets/frontend/src/components/contributions/answers/references/FicheSpDocumentInput.tsx b/targets/frontend/src/components/contributions/answers/references/FicheSpDocumentInput.tsx index d081e3693..1591c2403 100644 --- a/targets/frontend/src/components/contributions/answers/references/FicheSpDocumentInput.tsx +++ b/targets/frontend/src/components/contributions/answers/references/FicheSpDocumentInput.tsx @@ -1,4 +1,4 @@ -import { getRouteBySource } from "@socialgouv/cdtn-sources"; +import { SourceRoute, getRouteBySource } from "@socialgouv/cdtn-sources"; import { Control } from "react-hook-form"; import { Document } from "../../type"; import { ReferenceInput } from "./ReferenceInput"; @@ -26,9 +26,9 @@ export const FicheSpDocumentInput = ({ getLabel={(item) => `${item.title} (${item.slug})`} onClick={(item) => { const newWindow = window.open( - `https://code.travail.gouv.fr/${getRouteBySource(item.source)}/${ - item.slug - }`, + `https://code.travail.gouv.fr/${getRouteBySource( + item.source as SourceRoute + )}/${item.slug}`, "_blank", "noopener,noreferrer" ); diff --git a/targets/frontend/src/components/contributions/answers/references/KaliReferenceInput.tsx b/targets/frontend/src/components/contributions/answers/references/KaliReferenceInput.tsx index e46aaacac..194b8e2f5 100644 --- a/targets/frontend/src/components/contributions/answers/references/KaliReferenceInput.tsx +++ b/targets/frontend/src/components/contributions/answers/references/KaliReferenceInput.tsx @@ -136,13 +136,13 @@ export const KaliReferenceInput = ({ ); })} - {!disabled && ( + {!disabled && agreement.id && ( { if (value) { - append({ kaliArticle: value }); + append({ kaliArticle: value, label: "" }); } }} /> diff --git a/targets/frontend/src/components/contributions/answers/references/ReferenceInput.tsx b/targets/frontend/src/components/contributions/answers/references/ReferenceInput.tsx index 28b92c258..a99da315e 100644 --- a/targets/frontend/src/components/contributions/answers/references/ReferenceInput.tsx +++ b/targets/frontend/src/components/contributions/answers/references/ReferenceInput.tsx @@ -15,7 +15,7 @@ type Props = { label: string; name: string; control: Control; - fetcher: (query: string | undefined) => Result; + fetcher: (query: string | undefined, idcc?: string) => Result; isEqual: (value: Type, option: Type) => boolean; getLabel: (option: Type) => string; onClick: (value: Type) => void; @@ -29,6 +29,7 @@ type Props = { | "warning"; disabled: boolean; isMultiple?: true; + idcc?: string; }; export const ReferenceInput = ({ @@ -42,9 +43,10 @@ export const ReferenceInput = ({ color, disabled, isMultiple, + idcc, }: Props): ReactElement | null => { const [query, setQuery] = useState(); - const { data, fetching, error } = fetcher(query); + const { data, fetching, error } = fetcher(query, idcc); const [open, setOpen] = useState(false); const [options, setOptions] = useState([]); diff --git a/targets/frontend/src/components/contributions/answers/references/cdtnReferencesSearch.query.ts b/targets/frontend/src/components/contributions/answers/references/cdtnReferencesSearch.query.ts index 14722b12d..e9a48e11a 100644 --- a/targets/frontend/src/components/contributions/answers/references/cdtnReferencesSearch.query.ts +++ b/targets/frontend/src/components/contributions/answers/references/cdtnReferencesSearch.query.ts @@ -21,13 +21,14 @@ export const getSlugFromUrl = (query: string | undefined): string => { }; export const SearchCdtnReferencesQuery = ` -query SearchCdtnReferences($sources: [String!], $slug: String, $title: String) { +query SearchCdtnReferences($sources: [String!], $slug: String, $title: String, $slugRegex: String) { documents(where: { _or: [{ title: {_ilike: $title} }, { slug: {_eq: $slug} }], + slug: { _regex: $slugRegex }, is_available: {_eq: true}, is_published: {_eq: true}, source: {_in: $sources} @@ -46,10 +47,14 @@ query SearchCdtnReferences($sources: [String!], $slug: String, $title: String) { `; export const useContributionSearchCdtnReferencesQuery = ( - query: string | undefined + query: string | undefined, + idcc?: string ): Result> => { const slug = getSlugFromUrl(query); const title = getNormalizedTitle(slug); + const slugRegex = `^(${idcc ? `${idcc}|` : ""}[^0-9])${ + idcc ? `{${idcc.length}}` : "" + }[\-a-zA-Z0-9_]+$`; const [{ data, fetching, error }] = useQuery( { query: SearchCdtnReferencesQuery, @@ -66,6 +71,7 @@ export const useContributionSearchCdtnReferencesQuery = ( ], slug, title, + slugRegex, }, } ); diff --git a/targets/frontend/src/components/contributions/questionList/QuestionList.query.ts b/targets/frontend/src/components/contributions/questionList/QuestionList.query.ts index 0349c320f..ca0d7d5ec 100644 --- a/targets/frontend/src/components/contributions/questionList/QuestionList.query.ts +++ b/targets/frontend/src/components/contributions/questionList/QuestionList.query.ts @@ -23,10 +23,8 @@ export const questionListQuery = `query questions_answers($search: String) { } } }`; -export type QueryQuestionAnswer = Pick; -export type QueryQuestion = Pick & { - answers: QueryQuestionAnswer[]; -}; +export type QueryQuestionAnswer = Answer; +export type QueryQuestion = Question; export type QueryResult = { contribution_questions: QueryQuestion[]; @@ -44,10 +42,10 @@ function formatAnswers(questions: QueryQuestion[] | undefined) { if (!questions) return []; return questions.map((question) => { - question.answers = question.answers.map((answer) => { - answer.status = initStatus(answer); - return answer; - }); + question.answers = question?.answers?.map((answer) => ({ + ...answer, + status: initStatus(answer as Answer), + })); return question; }); } diff --git a/targets/frontend/src/components/contributions/questionList/QuestionList.tsx b/targets/frontend/src/components/contributions/questionList/QuestionList.tsx index d4a6c6793..2021c02c6 100644 --- a/targets/frontend/src/components/contributions/questionList/QuestionList.tsx +++ b/targets/frontend/src/components/contributions/questionList/QuestionList.tsx @@ -23,6 +23,7 @@ import { QuestionRow } from "./QuestionRow"; import { fr } from "@codegouvfr/react-dsfr"; import { statusesMapping } from "../status/data"; import { StatusStats } from "../status/StatusStats"; +import { Answer } from "../type"; export const countAnswersWithStatus = ( answers: QueryQuestionAnswer[] | undefined, @@ -40,7 +41,9 @@ export const QuestionList = (): JSX.Element => { search, }); - const aggregatedRow = rows.map(({ answers }) => answers).flat(); + const aggregatedRow = rows.flatMap(({ answers }) => + answers?.length ? (answers as Answer[]) : [] + ); const total = aggregatedRow.length; return ( diff --git a/targets/frontend/src/components/contributions/questionList/QuestionRow.tsx b/targets/frontend/src/components/contributions/questionList/QuestionRow.tsx index 87a2454ef..9abc25d57 100644 --- a/targets/frontend/src/components/contributions/questionList/QuestionRow.tsx +++ b/targets/frontend/src/components/contributions/questionList/QuestionRow.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import { StatusRecap } from "../status"; import { QueryQuestion } from "./QuestionList.query"; +import { Answer } from "../type"; export const QuestionRow = (props: { row: QueryQuestion }) => { const { row } = props; @@ -22,7 +23,10 @@ export const QuestionRow = (props: { row: QueryQuestion }) => { {row.order} - {row.content} - + ); }; diff --git a/targets/frontend/src/components/contributions/questions/EditQuestion.tsx b/targets/frontend/src/components/contributions/questions/EditQuestion.tsx index 87e80a849..7c15b4cf2 100644 --- a/targets/frontend/src/components/contributions/questions/EditQuestion.tsx +++ b/targets/frontend/src/components/contributions/questions/EditQuestion.tsx @@ -122,7 +122,7 @@ export const EditQuestion = ({ justifyContent="start" spacing={2} > -
+
diff --git a/targets/frontend/src/components/contributions/questions/EditQuestionAnswerList.tsx b/targets/frontend/src/components/contributions/questions/EditQuestionAnswerList.tsx index 29e98eafd..110b55b7d 100644 --- a/targets/frontend/src/components/contributions/questions/EditQuestionAnswerList.tsx +++ b/targets/frontend/src/components/contributions/questions/EditQuestionAnswerList.tsx @@ -44,8 +44,8 @@ export const EditQuestionAnswerList = ({ router.push(`/contributions/answers/${answer.id}`); }} > - {answer.agreement.id} - {answer.agreement.name} + {answer?.agreement?.id} + {answer?.agreement?.name} {answer.status && ( diff --git a/targets/frontend/src/components/contributions/questions/EditQuestionForm.tsx b/targets/frontend/src/components/contributions/questions/EditQuestionForm.tsx index abdf987d9..43256ed48 100644 --- a/targets/frontend/src/components/contributions/questions/EditQuestionForm.tsx +++ b/targets/frontend/src/components/contributions/questions/EditQuestionForm.tsx @@ -1,10 +1,12 @@ import { AlertColor, Button, Card, Stack, Typography } from "@mui/material"; import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { FormSelect, FormTextField } from "src/components/forms"; import { useQuestionUpdateMutation } from "./Question.mutation"; -import { Message, Question } from "../type"; +import { Message, Question, questionRelationSchema } from "../type"; import { SnackBar } from "../../utils/SnackBar"; type EditQuestionProps = { @@ -12,15 +14,22 @@ type EditQuestionProps = { messages: Message[]; }; -type FormData = Omit & { - message_id: string; -}; +const formDataSchema = questionRelationSchema.extend({ + message_id: z + .string({ + required_error: "Un message doit être sélectionné", + }) + .uuid("Un message doit être sélectionné"), +}); +export type FormData = z.infer; export const EditQuestionForm = ({ question, messages, }: EditQuestionProps): JSX.Element => { const { control, watch, handleSubmit } = useForm({ + resolver: zodResolver(formDataSchema), + shouldFocusError: true, defaultValues: { content: question.content, id: question.id, @@ -77,7 +86,6 @@ export const EditQuestionForm = ({ name="content" control={control} label="Nom de la question" - rules={{ required: true }} multiline fullWidth /> @@ -89,7 +97,6 @@ export const EditQuestionForm = ({ }))} name="message_id" control={control} - rules={{ required: true }} label="Message associé à la question" fullWidth /> diff --git a/targets/frontend/src/components/contributions/questions/Question.query.ts b/targets/frontend/src/components/contributions/questions/Question.query.ts index e12834b7b..528599923 100644 --- a/targets/frontend/src/components/contributions/questions/Question.query.ts +++ b/targets/frontend/src/components/contributions/questions/Question.query.ts @@ -1,7 +1,7 @@ import { useQuery } from "urql"; import { initStatus } from "../status/utils"; -import { Message, Question } from "../type"; +import { Answer, Message, Question } from "../type"; export const contributionQuestionQuery = ` query SelectQuestion($questionId: uuid) { @@ -75,12 +75,11 @@ export const useQuestionQuery = ({ ) { return "not_found"; } - const answers = result.data.contribution_questions[0].answers.map( - (answer) => ({ + const answers = + result.data.contribution_questions[0]?.answers?.map((answer) => ({ ...answer, - status: initStatus(answer), - }) - ); + status: initStatus(answer as Answer), + })) ?? []; const question = { ...result.data.contribution_questions[0], answers, diff --git a/targets/frontend/src/components/contributions/status/utils.ts b/targets/frontend/src/components/contributions/status/utils.ts index d18d219a5..b64c28d9f 100644 --- a/targets/frontend/src/components/contributions/status/utils.ts +++ b/targets/frontend/src/components/contributions/status/utils.ts @@ -1,7 +1,12 @@ -import { Status } from "../type"; +import { Answer, Status } from "../type"; -export const initStatus = (answer: any) => { - return answer.statuses?.[0] || { status: "TODO" }; +export const initStatus = (answer: Answer) => { + return ( + answer.statuses?.[0] || { + status: "TODO", + createdAt: new Date().toISOString(), + } + ); }; export const getNextStatus = (status: Status): Status => { diff --git a/targets/frontend/src/components/contributions/type.ts b/targets/frontend/src/components/contributions/type.ts index fc320a9b8..7c45ff629 100644 --- a/targets/frontend/src/components/contributions/type.ts +++ b/targets/frontend/src/components/contributions/type.ts @@ -1,120 +1,215 @@ -import { User } from "src/types"; -import { SourceRoute } from "@socialgouv/cdtn-sources"; - -export type Agreement = { - id: string; - name: string; - kaliId: string; -}; - -export type Status = - | "TODO" - | "REDACTING" - | "REDACTED" - | "VALIDATING" - | "VALIDATED" - | "PUBLISHED"; - -export type AnswerStatus = { - id: string; - createdAt: string; - status: Status; - userId: string; - user: User; -}; - -export type Message = { - id: string; - label: string; - content: string; -}; - -export type Question = { - id: string; - content: string; - order: number; - answers: Answer[]; - message?: Message; -}; - -export type Comments = { - id: string; - content: string; - answer: Answer; - answerId: string; - userId: string; - user: User; - createdAt: string; -}; +import { z } from "zod"; + +export const userSchema = z.object({ + id: z.string().uuid().optional(), + name: z.string(), + email: z.string(), + created_at: z.string(), +}); +export type User = z.infer; + +export const agreementSchema = z.object({ + id: z.string(), + name: z.string(), + kaliId: z.string(), +}); +export type Agreement = z.infer; + +export const statusSchema = z.enum([ + "TODO", + "REDACTING", + "REDACTED", + "VALIDATING", + "VALIDATED", + "PUBLISHED", +]); +export type Status = z.infer; + +export const answerStatusSchema = z.object({ + id: z.string().uuid(), + createdAt: z.string(), + status: statusSchema, + userId: z.string(), + user: userSchema, +}); +export type AnswerStatus = z.infer; + +export const messageSchema = z.object({ + id: z.string().uuid(), + label: z.string(), + content: z.string(), +}); +export type Message = z.infer; export type CommentsAndStatuses = (AnswerStatus | Comments) & { createdAtDate: Date; }; -export type KaliArticle = { - cid: string; - id: string; - path: string; - label: string; - agreementId: string; - createdAt: string; -}; - -export type LegiArticle = { - cid: string; - id: string; - label: string; -}; - -export type KaliReference = { - kaliArticle: KaliArticle; - label?: string; -}; - -export type LegiReference = { - legiArticle: LegiArticle; -}; - -export type OtherReference = { - label: string; - url: string; -}; - -export type Document = { - title: string; - cdtnId: string; - source: SourceRoute; - slug: string; -}; - -export type CdtnReference = { - document: Document; -}; - -export type ContentType = - | "ANSWER" - | "NOTHING" - | "CDT" - | "UNFAVOURABLE" - | "UNKNOWN" - | "SP"; - -export type Answer = { - id: string; - agreementId: string; - questionId: string; - contentType?: ContentType; - contentServicePublicCdtnId?: string; - agreement: Agreement; - statuses: AnswerStatus[]; - status: AnswerStatus; - content?: string; - question: Omit; - answerComments: Comments[]; - updatedAt: string; - kaliReferences: KaliReference[]; - legiReferences: LegiReference[]; - otherReferences: OtherReference[]; - cdtnReferences: CdtnReference[]; - contentFichesSpDocument?: Document; -}; +export const kaliArticleSchema = z.object({ + cid: z.string(), + id: z.string(), + path: z.string(), + label: z + .string({ required_error: "Un libellé doit être renseigner" }) + .min(1, "un label doit être renseigner"), + agreementId: z.string(), + createdAt: z.string(), +}); +export type KaliArticle = z.infer; + +export const legiArticleSchema = z.object({ + cid: z.string(), + id: z.string(), + label: z.string(), +}); +export type LegiArticle = z.infer; + +export const kaliReferenceSchema = z.object({ + kaliArticle: kaliArticleSchema.partial(), + label: z + .string({ required_error: "Un libellé doit être renseigner" }) + .min(1, "un label doit être renseigner"), +}); +export type KaliReference = z.infer; + +export const legiReferenceSchema = z.object({ + legiArticle: legiArticleSchema, +}); +export type LegiReference = z.infer; + +export const otherReferenceSchema = z.object({ + label: z + .string({ required_error: "un libellé doit être renseigner" }) + .min(1, "un nom doit être renseigné"), + url: z + .string({ required_error: "Une url doit être renseigné" }) + .url("le format du lien est invalide") + .optional() + .or(z.literal("")), +}); +export type OtherReference = z.infer; + +export const documentSchema = z.object({ + title: z.string(), + cdtnId: z.string(), + source: z.string(), + slug: z.string(), +}); +export type Document = z.infer; + +export const cdtnReferenceSchema = z.object({ + document: documentSchema, +}); +export type CdtnReference = z.infer; + +export const contentTypeSchema = z.enum([ + "ANSWER", + "NOTHING", + "CDT", + "UNFAVOURABLE", + "UNKNOWN", + "SP", +]); +export type ContentType = z.infer; + +const answerBaseSchema = z.object({ + id: z.string().uuid(), + agreementId: z.string(), + questionId: z.string().uuid(), + contentType: z.string({ + required_error: "Un type de réponse doit être sélectionner", + invalid_type_error: " type de réponse doit être sélectionner", + }), + contentServicePublicCdtnId: z.string().nullable().optional(), + content: z.string().nullable().optional(), + updatedAt: z.string(), +}); + +export const questionBaseSchema = z.object({ + id: z.string().uuid(), + content: z + .string({ + required_error: "une question doit être renseigner", + }) + .min(1, "une question doit être renseigner"), + order: z.number(), +}); + +export const commentsSchema = z.object({ + id: z.string().uuid(), + content: z.string(), + answer: answerBaseSchema, + answerId: z.string(), + user: userSchema, + userId: z.string(), + createdAt: z.string(), +}); +export type Comments = z.infer; + +const answerRelationSchema = answerBaseSchema.extend({ + agreement: agreementSchema, + statuses: z.array(answerStatusSchema), + status: answerStatusSchema, + kaliReferences: z.array(kaliReferenceSchema), + legiReferences: z.array(legiReferenceSchema), + otherReferences: z.array(otherReferenceSchema), + cdtnReferences: z.array(cdtnReferenceSchema), + contentFichesSpDocument: documentSchema.nullable().optional(), + question: questionBaseSchema, + answerComments: z.array(commentsSchema), +}); +export type Answer = z.infer; + +export const answerFormBaseSchema = answerRelationSchema.extend({ + id: z.string().uuid().optional(), + agreementId: z.string().optional(), + questionId: z.string().uuid().optional(), + updatedAt: z.string().optional(), + question: questionBaseSchema.deepPartial().optional(), + answerComments: z.array(commentsSchema.deepPartial()).optional(), + agreement: agreementSchema.deepPartial().optional(), + statuses: z.array(answerStatusSchema.deepPartial()).optional(), + status: answerStatusSchema.deepPartial().optional(), +}); + +export const questionRelationSchema = questionBaseSchema.extend({ + answers: z.array(answerBaseSchema.deepPartial()).optional(), + message: messageSchema.deepPartial().optional(), +}); +export type Question = z.infer; + +const answerWithAnswerSchema = answerFormBaseSchema.extend({ + contentType: z.literal("ANSWER"), + content: z + .string({ + required_error: "Une réponse doit être renseigner", + invalid_type_error: "Une réponse doit être renseigner", + }) + .min(1, "Une réponse doit être renseigner"), +}); +const answerWithNothingSchema = answerFormBaseSchema.extend({ + contentType: z.literal("NOTHING"), +}); +const answerWithCdtSchema = answerFormBaseSchema.extend({ + contentType: z.literal("CDT"), +}); +const answerWithUnfavourableSchema = answerFormBaseSchema.extend({ + contentType: z.literal("UNFAVOURABLE"), +}); +const answerWithUnknownSchema = answerFormBaseSchema.extend({ + contentType: z.literal("UNKNOWN"), +}); +const answerWithSPSchema = answerFormBaseSchema.extend({ + contentType: z.literal("SP"), + contentFichesSpDocument: documentSchema, +}); + +export const answerFormSchema = z.discriminatedUnion("contentType", [ + answerWithAnswerSchema, + answerWithNothingSchema, + answerWithCdtSchema, + answerWithUnfavourableSchema, + answerWithUnknownSchema, + answerWithSPSchema, +]); +export type AnswerForm = z.infer; diff --git a/targets/frontend/src/components/forms/Autocomplete/index.tsx b/targets/frontend/src/components/forms/Autocomplete/index.tsx index 9a4c94ba3..428a5cda0 100644 --- a/targets/frontend/src/components/forms/Autocomplete/index.tsx +++ b/targets/frontend/src/components/forms/Autocomplete/index.tsx @@ -11,6 +11,7 @@ import { TextField, } from "@mui/material"; import { AutocompleteRenderGetTagProps } from "@mui/material/Autocomplete/Autocomplete"; +import { styled } from "@mui/system"; import React, { PropsWithChildren } from "react"; import { Controller } from "react-hook-form"; @@ -94,6 +95,7 @@ export const FormAutocomplete = ({ renderInput={(params) => ( ({ /> )} /> - {error && error.type === "required" ? ( + {error && error.message === "Required" ? ( Ce champ est requis ) : null} diff --git a/targets/frontend/src/components/forms/EditionField/Editor.tsx b/targets/frontend/src/components/forms/EditionField/Editor.tsx index 3d6fe7164..533c0adbd 100644 --- a/targets/frontend/src/components/forms/EditionField/Editor.tsx +++ b/targets/frontend/src/components/forms/EditionField/Editor.tsx @@ -19,15 +19,20 @@ import { DetailsContent } from "@tiptap-pro/extension-details-content"; import { Placeholder } from "@tiptap/extension-placeholder"; export type EditorProps = { - content?: string | null; + content?: string; onUpdate: (content: string) => void; - error?: FieldErrors; disabled?: boolean; + isError?: boolean; }; const emptyHtml = "

"; -export const Editor = ({ content, onUpdate, error, disabled }: EditorProps) => { +export const Editor = ({ + content, + onUpdate, + disabled, + isError = false, +}: EditorProps) => { const [focus, setFocus] = useState(false); const [isClient, setIsClient] = useState(false); const editor = useEditor({ @@ -83,7 +88,12 @@ export const Editor = ({ content, onUpdate, error, disabled }: EditorProps) => { return ( <> {isClient && ( - + @@ -119,9 +129,12 @@ const StyledEditorContent = styled(EditorContent)(() => { }, ".details": { display: "flex", - margin: "1rem 0", border: "0", - padding: "0.5rem", + padding: "1rem 0", + borderTop: `1px solid ${fr.colors.decisions.text.default.grey.default}`, + ":first-child": { + border: "none", + }, "> button": { display: "flex", cursor: "pointer", @@ -159,7 +172,7 @@ const StyledEditorContent = styled(EditorContent)(() => { }, th: { border: `1px solid ${fr.colors.decisions.text.default.grey.default}`, - backgroundColor: fr.colors.decisions.background.contrast.grey.default, + backgroundColor: fr.colors.decisions.background.default.grey.default, minWidth: "100px", }, td: { diff --git a/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx b/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx index 8737f5e04..d5933881e 100644 --- a/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx +++ b/targets/frontend/src/components/forms/EditionField/MenuSpecial.tsx @@ -60,11 +60,7 @@ export const MenuSpecial = ({ editor }: { editor: Editor | null }) => {