Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Legg til displayAttribute til SearchableDropdown og AccountSelector #2583

Merged
merged 2 commits into from
Feb 28, 2025
Merged
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
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
Loading