From 8c8a0f852d861febebc48674b8aceac6b1c3911f Mon Sep 17 00:00:00 2001 From: Ian Bolton Date: Tue, 21 May 2024 14:18:14 -0400 Subject: [PATCH] :sparkles: Reduce form fields in assessment wizard stakeholder page to a single dropdown (#1799) Resolves: https://github.com/konveyor/tackle2-ui/issues/1709 https://github.com/konveyor/tackle2-ui/assets/11218376/9969be00-dad7-4b3d-920e-27be7e3218c7 --------- Signed-off-by: Ian Bolton --- client/src/app/api/models.ts | 9 + client/src/app/components/Autocomplete.tsx | 336 ------------------ .../components/Autocomplete/Autocomplete.tsx | 177 +++++++++ .../Autocomplete/GroupedAutocomplete.tsx | 179 ++++++++++ .../components/Autocomplete/SearchInput.tsx | 62 ++++ .../app/components/Autocomplete/type-utils.ts | 30 ++ .../Autocomplete/useAutocompleteHandlers.ts | 180 ++++++++++ .../HookFormPFFields/HookFormAutocomplete.tsx | 53 ++- client/src/app/components/LabelTooltip.tsx | 14 + .../assessment-stakeholders-form.tsx | 65 ++-- .../assessment-wizard/assessment-wizard.tsx | 138 ++++--- client/src/app/utils/utils.ts | 3 + 12 files changed, 817 insertions(+), 429 deletions(-) delete mode 100644 client/src/app/components/Autocomplete.tsx create mode 100644 client/src/app/components/Autocomplete/Autocomplete.tsx create mode 100644 client/src/app/components/Autocomplete/GroupedAutocomplete.tsx create mode 100644 client/src/app/components/Autocomplete/SearchInput.tsx create mode 100644 client/src/app/components/Autocomplete/type-utils.ts create mode 100644 client/src/app/components/Autocomplete/useAutocompleteHandlers.ts create mode 100644 client/src/app/components/LabelTooltip.tsx diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 38885fce94..c106243c3e 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -800,3 +800,12 @@ export interface AssessmentsWithArchetype { archetype: Archetype; assessments: Assessment[]; } + +export enum StakeholderType { + Stakeholder = "Stakeholder", + StakeholderGroup = "Stakeholder Group", +} +export interface GroupedStakeholderRef extends Ref { + group: StakeholderType.Stakeholder | StakeholderType.StakeholderGroup; + uniqueId: string; +} diff --git a/client/src/app/components/Autocomplete.tsx b/client/src/app/components/Autocomplete.tsx deleted file mode 100644 index 9d49485eb4..0000000000 --- a/client/src/app/components/Autocomplete.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import React, { useState, useRef, useMemo } from "react"; -import { - Label, - LabelProps, - Flex, - FlexItem, - Menu, - MenuContent, - MenuItem, - MenuList, - Popper, - SearchInput, - Divider, - Tooltip, -} from "@patternfly/react-core"; - -const toString = (input: string | (() => string)) => - typeof input === "function" ? input() : input; - -export interface AutocompleteOptionProps { - /** id for the option */ - id: number; - - /** the text to display for the option */ - name: string | (() => string); - - /** the text to display on a label when the option is selected, defaults to `name` if not supplied */ - labelName?: string | (() => string); - - /** the tooltip to display on the Label when the option has been selected */ - tooltip?: string | (() => string); -} - -export interface IAutocompleteProps { - onChange: (selections: AutocompleteOptionProps[]) => void; - id?: string; - - /** The set of options to use for selection */ - options?: AutocompleteOptionProps[]; - selections?: AutocompleteOptionProps[]; - - placeholderText?: string; - searchString?: string; - searchInputAriaLabel?: string; - labelColor?: LabelProps["color"]; - menuHeader?: string; - noResultsMessage?: string; -} - -/** - * Multiple type-ahead with table complete and selection labels - */ -export const Autocomplete: React.FC = ({ - id = "", - onChange, - options = [], - placeholderText = "Search", - searchString = "", - searchInputAriaLabel = "Search input", - labelColor, - selections = [], - menuHeader = "", - noResultsMessage = "No results found", -}) => { - const [inputValue, setInputValue] = useState(searchString); - 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 */ - const menuRef = useRef(null); - const searchInputRef = useRef(null); - - const selectedOptions = useMemo(() => { - if (!selections || selections.length === 0) { - return []; - } - return options.filter( - ({ id }) => selections.findIndex((s) => s.id === id) > -1 - ); - }, [options, selections]); - - const filteredOptions = useMemo(() => { - return options.filter( - ({ id, name }) => - selections.findIndex((s) => s.id === id) === -1 && - toString(name).toLowerCase().includes(inputValue.toLocaleLowerCase()) - ); - }, [options, selections, inputValue]); - - /** callback for removing a selection */ - const deleteSelectionByItemId = (idToDelete: number) => { - onChange(selections.filter(({ id }) => id !== idToDelete)); - }; - - /** 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); - - onChange([...selections, matchingOption].filter(Boolean)); - setInputValue(""); - setMenuIsOpen(false); - }; - - /** callback for updating the inputValue state in this component so that the input can be controlled */ - const handleSearchInputOnChange = ( - _event: React.FormEvent, - value: string - ) => { - setInputValue(value); - }; - - /** add the current input value as a selection */ - const handleEnter = () => { - if (tabSelectedItemId) { - addSelectionByItemId(tabSelectedItemId); - setTabSelectedItemId(undefined); - } - }; - - /** 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); - event.preventDefault(); - } - setMenuIsOpen(false); - }; - - /** close the menu when escape is hit */ - const handleEscape = () => { - setMenuIsOpen(false); - }; - - /** allow the user to focus on the menu and navigate using the arrow keys */ - const handleArrowKey = () => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector( - "li > button:not(:disabled)" - ); - firstElement?.focus(); - } - }; - - /** reopen the menu if it's closed and any un-designated keys are hit */ - const handleDefault = () => { - if (!menuIsOpen) { - setMenuIsOpen(true); - } - }; - - /** enable keyboard only usage while focused on the text input */ - const handleSearchInputOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case "Enter": - handleEnter(); - break; - case "Escape": - handleEscape(); - break; - case "Tab": - handleTab(event); - break; - case "ArrowUp": - case "ArrowDown": - handleArrowKey(); - break; - default: - handleDefault(); - } - }; - - /** apply focus to the text input */ - const focusTextInput = (closeMenu = false) => { - searchInputRef.current?.querySelector("input")?.focus(); - closeMenu && setMenuIsOpen(false); - }; - - /** add the text of the selected menu item to the selected items */ - const handleMenuItemOnSelect = ( - event: React.MouseEvent | undefined, - itemId: number - ) => { - if (!event || !itemId) { - return; - } - event.stopPropagation(); - focusTextInput(true); - addSelectionByItemId(itemId); - }; - - /** close the menu when a click occurs outside of the menu or text input group */ - const handleOnDocumentClick = (event?: MouseEvent) => { - if (!event) { - return; - } - if (searchInputRef.current?.contains(event.target as HTMLElement)) { - setMenuIsOpen(true); - } - if ( - menuRef.current && - !menuRef.current.contains(event.target as HTMLElement) && - searchInputRef.current && - !searchInputRef.current.contains(event.target as HTMLElement) - ) { - setMenuIsOpen(false); - } - }; - - /** enable keyboard only usage while focused on the menu */ - const handleMenuOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case "Tab": - case "Escape": - event.preventDefault(); - focusTextInput(); - setMenuIsOpen(false); - break; - } - }; - - const hint = useMemo(() => { - if (filteredOptions.length === 0) { - return ""; - } - - if (filteredOptions.length === 1 && inputValue) { - const fullHint = toString(filteredOptions[0].name); - - if (fullHint.toLowerCase().indexOf(inputValue.toLowerCase())) { - // the match was found in a place other than the start, so typeahead wouldn't work right - return ""; - } else { - // use the input for the first part, otherwise case difference could make things look wrong - return inputValue + fullHint.substring(inputValue.length); - } - } - - return ""; - }, [filteredOptions, inputValue]); - - const inputGroup = ( -
- setInputValue("")} - onFocus={() => setMenuIsOpen(true)} - onKeyDown={handleSearchInputOnKeyDown} - placeholder={placeholderText} - aria-label={searchInputAriaLabel} - /> -
- ); - - const menu = ( - - - - {/* 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} - - {/* only show items that include the text in the input */} - {filteredOptions.map(({ id, name }, _index) => ( - handleMenuItemOnSelect(e, id)} - > - {toString(name)} - - ))} - - - - ); - - return ( - - - searchInputRef.current || document.body} - isVisible={menuIsOpen} - onDocumentClick={handleOnDocumentClick} - /> - - - - {selectedOptions.map(({ id, name, labelName, tooltip }) => ( - - - - - - ))} - - - - ); -}; - -const LabelToolip: React.FC<{ - content?: AutocompleteOptionProps["tooltip"]; - children: React.ReactElement; -}> = ({ content, children }) => - content ? ( - {toString(content)}}>{children} - ) : ( - children - ); diff --git a/client/src/app/components/Autocomplete/Autocomplete.tsx b/client/src/app/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000000..b5d6630215 --- /dev/null +++ b/client/src/app/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,177 @@ +import React, { useRef } from "react"; +import { + Label, + LabelProps, + Flex, + FlexItem, + Menu, + MenuContent, + MenuItem, + MenuList, + Popper, + Divider, + MenuGroup, +} from "@patternfly/react-core"; +import { LabelToolip } from "../LabelTooltip"; +import { getString } from "@app/utils/utils"; +import { useAutocompleteHandlers } from "./useAutocompleteHandlers"; +import { SearchInputComponent } from "./SearchInput"; +import { AnyAutocompleteOptionProps, getUniqueId } from "./type-utils"; + +export interface AutocompleteOptionProps { + /** id for the option */ + id: number; + + /** the text to display for the option */ + name: string | (() => string); + + /** the text to display on a label when the option is selected, defaults to `name` if not supplied */ + labelName?: string | (() => string); + + /** the tooltip to display on the Label when the option has been selected */ + tooltip?: string | (() => string); +} + +export interface IAutocompleteProps { + onChange: (selections: AnyAutocompleteOptionProps[]) => void; + id?: string; + + /** The set of options to use for selection */ + options?: AutocompleteOptionProps[]; + selections?: AutocompleteOptionProps[]; + + placeholderText?: string; + searchString?: string; + searchInputAriaLabel?: string; + labelColor?: LabelProps["color"]; + menuHeader?: string; + noResultsMessage?: string; +} + +/** + * Multiple type-ahead with table complete and selection labels + */ +export const Autocomplete: React.FC = ({ + id = "", + onChange, + options = [], + placeholderText = "Search", + searchString = "", + searchInputAriaLabel = "Search input", + labelColor, + selections = [], + menuHeader = "", + noResultsMessage = "No results found", +}) => { + const menuRef = useRef(null); + const searchInputRef = useRef(null); + const { + setInputValue, + inputValue, + menuIsOpen, + groupedFilteredOptions, + removeSelectionById, + handleMenuItemOnSelect, + handleMenuOnKeyDown, + handleOnDocumentClick, + handleInputChange, + handleKeyDown, + selectedOptions, + } = useAutocompleteHandlers({ + options, + searchString, + selections, + onChange, + menuRef, + searchInputRef, + }); + + const inputGroup = ( + setInputValue("")} + onKeyHandling={handleKeyDown} + options={options} + inputValue={inputValue} + inputRef={searchInputRef} + /> + ); + const renderMenuItems = () => { + const allGroups = Object.entries(groupedFilteredOptions); + if (allGroups.length === 0) { + return ( + + + {noResultsMessage || "No options available"} + + + ); + } + + return allGroups.map(([groupName, groupOptions], index) => ( + + + + {groupOptions.length > 0 ? ( + groupOptions.map((option) => ( + handleMenuItemOnSelect(e, option)} + > + {getString(option.labelName || option.name)} + + )) + ) : ( + + {noResultsMessage} + + )} + + + {index < allGroups.length - 1 && } + + )); + }; + + const menu = ( + + {renderMenuItems()} + + ); + + return ( + + + searchInputRef.current || document.body} + isVisible={menuIsOpen} + onDocumentClick={handleOnDocumentClick} + /> + + + + {selectedOptions.map((option) => ( + + + + + + ))} + + + + ); +}; diff --git a/client/src/app/components/Autocomplete/GroupedAutocomplete.tsx b/client/src/app/components/Autocomplete/GroupedAutocomplete.tsx new file mode 100644 index 0000000000..fcd621bcdc --- /dev/null +++ b/client/src/app/components/Autocomplete/GroupedAutocomplete.tsx @@ -0,0 +1,179 @@ +import React, { useRef } from "react"; +import { + Label, + LabelProps, + Flex, + FlexItem, + Menu, + MenuContent, + MenuItem, + MenuList, + Popper, + Divider, + MenuGroup, +} from "@patternfly/react-core"; +import { getString } from "@app/utils/utils"; +import { LabelToolip } from "../LabelTooltip"; +import { SearchInputComponent } from "./SearchInput"; +import { useAutocompleteHandlers } from "./useAutocompleteHandlers"; +import { AnyAutocompleteOptionProps, getUniqueId } from "./type-utils"; + +export interface GroupedAutocompleteOptionProps { + /** id for the option - unique id not the ref id */ + uniqueId: string; + + /** the text to display for the option */ + name: string | (() => string); + + /** the text to display on a label when the option is selected, defaults to `name` if not supplied */ + labelName?: string | (() => string); + + /** the tooltip to display on the Label when the option has been selected */ + tooltip?: string | (() => string); + /** the group to display the option in */ + group?: string; +} + +export interface IGroupedAutocompleteProps { + onChange: (selections: AnyAutocompleteOptionProps[]) => void; + id?: string; + + /** The set of options to use for selection */ + options?: GroupedAutocompleteOptionProps[]; + isGrouped?: boolean; + selections?: GroupedAutocompleteOptionProps[]; + + placeholderText?: string; + searchString?: string; + searchInputAriaLabel?: string; + labelColor?: LabelProps["color"]; + menuHeader?: string; + noResultsMessage?: string; +} + +/** + * Multiple type-ahead with table complete and selection labels + */ +export const GroupedAutocomplete: React.FC = ({ + id = "", + onChange, + options = [], + placeholderText = "Search", + searchString = "", + searchInputAriaLabel = "Search input", + labelColor, + selections = [], + noResultsMessage = "No results found", +}) => { + const menuRef = useRef(null); + const searchInputRef = useRef(null); + const { + setInputValue, + inputValue, + menuIsOpen, + groupedFilteredOptions, + removeSelectionById, + handleMenuItemOnSelect, + handleMenuOnKeyDown, + handleOnDocumentClick, + handleInputChange, + handleKeyDown, + selectedOptions, + } = useAutocompleteHandlers({ + options, + searchString, + selections, + onChange, + menuRef, + searchInputRef, + }); + + const inputGroup = ( + setInputValue("")} + onKeyHandling={handleKeyDown} + options={options} + inputValue={inputValue} + inputRef={searchInputRef} + /> + ); + const renderMenuItems = () => { + const allGroups = Object.entries(groupedFilteredOptions); + if (allGroups.length === 0) { + return ( + + + {noResultsMessage || "No options available"} + + + ); + } + + return allGroups.map(([groupName, groupOptions], index) => ( + + + + {groupOptions.length > 0 ? ( + groupOptions.map((option) => ( + handleMenuItemOnSelect(e, option)} + > + {getString(option.labelName || option.name)} + + )) + ) : ( + + {noResultsMessage} + + )} + + + {index < allGroups.length - 1 && } + + )); + }; + + const menu = ( + + {renderMenuItems()} + + ); + + return ( + + + searchInputRef.current || document.body} + isVisible={menuIsOpen} + onDocumentClick={handleOnDocumentClick} + /> + + + + {selectedOptions.map((option) => ( + + + + + + ))} + + + + ); +}; diff --git a/client/src/app/components/Autocomplete/SearchInput.tsx b/client/src/app/components/Autocomplete/SearchInput.tsx new file mode 100644 index 0000000000..ab8a867b44 --- /dev/null +++ b/client/src/app/components/Autocomplete/SearchInput.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { SearchInput } from "@patternfly/react-core"; +import { getString } from "@app/utils/utils"; +import { AnyAutocompleteOptionProps } from "./type-utils"; + +export interface SearchInputProps { + id: string; + placeholderText: string; + searchInputAriaLabel: string; + onSearchChange: (value: string) => void; + onClear: () => void; + onKeyHandling: (event: React.KeyboardEvent) => void; + inputValue: string; + inputRef: React.RefObject; + options: AnyAutocompleteOptionProps[]; +} + +export const SearchInputComponent: React.FC = ({ + id, + placeholderText, + searchInputAriaLabel, + onSearchChange, + onClear, + onKeyHandling, + options, + inputValue, + inputRef, +}) => { + const getHint = (): string => { + if (options.length === 0) { + return ""; + } + + if (options.length === 1 && inputValue) { + const fullHint = getString(options[0].name); + + if (fullHint.toLowerCase().indexOf(inputValue.toLowerCase()) === 0) { + return inputValue + fullHint.substring(inputValue.length); + } + } + + return ""; + }; + + const hint = getHint(); + + return ( +
+ onSearchChange(value)} + onClear={onClear} + onFocus={() => onKeyHandling(event as any)} + onKeyDown={onKeyHandling} + placeholder={placeholderText} + aria-label={searchInputAriaLabel} + /> +
+ ); +}; diff --git a/client/src/app/components/Autocomplete/type-utils.ts b/client/src/app/components/Autocomplete/type-utils.ts new file mode 100644 index 0000000000..847b40f436 --- /dev/null +++ b/client/src/app/components/Autocomplete/type-utils.ts @@ -0,0 +1,30 @@ +interface BaseOptionProps { + name: string | (() => string); + labelName?: string | (() => string); + tooltip?: string | (() => string); +} + +export interface GroupedAutocompleteOptionProps extends BaseOptionProps { + uniqueId: string; + group?: string; +} + +export interface AutocompleteOptionProps extends BaseOptionProps { + id: number; +} + +// Helper type for use in the hook and components +export type AnyAutocompleteOptionProps = + | GroupedAutocompleteOptionProps + | AutocompleteOptionProps; + +// Function to get the unique identifier from either type +export const getUniqueId = ( + option: AnyAutocompleteOptionProps +): string | number => { + return "uniqueId" in option ? option.uniqueId : option.id; +}; + +export interface GroupMap { + [key: string]: AnyAutocompleteOptionProps[]; +} diff --git a/client/src/app/components/Autocomplete/useAutocompleteHandlers.ts b/client/src/app/components/Autocomplete/useAutocompleteHandlers.ts new file mode 100644 index 0000000000..f57517813b --- /dev/null +++ b/client/src/app/components/Autocomplete/useAutocompleteHandlers.ts @@ -0,0 +1,180 @@ +import { useMemo, useState } from "react"; +import { + AnyAutocompleteOptionProps, + GroupMap, + getUniqueId, +} from "./type-utils"; + +interface AutocompleteLogicProps { + options: AnyAutocompleteOptionProps[]; + searchString: string; + selections: AnyAutocompleteOptionProps[]; + onChange: (selections: AnyAutocompleteOptionProps[]) => void; + menuRef: React.RefObject; + searchInputRef: React.RefObject; +} + +export const useAutocompleteHandlers = ({ + options, + searchString, + selections, + onChange, + menuRef, + searchInputRef, +}: AutocompleteLogicProps) => { + const [inputValue, setInputValue] = useState(searchString); + const [menuIsOpen, setMenuIsOpen] = useState(false); + const [tabSelectedItemId, setTabSelectedItemId] = useState< + string | number | null + >(null); + + const groupedFilteredOptions = useMemo(() => { + const groups: GroupMap = {}; + + options.forEach((option) => { + const isOptionSelected = selections.some( + (selection) => getUniqueId(selection) === getUniqueId(option) + ); + + const optionName = + typeof option.name === "function" ? option.name() : option.name; + + if ( + !isOptionSelected && + optionName.toLowerCase().includes(inputValue.toLowerCase()) + ) { + const groupName = "group" in option && option.group ? option.group : ""; + + if (!groups[groupName]) { + groups[groupName] = []; + } + + // Add the option to the appropriate group + groups[groupName].push(option); + } + }); + + return groups; + }, [options, selections, inputValue]); + const allOptions = Object.values(groupedFilteredOptions).flat(); + + const handleInputChange = (value: string) => { + setInputValue(value); + }; + + const addSelectionByItemId = (itemId: string | number) => { + const matchingOption = options.find( + (option) => getUniqueId(option) === itemId + ); + + if (matchingOption) { + const updatedSelections = [...selections, matchingOption].filter(Boolean); + onChange(updatedSelections); + setInputValue(""); + setMenuIsOpen(false); + } + }; + + const removeSelectionById = (idToDelete: string | number) => { + const updatedSelections = selections.filter( + (selection) => getUniqueId(selection) !== idToDelete + ); + + onChange(updatedSelections); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "enter": + if (tabSelectedItemId) { + addSelectionByItemId(tabSelectedItemId); + setTabSelectedItemId(null); + } + break; + case "Escape": + event.stopPropagation(); + setMenuIsOpen(false); + break; + case "Tab": + break; + + case "ArrowUp": + case "ArrowDown": + if (menuRef.current) { + const firstElement = menuRef.current.querySelector( + "li > button:not(:disabled)" + ); + firstElement?.focus(); + } + break; + default: + if (!menuIsOpen) setMenuIsOpen(true); + break; + } + }; + + // Click handling outside of component to close menu + const handleOnDocumentClick = (event?: MouseEvent) => { + if (!event) { + return; + } + if (searchInputRef.current?.contains(event.target as HTMLElement)) { + setMenuIsOpen(true); + } + if ( + menuRef.current && + !menuRef.current.contains(event.target as HTMLElement) && + searchInputRef.current && + !searchInputRef.current.contains(event.target as HTMLElement) + ) { + setMenuIsOpen(false); + } + }; + + // Menu-specific key handling + const handleMenuOnKeyDown = (event: React.KeyboardEvent) => { + if (["Tab", "Escape"].includes(event.key)) { + event.preventDefault(); + searchInputRef.current?.querySelector("input")?.focus(); + setMenuIsOpen(false); + } + }; + + // Selecting an item from the menu + const handleMenuItemOnSelect = ( + event: React.MouseEvent | undefined, + option: AnyAutocompleteOptionProps + ) => { + if (!event) return; + event.stopPropagation(); + searchInputRef.current?.querySelector("input")?.focus(); + addSelectionByItemId(getUniqueId(option)); + }; + + const selectedOptions = useMemo(() => { + if (!selections || selections.length === 0) { + return []; + } + return options.filter((option) => { + return selections.some((selection) => { + return getUniqueId(selection) === getUniqueId(option); + }); + }); + }, [options, selections]); + + return { + setInputValue, + inputValue, + menuIsOpen, + groupedFilteredOptions, + handleInputChange, + handleKeyDown, + handleMenuItemOnSelect, + handleOnDocumentClick, + handleMenuOnKeyDown, + menuRef, + searchInputRef, + removeSelectionById, + selectedOptions, + }; +}; diff --git a/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx b/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx index fcfb089795..05edef2a59 100644 --- a/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx +++ b/client/src/app/components/HookFormPFFields/HookFormAutocomplete.tsx @@ -4,15 +4,20 @@ import { HookFormPFGroupController } from "@app/components/HookFormPFFields"; import { Autocomplete, AutocompleteOptionProps, -} from "@app/components/Autocomplete"; +} from "@app/components/Autocomplete/Autocomplete"; +import { + GroupedAutocomplete, + GroupedAutocompleteOptionProps, +} from "../Autocomplete/GroupedAutocomplete"; -// 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 export const HookFormAutocomplete = ({ items = [], + groupedItems = [], + isGrouped = false, label, fieldId, name, @@ -22,7 +27,9 @@ export const HookFormAutocomplete = ({ searchInputAriaLabel, isRequired = false, }: { - items: AutocompleteOptionProps[]; + items?: AutocompleteOptionProps[]; + groupedItems?: GroupedAutocompleteOptionProps[]; + isGrouped?: boolean; name: Path; control: Control; label: string; @@ -38,19 +45,33 @@ export const HookFormAutocomplete = ({ name={name} label={label} fieldId={fieldId} - renderInput={({ field: { value, onChange } }) => ( - { - onChange(selection); - }} - /> - )} + renderInput={({ field: { value, onChange } }) => + isGrouped ? ( + { + onChange(selection); + }} + /> + ) : ( + { + onChange(selection); + }} + /> + ) + } /> ); diff --git a/client/src/app/components/LabelTooltip.tsx b/client/src/app/components/LabelTooltip.tsx new file mode 100644 index 0000000000..1436d95dee --- /dev/null +++ b/client/src/app/components/LabelTooltip.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Tooltip } from "@patternfly/react-core"; +import { getString } from "@app/utils/utils"; +import { AutocompleteOptionProps } from "./Autocomplete/Autocomplete"; + +export const LabelToolip: React.FC<{ + content?: AutocompleteOptionProps["tooltip"]; + children: React.ReactElement; +}> = ({ content, children }) => + content ? ( + {getString(content)}}>{children} + ) : ( + children + ); 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 6231f92bea..86220e0a4a 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,27 +14,17 @@ 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 { GroupedStakeholderRef, Ref, StakeholderType } 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] ); return ( @@ -54,29 +44,18 @@ export const AssessmentStakeholdersForm: React.FC = () => { - items={stakeholderItems} + isGrouped + groupedItems={stakeholdersAndGroupsItems} control={control} - name="stakeholders" - label="Stakeholder(s)" - fieldId="stakeholders" + name="stakeholdersAndGroupsRefs" + label="Stakeholder(s) and Stakeholder Group(s)" + fieldId="stakeholdersAndGroups" noResultsMessage={t("message.noResultsFoundTitle")} placeholderText={t("composed.selectMany", { what: t("terms.stakeholder(s)").toLowerCase(), })} - searchInputAriaLabel="stakeholder-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" + isRequired + searchInputAriaLabel="stakeholders-and-groups-select-toggle" /> @@ -84,3 +63,25 @@ export const AssessmentStakeholdersForm: React.FC = () => { ); }; + +const createCompositeKey = (group: string, id: number) => `${group}:${id}`; + +export const combineAndGroupStakeholderRefs = ( + stakeholderRefs: Ref[], + stakeholderGroupRefs: Ref[] +) => { + const groupedRefs: GroupedStakeholderRef[] = [ + ...stakeholderRefs.map((ref) => ({ + ...ref, + uniqueId: createCompositeKey("Stakeholder", ref.id), + group: StakeholderType.Stakeholder, + })), + ...stakeholderGroupRefs.map((ref) => ({ + ...ref, + uniqueId: createCompositeKey("Stakeholder Group", ref.id), + group: StakeholderType.StakeholderGroup, + })), + ]; + + return groupedRefs; +}; 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 7c2e1950be..660f2c2665 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,6 +14,7 @@ import { Assessment, AssessmentStatus, AssessmentWithSectionOrder, + GroupedStakeholderRef, QuestionWithSectionOrder, Ref, SectionWithQuestionOrder, @@ -39,7 +40,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { formatPath, getAxiosErrorMessage } from "@app/utils/utils"; import { Paths } from "@app/Paths"; import { yupResolver } from "@hookform/resolvers/yup"; -import { AssessmentStakeholdersForm } from "../assessment-stakeholders-form/assessment-stakeholders-form"; +import { + AssessmentStakeholdersForm, + combineAndGroupStakeholderRefs, +} from "../assessment-stakeholders-form/assessment-stakeholders-form"; import useIsArchetype from "@app/hooks/useIsArchetype"; import { WizardStepNavDescription } from "../wizard-step-nav-description"; import { AppPlaceholder } from "@app/components/AppPlaceholder"; @@ -53,8 +57,7 @@ export enum SAVE_ACTION_VALUE { } export interface AssessmentWizardValues { - stakeholders: Ref[]; - stakeholderGroups: Ref[]; + stakeholdersAndGroupsRefs: GroupedStakeholderRef[]; [COMMENTS_KEY]: { [key: string]: string; // @@ -135,21 +138,27 @@ export const AssessmentWizard: React.FC = ({ }, [assessment]); const validationSchema = yup.object().shape({ - stakeholders: yup - .array() - .of(yup.object({ id: yup.number(), name: yup.string() })), // Ref - - stakeholderGroups: yup - .array() - .of(yup.object({ id: yup.number(), name: yup.string() })), // Ref + stakeholdersAndGroupsRefs: yup.array().of( + yup.object().shape({ + id: yup.number().required(), + name: yup.string().required(), + group: yup + .string() + .oneOf(["Stakeholder", "Stakeholder Group"]) + .required(), + }) + ), }); const methods = useForm({ resolver: yupResolver(validationSchema), mode: "all", defaultValues: { - stakeholders: initialStakeholders, - stakeholderGroups: initialStakeholderGroups, + // stakeholders: assessment?.stakeholders ?? [], + // stakeholderGroups: assessment?.stakeholderGroups ?? [], + stakeholdersAndGroupsRefs: assessment + ? combineStakeholdersAndGroups(assessment) + : [], [COMMENTS_KEY]: initialComments, [QUESTIONS_KEY]: initialQuestions, @@ -166,9 +175,9 @@ export const AssessmentWizard: React.FC = ({ const disableNavigation = !isValid || isSubmitting; const isFirstStepValid = () => { - const numberOfStakeholdlers = values?.stakeholders?.length || 0; - const numberOfGroups = values?.stakeholderGroups?.length || 0; - return numberOfStakeholdlers + numberOfGroups > 0; + const numberOfStakeholdlersAndGroups = + values?.stakeholdersAndGroupsRefs?.length || 0; + return numberOfStakeholdlersAndGroups > 0; }; const isQuestionValid = (question: QuestionWithSectionOrder): boolean => { @@ -252,20 +261,19 @@ export const AssessmentWizard: React.FC = ({ }) || []; return sections; }; - - const stakeholdersToPayload = ( - stakeholders?: AssessmentWizardValues["stakeholders"] - ): Ref[] | undefined => - !stakeholders - ? undefined - : stakeholders.map(({ id, name }) => ({ id, name })).filter(Boolean); - - const stakeholderGroupsToPayload = ( - stakeholderGroups?: AssessmentWizardValues["stakeholderGroups"] - ): Ref[] | undefined => - !stakeholderGroups - ? undefined - : stakeholderGroups.map(({ id, name }) => ({ id, name })).filter(Boolean); + const mapAndSeparateStakeholdersAndGroups = ( + combinedRefs: GroupedStakeholderRef[] + ): { stakeholdersPayload: Ref[]; stakeholderGroupsPayload: Ref[] } => { + const stakeholdersPayload = combinedRefs + .filter((ref) => ref.group === "Stakeholder") + .map(({ id, name }) => ({ id, name })); + + const stakeholderGroupsPayload = combinedRefs + .filter((ref) => ref.group === "Stakeholder Group") + .map(({ id, name }) => ({ id, name })); + + return { stakeholdersPayload, stakeholderGroupsPayload }; + }; const handleSaveAsDraft = async (formValues: AssessmentWizardValues) => { try { @@ -276,13 +284,17 @@ export const AssessmentWizard: React.FC = ({ const sections = assessment ? buildSectionsFromFormValues(formValues) : []; + const { stakeholdersPayload, stakeholderGroupsPayload } = + mapAndSeparateStakeholdersAndGroups( + formValues.stakeholdersAndGroupsRefs + ); const assessmentStatus: AssessmentStatus = "started"; const payload: AssessmentWithSectionOrder = { ...assessment, - stakeholders: stakeholdersToPayload(values.stakeholders), - stakeholderGroups: stakeholderGroupsToPayload(values.stakeholderGroups), + stakeholders: stakeholdersPayload, + stakeholderGroups: stakeholderGroupsPayload, sections, status: assessmentStatus, @@ -313,11 +325,15 @@ export const AssessmentWizard: React.FC = ({ ? buildSectionsFromFormValues(formValues) : []; + const { stakeholdersPayload, stakeholderGroupsPayload } = + mapAndSeparateStakeholdersAndGroups( + formValues.stakeholdersAndGroupsRefs + ); const payload: AssessmentWithSectionOrder = { ...assessment, - stakeholders: stakeholdersToPayload(values.stakeholders), - stakeholderGroups: stakeholderGroupsToPayload(values.stakeholderGroups), + stakeholders: stakeholdersPayload, + stakeholderGroups: stakeholderGroupsPayload, sections, status: assessmentStatus, @@ -350,12 +366,16 @@ export const AssessmentWizard: React.FC = ({ ? buildSectionsFromFormValues(formValues) : []; + const { stakeholdersPayload, stakeholderGroupsPayload } = + mapAndSeparateStakeholdersAndGroups( + formValues.stakeholdersAndGroupsRefs + ); + const payload: AssessmentWithSectionOrder = { ...assessment, - stakeholders: stakeholdersToPayload(values.stakeholders), - stakeholderGroups: stakeholderGroupsToPayload(values.stakeholderGroups), - + stakeholders: stakeholdersPayload, + stakeholderGroups: stakeholderGroupsPayload, sections, status: assessmentStatus, }; @@ -441,19 +461,38 @@ export const AssessmentWizard: React.FC = ({ }; const isAssessmentChanged = () => { + // Checking if any questions have changed const questionsChanged = Object.entries(values[QUESTIONS_KEY]).some( ([name, answer]) => initialQuestions[name] !== answer ); - const stakeholdersChanged = - initialStakeholders.length !== values.stakeholders.length || - initialStakeholderGroups.length !== values.stakeholderGroups.length || - !values.stakeholders.every(({ id, name }) => - initialStakeholders.find((it) => it.id === id && it.name === name) - ) || - !values.stakeholderGroups.every(({ id, name }) => - initialStakeholderGroups.find((it) => it.id === id && it.name === name) + + // Checking if any stakeholders or stakeholder groups have changed + const stakeholdersAndGroupsChanged = ( + initialRefs: GroupedStakeholderRef[], + currentRefs: GroupedStakeholderRef[] + ) => { + if (initialRefs.length !== currentRefs.length) return true; + const refMap = new Map( + initialRefs.map((ref) => [`${ref.id}-${ref.group}`, ref.name]) + ); + return currentRefs.some( + (ref) => refMap.get(`${ref.id}-${ref.group}`) !== ref.name ); - return questionsChanged || stakeholdersChanged; + }; + + // Extract initial combined stakeholders and groups from the assessment + const initialCombinedRefs = assessment + ? combineStakeholdersAndGroups(assessment) + : []; + + // Current combined stakeholders and groups from form values + const currentCombinedRefs = values.stakeholdersAndGroupsRefs; + + // Determine if there's been any change + return ( + questionsChanged || + stakeholdersAndGroupsChanged(initialCombinedRefs, currentCombinedRefs) + ); }; const handleCancelAssessment = () => { @@ -596,3 +635,12 @@ export const AssessmentWizard: React.FC = ({ ); }; + +const combineStakeholdersAndGroups = ( + assessment: AssessmentWithSectionOrder +): GroupedStakeholderRef[] => { + const stakeholders = assessment.stakeholders ?? []; + const stakeholderGroups = assessment.stakeholderGroups ?? []; + + return combineAndGroupStakeholderRefs(stakeholders, stakeholderGroups); +}; diff --git a/client/src/app/utils/utils.ts b/client/src/app/utils/utils.ts index 3726d7a6e8..ed5c80c152 100644 --- a/client/src/app/utils/utils.ts +++ b/client/src/app/utils/utils.ts @@ -197,3 +197,6 @@ export const localeNumericCompare = ( b: string, locale: string ): number => a.localeCompare(b, locale, { numeric: true }); + +export const getString = (input: string | (() => string)) => + typeof input === "function" ? input() : input;