@genspectrum/dashboard-components 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/custom-elements.json +114 -25
  2. package/dist/{LocationChangedEvent-CORvQvXv.js → LineageFilterChangedEvent-GedKNGFI.js} +25 -3
  3. package/dist/LineageFilterChangedEvent-GedKNGFI.js.map +1 -0
  4. package/dist/components.d.ts +55 -21
  5. package/dist/components.js +229 -286
  6. package/dist/components.js.map +1 -1
  7. package/dist/util.d.ts +28 -14
  8. package/dist/util.js +3 -1
  9. package/package.json +1 -1
  10. package/src/preact/components/downshift-combobox.tsx +145 -0
  11. package/src/preact/lineageFilter/LineageFilterChangedEvent.ts +11 -0
  12. package/src/preact/lineageFilter/fetchLineageAutocompleteList.spec.ts +16 -2
  13. package/src/preact/lineageFilter/fetchLineageAutocompleteList.ts +13 -2
  14. package/src/preact/lineageFilter/lineage-filter.stories.tsx +110 -9
  15. package/src/preact/lineageFilter/lineage-filter.tsx +40 -50
  16. package/src/preact/locationFilter/LocationChangedEvent.ts +1 -1
  17. package/src/preact/locationFilter/fetchAutocompletionList.spec.ts +6 -2
  18. package/src/preact/locationFilter/fetchAutocompletionList.ts +16 -6
  19. package/src/preact/locationFilter/location-filter.stories.tsx +33 -30
  20. package/src/preact/locationFilter/location-filter.tsx +47 -144
  21. package/src/preact/textInput/TextInputChangedEvent.ts +1 -1
  22. package/src/preact/textInput/fetchStringAutocompleteList.ts +20 -0
  23. package/src/preact/textInput/text-input.stories.tsx +14 -11
  24. package/src/preact/textInput/text-input.tsx +39 -140
  25. package/src/types.ts +3 -0
  26. package/src/utilEntrypoint.ts +2 -0
  27. package/src/web-components/input/gs-lineage-filter.stories.ts +120 -31
  28. package/src/web-components/input/gs-lineage-filter.tsx +24 -8
  29. package/src/web-components/input/gs-location-filter.stories.ts +9 -0
  30. package/src/web-components/input/gs-location-filter.tsx +21 -3
  31. package/src/web-components/input/gs-text-input.stories.ts +14 -5
  32. package/src/web-components/input/gs-text-input.tsx +23 -7
  33. package/standalone-bundle/dashboard-components.js +5574 -5605
  34. package/standalone-bundle/dashboard-components.js.map +1 -1
  35. package/dist/LocationChangedEvent-CORvQvXv.js.map +0 -1
  36. package/src/preact/textInput/fetchAutocompleteList.ts +0 -9
@@ -1,16 +1,26 @@
1
1
  import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
2
+ import type { LapisFilter } from '../../types';
2
3
 
3
- export async function fetchAutocompletionList(
4
- fields: string[],
5
- lapis: string,
6
- signal?: AbortSignal,
7
- ): Promise<Record<string, string | undefined>[]> {
4
+ export async function fetchAutocompletionList({
5
+ fields,
6
+ lapis,
7
+ signal,
8
+ lapisFilter,
9
+ }: {
10
+ fields: string[];
11
+ lapis: string;
12
+ lapisFilter?: LapisFilter;
13
+ signal?: AbortSignal;
14
+ }): Promise<Record<string, string | undefined>[]> {
8
15
  const toAncestorInHierarchyOverwriteValues = Array(fields.length - 1)
9
16
  .fill(0)
10
17
  .map((_, i) => i + 1)
11
18
  .map((i) => fields.slice(i).reduce((acc, field) => ({ ...acc, [field]: null }), {}));
12
19
 
13
- const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string | null>>({}, fields);
20
+ const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string | null>>(
21
+ lapisFilter ?? {},
22
+ fields,
23
+ );
14
24
 
15
25
  const data = (await fetchAggregatedOperator.evaluate(lapis, signal)).content;
16
26
 
@@ -21,6 +21,7 @@ const meta: Meta<LocationFilterProps> = {
21
21
  url: AGGREGATED_ENDPOINT,
22
22
  body: {
23
23
  fields: ['region', 'country', 'division', 'location'],
24
+ age: 18,
24
25
  },
25
26
  },
26
27
  response: {
@@ -39,6 +40,9 @@ const meta: Meta<LocationFilterProps> = {
39
40
  fields: ['region', 'country', 'division', 'location'],
40
41
  value: { region: 'Europe', country: undefined, division: undefined, location: undefined },
41
42
  placeholderText: 'Enter a location',
43
+ lapisFilter: {
44
+ age: 18,
45
+ },
42
46
  },
43
47
  argTypes: {
44
48
  fields: {
@@ -61,6 +65,11 @@ const meta: Meta<LocationFilterProps> = {
61
65
  type: 'text',
62
66
  },
63
67
  },
68
+ lapisFilter: {
69
+ control: {
70
+ type: 'object',
71
+ },
72
+ },
64
73
  },
65
74
  };
66
75
 
@@ -81,16 +90,14 @@ export const Primary: StoryObj<LocationFilterProps> = {
81
90
  await userEvent.type(input, 'Germany');
82
91
  await userEvent.click(canvas.getByRole('option', { name: 'Germany Europe / Germany' }));
83
92
 
84
- await expect(locationChangedListenerMock).toHaveBeenCalledWith(
85
- expect.objectContaining({
86
- detail: {
87
- country: 'Germany',
88
- region: 'Europe',
89
- division: undefined,
90
- location: undefined,
91
- },
92
- }),
93
- );
93
+ await waitFor(() => {
94
+ return expect(locationChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
95
+ country: 'Germany',
96
+ region: 'Europe',
97
+ division: undefined,
98
+ location: undefined,
99
+ });
100
+ });
94
101
  });
