@genspectrum/dashboard-components 0.19.7 → 0.19.8

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.
@@ -4,25 +4,283 @@ import { fetchLineageAutocompleteList } from './fetchLineageAutocompleteList';
4
4
  import { DUMMY_LAPIS_URL, lapisRequestMocks } from '../../../vitest.setup';
5
5
 
6
6
  describe('fetchLineageAutocompleteList', () => {
7
+ const lapisFilter = { country: 'Germany' };
8
+ const lineageField = 'lineageField';
9
+
10
+ test('should return single lineage', async () => {
11
+ lapisRequestMocks.aggregated(
12
+ { fields: [lineageField], ...lapisFilter },
13
+ {
14
+ data: [
15
+ {
16
+ [lineageField]: 'A',
17
+ count: 1,
18
+ },
19
+ ],
20
+ },
21
+ );
22
+
23
+ lapisRequestMocks.lineageDefinition(
24
+ {
25
+ A: {
26
+ aliases: ['a'],
27
+ },
28
+ },
29
+ lineageField,
30
+ );
31
+
32
+ const result = await fetchLineageAutocompleteList({
33
+ lapisUrl: DUMMY_LAPIS_URL,
34
+ lapisField: lineageField,
35
+ lapisFilter,
36
+ });
37
+
38
+ expect(result).to.deep.equal([
39
+ {
40
+ lineage: 'A',
41
+ count: 1,
42
+ },
43
+ {
44
+ lineage: 'A*',
45
+ count: 1,
46
+ },
47
+ ]);
48
+ });
49
+
7
50
  test('should add sublineage values', async () => {
8
51
  lapisRequestMocks.aggregated(
9
- { fields: ['lineageField'], country: 'Germany' },
52
+ { fields: [lineageField], ...lapisFilter },
10
53
  {
11
54
  data: [
12
55
  {
13
- lineageField: 'B.1.1.7',
56
+ [lineageField]: 'A',
14
57
  count: 1,
15
58
  },
59
+ {
60
+ [lineageField]: 'A.1',
61
+ count: 2,
62
+ },
63
+ {
64
+ [lineageField]: 'A.2',
65
+ count: 3,
66
+ },
16
67
  ],
17
68
  },
18
69
  );
19
70
 
71
+ lapisRequestMocks.lineageDefinition(
72
+ {
73
+ A: {
74
+ aliases: ['a'],
75
+ },
76
+ 'A.1': {
77
+ parents: ['A'],
78
+ aliases: ['a.1'],
79
+ },
80
+ 'A.2': {
81
+ parents: ['A'],
82
+ aliases: ['a.1'],
83
+ },
84
+ },
85
+ lineageField,
86
+ );
87
+
20
88
  const result = await fetchLineageAutocompleteList({
21
- lapis: DUMMY_LAPIS_URL,
22
- field: 'lineageField',
23
- lapisFilter: { country: 'Germany' },
89
+ lapisUrl: DUMMY_LAPIS_URL,
90
+ lapisField: lineageField,
91
+ lapisFilter,
24
92
  });
25
93
 
26
- expect(result).to.deep.equal(['B.1.1.7', 'B.1.1.7*']);
94
+ expect(result).to.deep.equal([
95
+ {
96
+ lineage: 'A',
97
+ count: 1,
98
+ },
99
+ {
100
+ lineage: 'A*',
101
+ count: 6,
102
+ },
103
+
104
+ {
105
+ lineage: 'A.1',
106
+ count: 2,
107
+ },
108
+ {
109
+ lineage: 'A.1*',
110
+ count: 2,
111
+ },
112
+
113
+ {
114
+ lineage: 'A.2',
115
+ count: 3,
116
+ },
117
+ {
118
+ lineage: 'A.2*',
119
+ count: 3,
120
+ },
121
+ ]);
122
+ });
123
+
124
+ test('should work with recombinations', async () => {
125
+ lapisRequestMocks.aggregated(
126
+ { fields: [lineageField], ...lapisFilter },
127
+ {
128
+ data: [
129
+ {
130
+ [lineageField]: 'A',
131
+ count: 1,
132
+ },
133
+ {
134
+ [lineageField]: 'A.1',
135
+ count: 2,
136
+ },
137
+ {
138
+ [lineageField]: 'A.2',
139
+ count: 3,
140
+ },
141
+ {
142
+ [lineageField]: 'XA',
143
+ count: 4,
144
+ },
145
+ ],
146
+ },
147
+ );
148
+
149
+ lapisRequestMocks.lineageDefinition(
150
+ {
151
+ A: {
152
+ aliases: ['a'],
153
+ },
154
+ 'A.1': {
155
+ parents: ['A'],
156
+ aliases: ['a.1'],
157
+ },
158
+ 'A.2': {
159
+ parents: ['A'],
160
+ aliases: ['a.1'],
161
+ },
162
+ XA: {
163
+ aliases: ['xa'],
164
+ parents: ['A.1', 'A.2'],
165
+ },
166
+ },
167
+ lineageField,
168
+ );
169
+
170
+ const result = await fetchLineageAutocompleteList({
171
+ lapisUrl: DUMMY_LAPIS_URL,
172
+ lapisField: lineageField,
173
+ lapisFilter,
174
+ });
175
+
176
+ expect(result).to.deep.equal([
177
+ {
178
+ lineage: 'A',
179
+ count: 1,
180
+ },
181
+ {
182
+ lineage: 'A*',
183
+ count: 10,
184
+ },
185
+
186
+ {
187
+ lineage: 'A.1',
188
+ count: 2,
189
+ },
190
+ {
191
+ lineage: 'A.1*',
192
+ count: 6,
193
+ },
194
+ {
195
+ lineage: 'A.2',
196
+ count: 3,
197
+ },
198
+ {
199
+ lineage: 'A.2*',
200
+ count: 7,
201
+ },
202
+ {
203
+ lineage: 'XA',
204
+ count: 4,
205
+ },
206
+ {
207
+ lineage: 'XA*',
208
+ count: 4,
209
+ },
210
+ ]);
211
+ });
212
+
213
+ test('should work with grandchildren', async () => {
214
+ lapisRequestMocks.aggregated(
215
+ { fields: [lineageField], ...lapisFilter },
216
+ {
217
+ data: [
218
+ {
219
+ [lineageField]: 'A',
220
+ count: 1,
221
+ },
222
+ {
223
+ [lineageField]: 'A.1',
224
+ count: 2,
225
+ },
226
+ {
227
+ [lineageField]: 'A.1.1',
228
+ count: 3,
229
+ },
230
+ ],
231
+ },
232
+ );
233
+
234
+ lapisRequestMocks.lineageDefinition(
235
+ {
236
+ 'A.1': {
237
+ parents: ['A'],
238
+ aliases: ['a.1'],
239
+ },
240
+ A: {
241
+ aliases: ['a'],
242
+ },
243
+ 'A.1.1': {
244
+ parents: ['A.1'],
245
+ aliases: ['a.1.1'],
246
+ },
247
+ },
248
+ lineageField,
249
+ );
250
+
251
+ const result = await fetchLineageAutocompleteList({
252
+ lapisUrl: DUMMY_LAPIS_URL,
253
+ lapisField: lineageField,
254
+ lapisFilter,
255
+ });
256
+
257
+ expect(result).to.deep.equal([
258
+ {
259
+ lineage: 'A',
260
+ count: 1,
261
+ },
262
+ {
263
+ lineage: 'A*',
264
+ count: 6,
265
+ },
266
+
267
+ {
268
+ lineage: 'A.1',
269
+ count: 2,
270
+ },
271
+ {
272
+ lineage: 'A.1*',
273
+ count: 5,
274
+ },
275
+
276
+ {
277
+ lineage: 'A.1.1',
278
+ count: 3,
279
+ },
280
+ {
281
+ lineage: 'A.1.1*',
282
+ count: 3,
283
+ },
284
+ ]);
27
285
  });
28
286
  });
