Skip to content

Commit

Permalink
Merge pull request #288 from LifeSG/nested-multi-select-checkbox
Browse files Browse the repository at this point in the history
Nested multi select checkbox
  • Loading branch information
qroll authored Aug 24, 2023
2 parents 621f16c + 55801cc commit 2936521
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ export const InputNestedMultiSelect = <V1, V2, V3>({

setSelectedKeyPaths(newKeyPaths);
setSelectedItems(newSelectedItems);
triggerOptionDisplayCallback(false);

if (selectorRef.current) selectorRef.current.focus();

Expand Down
3 changes: 2 additions & 1 deletion src/input-nested-multi-select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export interface InputNestedMultiSelectProps<V1, V2, V3>
InputNestedSelectSharedProps<V1, V2, V3>,
DropdownSearchProps,
DropdownStyleProps {
/** Specifies key path to select particular option label */
/** Specifies key paths to select particular option label */
selectedKeyPaths?: string[][] | undefined;
/** Called when a selection is made. Returns the key paths and values of selected items in the next selection state */
onSelectOptions?:
| ((keyPaths: string[][], values: Array<V1 | V2 | V3>) => void)
| undefined;
Expand Down
1 change: 1 addition & 0 deletions src/input-nested-select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface InputNestedSelectProps<V1, V2, V3>
selectedKeyPath?: string[] | undefined;
/** If specified, the category label is selectable */
selectableCategory?: boolean | undefined;
/** Called when an option is selected. Returns the option's key path and value */
onSelectOption?:
| ((keyPath: string[], value: V1 | V2 | V3) => void)
| undefined;
Expand Down
15 changes: 4 additions & 11 deletions src/shared/nested-dropdown-list/list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {

interface ListItemProps<V1, V2, V3> {
item: CombinedFormattedOptionProps<V1, V2, V3>;
selectedKeyPaths: string[][];
selectableCategory?: boolean | undefined;
searchValue: string | undefined;
itemTruncationType?: TruncateType | undefined;
Expand All @@ -35,7 +34,6 @@ interface ListItemProps<V1, V2, V3> {

export const ListItem = <V1, V2, V3>({
item,
selectedKeyPaths,
selectableCategory,
searchValue,
itemTruncationType,
Expand Down Expand Up @@ -66,7 +64,7 @@ export const ListItem = <V1, V2, V3>({
};

const handleSelectParent = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
event.stopPropagation();
onSelectCategory(item);
};

Expand All @@ -79,11 +77,6 @@ export const ListItem = <V1, V2, V3>({
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
const checkListItemSelected = (keyPath: string[]): boolean =>
selectedKeyPaths.some(
(key) => JSON.stringify(key) === JSON.stringify(keyPath)
);

const hasExceededContainer = (
item: CombinedFormattedOptionProps<V1, V2, V3>
) => {
Expand Down Expand Up @@ -146,7 +139,6 @@ export const ListItem = <V1, V2, V3>({
<ListItem
key={item.keyPath.join("-")}
item={item}
selectedKeyPaths={selectedKeyPaths}
selectableCategory={selectableCategory}
searchValue={searchValue}
itemTruncationType={itemTruncationType}
Expand Down Expand Up @@ -179,6 +171,7 @@ export const ListItem = <V1, V2, V3>({
displaySize="small"
$type="category"
checked={item.checked}
indeterminate={item.indeterminate}
onChange={handleSelectParent}
/>
)}
Expand Down Expand Up @@ -223,7 +216,7 @@ export const ListItem = <V1, V2, V3>({
{multiSelect && (
<CheckboxInput
displaySize="small"
checked={checkListItemSelected(item.keyPath)}
checked={item.checked}
$type="label"
/>
)}
Expand All @@ -248,7 +241,7 @@ export const ListItem = <V1, V2, V3>({
ref={(ref) => onRef(ref, item.keyPath)}
type="button"
tabIndex={visible ? 0 : -1}
$selected={checkListItemSelected(item.keyPath)}
$selected={item.selected}
$multiSelect={multiSelect}
onBlur={handleBlur}
onClick={handleSelect}
Expand Down
126 changes: 84 additions & 42 deletions src/shared/nested-dropdown-list/nested-dropdown-list-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export namespace NestedDropdownListHelper {
value,
expanded: mode === "expand",
isSearchTerm: false,
selected: false,
checked: false,
indeterminate: false,
keyPath,
subItems: subItems
? formatted(subItems, keyPath)
Expand Down Expand Up @@ -79,9 +81,15 @@ export namespace NestedDropdownListHelper {
keyPath.forEach((key) => {
targetKey.push(key);
const item = getItemAtKeyPath(draft, targetKey);
if (item.subItems) {
item.expanded = true;
}

const selected = selectedKeyPaths.some(
(keyPath) =>
JSON.stringify(keyPath) ===
JSON.stringify(item.keyPath)
);

if (item.subItems) item.expanded = true;
if (selected) item.selected = true;
});
});
}
Expand Down Expand Up @@ -157,6 +165,79 @@ export namespace NestedDropdownListHelper {
return keyPaths;
};

export const getUpdateCheckbox = <V1, V2, V3>(
list: FormattedOptionMap<V1, V2, V3>,
selectedKeyPaths: string[][]
) => {
const result = produce(
list,
(draft: FormattedOptionMap<V1, V2, V3>) => {
const update = (
items: Map<string, CombinedFormattedOptionProps<V1, V2, V3>>
) => {
for (const item of items.values()) {
if (!item.subItems) {
const checked = selectedKeyPaths.some(
(keyPath) =>
JSON.stringify(keyPath) ===
JSON.stringify(item.keyPath)
);
item.checked = checked;
} else {
update(item.subItems);

const subItems: Map<
string,
CombinedFormattedOptionProps<V1, V2, V3>
> = item.subItems;

const { checked, indeterminate } = Array.from(
subItems
).reduce(
(result, subItemMap) => {
const item = subItemMap[1];
result.checked.push(item.checked);
result.indeterminate.push(
item.indeterminate
);

return result;
},
{
checked: [],
indeterminate: [],
}
);

const isAllChecked = checked.every(Boolean);
const isPartialChecked = checked.some(Boolean);
const isPartialIndeterminate =
indeterminate.some(Boolean);

if (isAllChecked) {
item.checked = true;
item.indeterminate = false;
} else if (
isPartialChecked ||
isPartialIndeterminate
) {
item.checked = false;
item.indeterminate = true;
} else {
item.checked = false;
item.indeterminate = false;
}
}
}
};

update(draft);
}
);

return result;
};

export const getItemAtKeyPath = <V1, V2, V3>(
draft: FormattedOptionMap<V1, V2, V3>,
keyPath: string[]
Expand All @@ -175,45 +256,6 @@ export namespace NestedDropdownListHelper {

return item;
};

export const updateCategoryChecked = <V1, V2, V3>(
list: FormattedOptionMap<V1, V2, V3>,
selectedKeyPaths: string[][]
) => {
const resetList = produce(
list,
(draft: Map<string, CombinedFormattedOptionProps<V1, V2, V3>>) => {
const resetChecked = (
items: Map<string, CombinedFormattedOptionProps<V1, V2, V3>>
) => {
if (!items || !items.size) return;
for (const item of items.values()) {
item.checked = false;
if (item.subItems) resetChecked(item.subItems);
}
};
resetChecked(draft);
}
);

return produce(resetList, (draft: FormattedOptionMap<V1, V2, V3>) => {
let targetKey: string[] = [];
selectedKeyPaths.forEach((keyPathArr) => {
targetKey = [];
const relevantKeys = keyPathArr.slice(0, -1);
relevantKeys.forEach((key) => {
targetKey.push(key);
const item = NestedDropdownListHelper.getItemAtKeyPath(
draft,
targetKey
);
if (item) {
item.checked = true;
}
});
});
});
};
}

// =============================================================================
Expand Down
16 changes: 8 additions & 8 deletions src/shared/nested-dropdown-list/nested-dropdown-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,26 +91,26 @@ export const NestedDropdownList = <V1, V2, V3>({
const list = getInitialDropdown();
const keyPaths = NestedDropdownListHelper.getVisibleKeyPaths(list);

setCurrentItems(list);
setVisibleKeyPaths(keyPaths);

if (searchInputRef.current) {
searchInputRef.current.focus();
} else if (listItemRefs.current) {
const target = keyPaths[focusedIndex];
listItemRefs.current[target[0]].ref.focus();
listItemRefs.current[target[0]]?.ref.focus();
}

if (multiSelect) {
const multiSelectList =
NestedDropdownListHelper.updateCategoryChecked(
NestedDropdownListHelper.getUpdateCheckbox(
list,
selectedKeyPaths
);

setCurrentItems(multiSelectList);
} else {
setCurrentItems(list);
}

setVisibleKeyPaths(keyPaths);
// Give some time for the custom call-to-action to be rendered
setTimeout(() => {
setContentHeight(getContentHeight());
Expand Down Expand Up @@ -139,7 +139,8 @@ export const NestedDropdownList = <V1, V2, V3>({
useEffect(() => {
if (visible && multiSelect) {
const targetList = isSearch ? filteredItems : currentItems;
const list = NestedDropdownListHelper.updateCategoryChecked(

const list = NestedDropdownListHelper.getUpdateCheckbox(
targetList,
selectedKeyPaths
);
Expand Down Expand Up @@ -437,7 +438,7 @@ export const NestedDropdownList = <V1, V2, V3>({

if (multiSelect) {
const multiSelectList =
NestedDropdownListHelper.updateCategoryChecked(
NestedDropdownListHelper.getUpdateCheckbox(
filtered,
selectedKeyPaths
);
Expand All @@ -458,7 +459,6 @@ export const NestedDropdownList = <V1, V2, V3>({
<ListItem
key={key}
item={item}
selectedKeyPaths={selectedKeyPaths}
selectableCategory={selectableCategory}
searchValue={searchValue}
itemTruncationType={itemTruncationType}
Expand Down
2 changes: 2 additions & 0 deletions src/shared/nested-dropdown-list/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ interface BaseFormattedOptionProps {
label: string;
keyPath: string[];
expanded: boolean;
selected: boolean;
checked: boolean;
indeterminate: boolean;
isSearchTerm: boolean;
}

Expand Down
12 changes: 3 additions & 9 deletions stories/form/form-nested-multi-select/props-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SHARED_FORM_PROPS_DATA } from "../shared-props-data";

const DATA: ApiTableSectionProps[] = [
{
name: "InputNestedSelect specific props",
name: "InputNestedMultiSelect specific props",
attributes: [
{
name: "options",
Expand All @@ -15,7 +15,7 @@ const DATA: ApiTableSectionProps[] = [
},
{
name: "selectedKeyPaths",
description: "The key path of the selected options",
description: "The key paths of the selected options",
propTypes: ["string[][]"],
},
{
Expand Down Expand Up @@ -69,12 +69,6 @@ const DATA: ApiTableSectionProps[] = [
"The function to convert a value to a string. Only single callback used for both selects. Assumption: values are homogenous for both selects.",
propTypes: ["(value: V1 | V2 | V3) => string"],
},
{
name: "selectableCategory",
description: "When specified, allows selection of categories",
propTypes: ["boolean"],
defaultValue: `"false"`,
},
{
name: "optionsLoadState",
description:
Expand All @@ -94,7 +88,7 @@ const DATA: ApiTableSectionProps[] = [
description:
"If specified, the default no results display will not be rendered",
propTypes: ["boolean"],
defaultValue: `"false"`,
defaultValue: "false",
},
{
name: "listStyleWidth",
Expand Down

0 comments on commit 2936521

Please sign in to comment.