diff --git a/targets/export-elasticsearch/src/ingester/contributions/generateMetadata.ts b/targets/export-elasticsearch/src/ingester/contributions/generateMetadata.ts index 9ce069781..9911a8be1 100644 --- a/targets/export-elasticsearch/src/ingester/contributions/generateMetadata.ts +++ b/targets/export-elasticsearch/src/ingester/contributions/generateMetadata.ts @@ -11,6 +11,12 @@ export const generateMetadata = ( contribution: DocumentElasticWithSource, breadcrumbs: Breadcrumb[] ): ContributionMetadata => { + if (breadcrumbs.length === 0) { + throw new Error( + `Merci d'assigner un thème à la contribution ${contribution.questionIndex} - ${contribution.questionName} (${contribution.id}). Cette opération est disponible dans le menu Vérifications -> Contenus sans thèmes.` + ); + } + if (contribution.idcc === "0000") { return generateGenericMetadata(contribution); } @@ -25,12 +31,6 @@ export const generateMetadata = ( ); } - if (breadcrumbs.length === 0) { - throw new Error( - `Contribution ${contribution.questionIndex} - ${contribution.questionName} (${contribution.id}) must be themed` - ); - } - return generateCustomMetadata( contribution, breadcrumbs[breadcrumbs.length - 1], diff --git a/targets/frontend/src/components/contributions/questionList/QuestionList.tsx b/targets/frontend/src/components/contributions/questionList/QuestionList.tsx index 75ae5d6ed..b0c4208eb 100644 --- a/targets/frontend/src/components/contributions/questionList/QuestionList.tsx +++ b/targets/frontend/src/components/contributions/questionList/QuestionList.tsx @@ -17,6 +17,7 @@ import { useQuestionListQuery, } from "./QuestionList.query"; import { QuestionRow } from "./QuestionRow"; +import { useRouter } from "next/router"; export const countAnswersWithStatus = ( answers: QueryQuestionAnswer[] | undefined, @@ -29,6 +30,7 @@ export const countAnswersWithStatus = ( }; export const QuestionList = (): JSX.Element => { + const router = useRouter(); const [search, setSearch] = useState(); const { rows } = useQuestionListQuery({ @@ -53,6 +55,14 @@ export const QuestionList = (): JSX.Element => { }} data-testid="contributions-list-search" /> + diff --git a/targets/frontend/src/components/contributions/questions/edit/Form.tsx b/targets/frontend/src/components/contributions/questions/common/Form.tsx similarity index 88% rename from targets/frontend/src/components/contributions/questions/edit/Form.tsx rename to targets/frontend/src/components/contributions/questions/common/Form.tsx index ef96f8322..42cd75c79 100644 --- a/targets/frontend/src/components/contributions/questions/edit/Form.tsx +++ b/targets/frontend/src/components/contributions/questions/common/Form.tsx @@ -22,15 +22,17 @@ import { SnackBar } from "../../../utils/SnackBar"; import { LoadingButton } from "../../../button/LoadingButton"; type EditQuestionProps = { - question: QuestionBase; + question?: QuestionBase; messages: Message[]; - onUpsert: (props: QuestionFormData) => Promise; + defaultOrder?: number; + onSubmit: (props: QuestionFormData) => Promise; }; const formDataSchema = z.object({ message_id: questionRelationSchema.shape.message_id.or(z.literal("")), content: questionRelationSchema.shape.content, seo_title: questionRelationSchema.shape.seo_title, + order: questionRelationSchema.shape.order, }); export type QuestionFormData = z.infer; @@ -38,19 +40,21 @@ export type QuestionFormData = z.infer; export const Form = ({ question, messages, - onUpsert, + defaultOrder, + onSubmit, }: EditQuestionProps): JSX.Element => { const { control, watch, handleSubmit } = useForm({ resolver: zodResolver(formDataSchema), shouldFocusError: true, defaultValues: { - content: question.content, - message_id: question.message_id ?? "", - seo_title: question.seo_title ?? "", + content: question?.content ?? "", + message_id: question?.message_id ?? "", + seo_title: question?.seo_title ?? "", + order: question?.order ?? defaultOrder ?? -1, }, }); const [message, setMessage] = useState(undefined); - const watchMessageId = watch("message_id", question.message_id); + const watchMessageId = watch("message_id", question?.message_id); const [submitting, setSubmit] = useState(false); const [snack, setSnack] = useState<{ @@ -70,10 +74,10 @@ export const Form = ({ } }, [watchMessageId, messages]); - const onSubmit = async (formData: QuestionFormData) => { + const onSubmitForm = async (formData: QuestionFormData) => { setSubmit(true); try { - await onUpsert(formData); + await onSubmit(formData); setSnack({ open: true, severity: "success", message: "Sauvegardé" }); } catch (e: any) { setSnack({ @@ -87,14 +91,31 @@ export const Form = ({ return ( -
+ + + Cette valeur est indicative. Vous pouvez modifier cette valeur + mais elle ne doit pas déjà être utilisée.{" "} + + Il est recommandé de ne pas modifier la valeur par défaut. + + + } + label="Ordre" + fullWidth + disabled={question !== undefined} + /> - Sauvegarder + {question ? "Sauvegarder" : "Créer"} diff --git a/targets/frontend/src/components/contributions/questions/common/index.ts b/targets/frontend/src/components/contributions/questions/common/index.ts new file mode 100644 index 000000000..530e307e0 --- /dev/null +++ b/targets/frontend/src/components/contributions/questions/common/index.ts @@ -0,0 +1 @@ +export * from "./Form"; diff --git a/targets/frontend/src/components/contributions/questions/creation/index.tsx b/targets/frontend/src/components/contributions/questions/creation/index.tsx new file mode 100644 index 000000000..158320a70 --- /dev/null +++ b/targets/frontend/src/components/contributions/questions/creation/index.tsx @@ -0,0 +1,56 @@ +import { Form, QuestionFormData } from "../common"; +import { useQuestionCreationMutation } from "./question.mutation"; +import { useQuestionCreationDataQuery } from "./question.query"; +import { useRouter } from "next/router"; +import { Link, Stack, Typography } from "@mui/material"; +import SentimentVeryDissatisfiedIcon from "@mui/icons-material/SentimentVeryDissatisfied"; + +export const NewQuestion = (): JSX.Element => { + const router = useRouter(); + const { data } = useQuestionCreationDataQuery(); + const create = useQuestionCreationMutation(); + + const onCreate = async (formData: QuestionFormData) => { + console.log("On create new contrib"); + if (!data) { + throw new Error("Missing agreement list to create a new question"); + } + const result = await create({ + order: formData.order, + seo_title: formData.seo_title ? formData.seo_title : undefined, + content: formData.content, + message_id: formData.message_id ? formData.message_id : undefined, // use to transform empty string sent by the form to undefined + answers: { + data: data.agreementIds.map((item) => ({ + display_date: "01/01/2025", + agreement_id: item.id, + content_type: item.unextended ? "NOTHING" : undefined, + })), + }, + }); + + router.push( + `/contributions/questions/${result.insert_contribution_questions_one.id}` + ); + }; + if (!data) { + return ( + + + + Une erreur est survenue + + Retour à la liste des contributions + + ); + } + return ( + <> + + + ); +}; diff --git a/targets/frontend/src/components/contributions/questions/creation/question.mutation.ts b/targets/frontend/src/components/contributions/questions/creation/question.mutation.ts new file mode 100644 index 000000000..493e563c6 --- /dev/null +++ b/targets/frontend/src/components/contributions/questions/creation/question.mutation.ts @@ -0,0 +1,52 @@ +import { gql, useMutation } from "urql"; + +import { Answer, Question } from "../../type"; + +const questionCreationMutation = gql` + mutation CreateQuestion($question: contribution_questions_insert_input!) { + insert_contribution_questions_one(object: $question) { + id + } + } +`; + +type MutationProps = { + question: Pick & { + answers: { + data: { + display_date: Answer["displayDate"]; + agreement_id: Answer["agreementId"]; + content_type?: Answer["contentType"]; + }[]; + }; + }; +}; + +type Result = { + insert_contribution_questions_one: { + id: string; + }; +}; + +export type MutationResult = ( + props: MutationProps["question"] +) => Promise; + +export const useQuestionCreationMutation = (): MutationResult => { + const [, executeUpdate] = useMutation( + questionCreationMutation + ); + const resultFunction = async (question: MutationProps["question"]) => { + const result = await executeUpdate({ question }); + if (result.error) { + throw new Error(result.error.message); + } + if (!result.data) { + throw new Error( + "No data returned from 'useQuestionCreationMutation' mutation" + ); + } + return result.data; + }; + return resultFunction; +}; diff --git a/targets/frontend/src/components/contributions/questions/creation/question.query.ts b/targets/frontend/src/components/contributions/questions/creation/question.query.ts new file mode 100644 index 000000000..77185d4e5 --- /dev/null +++ b/targets/frontend/src/components/contributions/questions/creation/question.query.ts @@ -0,0 +1,64 @@ +import { CombinedError, gql, useQuery } from "urql"; +import { Message } from "../../type"; + +export const contributionQuestionCreationDataQuery = gql` + query SelectQuestionCreationData { + messages: contribution_question_messages { + contentAgreement + contentLegal + contentNotHandled + contentNotHandledWithoutLegal + contentAgreementWithoutLegal + id + label + } + agreements: agreement_agreements(where: { isSupported: { _eq: true } }) { + id + unextended + } + maxOrder: contribution_questions(order_by: { order: desc }, limit: 1) { + order + } + } +`; + +type QueryOutput = { + messages: Message[]; + agreements: { id: string; unextended: boolean }[]; + maxOrder: [{ order: number }]; +}; + +export type QueryResult = { + messages: Message[]; + agreementIds: { id: string; unextended: boolean }[]; + nextOrder: number; +}; + +type Result = { + data?: QueryResult; + error?: CombinedError; + fetching: boolean; +}; + +export const useQuestionCreationDataQuery = (): Result => { + const [{ data, error, fetching }] = useQuery({ + query: contributionQuestionCreationDataQuery, + requestPolicy: "cache-and-network", + }); + + let formattedData: Result["data"] = undefined; + if (data) { + formattedData = { + messages: data.messages, + agreementIds: data.agreements + .flatMap((item) => item) + .concat([{ id: "0000", unextended: false }]), + nextOrder: data.maxOrder[0].order + 1, + }; + } + return { + data: formattedData, + fetching, + error, + }; +}; diff --git a/targets/frontend/src/components/contributions/questions/edit/index.tsx b/targets/frontend/src/components/contributions/questions/edit/index.tsx index 977fa518d..30ffcb012 100644 --- a/targets/frontend/src/components/contributions/questions/edit/index.tsx +++ b/targets/frontend/src/components/contributions/questions/edit/index.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Form, QuestionFormData } from "./Form"; +import { Form, QuestionFormData } from "../common"; import { useQuestionUpdateMutation } from "./Question.mutation"; import { useQuestionQuery } from "./Question.query"; @@ -29,7 +29,7 @@ export const EditQuestion = ({ )} diff --git a/targets/frontend/src/components/contributions/type.ts b/targets/frontend/src/components/contributions/type.ts index 299da0ad7..b5b8d0d68 100644 --- a/targets/frontend/src/components/contributions/type.ts +++ b/targets/frontend/src/components/contributions/type.ts @@ -154,7 +154,7 @@ export const questionBaseSchema = z.object({ required_error: "Une question doit être renseignée", }) .min(1, "Une question doit être renseignée"), - order: z.number(), + order: z.coerce.number(), message_id: z.string().uuid().optional(), seo_title: z.string().optional(), }); diff --git a/targets/frontend/src/components/forms/TextField/index.tsx b/targets/frontend/src/components/forms/TextField/index.tsx index 49f73ee1c..f39ed0ef6 100644 --- a/targets/frontend/src/components/forms/TextField/index.tsx +++ b/targets/frontend/src/components/forms/TextField/index.tsx @@ -16,7 +16,7 @@ type FormTextFieldProps = CommonFormProps & { labelFixed?: boolean; id?: string; type?: React.InputHTMLAttributes["type"]; - hintText?: string; + hintText?: string | React.ReactElement; placeholder?: string; }; diff --git a/targets/frontend/src/modules/documents/api/documents.service.ts b/targets/frontend/src/modules/documents/api/documents.service.ts index 35a4fec43..9596fdfa2 100644 --- a/targets/frontend/src/modules/documents/api/documents.service.ts +++ b/targets/frontend/src/modules/documents/api/documents.service.ts @@ -13,6 +13,7 @@ import pMap from "p-map"; import { mapAgreementToDocument } from "src/modules/agreements/mapAgreementToDocument"; import { mapInformationToDocument } from "src/modules/informations/mapInformationToDocument"; import { mapModelToDocument } from "src/modules/models/mapModelToDocument"; +import { HasuraDocument } from "@socialgouv/cdtn-types"; export class DocumentsService { private readonly informationsRepository: InformationsRepository; @@ -40,6 +41,11 @@ export class DocumentsService { source, initialId: id, }); + + let postTreatment: + | ((document: HasuraDocument) => Promise) + | undefined = undefined; + switch (source) { case "information": const information = await this.informationsRepository.fetchInformation( @@ -89,11 +95,13 @@ export class DocumentsService { } ); - if (!contribution.cdtnId && document?.cdtn_id) { - this.contributionRepository.updateCdtnId( - contribution.id, - document.cdtn_id - ); + if (!contribution.cdtnId) { + postTreatment = async (document) => { + await this.contributionRepository.updateCdtnId( + contribution.id, + document.cdtn_id + ); + }; } break; @@ -129,7 +137,13 @@ export class DocumentsService { return await this.documentsRepository.remove(id); } - return await this.documentsRepository.update(document); + const result = await this.documentsRepository.update(document); + + if (postTreatment) { + await postTreatment(document); + } + + return result; } public async publishAll( diff --git a/targets/frontend/src/pages/contributions/questions/creation.tsx b/targets/frontend/src/pages/contributions/questions/creation.tsx new file mode 100644 index 000000000..8aaacd205 --- /dev/null +++ b/targets/frontend/src/pages/contributions/questions/creation.tsx @@ -0,0 +1,12 @@ +import { NewQuestion } from "../../../components/contributions/questions/creation"; +import { Layout } from "src/components/layout/auth.layout"; + +export function ContributionsPage() { + return ( + + + + ); +} + +export default ContributionsPage; diff --git a/targets/hasura/metadata/databases/default/tables/contribution_answers.yaml b/targets/hasura/metadata/databases/default/tables/contribution_answers.yaml index 5bb26ba9f..32e8b0217 100644 --- a/targets/hasura/metadata/databases/default/tables/contribution_answers.yaml +++ b/targets/hasura/metadata/databases/default/tables/contribution_answers.yaml @@ -75,6 +75,16 @@ array_relationships: remote_table: name: answer_statuses schema: contribution +insert_permissions: + - role: super + permission: + check: {} + columns: + - agreement_id + - content_type + - display_date + - question_id + comment: "" select_permissions: - role: super permission: