Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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']);
});
});
});
100 changes: 100 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,100 @@
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;

/**
* 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