diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index ccf9810372..73e78cd41e 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -128,6 +128,7 @@ "newTag": "New Tag", "newTagCategory": "New tag category", "update": "Update {{what}}", + "updateArchetype": "Update archetype", "updateApplication": "Update application", "updateBusinessService": "Update business service", "updateJobFunction": "Update job function", diff --git a/client/src/app/components/Autocomplete.tsx b/client/src/app/components/Autocomplete.tsx index ea4b5ed520..71b4672e91 100644 --- a/client/src/app/components/Autocomplete.tsx +++ b/client/src/app/components/Autocomplete.tsx @@ -15,6 +15,7 @@ import { export interface IAutocompleteProps { onChange: (selections: string[]) => void; + id?: string; allowUserOptions?: boolean; options?: string[]; placeholderText?: string; @@ -27,6 +28,7 @@ export interface IAutocompleteProps { } export const Autocomplete: React.FC = ({ + id = "", onChange, options = [], allowUserOptions = false, @@ -258,6 +260,7 @@ export const Autocomplete: React.FC = ({ const inputGroup = (
= ({ return ( - + = ({ onDocumentClick={handleClick} /> - + {Array.from(currentChips).map((currentChip) => ( diff --git a/client/src/app/pages/archetypes/archetypes-page.tsx b/client/src/app/pages/archetypes/archetypes-page.tsx index 3978a068f0..58ec34debf 100644 --- a/client/src/app/pages/archetypes/archetypes-page.tsx +++ b/client/src/app/pages/archetypes/archetypes-page.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { @@ -9,6 +9,7 @@ import { EmptyStateFooter, EmptyStateHeader, EmptyStateIcon, + Modal, PageSection, PageSectionVariants, Text, @@ -46,6 +47,7 @@ import { import ArchetypeApplicationsColumn from "./components/archetype-applications-column"; import ArchetypeDescriptionColumn from "./components/archetype-description-column"; +import ArchetypeForm from "./components/archetype-form"; import ArchetypeMaintainersColumn from "./components/archetype-maintainers-column"; import ArchetypeTagsColumn from "./components/archetype-tags-column"; import { Archetype } from "@app/api/models"; @@ -58,6 +60,13 @@ const Archetypes: React.FC = () => { const history = useHistory(); const { pushNotification } = React.useContext(NotificationsContext); + const [openCreateArchetype, setOpenCreateArchetype] = + useState(false); + + const [archetypeToEdit, setArchetypeToEdit] = useState( + null + ); + const { archetypes, isFetching, error: fetchError } = useFetchArchetypes(); const onError = (error: AxiosError) => { @@ -143,7 +152,7 @@ const Archetypes: React.FC = () => { id="create-new-archetype" aria-label="Create new archetype" variant={ButtonVariant.primary} - onClick={() => {}} // TODO: Add create archetype modal + onClick={() => setOpenCreateArchetype(true)} > {t("dialog.title.newArchetype")} @@ -247,7 +256,7 @@ const Archetypes: React.FC = () => { }, { title: t("actions.edit"), - onClick: () => alert("TODO"), + onClick: () => setArchetypeToEdit(archetype), }, { isSeparator: true }, { @@ -270,8 +279,33 @@ const Archetypes: React.FC = () => { - {/* TODO: Add create/edit modal */} + {/* Create modal */} + setOpenCreateArchetype(false)} + > + setOpenCreateArchetype(false)} /> + + + {/* Edit modal */} + setArchetypeToEdit(null)} + > + setArchetypeToEdit(null)} + /> + + {/* TODO: Add duplicate confirm modal */} + + {/* Delete confirm modal */} void; +} + +export const ArchetypeForm: React.FC = ({ + toEdit = null, + onClose, +}) => { + const isCreate = toEdit === null; + const { t } = useTranslation(); + + const { + archetype, // TODO: Use this or just rely on `toEdit`? + existingArchetypes, + tags, + stakeholders, + stakeholderGroups, + createArchetype, + updateArchetype, + } = useArchetypeFormData({ + id: toEdit?.id, + onActionSuccess: onClose, + }); + + const validationSchema = yup.object().shape({ + // for text input fields + name: yup + .string() + .trim() + .required(t("validation.required")) + .min(3, t("validation.minLength", { length: 3 })) + .max(120, t("validation.maxLength", { length: 120 })) + .test( + "Duplicate name", + "An archetype with this name already exists. Use a different name.", + (value) => + duplicateNameCheck(existingArchetypes, toEdit || null, value ?? "") + ), + + description: yup + .string() + .trim() + .max(250, t("validation.maxLength", { length: 250 })), + + comments: yup + .string() + .trim() + .max(250, t("validation.maxLength", { length: 250 })), + + // for complex data fields + criteriaTags: yup + .array() + .of(yup.string()) + .min(1) + .required(t("validation.required")), + + tags: yup + .array() + .of(yup.string()) + .min(1) + .required(t("validation.required")), + + stakeholders: yup.array().of(yup.string()), + + stakeholderGroups: yup.array().of(yup.string()), + }); + + const { + handleSubmit, + formState: { isSubmitting, isValidating, isValid, isDirty }, + control, + } = useForm({ + defaultValues: { + name: toEdit?.name || "", + description: toEdit?.description || "", + comments: toEdit?.comments || "", + + criteriaTags: toEdit?.criteriaTags?.map((tag) => tag.name).sort() ?? [], + tags: toEdit?.archetypeTags?.map((tag) => tag.name).sort() ?? [], + + stakeholders: toEdit?.stakeholders?.map((sh) => sh.name).sort() ?? [], + stakeholderGroups: + toEdit?.stakeholderGroups?.map((sg) => sg.name).sort() ?? [], + }, + resolver: yupResolver(validationSchema), + mode: "all", + }); + + const onValidSubmit = (values: ArchetypeFormValues) => { + const payload: Archetype = { + id: toEdit?.id || -1, // TODO: verify the -1 will be thrown out on create + name: values.name.trim(), + description: values.description?.trim() ?? "", + comments: values.comments?.trim() ?? "", + + criteriaTags: values.criteriaTags + .map((tagName) => tags.find((tag) => tag.name === tagName)) + .filter(Boolean) as Tag[], + + archetypeTags: values.tags + .map((tagName) => tags.find((tag) => tag.name === tagName)) + .filter(Boolean) as Tag[], + + stakeholders: + values.stakeholders === undefined + ? undefined + : (values.stakeholders + .map((name) => stakeholders.find((s) => s.name === name)) + .filter(Boolean) as Stakeholder[]), + + stakeholderGroups: + values.stakeholderGroups === undefined + ? undefined + : (values.stakeholderGroups + .map((name) => stakeholderGroups.find((s) => s.name === name)) + .filter(Boolean) as StakeholderGroup[]), + }; + + if (isCreate) { + createArchetype(payload); + } else { + updateArchetype(payload); + } + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + ); +}; + +export default ArchetypeForm; + +const useArchetypeFormData = ({ + id, + onActionSuccess = () => {}, + onActionFail = () => {}, +}: { + id?: number; + onActionSuccess?: () => void; + onActionFail?: () => void; +}) => { + const { t } = useTranslation(); + const { pushNotification } = React.useContext(NotificationsContext); + + const { archetypes: existingArchetypes } = useFetchArchetypes(); + const { archetype } = useFetchArchetypeById(id); + + const { tagCategories } = useFetchTagCategories(); + const tags = useMemo( + () => tagCategories.flatMap((tc) => tc.tags).filter(Boolean) as Tag[], + [tagCategories] + ); + + const { stakeholderGroups } = useFetchStakeholderGroups(); + const { stakeholders } = useFetchStakeholders(); + + const onCreateSuccess = (archetype: Archetype) => { + pushNotification({ + title: t("toastr.success.createWhat", { + type: t("terms.archetype"), + what: archetype.name, + }), + variant: "success", + }); + onActionSuccess(); + }; + + const onUpdateSuccess = (_id: number) => { + pushNotification({ + title: t("toastr.success.save", { + type: t("terms.archetype"), + }), + variant: "success", + }); + onActionSuccess(); + }; + + const onCreateUpdateError = (error: AxiosError) => { + pushNotification({ + title: getAxiosErrorMessage(error), + variant: "danger", + }); + onActionFail(); + }; + + const { mutate: createArchetype } = useCreateArchetypeMutation( + onCreateSuccess, + onCreateUpdateError + ); + + const { mutate: updateArchetype } = useUpdateArchetypeMutation( + onUpdateSuccess, + onCreateUpdateError + ); + + return { + archetype, + existingArchetypes, + createArchetype, + updateArchetype, + tagCategories, + tags, + stakeholders, + stakeholderGroups, + }; +}; diff --git a/client/src/app/pages/archetypes/components/archetype-form/index.ts b/client/src/app/pages/archetypes/components/archetype-form/index.ts new file mode 100644 index 0000000000..1c9e560e78 --- /dev/null +++ b/client/src/app/pages/archetypes/components/archetype-form/index.ts @@ -0,0 +1,2 @@ +export * from "./archetype-form"; +export { ArchetypeForm as default } from "./archetype-form"; diff --git a/client/src/app/pages/archetypes/components/items-select.tsx b/client/src/app/pages/archetypes/components/items-select.tsx new file mode 100644 index 0000000000..732cc0a2f2 --- /dev/null +++ b/client/src/app/pages/archetypes/components/items-select.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Control, Path } from "react-hook-form"; +import { HookFormPFGroupController } from "@app/components/HookFormPFFields"; +import { Autocomplete } from "@app/components/Autocomplete"; +import type { ArchetypeFormValues } from "./archetype-form"; + +// TODO: Currently only supports working with tag names (which only work if item names are globally unique) +// TODO: Does not support select menu grouping by category +// TODO: Does not support select menu selection checkboxes +// TODO: Does not support rendering item labels with item category color +// TODO: Does not support rendering item labels in item category groups +const ItemsSelect = ({ + items = [], + control, + name, + label, + fieldId, + noResultsMessage, + placeholderText, + searchInputAriaLabel, + isRequired = false, +}: { + items: ItemType[]; + control: Control; + name: Path; + label: string; + fieldId: string; + noResultsMessage: string; + placeholderText: string; + searchInputAriaLabel: string; + isRequired?: boolean; +}) => { + const itemsToName = () => items.map((item) => item.name).sort(); + + const normalizeSelections = (values: string | string[] | undefined) => + (Array.isArray(values) ? values : [values]).filter(Boolean) as string[]; + + return ( + ( + + )} + /> + ); +}; + +export default ItemsSelect; diff --git a/client/src/app/queries/archetypes.ts b/client/src/app/queries/archetypes.ts index 87b6a18e0f..5ab042b81e 100644 --- a/client/src/app/queries/archetypes.ts +++ b/client/src/app/queries/archetypes.ts @@ -30,11 +30,13 @@ export const useFetchArchetypes = () => { }; }; -export const useFetchArchetypeById = (id: number) => { +export const useFetchArchetypeById = (id?: number) => { const { data, isLoading, error } = useQuery({ queryKey: [ARCHETYPE_QUERY_KEY, id], - queryFn: () => getArchetypeById(id), + queryFn: () => + id === undefined ? Promise.resolve(undefined) : getArchetypeById(id), onError: (error: AxiosError) => console.log("error, ", error), + enabled: id !== undefined, }); return { diff --git a/client/src/mocks/stub-new-work/index.ts b/client/src/mocks/stub-new-work/index.ts index ea7b90f65e..d1c74db755 100644 --- a/client/src/mocks/stub-new-work/index.ts +++ b/client/src/mocks/stub-new-work/index.ts @@ -1,13 +1,10 @@ import { type RestHandler } from "msw"; -import questionnaires from "./questionnaires"; -import assessments from "./assessments"; -import applications from "./applications"; import archetypes from "./archetypes"; export default [ // ...questionnaires, // ...assessments, // ...applications, - // ...archetypes, + ...archetypes, ] as RestHandler[];