From 79d7a4f61ab7490513f54242a4827d61fb9f7e34 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Thu, 31 Oct 2024 12:44:28 +0100 Subject: [PATCH] feat: Add StudioTextResourcePicker component (#13954) --- .../StudioTextResourcePicker.stories.tsx | 21 ++ .../StudioTextResourcePicker.test.tsx | 100 +++++++++ .../StudioTextResourcePicker.tsx | 57 +++++ .../StudioTextResourcePicker/index.ts | 1 + .../test-data/textResourcesMock.ts | 198 ++++++++++++++++++ .../types/TextResource.ts | 5 + .../studio-components/src/components/index.ts | 1 + 7 files changed, 383 insertions(+) create mode 100644 frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.stories.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.test.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx create mode 100644 frontend/libs/studio-components/src/components/StudioTextResourcePicker/index.ts create mode 100644 frontend/libs/studio-components/src/components/StudioTextResourcePicker/test-data/textResourcesMock.ts create mode 100644 frontend/libs/studio-components/src/components/StudioTextResourcePicker/types/TextResource.ts diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.stories.tsx b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.stories.tsx new file mode 100644 index 00000000000..add67370c2d --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { StudioTextResourcePicker } from './StudioTextResourcePicker'; +import { textResourcesMock } from './test-data/textResourcesMock'; + +type Story = StoryObj; + +const meta: Meta = { + title: 'Components/StudioTextResourcePicker', + component: StudioTextResourcePicker, +}; +export default meta; + +export const Preview: Story = { + args: { + label: 'Velg tekst', + emptyListText: 'Fant ingen tekster', + textResources: textResourcesMock, + onValueChange: (id: string) => console.log(id), + value: 'land.NO', + }, +}; diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.test.tsx b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.test.tsx new file mode 100644 index 00000000000..f6cf339a9ff --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.test.tsx @@ -0,0 +1,100 @@ +import type { ForwardedRef } from 'react'; +import React from 'react'; +import { textResourcesMock } from './test-data/textResourcesMock'; +import type { StudioTextResourcePickerProps } from './StudioTextResourcePicker'; +import { StudioTextResourcePicker } from './StudioTextResourcePicker'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { testRefForwarding } from '../../test-utils/testRefForwarding'; +import { testRootClassNameAppending } from '../../test-utils/testRootClassNameAppending'; +import { testCustomAttributes } from '../../test-utils/testCustomAttributes'; +import userEvent from '@testing-library/user-event'; +import type { TextResource } from './types/TextResource'; + +// Test data: +const textResources = textResourcesMock; +const onValueChange = jest.fn(); +const emptyListText = 'No text resources'; +const defaultProps: StudioTextResourcePickerProps = { + emptyListText, + onValueChange, + textResources, +}; + +describe('StudioTextResourcePicker', () => { + it('Renders a combobox', () => { + renderTextResourcePicker(); + expect(getCombobox()).toBeInTheDocument(); + }); + + it('Renders with the given label', () => { + const label = 'Test label'; + renderTextResourcePicker({ label }); + expect(getCombobox()).toHaveAccessibleName(label); + }); + + it('Displays the given text resources when the user clicks', async () => { + const user = userEvent.setup(); + const testTextResources: TextResource[] = [ + { id: '1', value: 'Test 1' }, + { id: '2', value: 'Test 2' }, + ]; + renderTextResourcePicker({ textResources: testTextResources }); + await user.click(getCombobox()); + testTextResources.forEach((textResource) => { + const expectedName = expectedOptionName(textResource); + expect(screen.getByRole('option', { name: expectedName })).toBeInTheDocument(); + }); + }); + + it('Calls the onValueChange callback when the user picks a text resource', async () => { + const user = userEvent.setup(); + renderTextResourcePicker(); + await user.click(getCombobox()); + const textResourceToPick = textResources[129]; + await user.click(screen.getByRole('option', { name: expectedOptionName(textResourceToPick) })); + await waitFor(expect(onValueChange).toBeCalled); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith(textResourceToPick.id); + }); + + it('Displays the empty list text when the user clicks and there are no text resources', async () => { + const user = userEvent.setup(); + renderTextResourcePicker({ textResources: [] }); + await user.click(getCombobox()); + expect(screen.getByText(emptyListText)).toBeInTheDocument(); + }); + + it("Renders with the text of the text resource of which the ID is given by the component's value prop", () => { + const pickedTextResource = textResources[129]; + renderTextResourcePicker({ value: pickedTextResource.id }); + expect(getCombobox()).toHaveValue(pickedTextResource.value); + }); + + it('Forwards the ref', () => { + testRefForwarding((ref) => renderTextResourcePicker({}, ref), getCombobox); + }); + + it('Applies the class name to the root element', () => { + testRootClassNameAppending((className) => renderTextResourcePicker({ className })); + }); + + it('Accepts additional props', () => { + testCustomAttributes(renderTextResourcePicker, getCombobox); + }); +}); + +function renderTextResourcePicker( + props: Partial = {}, + ref?: ForwardedRef, +): RenderResult { + return render(); +} + +function getCombobox(): HTMLInputElement { + return screen.getByRole('combobox') as HTMLInputElement; +} + +function expectedOptionName(textResource: TextResource): string { + return textResource.value + ' ' + textResource.id; +} diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx new file mode 100644 index 00000000000..45d7ff01ff2 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx @@ -0,0 +1,57 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef, useCallback } from 'react'; +import type { TextResource } from './types/TextResource'; +import type { StudioComboboxProps } from '../StudioCombobox'; +import { StudioCombobox } from '../StudioCombobox'; + +export type StudioTextResourcePickerProps = Omit & + OverriddenProps & + AdditionalProps; + +type OverriddenProps = { + onValueChange: (id: string) => void; + value?: string; +}; + +type AdditionalProps = { + emptyListText: string; + textResources: TextResource[]; +}; + +export const StudioTextResourcePicker = forwardRef( + ({ textResources, onSelect, onValueChange, emptyListText, value, ...rest }, ref) => { + const handleValueChange = useCallback(([id]: string[]) => onValueChange(id), [onValueChange]); + + return ( + + {emptyListText} + {renderTextResourceOptions(textResources)} + + ); + }, +); + +function renderTextResourceOptions(textResources: TextResource[]): ReactElement[] { + // This cannot be a component function since the option components must be direct children of the combobox component. + return textResources.map(renderTextResourceOption); +} + +function renderTextResourceOption(textResource: TextResource): ReactElement { + return ( + + {textResource.value} + + ); +} + +StudioTextResourcePicker.displayName = 'StudioTextResourcePicker'; diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/index.ts b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/index.ts new file mode 100644 index 00000000000..8d409026ed1 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/index.ts @@ -0,0 +1 @@ +export * from './StudioTextResourcePicker'; diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/test-data/textResourcesMock.ts b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/test-data/textResourcesMock.ts new file mode 100644 index 00000000000..acb12d85165 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/test-data/textResourcesMock.ts @@ -0,0 +1,198 @@ +import type { TextResource } from '../types/TextResource'; + +export const textResourcesMock: TextResource[] = [ + { id: 'land.AF', value: 'Afghanistan' }, + { id: 'land.AL', value: 'Albania' }, + { id: 'land.DZ', value: 'Algerie' }, + { id: 'land.AD', value: 'Andorra' }, + { id: 'land.AO', value: 'Angola' }, + { id: 'land.AG', value: 'Antigua og Barbuda' }, + { id: 'land.AR', value: 'Argentina' }, + { id: 'land.AM', value: 'Armenia' }, + { id: 'land.AU', value: 'Australia' }, + { id: 'land.AT', value: 'Østerrike' }, + { id: 'land.AZ', value: 'Aserbajdsjan' }, + { id: 'land.BS', value: 'Bahamas' }, + { id: 'land.BH', value: 'Bahrain' }, + { id: 'land.BD', value: 'Bangladesh' }, + { id: 'land.BB', value: 'Barbados' }, + { id: 'land.BY', value: 'Hviterussland' }, + { id: 'land.BE', value: 'Belgia' }, + { id: 'land.BZ', value: 'Belize' }, + { id: 'land.BJ', value: 'Benin' }, + { id: 'land.BT', value: 'Bhutan' }, + { id: 'land.BO', value: 'Bolivia' }, + { id: 'land.BA', value: 'Bosnia-Hercegovina' }, + { id: 'land.BW', value: 'Botswana' }, + { id: 'land.BR', value: 'Brasil' }, + { id: 'land.BN', value: 'Brunei' }, + { id: 'land.BG', value: 'Bulgaria' }, + { id: 'land.BF', value: 'Burkina Faso' }, + { id: 'land.BI', value: 'Burundi' }, + { id: 'land.CV', value: 'Kapp Verde' }, + { id: 'land.KH', value: 'Kambodsja' }, + { id: 'land.CM', value: 'Kamerun' }, + { id: 'land.CA', value: 'Canada' }, + { id: 'land.CF', value: 'Den sentralafrikanske republikk' }, + { id: 'land.TD', value: 'Tsjad' }, + { id: 'land.CL', value: 'Chile' }, + { id: 'land.CN', value: 'Kina' }, + { id: 'land.CO', value: 'Colombia' }, + { id: 'land.KM', value: 'Komorene' }, + { id: 'land.CG', value: 'Kongo (Brazzaville)' }, + { id: 'land.CD', value: 'Kongo (Kinshasa)' }, + { id: 'land.CR', value: 'Costa Rica' }, + { id: 'land.HR', value: 'Kroatia' }, + { id: 'land.CU', value: 'Cuba' }, + { id: 'land.CY', value: 'Kypros' }, + { id: 'land.CZ', value: 'Tsjekkia' }, + { id: 'land.DK', value: 'Danmark' }, + { id: 'land.DJ', value: 'Djibouti' }, + { id: 'land.DM', value: 'Dominica' }, + { id: 'land.DO', value: 'Den dominikanske republikk' }, + { id: 'land.EC', value: 'Ecuador' }, + { id: 'land.EG', value: 'Egypt' }, + { id: 'land.SV', value: 'El Salvador' }, + { id: 'land.GQ', value: 'Ekvatorial-Guinea' }, + { id: 'land.ER', value: 'Eritrea' }, + { id: 'land.EE', value: 'Estland' }, + { id: 'land.SZ', value: 'Eswatini' }, + { id: 'land.ET', value: 'Etiopia' }, + { id: 'land.FJ', value: 'Fiji' }, + { id: 'land.FI', value: 'Finland' }, + { id: 'land.FR', value: 'Frankrike' }, + { id: 'land.GA', value: 'Gabon' }, + { id: 'land.GM', value: 'Gambia' }, + { id: 'land.GE', value: 'Georgia' }, + { id: 'land.DE', value: 'Tyskland' }, + { id: 'land.GH', value: 'Ghana' }, + { id: 'land.GR', value: 'Hellas' }, + { id: 'land.GD', value: 'Grenada' }, + { id: 'land.GT', value: 'Guatemala' }, + { id: 'land.GN', value: 'Guinea' }, + { id: 'land.GW', value: 'Guinea-Bissau' }, + { id: 'land.GY', value: 'Guyana' }, + { id: 'land.HT', value: 'Haiti' }, + { id: 'land.HN', value: 'Honduras' }, + { id: 'land.HU', value: 'Ungarn' }, + { id: 'land.IS', value: 'Island' }, + { id: 'land.IN', value: 'India' }, + { id: 'land.ID', value: 'Indonesia' }, + { id: 'land.IR', value: 'Iran' }, + { id: 'land.IQ', value: 'Irak' }, + { id: 'land.IE', value: 'Irland' }, + { id: 'land.IL', value: 'Israel' }, + { id: 'land.IT', value: 'Italia' }, + { id: 'land.CI', value: 'Elfenbenskysten' }, + { id: 'land.JM', value: 'Jamaica' }, + { id: 'land.JP', value: 'Japan' }, + { id: 'land.JO', value: 'Jordan' }, + { id: 'land.KZ', value: 'Kasakhstan' }, + { id: 'land.KE', value: 'Kenya' }, + { id: 'land.KI', value: 'Kiribati' }, + { id: 'land.KP', value: 'Nord-Korea' }, + { id: 'land.KR', value: 'Sør-Korea' }, + { id: 'land.KW', value: 'Kuwait' }, + { id: 'land.KG', value: 'Kirgisistan' }, + { id: 'land.LA', value: 'Laos' }, + { id: 'land.LV', value: 'Latvia' }, + { id: 'land.LB', value: 'Libanon' }, + { id: 'land.LS', value: 'Lesotho' }, + { id: 'land.LR', value: 'Liberia' }, + { id: 'land.LY', value: 'Libya' }, + { id: 'land.LI', value: 'Liechtenstein' }, + { id: 'land.LT', value: 'Litauen' }, + { id: 'land.LU', value: 'Luxembourg' }, + { id: 'land.MG', value: 'Madagaskar' }, + { id: 'land.MW', value: 'Malawi' }, + { id: 'land.MY', value: 'Malaysia' }, + { id: 'land.MV', value: 'Maldivene' }, + { id: 'land.ML', value: 'Mali' }, + { id: 'land.MT', value: 'Malta' }, + { id: 'land.MH', value: 'Marshalløyene' }, + { id: 'land.MR', value: 'Mauritania' }, + { id: 'land.MU', value: 'Mauritius' }, + { id: 'land.MX', value: 'Mexico' }, + { id: 'land.FM', value: 'Mikronesiaføderasjonen' }, + { id: 'land.MD', value: 'Moldova' }, + { id: 'land.MC', value: 'Monaco' }, + { id: 'land.MN', value: 'Mongolia' }, + { id: 'land.ME', value: 'Montenegro' }, + { id: 'land.MA', value: 'Marokko' }, + { id: 'land.MZ', value: 'Mosambik' }, + { id: 'land.MM', value: 'Myanmar' }, + { id: 'land.NA', value: 'Namibia' }, + { id: 'land.NR', value: 'Nauru' }, + { id: 'land.NP', value: 'Nepal' }, + { id: 'land.NL', value: 'Nederland' }, + { id: 'land.NZ', value: 'New Zealand' }, + { id: 'land.NI', value: 'Nicaragua' }, + { id: 'land.NE', value: 'Niger' }, + { id: 'land.NG', value: 'Nigeria' }, + { id: 'land.MK', value: 'Nord-Makedonia' }, + { id: 'land.NO', value: 'Norge' }, + { id: 'land.OM', value: 'Oman' }, + { id: 'land.PK', value: 'Pakistan' }, + { id: 'land.PW', value: 'Palau' }, + { id: 'land.PA', value: 'Panama' }, + { id: 'land.PG', value: 'Papua Ny-Guinea' }, + { id: 'land.PY', value: 'Paraguay' }, + { id: 'land.PE', value: 'Peru' }, + { id: 'land.PH', value: 'Filippinene' }, + { id: 'land.PL', value: 'Polen' }, + { id: 'land.PT', value: 'Portugal' }, + { id: 'land.QA', value: 'Qatar' }, + { id: 'land.RO', value: 'Romania' }, + { id: 'land.RU', value: 'Russland' }, + { id: 'land.RW', value: 'Rwanda' }, + { id: 'land.KN', value: 'Saint Kitts og Nevis' }, + { id: 'land.LC', value: 'Saint Lucia' }, + { id: 'land.VC', value: 'Saint Vincent og Grenadinene' }, + { id: 'land.WS', value: 'Samoa' }, + { id: 'land.SM', value: 'San Marino' }, + { id: 'land.ST', value: 'São Tomé og Príncipe' }, + { id: 'land.SA', value: 'Saudi-Arabia' }, + { id: 'land.SN', value: 'Senegal' }, + { id: 'land.RS', value: 'Serbia' }, + { id: 'land.SC', value: 'Seychellene' }, + { id: 'land.SL', value: 'Sierra Leone' }, + { id: 'land.SG', value: 'Singapore' }, + { id: 'land.SK', value: 'Slovakia' }, + { id: 'land.SI', value: 'Slovenia' }, + { id: 'land.SB', value: 'Salomonøyene' }, + { id: 'land.SO', value: 'Somalia' }, + { id: 'land.ZA', value: 'Sør-Afrika' }, + { id: 'land.SS', value: 'Sør-Sudan' }, + { id: 'land.ES', value: 'Spania' }, + { id: 'land.LK', value: 'Sri Lanka' }, + { id: 'land.SD', value: 'Sudan' }, + { id: 'land.SR', value: 'Surinam' }, + { id: 'land.SE', value: 'Sverige' }, + { id: 'land.CH', value: 'Sveits' }, + { id: 'land.SY', value: 'Syria' }, + { id: 'land.TW', value: 'Taiwan' }, + { id: 'land.TJ', value: 'Tadsjikistan' }, + { id: 'land.TZ', value: 'Tanzania' }, + { id: 'land.TH', value: 'Thailand' }, + { id: 'land.TG', value: 'Togo' }, + { id: 'land.TO', value: 'Tonga' }, + { id: 'land.TT', value: 'Trinidad og Tobago' }, + { id: 'land.TN', value: 'Tunisia' }, + { id: 'land.TR', value: 'Tyrkia' }, + { id: 'land.TM', value: 'Turkmenistan' }, + { id: 'land.TV', value: 'Tuvalu' }, + { id: 'land.UG', value: 'Uganda' }, + { id: 'land.UA', value: 'Ukraina' }, + { id: 'land.AE', value: 'De forente arabiske emirater' }, + { id: 'land.GB', value: 'Storbritannia' }, + { id: 'land.US', value: 'USA' }, + { id: 'land.UY', value: 'Uruguay' }, + { id: 'land.UZ', value: 'Usbekistan' }, + { id: 'land.VU', value: 'Vanuatu' }, + { id: 'land.VA', value: 'Vatikanstaten' }, + { id: 'land.VE', value: 'Venezuela' }, + { id: 'land.VN', value: 'Vietnam' }, + { id: 'land.YE', value: 'Jemen' }, + { id: 'land.ZM', value: 'Zambia' }, + { id: 'land.ZW', value: 'Zimbabwe' }, +]; diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/types/TextResource.ts b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/types/TextResource.ts new file mode 100644 index 00000000000..5684a9f00c5 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/types/TextResource.ts @@ -0,0 +1,5 @@ +export type TextResource = { + id: string; + value: string; + [key: string]: any; +}; diff --git a/frontend/libs/studio-components/src/components/index.ts b/frontend/libs/studio-components/src/components/index.ts index bea5c89d367..3b8f891cfb0 100644 --- a/frontend/libs/studio-components/src/components/index.ts +++ b/frontend/libs/studio-components/src/components/index.ts @@ -50,6 +50,7 @@ export * from './StudioTableLocalPagination'; export * from './StudioTableRemotePagination'; export * from './StudioTabs'; export * from './StudioTag'; +export * from './StudioTextResourcePicker'; export * from './StudioTextarea'; export * from './StudioTextfield'; export * from './StudioToggleableTextfield';