From e8f1657c07922f02e92a5a43668a7634a62b231a Mon Sep 17 00:00:00 2001 From: "Jonas Zarzalis (TNG)" Date: Fri, 17 Jan 2025 18:17:52 +0100 Subject: [PATCH] feat(components): gs-lineage-filter: use downshift for selector Resolves #654 --- .../preact/components/downshift-combobox.tsx | 27 +---- .../LineageFilterChangedEvent.ts | 11 ++ .../lineageFilter/lineage-filter.stories.tsx | 80 ++++++++++++- .../preact/lineageFilter/lineage-filter.tsx | 81 +++++-------- .../locationFilter/LocationChangedEvent.ts | 2 +- .../preact/locationFilter/location-filter.tsx | 34 ++++-- .../preact/textInput/TextInputChangedEvent.ts | 2 +- .../src/preact/textInput/text-input.tsx | 3 +- components/src/types.ts | 3 + components/src/utilEntrypoint.ts | 2 + .../input/gs-lineage-filter.stories.ts | 108 +++++++++++++----- .../input/gs-lineage-filter.tsx | 5 +- .../input/gs-location-filter.tsx | 4 +- .../web-components/input/gs-text-input.tsx | 5 +- 14 files changed, 244 insertions(+), 123 deletions(-) create mode 100644 components/src/preact/lineageFilter/LineageFilterChangedEvent.ts diff --git a/components/src/preact/components/downshift-combobox.tsx b/components/src/preact/components/downshift-combobox.tsx index 4546fcd9..b2c3a009 100644 --- a/components/src/preact/components/downshift-combobox.tsx +++ b/components/src/preact/components/downshift-combobox.tsx @@ -1,10 +1,9 @@ import { useCombobox } from 'downshift/preact'; import { type ComponentChild } from 'preact'; -import { useMemo, useRef, useState } from 'preact/hooks'; +import { useRef, useState } from 'preact/hooks'; -export function DownshiftCombobox({ - allData, - convertToItem, +export function DownshiftCombobox({ + allItems, value, filterItemsByInputValue, createEvent, @@ -12,29 +11,15 @@ export function DownshiftCombobox({ placeholderText, formatItemInList, }: { - allData: Data[]; - convertToItem: (data: Data) => Item | undefined; - value?: Data; + allItems: Item[]; + value?: Item; filterItemsByInputValue: (item: Item, value: string) => boolean; createEvent: (item: Item | null) => CustomEvent; itemToString: (item: Item | undefined | null) => string; placeholderText?: string; formatItemInList: (item: Item) => ComponentChild; }) { - const allItems = useMemo( - () => - allData - .map((data) => { - return convertToItem(data); - }) - .filter((item): item is Item => item !== undefined), - [allData, convertToItem], - ); - - const initialSelectedItem = useMemo( - () => (value !== undefined ? convertToItem(value) : null), - [value, convertToItem], - ); + const initialSelectedItem = value ?? null; const [items, setItems] = useState( allItems.filter((item) => filterItemsByInputValue(item, itemToString(initialSelectedItem))), diff --git a/components/src/preact/lineageFilter/LineageFilterChangedEvent.ts b/components/src/preact/lineageFilter/LineageFilterChangedEvent.ts new file mode 100644 index 00000000..f57a8636 --- /dev/null +++ b/components/src/preact/lineageFilter/LineageFilterChangedEvent.ts @@ -0,0 +1,11 @@ +type LapisLineageFilter = Record; + +export class LineageFilterChangedEvent extends CustomEvent { + constructor(detail: LapisLineageFilter) { + super('gs-lineage-filter-changed', { + detail, + bubbles: true, + composed: true, + }); + } +} diff --git a/components/src/preact/lineageFilter/lineage-filter.stories.tsx b/components/src/preact/lineageFilter/lineage-filter.stories.tsx index 3ce4a033..92752835 100644 --- a/components/src/preact/lineageFilter/lineage-filter.stories.tsx +++ b/components/src/preact/lineageFilter/lineage-filter.stories.tsx @@ -1,4 +1,6 @@ -import { type Meta, type StoryObj } from '@storybook/preact'; +import { type Meta, type PreactRenderer, type StoryObj } from '@storybook/preact'; +import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import type { StepFunction } from '@storybook/types'; import { LineageFilter, type LineageFilterProps } from './lineage-filter'; import { previewHandles } from '../../../.storybook/preview'; @@ -34,8 +36,8 @@ const meta: Meta = { }, args: { lapisField: 'pangoLineage', - placeholderText: 'Enter lineage', - initialValue: '', + placeholderText: 'Enter a lineage', + initialValue: 'A.1', width: '100%', }, }; @@ -53,6 +55,59 @@ export const Default: StoryObj = { /> ), + play: async ({ canvasElement, step }) => { + const { canvas, lineageChangedListenerMock } = await prepare(canvasElement, step); + + step('change lineage filter value fires event', async () => { + const input = await inputField(canvas); + await userEvent.clear(input); + await userEvent.type(input, 'B.1'); + await userEvent.click(canvas.getByRole('option', { name: 'B.1' })); + + await waitFor(() => { + return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({ + pangoLineage: 'B.1', + }); + }); + }); + }, +}; + +export const ClearSelection: StoryObj = { + ...Default, + play: async ({ canvasElement, step }) => { + const { canvas, lineageChangedListenerMock } = await prepare(canvasElement, step); + + step('clear selection fires event with empty filter', async () => { + const clearSelectionButton = await canvas.findByLabelText('clear selection'); + await userEvent.click(clearSelectionButton); + + await waitFor(() => { + return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({ + pangoLineage: undefined, + }); + }); + }); + }, +}; + +export const OnBlurInput: StoryObj = { + ...Default, + play: async ({ canvasElement, step }) => { + const { canvas, lineageChangedListenerMock } = await prepare(canvasElement, step); + + step('after cleared selection by hand and then blur fires event with empty filter', async () => { + const input = await inputField(canvas); + await userEvent.clear(input); + await userEvent.click(canvas.getByLabelText('toggle menu')); + + await waitFor(() => { + return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({ + pangoLineage: undefined, + }); + }); + }); + }, }; export const WithNoLapisField: StoryObj = { @@ -67,3 +122,22 @@ export const WithNoLapisField: StoryObj = { }); }, }; + +async function prepare(canvasElement: HTMLElement, step: StepFunction) { + const canvas = within(canvasElement); + + const lineageChangedListenerMock = fn(); + step('Setup event listener mock', () => { + canvasElement.addEventListener('gs-lineage-filter-changed', lineageChangedListenerMock); + }); + + step('location filter is rendered with value', async () => { + await waitFor(async () => { + return expect(await inputField(canvas)).toHaveValue('A.1'); + }); + }); + + return { canvas, lineageChangedListenerMock }; +} + +const inputField = (canvas: ReturnType) => canvas.findByPlaceholderText('Enter a lineage'); diff --git a/components/src/preact/lineageFilter/lineage-filter.tsx b/components/src/preact/lineageFilter/lineage-filter.tsx index 537eed66..8377f1af 100644 --- a/components/src/preact/lineageFilter/lineage-filter.tsx +++ b/components/src/preact/lineageFilter/lineage-filter.tsx @@ -1,19 +1,20 @@ import { type FunctionComponent } from 'preact'; -import { useContext, useRef } from 'preact/hooks'; +import { useContext } from 'preact/hooks'; import z from 'zod'; import { fetchLineageAutocompleteList } from './fetchLineageAutocompleteList'; import { LapisUrlContext } from '../LapisUrlContext'; +import { LineageFilterChangedEvent } from './LineageFilterChangedEvent'; +import { DownshiftCombobox } from '../components/downshift-combobox'; import { ErrorBoundary } from '../components/error-boundary'; import { LoadingDisplay } from '../components/loading-display'; -import { NoDataDisplay } from '../components/no-data-display'; import { ResizeContainer } from '../components/resize-container'; import { useQuery } from '../useQuery'; const lineageFilterInnerPropsSchema = z.object({ lapisField: z.string().min(1), placeholderText: z.string().optional(), - initialValue: z.string(), + value: z.string(), }); const lineageFilterPropsSchema = lineageFilterInnerPropsSchema.extend({ @@ -36,15 +37,9 @@ export const LineageFilter: FunctionComponent = (props) => { ); }; -const LineageFilterInner: FunctionComponent = ({ - lapisField, - placeholderText, - initialValue, -}) => { +const LineageFilterInner: FunctionComponent = ({ lapisField, placeholderText, value }) => { const lapis = useContext(LapisUrlContext); - const inputRef = useRef(null); - const { data, error, isLoading } = useQuery( () => fetchLineageAutocompleteList(lapis, lapisField), [lapisField, lapis], @@ -58,47 +53,33 @@ const LineageFilterInner: FunctionComponent = ({ throw error; } - if (data === null) { - return ; - } - - const onInput = () => { - const value = inputRef.current?.value === '' ? undefined : inputRef.current?.value; - - if (isValidValue(value)) { - inputRef.current?.dispatchEvent( - new CustomEvent('gs-lineage-filter-changed', { - detail: { [lapisField]: value }, - bubbles: true, - composed: true, - }), - ); - } - }; - - const isValidValue = (value: string | undefined) => { - if (value === undefined) { - return true; - } - return data.includes(value); - }; + return ; +}; +const LineageSelector = ({ + lapisField, + value, + placeholderText, + data, +}: LineageFilterInnerProps & { + data: string[]; +}) => { return ( - <> - - - {data.map((item) => ( - - + new LineageFilterChangedEvent({ [lapisField]: item ?? undefined })} + itemToString={(item: string | undefined | null) => item ?? ''} + placeholderText={placeholderText} + formatItemInList={(item: string) => item} + /> ); }; + +function filterByInputValue(item: string, inputValue: string | undefined | null) { + if (inputValue === undefined || inputValue === null || inputValue === '') { + return true; + } + return item?.toLowerCase().includes(inputValue?.toLowerCase() || ''); +} diff --git a/components/src/preact/locationFilter/LocationChangedEvent.ts b/components/src/preact/locationFilter/LocationChangedEvent.ts index 9bc63064..b95fd7d6 100644 --- a/components/src/preact/locationFilter/LocationChangedEvent.ts +++ b/components/src/preact/locationFilter/LocationChangedEvent.ts @@ -1,4 +1,4 @@ -export type LapisLocationFilter = Record; +import { type LapisLocationFilter } from '../../types'; export class LocationChangedEvent extends CustomEvent { constructor(detail: LapisLocationFilter) { diff --git a/components/src/preact/locationFilter/location-filter.tsx b/components/src/preact/locationFilter/location-filter.tsx index ed4ddeed..59bb625c 100644 --- a/components/src/preact/locationFilter/location-filter.tsx +++ b/components/src/preact/locationFilter/location-filter.tsx @@ -1,35 +1,36 @@ import { type FunctionComponent } from 'preact'; -import { useContext } from 'preact/hooks'; +import { useContext, useMemo } from 'preact/hooks'; import z from 'zod'; import { fetchAutocompletionList } from './fetchAutocompletionList'; import { LapisUrlContext } from '../LapisUrlContext'; -import { type LapisLocationFilter, LocationChangedEvent } from './LocationChangedEvent'; +import { LocationChangedEvent } from './LocationChangedEvent'; +import { type LapisLocationFilter, lapisLocationFilterSchema } from '../../types'; import { DownshiftCombobox } from '../components/downshift-combobox'; import { ErrorBoundary } from '../components/error-boundary'; import { LoadingDisplay } from '../components/loading-display'; import { ResizeContainer } from '../components/resize-container'; import { useQuery } from '../useQuery'; -const lineageFilterInnerPropsSchema = z.object({ - value: z.record(z.string().nullable().optional()).optional(), +const locationFilterInnerPropsSchema = z.object({ + value: lapisLocationFilterSchema.optional(), placeholderText: z.string().optional(), fields: z.array(z.string()).min(1), }); -const lineageFilterPropsSchema = lineageFilterInnerPropsSchema.extend({ +const locationFilterPropsSchema = locationFilterInnerPropsSchema.extend({ width: z.string(), }); -export type LocationFilterInnerProps = z.infer; -export type LocationFilterProps = z.infer; +export type LocationFilterInnerProps = z.infer; +export type LocationFilterProps = z.infer; export const LocationFilter: FunctionComponent = (props) => { const { width, ...innerProps } = props; const size = { width, height: '3rem' }; return ( - + @@ -66,11 +67,20 @@ const LocationSelector = ({ }: LocationFilterInnerProps & { locationData: LapisLocationFilter[]; }) => { + const allItems = useMemo(() => { + return locationData + .map((location) => toSelectItem(location, fields)) + .filter((item): item is SelectItem => item !== undefined); + }, [fields, locationData]); + + const selectedItem = useMemo(() => { + return value !== undefined ? toSelectItem(value, fields) : undefined; + }, [fields, value]); + return ( toSelectOption(data, fields)} - value={value} + allItems={allItems} + value={selectedItem} filterItemsByInputValue={filterByInputValue} createEvent={(item: SelectItem | null) => new LocationChangedEvent(item?.lapisFilter ?? emptyLocationFilter(fields)) @@ -99,7 +109,7 @@ function filterByInputValue(item: SelectItem, inputValue: string | undefined | n ); } -function toSelectOption(locationFilter: LapisLocationFilter, fields: string[]) { +function toSelectItem(locationFilter: LapisLocationFilter, fields: string[]): SelectItem | undefined { const concatenatedLocation = concatenateLocation(locationFilter, fields); const lastNonUndefinedField = [...fields] diff --git a/components/src/preact/textInput/TextInputChangedEvent.ts b/components/src/preact/textInput/TextInputChangedEvent.ts index 70992124..4f41c1c5 100644 --- a/components/src/preact/textInput/TextInputChangedEvent.ts +++ b/components/src/preact/textInput/TextInputChangedEvent.ts @@ -1,4 +1,4 @@ -type LapisTextFilter = Record; +type LapisTextFilter = Record; export class TextInputChangedEvent extends CustomEvent { constructor(detail: LapisTextFilter) { diff --git a/components/src/preact/textInput/text-input.tsx b/components/src/preact/textInput/text-input.tsx index 26214839..e7acf91c 100644 --- a/components/src/preact/textInput/text-input.tsx +++ b/components/src/preact/textInput/text-input.tsx @@ -68,8 +68,7 @@ const TextSelector = ({ }) => { return ( data} + allItems={data} value={value} filterItemsByInputValue={filterByInputValue} createEvent={(item: string | null) => new TextInputChangedEvent({ [lapisField]: item ?? undefined })} diff --git a/components/src/types.ts b/components/src/types.ts index 614da443..fb4c609d 100644 --- a/components/src/types.ts +++ b/components/src/types.ts @@ -28,6 +28,9 @@ export const namedLapisFilterSchema = z.object({ }); export type NamedLapisFilter = z.infer; +export const lapisLocationFilterSchema = z.record(z.union([z.string(), z.undefined()])); +export type LapisLocationFilter = z.infer; + export const temporalGranularitySchema = z.union([ z.literal('day'), z.literal('week'), diff --git a/components/src/utilEntrypoint.ts b/components/src/utilEntrypoint.ts index 7d1c73b4..e177bc23 100644 --- a/components/src/utilEntrypoint.ts +++ b/components/src/utilEntrypoint.ts @@ -34,3 +34,5 @@ export type { ConfidenceIntervalMethod } from './preact/shared/charts/confideceI export type { AxisMax, YAxisMaxConfig } from './preact/shared/charts/getYAxisMax'; export { LocationChangedEvent } from './preact/locationFilter/LocationChangedEvent'; +export { LineageFilterChangedEvent } from './preact/lineageFilter/LineageFilterChangedEvent'; +export { TextInputChangedEvent } from './preact/textInput/TextInputChangedEvent'; diff --git a/components/src/web-components/input/gs-lineage-filter.stories.ts b/components/src/web-components/input/gs-lineage-filter.stories.ts index d9550927..ca31f675 100644 --- a/components/src/web-components/input/gs-lineage-filter.stories.ts +++ b/components/src/web-components/input/gs-lineage-filter.stories.ts @@ -54,7 +54,7 @@ const meta: Meta> = { export default meta; -export const Default: StoryObj> = { +const Template: StoryObj> = { render: (args) => { return html`
@@ -69,18 +69,82 @@ export const Default: StoryObj> = { }, args: { lapisField: 'pangoLineage', - placeholderText: 'Enter lineage', + placeholderText: 'Enter a lineage', initialValue: 'B.1.1.7', width: '100%', }, }; +const aggregatedEndpointMatcher = { + name: 'pangoLineage', + url: AGGREGATED_ENDPOINT, + body: { + fields: ['pangoLineage'], + }, +}; + +export const LineageFilter: StoryObj> = { + ...Template, + play: async ({ canvasElement }) => { + const canvas = await withinShadowRoot(canvasElement, 'gs-lineage-filter'); + await waitFor(() => { + return expect(canvas.getByPlaceholderText('Enter a lineage')).toBeVisible(); + }); + }, +}; + +export const DelayToShowLoadingState: StoryObj> = { + ...Template, + parameters: { + fetchMock: { + mocks: [ + { + matcher: aggregatedEndpointMatcher, + response: { + status: 200, + body: aggregatedData, + }, + options: { + delay: 5000, + }, + }, + ], + }, + }, +}; + +export const FetchingLocationsFails: StoryObj> = { + ...Template, + parameters: { + fetchMock: { + mocks: [ + { + matcher: aggregatedEndpointMatcher, + response: { + status: 400, + body: { + error: { status: 400, detail: 'Dummy error message from mock LAPIS', type: 'about:blank' }, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = await withinShadowRoot(canvasElement, 'gs-lineage-filter'); + + await waitFor(() => + expect(canvas.getByText('Oops! Something went wrong.', { exact: false })).toBeInTheDocument(), + ); + }, +}; + export const FiresEvent: StoryObj> = { - ...Default, + ...LineageFilter, play: async ({ canvasElement, step }) => { const canvas = await withinShadowRoot(canvasElement, 'gs-lineage-filter'); - const inputField = () => canvas.getByPlaceholderText('Enter lineage'); + const inputField = () => canvas.getByPlaceholderText('Enter a lineage'); const listenerMock = fn(); await step('Setup event listener mock', async () => { canvasElement.addEventListener('gs-lineage-filter-changed', listenerMock); @@ -99,38 +163,28 @@ export const FiresEvent: StoryObj> = { await step('Empty input', async () => { await userEvent.type(inputField(), '{backspace>9/}'); - await expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({ - pangoLineage: undefined, - }); - }); + await userEvent.click(canvas.getByLabelText('toggle menu')); - await step('Enter a valid lineage value', async () => { - await userEvent.type(inputField(), 'B.1.1.7'); - - await expect(listenerMock).toHaveBeenCalledWith( - expect.objectContaining({ - detail: { - pangoLineage: 'B.1.1.7', - }, - }), - ); + await waitFor(() => { + return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({ + pangoLineage: undefined, + }); + }); }); await step('Enter a valid lineage value', async () => { - await userEvent.type(inputField(), '{backspace>9/}'); await userEvent.type(inputField(), 'B.1.1.7*'); + await userEvent.click(canvas.getByRole('option', { name: 'B.1.1.7*' })); - await expect(listenerMock).toHaveBeenCalledWith( - expect.objectContaining({ - detail: { - pangoLineage: 'B.1.1.7*', - }, - }), - ); + await waitFor(() => { + return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({ + pangoLineage: 'B.1.1.7*', + }); + }); }); }, args: { - ...Default.args, + ...LineageFilter.args, initialValue: '', }, }; diff --git a/components/src/web-components/input/gs-lineage-filter.tsx b/components/src/web-components/input/gs-lineage-filter.tsx index 1b7d0bc9..c50d68f8 100644 --- a/components/src/web-components/input/gs-lineage-filter.tsx +++ b/components/src/web-components/input/gs-lineage-filter.tsx @@ -1,6 +1,7 @@ import { customElement, property } from 'lit/decorators.js'; import type { DetailedHTMLProps, HTMLAttributes } from 'react'; +import { type LineageFilterChangedEvent } from '../../preact/lineageFilter/LineageFilterChangedEvent'; import { LineageFilter, type LineageFilterProps } from '../../preact/lineageFilter/lineage-filter'; import type { Equals, Expect } from '../../utils/typeAssertions'; import { PreactLitAdapter } from '../PreactLitAdapter'; @@ -17,7 +18,7 @@ import { PreactLitAdapter } from '../PreactLitAdapter'; * and provides an autocomplete list with the available values of the lineage and sublineage queries * (a `*` appended to the lineage value). * - * @fires {CustomEvent>} gs-lineage-filter-changed + * @fires {CustomEvent>} gs-lineage-filter-changed * Fired when the input field is changed. * The `details` of this event contain an object with the `lapisField` as key and the input value as value. * Example: @@ -76,7 +77,7 @@ declare global { } interface HTMLElementEventMap { - 'gs-lineage-filter-changed': CustomEvent>; + 'gs-lineage-filter-changed': LineageFilterChangedEvent; } } diff --git a/components/src/web-components/input/gs-location-filter.tsx b/components/src/web-components/input/gs-location-filter.tsx index 6e128ab5..30007c88 100644 --- a/components/src/web-components/input/gs-location-filter.tsx +++ b/components/src/web-components/input/gs-location-filter.tsx @@ -15,7 +15,7 @@ import { PreactLitAdapter } from '../PreactLitAdapter'; * The component retrieves a list of all possible values for these fields from the Lapis instance. * This list is then utilized to display autocomplete suggestions and to validate the input. * - * @fires {CustomEvent>} gs-location-changed + * @fires {CustomEvent>} gs-location-changed * Fired when a value from the datalist is selected or when a valid value is typed into the field. * The `details` of this event contain an object with all `fields` as keys * and the corresponding values as values, even if they are `undefined`. @@ -35,7 +35,7 @@ export class LocationFilterComponent extends PreactLitAdapter { * The initial value to use for this location filter. */ @property({ type: Object }) - value: Record | undefined = undefined; + value: Record | undefined = undefined; /** * Required. diff --git a/components/src/web-components/input/gs-text-input.tsx b/components/src/web-components/input/gs-text-input.tsx index c92c88d6..5ed68add 100644 --- a/components/src/web-components/input/gs-text-input.tsx +++ b/components/src/web-components/input/gs-text-input.tsx @@ -1,6 +1,7 @@ import { customElement, property } from 'lit/decorators.js'; import type { DetailedHTMLProps, HTMLAttributes } from 'react'; +import { type TextInputChangedEvent } from '../../preact/textInput/TextInputChangedEvent'; import { TextInput, type TextInputProps } from '../../preact/textInput/text-input'; import type { Equals, Expect } from '../../utils/typeAssertions'; import { PreactLitAdapter } from '../PreactLitAdapter'; @@ -11,7 +12,7 @@ import { PreactLitAdapter } from '../PreactLitAdapter'; * * This component provides a text input field to specify filters for arbitrary fields of this LAPIS instance. * - * @fires {CustomEvent>} gs-text-input-changed + * @fires {CustomEvent>} gs-text-input-changed * Fired when the input field is changed. * The `details` of this event contain an object with the `lapisField` as key and the input value as value. * Example: @@ -70,7 +71,7 @@ declare global { } interface HTMLElementEventMap { - 'gs-text-input-changed': CustomEvent>; + 'gs-text-input-changed': TextInputChangedEvent; } }