95
102
  },
96
103
  };
@@ -104,16 +111,14 @@ export const ClearSelection: StoryObj<LocationFilterProps> = {
104
111
  const clearSelectionButton = await canvas.findByLabelText('clear selection');
105
112
  await userEvent.click(clearSelectionButton);
106
113
 
107
- await expect(locationChangedListenerMock).toHaveBeenCalledWith(
108
- expect.objectContaining({
109
- detail: {
110
- country: undefined,
111
- region: undefined,
112
- division: undefined,
113
- location: undefined,
114
- },
115
- }),
116
- );
114
+ await waitFor(() => {
115
+ return expect(locationChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
116
+ country: undefined,
117
+ region: undefined,
118
+ division: undefined,
119
+ location: undefined,
120
+ });
121
+ });
117
122
  });
118
123
  },
119
124
  };
@@ -128,16 +133,14 @@ export const OnBlurInput: StoryObj<LocationFilterProps> = {
128
133
  await userEvent.clear(input);
129
134
  await userEvent.click(canvas.getByLabelText('toggle menu'));
130
135
 
131
- await expect(locationChangedListenerMock).toHaveBeenCalledWith(
132
- expect.objectContaining({
133
- detail: {
134
- country: undefined,
135
- region: undefined,
136
- division: undefined,
137
- location: undefined,
138
- },
139
- }),
140
- );
136
+ await waitFor(() => {
137
+ return expect(locationChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
138
+ country: undefined,
139
+ region: undefined,
140
+ division: undefined,
141
+ location: undefined,
142
+ });
143
+ });
141
144
  });
142
145
  },
143
146
  };
@@ -1,35 +1,37 @@
1
- import { useCombobox } from 'downshift/preact';
2
1
  import { type FunctionComponent } from 'preact';
3
- import { useContext, useMemo, useRef, useState } from 'preact/hooks';
2
+ import { useContext, useMemo } from 'preact/hooks';
4
3
  import z from 'zod';
5
4
 
6
5
  import { fetchAutocompletionList } from './fetchAutocompletionList';
7
6
  import { LapisUrlContext } from '../LapisUrlContext';
8
- import { type LapisLocationFilter, LocationChangedEvent } from './LocationChangedEvent';
7
+ import { LocationChangedEvent } from './LocationChangedEvent';
8
+ import { lapisFilterSchema, type LapisLocationFilter, lapisLocationFilterSchema } from '../../types';
9
+ import { DownshiftCombobox } from '../components/downshift-combobox';
9
10
  import { ErrorBoundary } from '../components/error-boundary';
10
11
  import { LoadingDisplay } from '../components/loading-display';
11
12
  import { ResizeContainer } from '../components/resize-container';
12
13
  import { useQuery } from '../useQuery';
13
14
 
