Skip to content

Commit

Permalink
Merge pull request #2583 from alexander-svendsen/develop
Browse files Browse the repository at this point in the history
Legg til displayAttribute til SearchableDropdown og AccountSelector
  • Loading branch information
dagfrode authored Feb 28, 2025
2 parents 25f7fea + c1baf20 commit 3775a93
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ Dersom du ønsker å skjule kontodetaljer, kan du bruke `hideAccountDetails`.
Dersom du ønsker å ha ekstra tekst på bunnen av lista, kan du bruke `postListElement`.

<Canvas of={AccountSelectorStories.PostListElement} />

Dersom du ønsker å vise en annen informasjon enn kontonavn i inputen, så kan du velge hvilken attribute som brukes med `displayAttribute`.

<Canvas of={AccountSelectorStories.CustomDisplayAttribute} />
Original file line number Diff line number Diff line change
Expand Up @@ -618,4 +618,70 @@ describe('<AccountSelector/>', () => {
expect(a11yStatusMessage).toHaveTextContent('');
jest.useRealTimers();
});

it('allows passing a custom display attribute', async () => {
const handleAccountSelectedMock = jest.fn();

render(
<AccountSelector
id="id"
labelledById="labelId"
accounts={accounts}
displayAttribute={'accountNumber'}
locale="nb"
onAccountSelected={handleAccountSelectedMock}
onReset={onReset}
selectedAccount={selectedAccount}
ariaInvalid={false}
/>,
);

const input = screen.getByRole('combobox');

expect(input.getAttribute('value')).toBe('');
fireEvent.change(input, { target: { value: 'Gr' } });

fireEvent.click(screen.getByText('Gris'));

expect(handleAccountSelectedMock).toHaveBeenCalledTimes(1);
expect(handleAccountSelectedMock).toHaveBeenCalledWith(accounts[3]);
expect(input.getAttribute('value')).toEqual('1253 47 789102');
});

it('passing displayAttribute should make it searchable', async () => {
type FunkyAccounts = Account & { funkySmell: string };
const funkyAccounts: FunkyAccounts[] = accounts.map((account, idx) => ({
...account,
funkySmell: `Smells like money${idx}`,
}));

render(
<AccountSelector<FunkyAccounts>
id="id"
labelledById="labelId"
accounts={funkyAccounts}
displayAttribute={'funkySmell'}
locale="nb"
onAccountSelected={handleAccountSelected}
onReset={onReset}
selectedAccount={funkyAccounts[0]}
ariaInvalid={false}
/>,
);

const input = screen.getByRole('combobox');
fireEvent.click(input);

expect(screen.queryByText('Ingen samsvarende konto')).toBeNull();
fireEvent.change(input, {
target: { value: 'Dette skal få ingen match' },
});
expect(
screen.queryByText('Ingen samsvarende konto'),
).toBeInTheDocument();

fireEvent.change(input, { target: { value: 'money3' } });
fireEvent.click(screen.getByText('Gris'));
expect(input.getAttribute('value')).toEqual('Smells like money3');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AccountSelector } from './AccountSelector';
import { InputGroup } from '@sb1/ffe-form-react';
import type { StoryObj, Meta } from '@storybook/react';
import { SmallText } from '@sb1/ffe-core-react';
import { accountFormatter } from '../format';

const meta: Meta<typeof AccountSelector> = {
title: 'Komponenter/Account-selector/AccountSelector',
Expand Down Expand Up @@ -254,3 +255,41 @@ export const InitialValue: Story = {
);
},
};

type PrettyAccount = Account & { prettyName: string };

