Skip to content

Commit

Permalink
feat(components): gs-lineage-filter: use downshift for selector
Browse files Browse the repository at this point in the history
Resolves #654
  • Loading branch information
Jonas Zarzalis (TNG) committed Jan 21, 2025
1 parent dfe105e commit e8f1657
Show file tree
Hide file tree
Showing 14 changed files with 244 additions and 123 deletions.
27 changes: 6 additions & 21 deletions components/src/preact/components/downshift-combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,25 @@
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<Item, Data>({
allData,
convertToItem,
export function DownshiftCombobox<Item>({
allItems,
value,
filterItemsByInputValue,
createEvent,
itemToString,
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))),
Expand Down
11 changes: 11 additions & 0 deletions components/src/preact/lineageFilter/LineageFilterChangedEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type LapisLineageFilter = Record<string, string | undefined>;

export class LineageFilterChangedEvent extends CustomEvent<LapisLineageFilter> {
constructor(detail: LapisLineageFilter) {
super('gs-lineage-filter-changed', {
detail,
bubbles: true,
composed: true,
});
}
}
80 changes: 77 additions & 3 deletions components/src/preact/lineageFilter/lineage-filter.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,8 +36,8 @@ const meta: Meta = {
},
args: {
lapisField: 'pangoLineage',
placeholderText: 'Enter lineage',
initialValue: '',
placeholderText: 'Enter a lineage',
initialValue: 'A.1',
width: '100%',
},
};
Expand All @@ -53,6 +55,59 @@ export const Default: StoryObj<LineageFilterProps> = {
/>
</LapisUrlContext.Provider>
),
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<LineageFilterProps> = {
...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<LineageFilterProps> = {
...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<LineageFilterProps> = {
Expand All @@ -67,3 +122,22 @@ export const WithNoLapisField: StoryObj<LineageFilterProps> = {
});
},
};

async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRenderer, unknown>) {
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<typeof within>) => canvas.findByPlaceholderText('Enter a lineage');
81 changes: 31 additions & 50 deletions components/src/preact/lineageFilter/lineage-filter.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -36,15 +37,9 @@ export const LineageFilter: FunctionComponent<LineageFilterProps> = (props) => {
);
};

const LineageFilterInner: FunctionComponent<LineageFilterInnerProps> = ({
lapisField,
placeholderText,
initialValue,
}) => {
const LineageFilterInner: FunctionComponent<LineageFilterInnerProps> = ({ lapisField, placeholderText, value }) => {
const lapis = useContext(LapisUrlContext);

const inputRef = useRef<HTMLInputElement>(null);

const { data, error, isLoading } = useQuery(
() => fetchLineageAutocompleteList(lapis, lapisField),
[lapisField, lapis],
Expand All @@ -58,47 +53,33 @@ const LineageFilterInner: FunctionComponent<LineageFilterInnerProps> = ({
throw error;
}

if (data === null) {
return <NoDataDisplay />;
}

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 <LineageSelector lapisField={lapisField} value={value} placeholderText={placeholderText} data={data} />;
};

const LineageSelector = ({
lapisField,
value,
placeholderText,
data,
}: LineageFilterInnerProps & {
data: string[];
}) => {
return (
<>
<input
type='text'
class='input input-bordered w-full'
placeholder={placeholderText !== undefined ? placeholderText : lapisField}
onInput={onInput}
ref={inputRef}
list={lapisField}
value={initialValue}
/>
<datalist id={lapisField}>
{data.map((item) => (
<option value={item} key={item} />
))}
</datalist>
</>
<DownshiftCombobox
allItems={data}
value={value}
filterItemsByInputValue={filterByInputValue}
createEvent={(item: string | null) => 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() || '');
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type LapisLocationFilter = Record<string, string | null | undefined>;
import { type LapisLocationFilter } from '../../types';

export class LocationChangedEvent extends CustomEvent<LapisLocationFilter> {
constructor(detail: LapisLocationFilter) {
Expand Down
34 changes: 22 additions & 12 deletions components/src/preact/locationFilter/location-filter.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof lineageFilterInnerPropsSchema>;
export type LocationFilterProps = z.infer<typeof lineageFilterPropsSchema>;
export type LocationFilterInnerProps = z.infer<typeof locationFilterInnerPropsSchema>;
export type LocationFilterProps = z.infer<typeof locationFilterPropsSchema>;

export const LocationFilter: FunctionComponent<LocationFilterProps> = (props) => {
const { width, ...innerProps } = props;
const size = { width, height: '3rem' };

return (
<ErrorBoundary size={size} layout='horizontal' componentProps={props} schema={lineageFilterPropsSchema}>
<ErrorBoundary size={size} layout='horizontal' componentProps={props} schema={locationFilterPropsSchema}>
<ResizeContainer size={size}>
<LocationFilterInner {...innerProps} />
</ResizeContainer>
Expand Down Expand Up @@ -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 (
<DownshiftCombobox
allData={locationData}
convertToItem={(data: LapisLocationFilter) => toSelectOption(data, fields)}
value={value}
allItems={allItems}
value={selectedItem}
filterItemsByInputValue={filterByInputValue}
createEvent={(item: SelectItem | null) =>
new LocationChangedEvent(item?.lapisFilter ?? emptyLocationFilter(fields))
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion components/src/preact/textInput/TextInputChangedEvent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
type LapisTextFilter = Record<string, string | null | undefined>;
type LapisTextFilter = Record<string, string | undefined>;

export class TextInputChangedEvent extends CustomEvent<LapisTextFilter> {
constructor(detail: LapisTextFilter) {
Expand Down
3 changes: 1 addition & 2 deletions components/src/preact/textInput/text-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ const TextSelector = ({
}) => {
return (
<DownshiftCombobox
allData={data}
convertToItem={(data: string) => data}
allItems={data}
value={value}
filterItemsByInputValue={filterByInputValue}
createEvent={(item: string | null) => new TextInputChangedEvent({ [lapisField]: item ?? undefined })}
Expand Down
3 changes: 3 additions & 0 deletions components/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const namedLapisFilterSchema = z.object({
});
export type NamedLapisFilter = z.infer<typeof namedLapisFilterSchema>;

export const lapisLocationFilterSchema = z.record(z.union([z.string(), z.undefined()]));
export type LapisLocationFilter = z.infer<typeof lapisLocationFilterSchema>;

export const temporalGranularitySchema = z.union([
z.literal('day'),
z.literal('week'),
Expand Down
2 changes: 2 additions & 0 deletions components/src/utilEntrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading

0 comments on commit e8f1657

Please sign in to comment.