Skip to content

Commit

Permalink
Design system/select (#1496)
Browse files Browse the repository at this point in the history
* selectable findbyvalue

* expose Orientation

* singe select

* adding multiSelect using selectable[]

* tests for multi and optional props

* semicolumn

* removing undesired change on keycloak login

---------

Co-authored-by: Adam Loup <aloup@enquizit.com>
  • Loading branch information
hclarkEnq and adamloup-enquizit authored Jun 24, 2024
1 parent bbba5f5 commit 3aeb39f
Show file tree
Hide file tree
Showing 10 changed files with 313 additions and 2 deletions.
5 changes: 4 additions & 1 deletion apps/modernization-ui/src/components/Entry/EntryWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { ReactNode } from 'react';
import { HorizontalEntryWrapper } from './HorizontalEntryWrapper';
import { VerticalEntryWrapper } from './VerticalEntryWrapper';

type Orientation = 'horizontal' | 'vertical';

type Props = {
orientation: 'horizontal' | 'vertical';
orientation: Orientation;
htmlFor: string;
label: string;
error?: string;
Expand All @@ -28,3 +30,4 @@ const EntryWrapper = ({ orientation = 'vertical', htmlFor, label, required, erro
};

export { EntryWrapper };
export type { Orientation };
2 changes: 2 additions & 0 deletions apps/modernization-ui/src/design-system/select/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './single/SingleSelect';
export * from './multi/MultiSelect';
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MultiSelect } from './MultiSelect';

describe('MultiSelect', () => {
const options = [
{ name: 'Option One', value: '1', label: 'Option One' },
{ name: 'Option Two', value: '2', label: 'Option Two' },
{ name: 'Option Three', value: '3', label: 'Option Three' }
];

it('should display options when clicked', async () => {
const { getByText, getByRole } = render(
<MultiSelect id="test-multi-select" name="test-multi-select" label="Test Multi Select" options={options} />
);

const component = getByRole('combobox');

userEvent.click(component);

options.forEach((option) => {
expect(getByText(option.label)).toBeInTheDocument();
});
});

it('should allow selecting multiple options', async () => {
const onChange = jest.fn();
const { getByText, getByRole } = render(
<MultiSelect
id="test-multi-select"
name="test-multi-select"
label="Test Multi Select"
options={options}
onChange={onChange}
/>
);

const component = getByRole('combobox');

userEvent.click(component);

userEvent.click(getByText('Option One'));

expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ value: '1', label: 'Option One' })]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import Select, { MultiValue, components } from 'react-select';
import { EntryWrapper, Orientation } from 'components/Entry';
import { Selectable } from 'options';

type MultiSelectProps = {
id: string;
name: string;
label: string;
options: Selectable[];
value?: Selectable[];
onChange?: (value: Selectable[]) => void;
orientation?: Orientation;
error?: string;
required?: boolean;
placeholder?: string;
disabled?: boolean;
};

const CheckedOption = (props: any) => {
return (
<>
<components.Option {...props}>
<input type="checkbox" checked={props.isSelected} readOnly /> <label>{props.label}</label>
</components.Option>
</>
);
};

export const MultiSelect: React.FC<MultiSelectProps> = ({
id,
name,
label,
options,
value = [],
onChange,
orientation = 'vertical',
error,
required,
placeholder = '- Select -',
disabled = false
}) => {
const [searchText, setSearchText] = useState('');

const handleOnChange = (newValue: MultiValue<Selectable>) => {
if (onChange) {
onChange(newValue as Selectable[]);
}
};

const handleInputChange = (searchText: string, action: { action: string }) => {
if (action.action !== 'input-blur' && action.action !== 'set-value') {
setSearchText(searchText);
}
};
return (
<div>
<EntryWrapper orientation={orientation} label={label} htmlFor={id} required={required} error={error}>
<Select<Selectable, true>
isMulti
id={id}
name={name}
options={options}
value={value}
onChange={handleOnChange}
placeholder={placeholder}
isDisabled={disabled}
classNamePrefix="multi-select"
hideSelectedOptions={false}
closeMenuOnSelect={false}
closeMenuOnScroll={false}
inputValue={searchText}
onInputChange={handleInputChange}
getOptionLabel={(option) => option.label}
getOptionValue={(option) => option.value}
components={{ Option: CheckedOption }}
/>
</EntryWrapper>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SinlgeSelect } from './SingleSelect';

describe('when selecting a single item from a specific set of items', () => {
it('should display the SingleSelect without a value checked', () => {
const { queryByRole } = render(
<SinlgeSelect
id="test-id"
label="Test Label"
options={[
{ name: 'name-one', value: 'value-one', label: 'label-one' },
{ name: 'name-two', value: 'value-two', label: 'label-two' },
{ name: 'name-three', value: 'value-three', label: 'label-three' },
{ name: 'name-four', value: 'value-four', label: 'label-four' }
]}
name="test-name"
placeholder="place-holder-value"
/>
);

const checked = queryByRole('option', { selected: true });

expect(checked).toHaveTextContent('place-holder-value');
});

it('should display the SingleSelect with the value checked', () => {
const { getByRole } = render(
<SinlgeSelect
id="test-id"
label="Test Label"
options={[
{ name: 'name-one', value: 'value-one', label: 'label-one' },
{ name: 'name-two', value: 'value-two', label: 'label-two' },
{ name: 'name-three', value: 'value-three', label: 'label-three' },
{ name: 'name-four', value: 'value-four', label: 'label-four' }
]}
name="test-name"
value={{ name: 'name-three', value: 'value-three', label: 'label-three' }}
/>
);

const checked = getByRole('option', { name: 'name-three', selected: true });

expect(checked).toHaveTextContent('name-three');
});
});

describe('when one of the options is clicked', () => {
it('should mark the option as checked', () => {
const { getByRole } = render(
<SinlgeSelect
id="test-id"
label="Test Label"
options={[
{ name: 'name-one', value: 'value-one', label: 'label-one' },
{ name: 'name-two', value: 'value-two', label: 'label-two' },
{ name: 'name-three', value: 'value-three', label: 'label-three' },
{ name: 'name-four', value: 'value-four', label: 'label-four' }
]}
name="test-name"
/>
);

const select = getByRole('combobox', { name: 'Test Label' });

userEvent.selectOptions(select, 'name-four');

const checked = getByRole('option', { selected: true });

expect(checked).toHaveTextContent('name-four');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ChangeEvent } from 'react';
import { Select as TrussworksSelect } from '@trussworks/react-uswds';
import { EntryWrapper, Orientation } from 'components/Entry';
import { Selectable, findByValue } from 'options';

const renderOptions = (placeholder: string, options: Selectable[]) => (
<>
<option value="">{placeholder}</option>
{options?.map((item, index) => (
<option key={index} value={item.value}>
{item.name}
</option>
))}
</>
);

type Props = {
id: string;
orientation?: Orientation;
label: string;
options: Selectable[];
value?: Selectable | null;
onChange?: (value?: Selectable) => void;
error?: string;
required?: boolean;
} & Omit<JSX.IntrinsicElements['select'], 'defaultValue' | 'onChange' | 'value'>;

const SinlgeSelect = ({
id,
label,
options,
value,
onChange,
orientation = 'vertical',
error,
required,
placeholder = '- Select -',
...inputProps
}: Props) => {
const find = findByValue(options);

const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
if (onChange) {
const selected = find(event.target.value);
onChange(selected);
}
};

// In order for the defaultValue to be applied the component has to be re-created when it goes from null to non null.
const Wrapped = () => (
<TrussworksSelect
{...inputProps}
id={id}
name={inputProps.name ?? id}
defaultValue={value?.value}
placeholder="-Select-"
onChange={handleChange}>
{renderOptions(placeholder, options)}
</TrussworksSelect>
);

return (
<EntryWrapper orientation={orientation} label={label} htmlFor={id} required={required} error={error}>
{value && <Wrapped />}
{!value && <Wrapped />}
</EntryWrapper>
);
};

export { SinlgeSelect };
29 changes: 29 additions & 0 deletions apps/modernization-ui/src/options/findByValue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { findByValue } from './findByValue';

describe('when searching for selectables by value', () => {
it('should return selectable when searching by known value', () => {
const selectables = [
{ name: 'name-one', value: 'value-one', label: 'label-one' },
{ name: 'name-two', value: 'value-two', label: 'label-two' },
{ name: 'name-three', value: 'value-three', label: 'label-three' },
{ name: 'name-four', value: 'value-four', label: 'label-four' }
];

const actual = findByValue(selectables)('value-three');

expect(actual).toEqual(expect.objectContaining({ value: 'value-three' }));
});

it('should not return selectable when searching by unknown value', () => {
const selectables = [
{ name: 'name-one', value: 'value-one', label: 'label-one' },
{ name: 'name-two', value: 'value-two', label: 'label-two' },
{ name: 'name-three', value: 'value-three', label: 'label-three' },
{ name: 'name-four', value: 'value-four', label: 'label-four' }
];

const actual = findByValue(selectables)('value-unknown');

expect(actual).toBeUndefined();
});
});
6 changes: 6 additions & 0 deletions apps/modernization-ui/src/options/findByValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Selectable } from './selectable';

const findByValue = (selectables: Selectable[]) => (value: string) =>
selectables.find((selectable) => selectable.value === value);

export { findByValue };
1 change: 1 addition & 0 deletions apps/modernization-ui/src/options/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export type { Selectable } from './selectable';
export { findByValue } from './findByValue';
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ body > div {
padding: 2rem;
height: fit-content;
margin-top: 6rem;
min-width: 390px;
min-width: 388px;
}

label {
Expand Down

0 comments on commit 3aeb39f

Please sign in to comment.