14
- const lineageFilterInnerPropsSchema = z.object({
15
- value: z.record(z.string().nullable().optional()).optional(),
15
+ const locationSelectorPropsSchema = z.object({
16
+ value: lapisLocationFilterSchema.optional(),
16
17
  placeholderText: z.string().optional(),
17
18
  fields: z.array(z.string()).min(1),
18
19
  });
19
-
20
- const lineageFilterPropsSchema = lineageFilterInnerPropsSchema.extend({
20
+ const locationFilterInnerPropsSchema = locationSelectorPropsSchema.extend({ lapisFilter: lapisFilterSchema });
21
+ const locationFilterPropsSchema = locationFilterInnerPropsSchema.extend({
21
22
  width: z.string(),
22
23
  });
23
24
 
24
- export type LocationFilterInnerProps = z.infer<typeof lineageFilterInnerPropsSchema>;
25
- export type LocationFilterProps = z.infer<typeof lineageFilterPropsSchema>;
25
+ export type LocationFilterInnerProps = z.infer<typeof locationFilterInnerPropsSchema>;
26
+ export type LocationFilterProps = z.infer<typeof locationFilterPropsSchema>;
27
+ type LocationSelectorProps = z.infer<typeof locationSelectorPropsSchema>;
26
28
 
27
29
  export const LocationFilter: FunctionComponent<LocationFilterProps> = (props) => {
28
30
  const { width, ...innerProps } = props;
29
31
  const size = { width, height: '3rem' };
30
32
 
31
33
  return (
32
- <ErrorBoundary size={size} layout='horizontal' componentProps={props} schema={lineageFilterPropsSchema}>
34
+ <ErrorBoundary size={size} layout='horizontal' componentProps={props} schema={locationFilterPropsSchema}>
33
35
  <ResizeContainer size={size}>
34
36
  <LocationFilterInner {...innerProps} />
35
37
  </ResizeContainer>
@@ -37,10 +39,13 @@ export const LocationFilter: FunctionComponent<LocationFilterProps> = (props) =>
37
39
  );
38
40
  };
39
41
 
40
- export const LocationFilterInner = ({ value, fields, placeholderText }: LocationFilterInnerProps) => {
42
+ export const LocationFilterInner = ({ value, fields, placeholderText, lapisFilter }: LocationFilterInnerProps) => {
41
43
  const lapis = useContext(LapisUrlContext);
42
44
 
43
- const { data, error, isLoading } = useQuery(() => fetchAutocompletionList(fields, lapis), [fields, lapis]);
45
+ const { data, error, isLoading } = useQuery(
46
+ () => fetchAutocompletionList({ fields, lapis, lapisFilter }),
47
+ [fields, lapis, lapisFilter],
48
+ );
44
49
 
45
50
  if (isLoading) {
46
51
  return <LoadingDisplay />;
@@ -54,7 +59,7 @@ export const LocationFilterInner = ({ value, fields, placeholderText }: Location
54
59
 
55
60
  type SelectItem = {
56
61
  lapisFilter: LapisLocationFilter;
57
- label: string;
62
+ label: string | null | undefined;
58
63
  description: string;
59
64
  };
60
65
 
@@ -63,140 +68,38 @@ const LocationSelector = ({
63
68
  value,
64
69
  placeholderText,
65
70
  locationData,
66
- }: LocationFilterInnerProps & {
71
+ }: LocationSelectorProps & {
67
72
  locationData: LapisLocationFilter[];
68
73
  }) => {
69
- const allItems = useMemo(
70
- () =>
71
- locationData
72
- .map((locationFilter) => {
73
- return toSelectOption(locationFilter, fields);
74
- })
75
- .filter((item): item is SelectItem => item !== undefined),
76
- [locationData, fields],
77
- );
74
+ const allItems = useMemo(() => {
75
+ return locationData
76
+ .map((location) => toSelectItem(location, fields))
77
+ .filter((item): item is SelectItem => item !== undefined);
78
+ }, [fields, locationData]);
78
79
 
79
- const initialSelectedItem = useMemo(
80
- () => (value !== undefined ? toSelectOption(value, fields) : null),
81
- [value, fields],
82
- );
83
-
84
- const [items, setItems] = useState(allItems.filter((item) => filterByInputValue(item, initialSelectedItem?.label)));
85
- const divRef = useRef<HTMLDivElement>(null);
86
-
87
- const shadowRoot = divRef.current?.shadowRoot ?? undefined;
88
-
89
- const environment =
90
- shadowRoot !== undefined
91
- ? {
92
- addEventListener: window.addEventListener.bind(window),
93
- removeEventListener: window.removeEventListener.bind(window),
94
- document: shadowRoot.ownerDocument,
95
- Node: window.Node,
96
- }
97
- : undefined;
98
-
99
- const {
100
- isOpen,
101
- getToggleButtonProps,
102
- getMenuProps,
103
- getInputProps,
104
- highlightedIndex,
105
- getItemProps,
106
- selectedItem,
107
- inputValue,
108
- selectItem,
109
- setInputValue,
110
- closeMenu,
111
- } = useCombobox({
112
- onInputValueChange({ inputValue }) {
113
- setItems(allItems.filter((item) => filterByInputValue(item, inputValue)));
114
- },
115
- onSelectedItemChange({ selectedItem }) {
116
- if (selectedItem !== null) {
117
- divRef.current?.dispatchEvent(new LocationChangedEvent(selectedItem.lapisFilter));
118
- }
119
- },
120
- items,
121
- itemToString(item) {
122
- return item?.label ?? '';
123
- },
124
- initialSelectedItem,
125
- environment,
126
- });
127
-
128
- const onInputBlur = () => {
129
- if (inputValue === '') {
130
- divRef.current?.dispatchEvent(new LocationChangedEvent(emptyLocationFilter(fields)));
131
- selectItem(null);
132
- } else if (inputValue !== selectedItem?.label) {
133
- setInputValue(selectedItem?.label || '');
134
- }
135
- };
136
-
137
- const clearInput = () => {
138
- divRef.current?.dispatchEvent(new LocationChangedEvent(emptyLocationFilter(fields)));
139
- selectItem(null);
140
- };
141
-
142
- const buttonRef = useRef(null);
80
+ const selectedItem = useMemo(() => {
81
+ return value !== undefined ? toSelectItem(value, fields) : undefined;
82
+ }, [fields, value]);
143
83
 
144
84
  return (
145
- <div ref={divRef} className={'relative w-full'}>
146
- <div className='w-full flex flex-col gap-1'>
147
- <div
148
- className='flex gap-0.5 input input-bordered min-w-32'
149
- onBlur={(event) => {
150
- if (event.relatedTarget != buttonRef.current) {
151
- closeMenu();
152
- }
153
- }}
154
- >
155
- <input
156
- placeholder={placeholderText}
157
- className='w-full p-1.5'
158
- {...getInputProps()}
159
- onBlur={onInputBlur}
160
- />
161
- <button
162
- aria-label='clear selection'
163
- className={`px-2 ${inputValue === '' && 'hidden'}`}
164
- type='button'
165
- onClick={clearInput}
166
- tabIndex={-1}
167
- >
168
- ×
169
- </button>
170
- <button
171
- aria-label='toggle menu'
172
- className='px-2'
173
- type='button'
174
- {...getToggleButtonProps()}
175
- ref={buttonRef}
176
- >
177
- {isOpen ? <>↑</> : <>↓</>}
178
- </button>
179
- </div>
180
- </div>
181
- <ul
182
- className={`absolute bg-white mt-1 shadow-md max-h-80 overflow-scroll z-10 w-full min-w-32 ${
183
- !(isOpen && items.length > 0) && 'hidden'
184
- }`}
185
- {...getMenuProps()}
186
- >
187
- {isOpen &&
188
- items.map((item, index) => (
189
- <li
190
- className={`${highlightedIndex === index && 'bg-blue-300'} ${selectedItem !== null && selectedItem.description === item.description && 'font-bold'} py-2 px-3 shadow-sm flex flex-col`}
191
- key={item.description}
192
- {...getItemProps({ item, index })}
193
- >
194
- <span>{item.label}</span>
195
- <span className='text-sm text-gray-500'>{item.description}</span>
196
- </li>
197
- ))}
198
- </ul>
199
- </div>
85
+ <DownshiftCombobox
86
+ allItems={allItems}
87
+ value={selectedItem}
88
+ filterItemsByInputValue={filterByInputValue}
89
+ createEvent={(item: SelectItem | null) =>
90
+ new LocationChangedEvent(item?.lapisFilter ?? emptyLocationFilter(fields))
91
+ }
92
+ itemToString={(item: SelectItem | undefined | null) => item?.label ?? ''}
93
+ placeholderText={placeholderText}
94
+ formatItemInList={(item: SelectItem) => {
95
+ return (
96
+ <>
97
+ <span>{item.label}</span>
98
+ <span className='text-sm text-gray-500'>{item.description}</span>
99
+ </>
100
+ );
101
+ }}
102
+ />
200
103
  );
201
104
  };
202
105
 
@@ -205,12 +108,12 @@ function filterByInputValue(item: SelectItem, inputValue: string | undefined | n
205
108
  return true;
206
109
  }
207
110
  return (
208
- item?.label.toLowerCase().includes(inputValue.toLowerCase()) ||
111
+ item?.label?.toLowerCase().includes(inputValue.toLowerCase()) ||
209
112
  item?.description.toLowerCase().includes(inputValue.toLowerCase())
210
113
  );
211
114
  }
212
115
 
213
- function toSelectOption(locationFilter: LapisLocationFilter, fields: string[]) {
116
+ function toSelectItem(locationFilter: LapisLocationFilter, fields: string[]): SelectItem | undefined {
214
117
  const concatenatedLocation = concatenateLocation(locationFilter, fields);
215
118
 
216
119
  const lastNonUndefinedField = [...fields]
@@ -1,4 +1,4 @@
1
- type LapisTextFilter = Record<string, string | null | undefined>;
1
+ type LapisTextFilter = Record<string, string | undefined>;
2
2
 
3
3
  export class TextInputChangedEvent extends CustomEvent<LapisTextFilter> {
4
4
  constructor(detail: LapisTextFilter) {
@@ -0,0 +1,20 @@
1
+ import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
2
+ import { type LapisFilter } from '../../types';
3
+
4
+ export async function fetchStringAutocompleteList({
5
+ lapis,
6
+ field,
7
+ lapisFilter,
8
+ signal,
9
+ }: {
10
+ lapis: string;
11
+ field: string;
12
+ lapisFilter?: LapisFilter;
13
+ signal?: AbortSignal;
14
+ }) {
15
+ const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string>>(lapisFilter ?? {}, [field]);
16
+
17
+ const data = (await fetchAggregatedOperator.evaluate(lapis, signal)).content;
18
+
19
+ return data.map((item) => item[field]).sort();
20
+ }
@@ -23,6 +23,7 @@ const meta: Meta<TextInputProps> = {
23
23
  url: AGGREGATED_ENDPOINT,
24
24
  body: {
25
25
  fields: ['host'],
26
+ country: 'Germany',
26
27
  },
27
28
  },
28
29
  response: {
@@ -36,16 +37,15 @@ const meta: Meta<TextInputProps> = {
36
37
  argTypes: {
37
38
  lapisField: {
38
39
  control: {
39
- type: 'select',
40
+ type: 'text',
40
41
  },
41
- options: ['host'],
42
42
  },
43
43
  placeholderText: {
44
44
  control: {
45
45
  type: 'text',
46
46
  },
47
47
  },
48
- initialValue: {
48
+ value: {
49
49
  control: {
50
50
  type: 'text',
51
51
  },
@@ -55,6 +55,11 @@ const meta: Meta<TextInputProps> = {
55
55
  type: 'text',
56
56
  },
57
57
  },
58
+ lapisFilter: {
59
+ control: {
60
+ type: 'object',
61
+ },
62
+ },
58
63
  },
59
64
  };
60
65
 
@@ -63,19 +68,17 @@ export default meta;
63
68
  export const Default: StoryObj<TextInputProps> = {
64
69
  render: (args) => (
65
70
  <LapisUrlContext.Provider value={LAPIS_URL}>
66
- <TextInput
67
- lapisField={args.lapisField}
68
- placeholderText={args.placeholderText}
69
- initialValue={args.initialValue}
70
- width={args.width}
71
- />
71
+ <TextInput {...args} />
72
72
  </LapisUrlContext.Provider>
73
73
  ),
74
74
  args: {
75
75
  lapisField: 'host',
76
76
  placeholderText: 'Enter a host name',
77
- initialValue: '',
77
+ value: '',
78
78
  width: '100%',
79
+ lapisFilter: {
80
+ country: 'Germany',
81
+ },
79
82
  },
80
83
  };
81
84
 
@@ -83,7 +86,7 @@ export const RemoveInitialValue: StoryObj<TextInputProps> = {
83
86
  ...Default,
84
87
  args: {
85
88
  ...Default.args,
86
- initialValue: 'Homo sapiens',
89
+ value: 'Homo sapiens',
87
90
  },
88
91
  play: async ({ canvasElement, step }) => {
89
92
  const canvas = within(canvasElement);