Skip to content

Commit

Permalink
Add selection state support for grouped items
Browse files Browse the repository at this point in the history
Signed-off-by: Ian Bolton <ibolton@redhat.com>
  • Loading branch information
ibolton336 committed Mar 27, 2024
1 parent 1047c7f commit 0cff3f1
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 85 deletions.
4 changes: 2 additions & 2 deletions client/src/app/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
155 changes: 121 additions & 34 deletions client/src/app/components/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -72,7 +74,8 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
noResultsMessage = "No results found",
}) => {
const [inputValue, setInputValue] = useState(searchString);
const [tabSelectedItemId, setTabSelectedItemId] = useState<number>();
const [tabSelectedItemId, setTabSelectedItemId] = useState<string>();

const [menuIsOpen, setMenuIsOpen] = useState(false);

/** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */
Expand All @@ -83,22 +86,40 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
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 {};
}

Expand All @@ -113,18 +134,36 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
}, [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 */
Expand All @@ -146,15 +185,22 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
/** 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);
};

Expand Down Expand Up @@ -182,7 +228,7 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
handleEnter();
break;
case "Escape":
handleEscape();
handleEscape(event);
break;
case "Tab":
handleTab(event);
Expand All @@ -205,14 +251,18 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
/** add the text of the selected menu item to the selected items */
const handleMenuItemOnSelect = (
event: React.MouseEvent<Element, 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 */
Expand Down Expand Up @@ -291,11 +341,31 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
<MenuItem
key={option.id}
itemId={option.id.toString()}
onClick={(e) => handleMenuItemOnSelect(e, option.id)}
onClick={(e) =>
isGrouped
? handleMenuItemOnSelect(e, option.id, groupName)
: handleMenuItemOnSelect(e, option.id)
}
>
{toString(option.name)}
</MenuItem>
))}
{/* if supplied, add the menu heading */}
{menuHeader ? (
<>
<MenuItem isDisabled key="heading" itemId="-2">
{menuHeader}
</MenuItem>
<Divider key="divider" />
</>
) : undefined}

{/* show a disabled "no result" when all menu items are filtered out */}
{groupOptions.length === 0 ? (
<MenuItem isDisabled key="no result" itemId="-1">
{noResultsMessage}
</MenuItem>
) : undefined}
</MenuList>
</MenuGroup>
<Divider />
Expand All @@ -304,6 +374,22 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
} else {
return (
<MenuList>
{/* if supplied, add the menu heading */}
{menuHeader ? (
<>
<MenuItem isDisabled key="heading" itemId="-2">
{menuHeader}
</MenuItem>
<Divider key="divider" />
</>
) : undefined}

{/* show a disabled "no result" when all menu items are filtered out */}
{filteredOptions.length === 0 ? (
<MenuItem isDisabled key="no result" itemId="-1">
{noResultsMessage}
</MenuItem>
) : undefined}
{filteredOptions.map((option) => (
<MenuItem
key={option.id}
Expand All @@ -322,7 +408,6 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
<MenuContent>{renderMenuItems()}</MenuContent>
</Menu>
);

return (
<Flex direction={{ default: "column" }}>
<FlexItem key="input">
Expand All @@ -338,12 +423,14 @@ export const Autocomplete: React.FC<IAutocompleteProps> = ({
</FlexItem>
<FlexItem key="chips">
<Flex spaceItems={{ default: "spaceItemsXs" }}>
{selectedOptions.map(({ id, name, labelName, tooltip }) => (
<FlexItem key={id}>
{selectedOptions.map(({ id, name, group, labelName, tooltip }) => (
<FlexItem key={`${group}:${id}`}>
<LabelToolip content={tooltip}>
<Label
color={labelColor}
onClose={() => deleteSelectionByItemId(id)}
onClose={() => {
deleteSelectionByItemId(id, group);
}}
>
{toString(labelName || name)}
</Label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssessmentWizardValues>();

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]
Expand Down Expand Up @@ -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"
/>

{/* <HookFormAutocomplete<AssessmentWizardValues>
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"
/> */}
</FormSection>
</GridItem>
</Grid>
Expand All @@ -93,20 +67,20 @@ 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;
};

export const createGroupedRef = (
ref: Ref,
group: "stakeholder" | "stakeholderGroup"
): GroupedRef => ({
group: "Stakeholder" | "Stakeholder Group"
): GroupedStakeholderRef => ({
...ref,
group,
});
Loading

0 comments on commit 0cff3f1

Please sign in to comment.