@@ -1,20 +1,117 @@
1
+ import { fetchLineageDefinition } from '../../lapisApi/lapisApi';
1
2
  import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
2
3
  import type { LapisFilter } from '../../types';
3
4
 
4
5
  export async function fetchLineageAutocompleteList({
5
- lapis,
6
- field,
6
+ lapisUrl,
7
+ lapisField,
8
+ lapisFilter,
7
9
  signal,
10
+ }: {
11
+ lapisUrl: string;
12
+ lapisField: string;
13
+ lapisFilter?: LapisFilter;
14
+ signal?: AbortSignal;
15
+ }): Promise<LineageItem[]> {
16
+ const [countsByLineage, lineageTree] = await Promise.all([
17
+ getCountsByLineage({
18
+ lapisUrl,
19
+ lapisField,
20
+ lapisFilter,
21
+ signal,
22
+ }),
23
+ getLineageTree({ lapisUrl, lapisField, signal }),
24
+ ]);
25
+
26
+ return Array.from(lineageTree.keys())
27
+ .sort((a, b) => a.localeCompare(b))
28
+ .map((lineage) => {
29
+ return [
30
+ {
31
+ lineage,
32
+ count: countsByLineage.get(lineage) ?? 0,
33
+ },
34
+ {
35
+ lineage: `${lineage}*`,
36
+ count: getCountsIncludingSublineages(lineage, lineageTree, countsByLineage),
37
+ },
38
+ ];
39
+ })
40
+ .flat();
41
+ }
42
+
43
+ export type LineageItem = { lineage: string; count: number };
44
+
45
+ async function getCountsByLineage({
46
+ lapisUrl,
47
+ lapisField,
8
48
  lapisFilter,
49
+ signal,
9
50
  }: {
10
- lapis: string;
11
- field: string;
51
+ lapisUrl: string;
52
+ lapisField: string;
12
53
  lapisFilter?: LapisFilter;
13
54
  signal?: AbortSignal;
14
55
  }) {
15
- const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string>>(lapisFilter ?? {}, [field]);
56
+ const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string>>(lapisFilter ?? {}, [
57
+ lapisField,
58
+ ]);
59
+
60
+ const countsByLineageArray = (await fetchAggregatedOperator.evaluate(lapisUrl, signal)).content;
61
+ return new Map<string, number>(countsByLineageArray.map((value) => [value[lapisField], value.count]));
62
+ }
63
+
64
+ async function getLineageTree({
65
+ lapisUrl,
66
+ lapisField,
67
+ signal,
68
+ }: {
69
+ lapisUrl: string;
70
+ lapisField: string;
71
+ signal?: AbortSignal;
72
+ }) {
73
+ const lineageDefinitions = await fetchLineageDefinition({ lapisUrl, lapisField, signal });
74
+
75
+ const lineageTree = new Map<string, { children: string[] }>();
76
+
77
+ Object.entries(lineageDefinitions).forEach(([lineage, definition]) => {
78
+ if (!lineageTree.has(lineage)) {
79
+ lineageTree.set(lineage, { children: [] });
80
+ }
81
+
82
+ definition.parents?.forEach((parent) => {
83
+ const parentChildren = lineageTree.get(parent)?.children;
84
+
85
+ const newParentChildren = parentChildren ? [...parentChildren, lineage] : [lineage];
86
+
87
+ lineageTree.set(parent, { children: newParentChildren });
88
+ });
89
+ });
90
+
91
+ return lineageTree;
92
+ }
93
+
94
+ function getCountsIncludingSublineages(
95
+ lineage: string,
96
+ lineageTree: Map<string, { children: string[] }>,
97
+ countsByLineage: Map<string, number>,
98
+ ): number {
99
+ const descendants = getAllDescendants(lineage, lineageTree);
100
+
101
+ const countOfChildren = [...descendants].reduce((sum, child) => {
102
+ return sum + (countsByLineage.get(child) ?? 0);
103
+ }, 0);
104
+ const countLineage = countsByLineage.get(lineage) ?? 0;
105
+
106
+ return countOfChildren + countLineage;
107
+ }
108
+
109
+ function getAllDescendants(lineage: string, lineageTree: Map<string, { children: string[] }>): Set<string> {
110
+ const children = lineageTree.get(lineage)?.children ?? [];
16
111
 
17
- const data = (await fetchAggregatedOperator.evaluate(lapis, signal)).content;
112
+ const childrenOfChildren = children.flatMap((child) => {
113
+ return getAllDescendants(child, lineageTree);
114
+ });
18
115
 
19
- return data.flatMap((item) => [item[field], `${item[field]}*`]).sort();
116
+ return new Set([...children, ...childrenOfChildren.flatMap((child) => Array.from(child))]);
20
117
  }
