Skip to content
9 changes: 7 additions & 2 deletions src/community/components/form/select/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import Select, { IGroupedOptions, ISelectOption } from './select';
const meta: Meta<typeof Select> = {
component: Select,
title: 'Community/Form/Select',
parameters: {
status: {
type: ['deprecated', 'ExistsInTediReady'],
},
},
};

export default meta;
Expand Down Expand Up @@ -58,7 +63,7 @@ const groupedOptions2: OptionsOrGroups<ISelectOption, IGroupedOptions<ISelectOpt
label: 'Group 3 - Separately set styles have priority',
text: {
modifiers: ['small'],
color: 'inverted',
color: 'white',
},
backgroundColor: 'primary-main',
options: [
Expand Down Expand Up @@ -191,7 +196,7 @@ export const SelectWithStyledGroupedOptions: Story = {
label: 'Grouped options label',
optionGroupHeadingText: {
modifiers: ['italic'],
color: 'important',
color: 'danger',
},
optionGroupBackgroundColor: 'important-highlight',
options: groupedOptions2,
Expand Down
167 changes: 167 additions & 0 deletions src/tedi/components/form/select/components/select-bulk-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { IGroupedOptions, ISelectOption } from '../select';
import {
areAllSelected,
getEnabledOptions,
getGroupEnabledOptions,
isGroupedOptions,
isIndeterminate,
toggleBulkSelection,
} from './select-bulk-helpers';

const flat: ISelectOption[] = [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
{ value: 'c', label: 'C', isDisabled: true },
];

const grouped: IGroupedOptions<ISelectOption>[] = [
{
label: 'Letters',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B', isDisabled: true },
],
},
{
label: 'Numbers',
options: [
{ value: '1', label: 'One' },
{ value: '2', label: 'Two' },
],
},
];

describe('select-bulk-helpers', () => {
describe('isGroupedOptions', () => {
it('returns true for grouped options', () => {
expect(isGroupedOptions(grouped)).toBe(true);
});

it('returns false for flat options', () => {
expect(isGroupedOptions(flat)).toBe(false);
});

it('returns false for empty list', () => {
expect(isGroupedOptions([])).toBe(false);
});
});

describe('getEnabledOptions', () => {
it('returns all enabled options from a flat list', () => {
expect(getEnabledOptions(flat).map((o) => o.value)).toEqual(['a', 'b']);
});

it('flattens grouped options and excludes disabled ones', () => {
expect(getEnabledOptions(grouped).map((o) => o.value)).toEqual(['a', '1', '2']);
});

it('handles empty list', () => {
expect(getEnabledOptions([])).toEqual([]);
});
});

describe('getGroupEnabledOptions', () => {
it('returns enabled options of the passed group', () => {
expect(getGroupEnabledOptions(grouped[1]).map((o) => o.value)).toEqual(['1', '2']);
});

it('filters out disabled options within the group', () => {
// grouped[0] = { label: 'Letters', options: [a, b (disabled)] }
expect(getGroupEnabledOptions(grouped[0]).map((o) => o.value)).toEqual(['a']);
});

it('returns [] when group is null/undefined', () => {
expect(getGroupEnabledOptions(null)).toEqual([]);
expect(getGroupEnabledOptions(undefined)).toEqual([]);
});

it('returns [] when group has no options array', () => {
expect(getGroupEnabledOptions({ label: 'No options' } as never)).toEqual([]);
});

it('targets the correct group when two groups share the same label', () => {
// Regression: looking groups up by label would have always resolved to
// the first match, returning the wrong options for the second group.
const a: IGroupedOptions<ISelectOption> = {
label: 'Shared',
options: [{ value: 'a-1', label: 'A1' }],
};
const b: IGroupedOptions<ISelectOption> = {
label: 'Shared',
options: [
{ value: 'b-1', label: 'B1' },
{ value: 'b-2', label: 'B2' },
],
};

expect(getGroupEnabledOptions(a).map((o) => o.value)).toEqual(['a-1']);
expect(getGroupEnabledOptions(b).map((o) => o.value)).toEqual(['b-1', 'b-2']);
});
});

describe('areAllSelected', () => {
it('returns true when every enabled option is selected', () => {
const enabled = getEnabledOptions(flat);
expect(areAllSelected(enabled, enabled)).toBe(true);
});

it('returns false when some are missing', () => {
const enabled = getEnabledOptions(flat);
expect(areAllSelected([enabled[0]], enabled)).toBe(false);
});

it('returns false when target is empty', () => {
expect(areAllSelected([{ value: 'a', label: 'A' }], [])).toBe(false);
});
});

describe('isIndeterminate', () => {
it('returns true when some — but not all — enabled options are selected', () => {
const enabled = getEnabledOptions(flat);
expect(isIndeterminate([enabled[0]], enabled)).toBe(true);
});

it('returns false when none are selected', () => {
expect(isIndeterminate([], getEnabledOptions(flat))).toBe(false);
});

it('returns false when all are selected', () => {
const enabled = getEnabledOptions(flat);
expect(isIndeterminate(enabled, enabled)).toBe(false);
});

it('returns false for empty target', () => {
expect(isIndeterminate([], [])).toBe(false);
});
});

describe('toggleBulkSelection', () => {
it('removes the target options when all are selected', () => {
const enabled = getEnabledOptions(flat);
const result = toggleBulkSelection(enabled, enabled);
expect(result).toEqual([]);
});

it('preserves selections outside the target group when removing', () => {
const target: ISelectOption[] = [{ value: 'a', label: 'A' }];
const selected: ISelectOption[] = [
{ value: 'a', label: 'A' },
{ value: 'extra', label: 'Extra' },
];
const result = toggleBulkSelection(selected, target);
expect(result.map((o) => o.value)).toEqual(['extra']);
});

it('adds missing target options to the selection', () => {
const enabled = getEnabledOptions(flat);
const result = toggleBulkSelection([], enabled);
expect(result.map((o) => o.value)).toEqual(['a', 'b']);
});

it('does not duplicate already-selected items when adding', () => {
const enabled = getEnabledOptions(flat);
const result = toggleBulkSelection([enabled[0]], enabled);
expect(result.map((o) => o.value)).toEqual(['a', 'b']);
});
});
});
111 changes: 111 additions & 0 deletions src/tedi/components/form/select/components/select-bulk-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { GroupBase, OptionsOrGroups } from 'react-select';

import { ISelectOption } from '../select';

/**
* Sentinel value used by the "Select all" option when it is injected into
* react-select's option list. The sentinel is stripped from the value before
* it is exposed to consumers via onChange — it never leaks outside the
* component.
*/
export const SELECT_ALL_VALUE = '__tedi_select_all__';

export const isSelectAllSentinel = (option: { value?: string } | null | undefined): boolean =>
!!option && option.value === SELECT_ALL_VALUE;

/**
* Prefix for group sentinel options. When `selectableGroups + multiple` is on,
* each group is flattened into the option list with a sentinel option at the
* top of its run; toggling the sentinel toggles every enabled child of that
* group. The label after the prefix is the original group's label.
*/
export const GROUP_OPTION_PREFIX = '__tedi_select_group__:';

export const isGroupSentinel = (option: { value?: string } | null | undefined): boolean =>
!!option && typeof option.value === 'string' && option.value.startsWith(GROUP_OPTION_PREFIX);

/**
* Returns true when `options` is a grouped tree (i.e. each top-level entry
* has its own `options` array).
*/
export const isGroupedOptions = (
options: OptionsOrGroups<ISelectOption, GroupBase<ISelectOption>>
): options is ReadonlyArray<GroupBase<ISelectOption>> =>
options.length > 0 && Array.isArray((options[0] as GroupBase<ISelectOption>).options);

/**
* Flattens grouped/non-grouped options into a single list of enabled
* `ISelectOption`s. Used by Select All and group toggles to decide which
* options to flip on/off.
*
* Handles a mixed input where a flat option (e.g. the injected Select-all
* sentinel) sits alongside groups in the same top-level array, by checking
* each item individually rather than only inspecting `options[0]`.
*/
export const getEnabledOptions = (
options: OptionsOrGroups<ISelectOption, GroupBase<ISelectOption>>
): ISelectOption[] => {
if (!options || options.length === 0) return [];
const flat: ISelectOption[] = [];
for (const item of options) {
if (item && typeof item === 'object' && Array.isArray((item as GroupBase<ISelectOption>).options)) {
for (const opt of (item as GroupBase<ISelectOption>).options) {
if (!opt.isDisabled) flat.push(opt);
}
} else {
const opt = item as ISelectOption;
if (opt && !opt.isDisabled) flat.push(opt);
}
}
return flat;
};

/**
* Returns the enabled options of a specific group. Pass the group object
* directly (e.g. `GroupHeadingProps.data` from react-select) — looking groups
* up by label is unsafe because duplicate labels would always resolve to the
* first match, mutating the wrong group.
*/
export const getGroupEnabledOptions = (group: GroupBase<ISelectOption> | null | undefined): ISelectOption[] => {
if (!group || !Array.isArray(group.options)) return [];
return group.options.filter((o) => !o.isDisabled);
};

/** True iff every enabled option is currently in the selection. */
export const areAllSelected = (
selected: ReadonlyArray<ISelectOption>,
enabled: ReadonlyArray<ISelectOption>
): boolean => {
if (enabled.length === 0) return false;
return enabled.every((opt) => selected.some((s) => s.value === opt.value));
};

/** True when some — but not all — enabled options are selected. */
export const isIndeterminate = (
selected: ReadonlyArray<ISelectOption>,
enabled: ReadonlyArray<ISelectOption>
): boolean => {
if (enabled.length === 0) return false;
const count = enabled.filter((opt) => selected.some((s) => s.value === opt.value)).length;
return count > 0 && count < enabled.length;
};

/**
* Toggle behaviour for both Select All and group toggle: when every enabled
* option in `target` is selected, remove them all; otherwise add the missing
* ones to the existing selection. Other selected values (e.g. options
* outside `target`) are preserved.
*/
export const toggleBulkSelection = (
selected: ReadonlyArray<ISelectOption>,
target: ReadonlyArray<ISelectOption>
): ISelectOption[] => {
if (areAllSelected(selected, target)) {
return selected.filter((s) => !target.some((t) => t.value === s.value));
}
const next = [...selected];
for (const opt of target) {
if (!next.some((s) => s.value === opt.value)) next.push(opt);
}
return next;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createContext, useContext } from 'react';
import { SetValueAction } from 'react-select';

import { ISelectOption } from '../select';

/**
* Exposes react-select's `getValue` / `setValue` helpers from the `Group`
* component down to `GroupHeading`. react-select only forwards `selectProps`
* + theme/styles to the heading at runtime, so the heading can't read these
* helpers from its own props — it has to grab them from this context.
*
* Using `selectProps.value` / `selectProps.onChange` instead would only work
* in fully controlled mode: in uncontrolled mode `value` is undefined and
* `onChange` bypasses react-select's internal state.
*/
export interface SelectGroupBulkApi {
getValue: () => ReadonlyArray<ISelectOption>;
setValue: (value: ReadonlyArray<ISelectOption>, action: SetValueAction) => void;
}

export const SelectGroupBulkContext = createContext<SelectGroupBulkApi | null>(null);

export const useSelectGroupBulkApi = () => useContext(SelectGroupBulkContext);
Loading
Loading