From e2bf820bb6aa884233e978ee1b73c3a730a3e156 Mon Sep 17 00:00:00 2001 From: alexander-svendsen Date: Wed, 26 Feb 2025 12:47:18 +0100 Subject: [PATCH 1/2] feat(ffe-searchable-dropdown-react): add displayAttribute prop makes it possible to override what is displayed in the SearchableDropdown input previous default was the first attribute in the searchAttribute prop. The same should be the case with this change --- .../src/getListToRender.ts | 2 +- .../src/single/SearchableDropdown.spec.tsx | 29 +++++++++++++++++++ .../src/single/SearchableDropdown.stories.tsx | 24 +++++++++++++++ .../src/single/SearchableDropdown.tsx | 9 ++++-- .../src/single/reducer.ts | 18 +++++++----- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/ffe-searchable-dropdown-react/src/getListToRender.ts b/packages/ffe-searchable-dropdown-react/src/getListToRender.ts index e742e45409..15d006a613 100644 --- a/packages/ffe-searchable-dropdown-react/src/getListToRender.ts +++ b/packages/ffe-searchable-dropdown-react/src/getListToRender.ts @@ -35,7 +35,7 @@ export const getListToRender = >({ searchMatcher?: SearchMatcher; showAllItemsInDropdown: boolean; }): { noMatch: boolean; listToRender: Item[] } => { - const trimmedInput = inputValue ? inputValue.trim() : ''; + const trimmedInput = inputValue ? String(inputValue).trim() : ''; const shouldFilter = trimmedInput.length > 0; diff --git a/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.spec.tsx b/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.spec.tsx index 4ec07f2dd0..044f5d422f 100644 --- a/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.spec.tsx +++ b/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.spec.tsx @@ -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( + , + ); + + 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'); + }); }); diff --git a/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.stories.tsx b/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.stories.tsx index 6785ee2b41..f78b7510bd 100644 --- a/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.stories.tsx +++ b/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.stories.tsx @@ -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 ( + + + + ); + }, +}; diff --git a/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.tsx b/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.tsx index 48a6d53995..0076ba7fb1 100644 --- a/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.tsx +++ b/packages/ffe-searchable-dropdown-react/src/single/SearchableDropdown.tsx @@ -51,6 +51,8 @@ export interface SearchableDropdownProps> { 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 */ @@ -105,6 +107,7 @@ function SearchableDropdownWithForwardRef>( dropdownList, dropdownAttributes, searchAttributes, + displayAttribute = searchAttributes[0], maxRenderedDropdownElements = Number.MAX_SAFE_INTEGER, onChange, inputProps, @@ -127,6 +130,7 @@ function SearchableDropdownWithForwardRef>( const [state, dispatch] = useReducer( createReducer({ dropdownList, + displayAttribute: displayAttribute, searchAttributes, maxRenderedDropdownElements, noMatchDropdownList: noMatch?.dropdownList, @@ -137,7 +141,8 @@ function SearchableDropdownWithForwardRef>( isExpanded: false, selectedItems: [], highlightedIndex: -1, - inputValue: selectedItem ? selectedItem[dropdownAttributes[0]] : '', + formattedInputValue: '', + inputValue: selectedItem ? selectedItem[displayAttribute] : '', }, initialState => { return { @@ -189,7 +194,7 @@ function SearchableDropdownWithForwardRef>( isLoading, locale, resultCount: state.listToRender.length, - selectedValue: state.selectedItem?.[searchAttributes[0]], + selectedValue: state.selectedItem?.[displayAttribute], }); useLayoutEffect(() => { diff --git a/packages/ffe-searchable-dropdown-react/src/single/reducer.ts b/packages/ffe-searchable-dropdown-react/src/single/reducer.ts index efe0c6fc8d..8b85f51bca 100644 --- a/packages/ffe-searchable-dropdown-react/src/single/reducer.ts +++ b/packages/ffe-searchable-dropdown-react/src/single/reducer.ts @@ -24,6 +24,7 @@ export const createReducer = >({ searchAttributes, dropdownList, + displayAttribute, noMatchDropdownList, maxRenderedDropdownElements, searchMatcher, @@ -31,6 +32,7 @@ export const createReducer = }: { dropdownList: Item[]; searchAttributes: Array; + displayAttribute: keyof Item; noMatchDropdownList: Item[] | undefined; maxRenderedDropdownElements: number; searchMatcher: SearchMatcher | undefined; @@ -38,16 +40,17 @@ export const createReducer = }) => (state: State, action: Action): State => { 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, @@ -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': { @@ -153,7 +157,7 @@ export const createReducer = } const inputValue = selectedItem - ? selectedItem[searchAttributes[0]] + ? selectedItem[displayAttribute] : ''; return { ...state, From c1baf20795bcb35d0a9623bac63116da7a7817c7 Mon Sep 17 00:00:00 2001 From: alexander-svendsen Date: Wed, 26 Feb 2025 12:51:17 +0100 Subject: [PATCH 2/2] feat(ffe-account-selector-react): add displayAttribute prop makes it possible to override what is displayed in the AccountSelector input. So you can have a new custom attribute in the input, example a new attribute that contains: account_name - account_number previous default was the first attribute in the searchAttribute prop. The same should be the case with this change. Needed to add the displayAttribute to the searchAttributes passed to the SearchableDropdown, so the user don't see a message about 0 hits when changing the input. --- .../src/account-selector/AccountSelector.mdx | 4 ++ .../account-selector/AccountSelector.spec.tsx | 66 +++++++++++++++++++ .../AccountSelector.stories.tsx | 39 +++++++++++ .../src/account-selector/AccountSelector.tsx | 18 ++++- 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.mdx b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.mdx index dfaa940df5..031c505c91 100644 --- a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.mdx +++ b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.mdx @@ -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`. + +Dersom du ønsker å vise en annen informasjon enn kontonavn i inputen, så kan du velge hvilken attribute som brukes med `displayAttribute`. + + diff --git a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.spec.tsx b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.spec.tsx index 38c5e9fed8..85c21be46e 100644 --- a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.spec.tsx +++ b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.spec.tsx @@ -618,4 +618,70 @@ describe('', () => { expect(a11yStatusMessage).toHaveTextContent(''); jest.useRealTimers(); }); + + it('allows passing a custom display attribute', async () => { + const handleAccountSelectedMock = jest.fn(); + + render( + , + ); + + 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( + + 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'); + }); }); diff --git a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.stories.tsx b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.stories.tsx index 1c1f457879..905c0a322b 100644 --- a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.stories.tsx +++ b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.stories.tsx @@ -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 = { title: 'Komponenter/Account-selector/AccountSelector', @@ -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 +> = { + 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( + prettyAccounts[2], + ); + return ( + + + {...args} + selectedAccount={selectedAccount} + onAccountSelected={setSelectedAccount} + /> + + ); + }, +}; diff --git a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.tsx b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.tsx index e2d6ecb0c7..eb41191f7a 100644 --- a/packages/ffe-account-selector-react/src/account-selector/AccountSelector.tsx +++ b/packages/ffe-account-selector-react/src/account-selector/AccountSelector.tsx @@ -40,6 +40,8 @@ export interface AccountSelectorProps { 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. @@ -91,6 +93,7 @@ export const AccountSelector = ({ ariaInvalid, onOpen, onClose, + displayAttribute, ...rest }: AccountSelectorProps) => { const [inputValue, setInputValue] = useState(selectedAccount?.name || ''); @@ -119,6 +122,7 @@ export const AccountSelector = ({ onAccountSelected({ name: value.name, accountNumber: value.name, + ...(displayAttribute ? { [displayAttribute]: value.name } : {}), } as T); setInputValue(value.name); } else { @@ -137,6 +141,7 @@ export const AccountSelector = ({ id={id} labelledById={labelledById} + displayAttribute={displayAttribute} inputProps={{ ...inputProps, onChange: onInputChange, @@ -165,6 +170,13 @@ export const AccountSelector = ({ ? formatter(inputValue) : inputValue, accountNumber: '', + ...(displayAttribute + ? { + [displayAttribute]: formatter + ? formatter(inputValue) + : inputValue, + } + : {}), } as T, ], } @@ -172,7 +184,11 @@ export const AccountSelector = ({ } formatter={formatter} onChange={handleAccountSelected} - searchAttributes={['name', 'accountNumber']} + searchAttributes={[ + 'name', + 'accountNumber', + ...(displayAttribute ? [displayAttribute] : []), + ]} locale={locale} optionBody={({ item, isHighlighted, ...restOptionBody }) => { if (OptionBody) {