@@ -5,6 +5,8 @@ import type { StepFunction } from '@storybook/types';
5
5
  import { LineageFilter, type LineageFilterProps } from './lineage-filter';
6
6
  import { previewHandles } from '../../../.storybook/preview';
7
7
  import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
8
+ import lineageDefinition from './__mockData__/lineageDefinition.json';
9
+ import { lineageDefinitionEndpoint } from '../../lapisApi/lapisApi';
8
10
  import aggregatedData from '../../preact/lineageFilter/__mockData__/aggregated.json';
9
11
  import { gsEventNames } from '../../utils/gsEventNames';
10
12
  import { LapisUrlContextProvider } from '../LapisUrlContext';
@@ -33,6 +35,16 @@ const meta: Meta = {
33
35
  body: aggregatedData,
34
36
  },
35
37
  },
38
+ {
39
+ matcher: {
40
+ name: 'lineageDefinition',
41
+ url: lineageDefinitionEndpoint(LAPIS_URL, 'pangoLineage'),
42
+ },
43
+ response: {
44
+ status: 200,
45
+ body: lineageDefinition,
46
+ },
47
+ },
36
48
  ],
37
49
  },
38
50
  },
@@ -90,7 +102,7 @@ export const Default: StoryObj<LineageFilterProps> = {
90
102
  const input = await inputField(canvas);
91
103
  await userEvent.clear(input);
92
104
  await userEvent.type(input, 'B.1');
93
- await userEvent.click(canvas.getByRole('option', { name: 'B.1' }));
105
+ await userEvent.click(canvas.getByRole('option', { name: 'B.1(53802)' }));
94
106
 
95
107
  await waitFor(() => {
96
108
  return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
@@ -1,9 +1,10 @@
1
1
  import { type FunctionComponent } from 'preact';
2
+ import { useMemo } from 'preact/hooks';
2
3
  import z from 'zod';
3
4
 
4
5
  import { useLapisUrl } from '../LapisUrlContext';
5
6
  import { LineageFilterChangedEvent } from './LineageFilterChangedEvent';
6
- import { fetchLineageAutocompleteList } from './fetchLineageAutocompleteList';
7
+ import { fetchLineageAutocompleteList, type LineageItem } from './fetchLineageAutocompleteList';
7
8
  import { lapisFilterSchema } from '../../types';
8
9
  import { DownshiftCombobox } from '../components/downshift-combobox';
9
10
  import { ErrorBoundary } from '../components/error-boundary';
@@ -46,11 +47,11 @@ const LineageFilterInner: FunctionComponent<LineageFilterInnerProps> = ({
46
47
  value,
47
48
  lapisFilter,
48
49
  }) => {
49
- const lapis = useLapisUrl();
50
+ const lapisUrl = useLapisUrl();
50
51
 
51
52
  const { data, error, isLoading } = useQuery(
52
- () => fetchLineageAutocompleteList({ lapis, field: lapisField, lapisFilter }),
53
- [lapisField, lapis, lapisFilter],
53
+ () => fetchLineageAutocompleteList({ lapisUrl, lapisField, lapisFilter }),
54
+ [lapisField, lapisUrl, lapisFilter],
54
55
  );
55
56
 
56
57
  if (isLoading) {
@@ -70,24 +71,33 @@ const LineageSelector = ({
70
71
  placeholderText,
71
72
  data,
72
73
  }: LineageSelectorProps & {
73
- data: string[];
74
+ data: LineageItem[];
74
75
  }) => {
76
+ const selectedItem = useMemo(() => {
77
+ return data.find((item) => item.lineage === value) ?? null;
78
+ }, [data, value]);
79
+
75
80
  return (
76
81
  <DownshiftCombobox
77
82
  allItems={data}
78
- value={value}
83
+ value={selectedItem}
79
84
  filterItemsByInputValue={filterByInputValue}
80
- createEvent={(item) => new LineageFilterChangedEvent({ [lapisField]: item ?? undefined })}
81
- itemToString={(item) => item ?? ''}
85
+ createEvent={(item) => new LineageFilterChangedEvent({ [lapisField]: item?.lineage ?? undefined })}
86
+ itemToString={(item) => item?.lineage ?? ''}
82
87
  placeholderText={placeholderText}
83
- formatItemInList={(item: string) => item}
88
+ formatItemInList={(item: LineageItem) => (
89
+ <p>
90
+ <span>{item.lineage}</span>
91
+ <span className='ml-2 text-gray-500'>({item.count})</span>
92
+ </p>
93
+ )}
84
94
  />
85
95
  );
86
96
  };
87
97
 
88
- function filterByInputValue(item: string, inputValue: string | null) {
98
+ function filterByInputValue(item: LineageItem, inputValue: string | null) {
89
99
  if (inputValue === null || inputValue === '') {
90
100
  return true;
91
101
  }
92
- return item?.toLowerCase().includes(inputValue?.toLowerCase() || '');
102
+ return item.lineage?.toLowerCase().includes(inputValue?.toLowerCase() || '');
93
103
  }
@@ -7,7 +7,9 @@ import { previewHandles } from '../../../.storybook/preview';
7
7
  import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
8
8
  import '../gs-app';
9
9
  import './gs-lineage-filter';
10
+ import { lineageDefinitionEndpoint } from '../../lapisApi/lapisApi';
10
11
  import aggregatedData from '../../preact/lineageFilter/__mockData__/aggregated.json';
12
+ import lineageDefinition from '../../preact/lineageFilter/__mockData__/lineageDefinition.json';
11
13
  import { type LineageFilterProps } from '../../preact/lineageFilter/lineage-filter';
12
14
  import { gsEventNames } from '../../utils/gsEventNames';
13
15
  import { withinShadowRoot } from '../withinShadowRoot.story';
@@ -44,6 +46,16 @@ const meta: Meta<Required<LineageFilterProps>> = {
44
46
  body: aggregatedData,
45
47
  },
46
48
  },
49
+ {
50
+ matcher: {
51
+ name: 'lineageDefinition',
52
+ url: lineageDefinitionEndpoint(LAPIS_URL, 'pangoLineage'),
53
+ },
54
+ response: {
55
+ status: 200,
56
+ body: lineageDefinition,
57
+ },
58
+ },
47
59
  ],
48
60
  },
49
61
  componentDocs: {
@@ -210,7 +222,7 @@ export const FiresEvent: StoryObj<Required<LineageFilterProps>> = {
210
222
 
211
223
  await step('Enter a valid lineage value', async () => {
212
224
  await userEvent.type(inputField(), 'B.1.1.7*');
213
- await userEvent.click(canvas.getByRole('option', { name: 'B.1.1.7*' }));
225
+ await userEvent.click(canvas.getByRole('option', { name: 'B.1.1.7*(677146)' }));
214
226
 
215
227
  await waitFor(() => {
216
228
  return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({