const prettyAccounts: PrettyAccount[] = accounts.map(account => ({
...account,
prettyName: `${account.name} - ${accountFormatter(account.accountNumber)}`,
}));
export const CustomDisplayAttribute: StoryObj<
typeof AccountSelector<PrettyAccount>
> = {
args: {
id: 'input-id',
labelledById: 'label-id',
locale: 'nb',
formatAccountNumber: true,
allowCustomAccount: false,
displayAttribute: 'prettyName',
accounts: prettyAccounts,
},
render: function Render(args) {
const [selectedAccount, setSelectedAccount] = useState<PrettyAccount>(
prettyAccounts[2],
);
return (
<InputGroup
label="Velg konto"
inputId={args.id}
labelId={args.labelledById}
>
<AccountSelector<PrettyAccount>
{...args}
selectedAccount={selectedAccount}
onAccountSelected={setSelectedAccount}
/>
</InputGroup>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface AccountSelectorProps<T extends Account = Account> {
formatAccountNumber?: boolean;
/** id of element that labels input field */
labelledById?: string;
/** Attribute used in the input when an item is selected. **/
displayAttribute?: keyof T;
/**
* Allows selecting the text the user writes even if it does not match anything in the accounts array.
* Useful e.g. if you want to pay to account that is not in yur recipients list.
Expand Down Expand Up @@ -91,6 +93,7 @@ export const AccountSelector = <T extends Account = Account>({
ariaInvalid,
onOpen,
onClose,
displayAttribute,
...rest
}: AccountSelectorProps<T>) => {
const [inputValue, setInputValue] = useState(selectedAccount?.name || '');
Expand Down Expand Up @@ -119,6 +122,7 @@ export const AccountSelector = <T extends Account = Account>({
onAccountSelected({
name: value.name,
accountNumber: value.name,
...(displayAttribute ? { [displayAttribute]: value.name } : {}),
} as T);
setInputValue(value.name);
} else {
Expand All @@ -137,6 +141,7 @@ export const AccountSelector = <T extends Account = Account>({
<SearchableDropdown<T>
id={id}
labelledById={labelledById}
displayAttribute={displayAttribute}
inputProps={{
...inputProps,
onChange: onInputChange,
Expand Down Expand Up @@ -165,14 +170,25 @@ export const AccountSelector = <T extends Account = Account>({
? formatter(inputValue)
: inputValue,
accountNumber: '',
...(displayAttribute
? {
[displayAttribute]: formatter
? formatter(inputValue)
: inputValue,
}
: {}),
} as T,
],
}
: (noMatches ?? { text: texts[locale].noMatch })
}
formatter={formatter}
onChange={handleAccountSelected}
searchAttributes={['name', 'accountNumber']}
searchAttributes={[
'name',
'accountNumber',
...(displayAttribute ? [displayAttribute] : []),
]}
locale={locale}
optionBody={({ item, isHighlighted, ...restOptionBody }) => {
if (OptionBody) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const getListToRender = <Item extends Record<string, any>>({
searchMatcher?: SearchMatcher<Item>;
showAllItemsInDropdown: boolean;
}): { noMatch: boolean; listToRender: Item[] } => {
const trimmedInput = inputValue ? inputValue.trim() : '';
const trimmedInput = inputValue ? String(inputValue).trim() : '';

const shouldFilter = trimmedInput.length > 0;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1026,4 +1026,33 @@ describe('SearchableDropdown', () => {
await user.click(input);
await screen.findByText('Dette er et postListElement!');
});

it('allows passing a custom display attribute', async () => {
const onChange = jest.fn();
const user = userEvent.setup();

render(
<SearchableDropdown
id="id"
labelledById="labelId"
dropdownAttributes={['organizationName', 'organizationNumber']}
displayAttribute={'organizationNumber'}
dropdownList={companies}
onChange={onChange}
searchAttributes={['organizationName', 'organizationNumber']}
locale="nb"
/>,
);

const input = screen.getByRole('combobox');

expect(input.getAttribute('value')).toBe('');
await user.type(input, 'Be');

await user.click(screen.getByText('Beslag skytter'));

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(companies[2]);
expect(input.getAttribute('value')).toEqual('812602552');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,27 @@ export const PostListElement: Story = {
);
},
};

export const CustomDisplayAttribute: Story = {
args: {
...Standard.args,
displayAttribute: 'organizationNumber',
dropdownAttributes: ['organizationName', 'organizationNumber'],
searchAttributes: ['organizationNumber', 'organizationName'],
},
render: function Render({ id, labelledById, ...args }) {
return (
<InputGroup
label="Velg bedrift"
labelId={labelledById}
inputId={id}
>
<SearchableDropdown
id={id}
labelledById={labelledById}
{...args}
/>
</InputGroup>
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface SearchableDropdownProps<Item extends Record<string, any>> {
dropdownAttributes: (keyof Item)[];
/** Array of attributes used when filtering search */
searchAttributes: (keyof Item)[];
/** Attribute used in the input when an item is selected. Defaults to first in searchAttributes **/
displayAttribute?: keyof Item;
/** Props used on input field */
inputProps?: React.ComponentProps<'input'>;
/** Limits number of rendered dropdown elements */
Expand Down Expand Up @@ -105,6 +107,7 @@ function SearchableDropdownWithForwardRef<Item extends Record<string, any>>(
dropdownList,
dropdownAttributes,
searchAttributes,
displayAttribute = searchAttributes[0],
maxRenderedDropdownElements = Number.MAX_SAFE_INTEGER,
onChange,
inputProps,
Expand All @@ -127,6 +130,7 @@ function SearchableDropdownWithForwardRef<Item extends Record<string, any>>(
const [state, dispatch] = useReducer(
createReducer({
dropdownList,
displayAttribute: displayAttribute,
searchAttributes,
maxRenderedDropdownElements,
noMatchDropdownList: noMatch?.dropdownList,
Expand All @@ -137,7 +141,8 @@ function SearchableDropdownWithForwardRef<Item extends Record<string, any>>(
isExpanded: false,
selectedItems: [],
highlightedIndex: -1,
inputValue: selectedItem ? selectedItem[dropdownAttributes[0]] : '',
formattedInputValue: '',
inputValue: selectedItem ? selectedItem[displayAttribute] : '',
},
initialState => {
return {
Expand Down Expand Up @@ -189,7 +194,7 @@ function SearchableDropdownWithForwardRef<Item extends Record<string, any>>(
isLoading,
locale,
resultCount: state.listToRender.length,
selectedValue: state.selectedItem?.[searchAttributes[0]],
selectedValue: state.selectedItem?.[displayAttribute],
});

useLayoutEffect(() => {
Expand Down
18 changes: 11 additions & 7 deletions packages/ffe-searchable-dropdown-react/src/single/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,33 @@ export const createReducer =
<Item extends Record<string, any>>({
searchAttributes,
dropdownList,
displayAttribute,
noMatchDropdownList,
maxRenderedDropdownElements,
searchMatcher,
onChange,
}: {
dropdownList: Item[];
searchAttributes: Array<keyof Item>;
displayAttribute: keyof Item;
noMatchDropdownList: Item[] | undefined;
maxRenderedDropdownElements: number;
searchMatcher: SearchMatcher<Item> | undefined;
onChange: ((item: Item | null) => void) | undefined;
}) =>
(state: State<Item>, action: Action<Item>): State<Item> => {
switch (action.type) {
case 'InputKeyDownEscape':
case 'InputKeyDownEscape': {
return {
...state,
noMatch: false,
isExpanded: false,
highlightedIndex: -1,
inputValue: state.selectedItem
? state.selectedItem[searchAttributes[0]]
? state.selectedItem[displayAttribute]
: '',
};
}
case 'InputClick': {
const { noMatch, listToRender } = getListToRender({
inputValue: state.inputValue,
Expand Down Expand Up @@ -90,23 +93,24 @@ export const createReducer =
noMatch,
};
}
case 'ToggleButtonPressed':
case 'ToggleButtonPressed': {
return {
...state,
isExpanded: !state.isExpanded,
};
}
case 'ItemSelectedProgrammatically':
case 'ItemOnClick':
case 'InputKeyDownEnter':
case 'InputKeyDownEnter': {
return {
...state,
isExpanded: false,
highlightedIndex: -1,
selectedItem: action.payload?.selectedItem,
inputValue:
action.payload?.selectedItem?.[searchAttributes[0]] ||
'',
action.payload?.selectedItem?.[displayAttribute] || '',
};
}

case 'InputKeyDownArrowDown':
case 'InputKeyDownArrowUp': {
Expand Down Expand Up @@ -153,7 +157,7 @@ export const createReducer =
}

const inputValue = selectedItem
? selectedItem[searchAttributes[0]]
? selectedItem[displayAttribute]
: '';
return {
...state,
Expand Down

0 comments on commit 3775a93

Please sign in to comment.