diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index cc4e6d2dbd..353a7e5fdf 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -799,6 +799,6 @@ export interface AssessmentsWithArchetype { archetype: Archetype; assessments: Assessment[]; } -export interface GroupedRef extends Ref { - group: "stakeholder" | "stakeholderGroup"; +export interface GroupedStakeholderRef extends Ref { + group: "Stakeholder" | "Stakeholder Group"; } diff --git a/client/src/app/components/Autocomplete.tsx b/client/src/app/components/Autocomplete.tsx index 76d9a45b79..0a0cdbaf09 100644 --- a/client/src/app/components/Autocomplete.tsx +++ b/client/src/app/components/Autocomplete.tsx @@ -18,6 +18,8 @@ import { const toString = (input: string | (() => string)) => typeof input === "function" ? input() : input; +const createCompositeKey = (group: string, id: number) => `${group}:${id}`; + export interface AutocompleteOptionProps { /** id for the option */ id: number; @@ -72,7 +74,8 @@ export const Autocomplete: React.FC = ({ noResultsMessage = "No results found", }) => { const [inputValue, setInputValue] = useState(searchString); - const [tabSelectedItemId, setTabSelectedItemId] = useState(); + const [tabSelectedItemId, setTabSelectedItemId] = useState(); + const [menuIsOpen, setMenuIsOpen] = useState(false); /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ @@ -83,22 +86,40 @@ export const Autocomplete: React.FC = ({ if (!selections || selections.length === 0) { return []; } - return options.filter( - ({ id }) => selections.findIndex((s) => s.id === id) > -1 - ); - }, [options, selections]); + return isGrouped + ? options.filter((option) => { + return selections.some((selection) => { + return ( + selection.id === option.id && selection.group === option.group + ); + }); + }) + : options.filter((option) => { + return ( + selections.findIndex((selection) => selection.id === option.id) > -1 + ); + }); + }, [options, selections, isGrouped]); const filteredOptions = useMemo(() => { - return options.filter( - ({ id, name }) => - selections.findIndex((s) => s.id === id) === -1 && - toString(name).toLowerCase().includes(inputValue.toLocaleLowerCase()) - ); - }, [options, selections, inputValue]); + return options.filter((option) => { + const isOptionSelected = selections.some((selection) => { + const isSelectedById = selection.id === option.id; + const isSelectedByGroup = isGrouped + ? selection.group === option.group + : true; + return isSelectedById && isSelectedByGroup; + }); + + const isNameMatch = toString(option.name) + .toLowerCase() + .includes(inputValue.toLowerCase()); + return !isOptionSelected && isNameMatch; + }); + }, [options, selections, inputValue, isGrouped]); const groupedOptions = useMemo((): GroupedOptions => { if (!isGrouped) { - // If not grouped, return an empty object or handle accordingly return {}; } @@ -113,18 +134,36 @@ export const Autocomplete: React.FC = ({ }, [filteredOptions, isGrouped]); /** callback for removing a selection */ - const deleteSelectionByItemId = (idToDelete: number) => { - onChange(selections.filter(({ id }) => id !== idToDelete)); + const deleteSelectionByItemId = ( + idToDelete: number, + groupToDelete?: string + ) => { + const newSelections = selections.filter((selection) => { + if (isGrouped) { + return !( + selection.id === idToDelete && selection.group === groupToDelete + ); + } + return selection.id !== idToDelete; + }); + + onChange(newSelections); }; /** lookup the option matching the itemId and add as a selection */ - const addSelectionByItemId = (itemId: string | number) => { - const asNumber = typeof itemId === "string" ? parseInt(itemId, 10) : itemId; - const matchingOption = options.find(({ id }) => id === asNumber); + const addSelectionByItemId = (compositeKey: string) => { + const [group, idStr] = compositeKey.split(":"); + const id = parseInt(idStr, 10); + const matchingOption = options.find( + ({ id: optionId, group: optionGroup }) => + id === optionId && group === optionGroup + ); - onChange([...selections, matchingOption].filter(Boolean)); - setInputValue(""); - setMenuIsOpen(false); + if (matchingOption) { + onChange([...selections, matchingOption].filter(Boolean)); + setInputValue(""); + setMenuIsOpen(false); + } }; /** callback for updating the inputValue state in this component so that the input can be controlled */ @@ -146,15 +185,22 @@ export const Autocomplete: React.FC = ({ /** close the menu, and if only 1 filtered option exists, select it */ const handleTab = (event: React.KeyboardEvent) => { if (filteredOptions.length === 1) { - setInputValue(toString(filteredOptions[0].name)); - setTabSelectedItemId(filteredOptions[0].id); + const option = filteredOptions[0]; + const compositeKey = + isGrouped && option.group + ? createCompositeKey(option.group, option.id) + : option.id.toString(); + setInputValue(toString(option.name)); + setTabSelectedItemId(compositeKey); event.preventDefault(); } setMenuIsOpen(false); }; /** close the menu when escape is hit */ - const handleEscape = () => { + const handleEscape = (event: React.KeyboardEvent) => { + event.stopPropagation(); + setMenuIsOpen(false); }; @@ -182,7 +228,7 @@ export const Autocomplete: React.FC = ({ handleEnter(); break; case "Escape": - handleEscape(); + handleEscape(event); break; case "Tab": handleTab(event); @@ -205,14 +251,18 @@ export const Autocomplete: React.FC = ({ /** add the text of the selected menu item to the selected items */ const handleMenuItemOnSelect = ( event: React.MouseEvent | undefined, - itemId: number + itemId: number, + groupName?: string ) => { - if (!event || !itemId) { - return; - } + if (!event) return; event.stopPropagation(); focusTextInput(true); - addSelectionByItemId(itemId); + + const compositeKey = + isGrouped && groupName + ? createCompositeKey(groupName, itemId) + : itemId.toString(); + addSelectionByItemId(compositeKey); }; /** close the menu when a click occurs outside of the menu or text input group */ @@ -291,11 +341,31 @@ export const Autocomplete: React.FC = ({ handleMenuItemOnSelect(e, option.id)} + onClick={(e) => + isGrouped + ? handleMenuItemOnSelect(e, option.id, groupName) + : handleMenuItemOnSelect(e, option.id) + } > {toString(option.name)} ))} + {/* if supplied, add the menu heading */} + {menuHeader ? ( + <> + + {menuHeader} + + + + ) : undefined} + + {/* show a disabled "no result" when all menu items are filtered out */} + {groupOptions.length === 0 ? ( + + {noResultsMessage} + + ) : undefined} @@ -304,6 +374,22 @@ export const Autocomplete: React.FC = ({ } else { return ( + {/* if supplied, add the menu heading */} + {menuHeader ? ( + <> + + {menuHeader} + + + + ) : undefined} + + {/* show a disabled "no result" when all menu items are filtered out */} + {filteredOptions.length === 0 ? ( + + {noResultsMessage} + + ) : undefined} {filteredOptions.map((option) => ( = ({ {renderMenuItems()} ); - return ( @@ -338,12 +423,14 @@ export const Autocomplete: React.FC = ({ - {selectedOptions.map(({ id, name, labelName, tooltip }) => ( - + {selectedOptions.map(({ id, name, group, labelName, tooltip }) => ( + diff --git a/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx b/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx index f2871b2685..8577d7f1b2 100644 --- a/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx +++ b/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx @@ -6,7 +6,6 @@ import { AutocompleteOptionProps, } from "@app/components/Autocomplete"; -// 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 diff --git a/client/src/app/pages/assessment/components/assessment-stakeholders-form/assessment-stakeholders-form.tsx b/client/src/app/pages/assessment/components/assessment-stakeholders-form/assessment-stakeholders-form.tsx index 738569869e..756decb03a 100644 --- a/client/src/app/pages/assessment/components/assessment-stakeholders-form/assessment-stakeholders-form.tsx +++ b/client/src/app/pages/assessment/components/assessment-stakeholders-form/assessment-stakeholders-form.tsx @@ -14,29 +14,14 @@ import { useFetchStakeholders } from "@app/queries/stakeholders"; import { useFetchStakeholderGroups } from "@app/queries/stakeholdergroups"; import { HookFormAutocomplete } from "@app/components/HookFormPFFields"; import { AssessmentWizardValues } from "../assessment-wizard/assessment-wizard"; -import { GroupedRef, Ref } from "@app/api/models"; +import { GroupedStakeholderRef, Ref } from "@app/api/models"; export const AssessmentStakeholdersForm: React.FC = () => { const { t } = useTranslation(); const { control } = useFormContext(); const { stakeholders } = useFetchStakeholders(); - // const stakeholderItems = useMemo( - // () => - // stakeholders - // .map(({ id, name }) => ({ id, name })) - // .sort((a, b) => a.name.localeCompare(b.name)), - // [stakeholders] - // ); - const { stakeholderGroups } = useFetchStakeholderGroups(); - // const stakeholderGroupItems = useMemo( - // () => - // stakeholderGroups - // .map(({ id, name }) => ({ id, name })) - // .sort((a, b) => a.name.localeCompare(b.name)), - // [stakeholderGroups] - // ); const stakeholdersAndGroupsItems = useMemo( () => combineAndGroupStakeholderRefs(stakeholders, stakeholderGroups), [stakeholders, stakeholderGroups] @@ -68,21 +53,10 @@ export const AssessmentStakeholdersForm: React.FC = () => { placeholderText={t("composed.selectMany", { what: t("terms.stakeholder(s)").toLowerCase(), })} + isGrouped + isRequired searchInputAriaLabel="stakeholders-and-groups-select-toggle" /> - - {/* - items={stakeholderGroupItems} - control={control} - name="stakeholderGroups" - label="Stakeholder Group(s)" - fieldId="stakeholderGroups" - noResultsMessage={t("message.noResultsFoundTitle")} - placeholderText={t("composed.selectMany", { - what: t("terms.stakeholderGroup(s)").toLowerCase(), - })} - searchInputAriaLabel="stakeholder-groups-select-toggle" - /> */} @@ -93,11 +67,11 @@ export const AssessmentStakeholdersForm: React.FC = () => { export const combineAndGroupStakeholderRefs = ( stakeholderRefs: Ref[], stakeholderGroupRefs: Ref[] -): GroupedRef[] => { - const groupedRefs: GroupedRef[] = [ - ...stakeholderRefs.map((ref) => createGroupedRef(ref, "stakeholder")), +): GroupedStakeholderRef[] => { + const groupedRefs: GroupedStakeholderRef[] = [ + ...stakeholderRefs.map((ref) => createGroupedRef(ref, "Stakeholder")), ...stakeholderGroupRefs.map((ref) => - createGroupedRef(ref, "stakeholderGroup") + createGroupedRef(ref, "Stakeholder Group") ), ]; return groupedRefs; @@ -105,8 +79,8 @@ export const combineAndGroupStakeholderRefs = ( export const createGroupedRef = ( ref: Ref, - group: "stakeholder" | "stakeholderGroup" -): GroupedRef => ({ + group: "Stakeholder" | "Stakeholder Group" +): GroupedStakeholderRef => ({ ...ref, group, }); diff --git a/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx b/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx index c24dec8db0..2f69332e03 100644 --- a/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx +++ b/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx @@ -14,7 +14,7 @@ import { Assessment, AssessmentStatus, AssessmentWithSectionOrder, - GroupedRef, + GroupedStakeholderRef, QuestionWithSectionOrder, Ref, SectionWithQuestionOrder, @@ -57,7 +57,7 @@ export enum SAVE_ACTION_VALUE { } export interface AssessmentWizardValues { - stakeholdersAndGroupsRefs: GroupedRef[]; + stakeholdersAndGroupsRefs: GroupedStakeholderRef[]; [COMMENTS_KEY]: { [key: string]: string; // @@ -142,7 +142,7 @@ export const AssessmentWizard: React.FC = ({ name: yup.string().required(), group: yup .string() - .oneOf(["stakeholder", "stakeholderGroup"]) + .oneOf(["Stakeholder", "Stakeholder Group"]) .required(), }) ), @@ -260,21 +260,15 @@ export const AssessmentWizard: React.FC = ({ }) || []; return sections; }; - //Fix - // const { stakeholders, stakeholderGroups } = separateStakeholdersAndGroups( - // formValues.stakeholdersAndGroupsRefs - // ); const mapAndSeparateStakeholdersAndGroups = ( - combinedRefs: GroupedRef[] + combinedRefs: GroupedStakeholderRef[] ): { stakeholdersPayload: Ref[]; stakeholderGroupsPayload: Ref[] } => { - // Filter and map stakeholders const stakeholdersPayload = combinedRefs - .filter((ref) => ref.group === "stakeholder") + .filter((ref) => ref.group === "Stakeholder") .map(({ id, name }) => ({ id, name })); - // Filter and map stakeholder groups const stakeholderGroupsPayload = combinedRefs - .filter((ref) => ref.group === "stakeholderGroup") + .filter((ref) => ref.group === "Stakeholder Group") .map(({ id, name }) => ({ id, name })); return { stakeholdersPayload, stakeholderGroupsPayload }; @@ -614,7 +608,7 @@ export const AssessmentWizard: React.FC = ({ const combineStakeholdersAndGroups = ( assessment: AssessmentWithSectionOrder -): GroupedRef[] => { +): GroupedStakeholderRef[] => { const stakeholders = assessment.stakeholders ?? []; const stakeholderGroups = assessment.stakeholderGroups ?? [];