@genspectrum/dashboard-components 1.17.0 → 1.18.1

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.
@@ -2,9 +2,17 @@ import { renderHook } from '@testing-library/preact';
2
2
  import { type FunctionalComponent } from 'preact';
3
3
  import { describe, expect, it } from 'vitest';
4
4
 
5
- import { MutationAnnotationsContextProvider, useMutationAnnotationsProvider } from './MutationAnnotationsContext';
5
+ import {
6
+ MutationAnnotationsContextProvider,
7
+ type ResolvedMutationAnnotation,
8
+ useMutationAnnotationsProvider,
9
+ } from './MutationAnnotationsContext';
6
10
  import { SubstitutionClass } from '../utils/mutations';
7
- import { type MutationAnnotations } from '../web-components/mutation-annotations-context';
11
+ import { type MutationAnnotation, type MutationAnnotations } from '../web-components/mutation-annotations-context';
12
+
13
+ function resolved(annotation: MutationAnnotation): ResolvedMutationAnnotation {
14
+ return { annotation, name: annotation.name, description: annotation.description };
15
+ }
8
16
 
9
17
  describe('useMutationAnnotation', () => {
10
18
  function renderAnnotationsHook(mockAnnotations: MutationAnnotations) {
@@ -35,13 +43,13 @@ describe('useMutationAnnotation', () => {
35
43
  it('should return the correct annotation for a given mutation', () => {
36
44
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A123')!, 'nucleotide');
37
45
 
38
- expect(result).toEqual([mockAnnotations[0]]);
46
+ expect(result).toEqual([resolved(mockAnnotations[0])]);
39
47
  });
40
48
 
41
49
  it('should return the correct annotations if multiple contain a mutation', () => {
42
50
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A456')!, 'nucleotide');
43
51
 
44
- expect(result).toEqual([mockAnnotations[0], mockAnnotations[1]]);
52
+ expect(result).toEqual([resolved(mockAnnotations[0]), resolved(mockAnnotations[1])]);
45
53
  });
46
54
  });
47
55
 
@@ -58,7 +66,7 @@ describe('useMutationAnnotation', () => {
58
66
  it('should return the correct mutation annotation for a given mutations', () => {
59
67
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('B456')!, 'amino acid');
60
68
 
61
- expect(result).toEqual([mockAnnotations[0]]);
69
+ expect(result).toEqual([resolved(mockAnnotations[0])]);
62
70
  });
63
71
  });
64
72
 
@@ -81,17 +89,17 @@ describe('useMutationAnnotation', () => {
81
89
 
82
90
  it('should return the correct mutation annotation covered by position only', () => {
83
91
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A543T')!, 'nucleotide');
84
- expect(result).toEqual([mockAnnotations[0]]);
92
+ expect(result).toEqual([resolved(mockAnnotations[0])]);
85
93
  });
86
94
 
87
95
  it('should return the correct mutation annotation covered both by position and mutation', () => {
88
96
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A321T')!, 'nucleotide');
89
- expect(result).toEqual([mockAnnotations[0]]);
97
+ expect(result).toEqual([resolved(mockAnnotations[0])]);
90
98
  });
91
99
 
92
100
  it('should return both annotations if one matches the mutations and the other the position', () => {
93
101
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A432T')!, 'nucleotide');
94
- expect(result).toEqual([mockAnnotations[1], mockAnnotations[0]]);
102
+ expect(result).toEqual([resolved(mockAnnotations[0]), resolved(mockAnnotations[1])]);
95
103
  });
96
104
  });
97
105
 
@@ -108,12 +116,12 @@ describe('useMutationAnnotation', () => {
108
116
 
109
117
  it('should return the correct mutation annotation covered both by position and mutation', () => {
110
118
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('Gene:B432G')!, 'amino acid');
111
- expect(result).toEqual([mockAnnotations[0]]);
119
+ expect(result).toEqual([resolved(mockAnnotations[0])]);
112
120
  });
113
121
 
114
122
  it('should return the correct mutation annotation covered both by position only', () => {
115
123
  const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('Gene:B543G')!, 'amino acid');
116
- expect(result).toEqual([mockAnnotations[0]]);
124
+ expect(result).toEqual([resolved(mockAnnotations[0])]);
117
125
  });
118
126
 
119
127
  it('should return no mutation annotation for an amino acid position of wrong gene', () => {
@@ -124,4 +132,68 @@ describe('useMutationAnnotation', () => {
124
132
  expect(result).toBeUndefined();
125
133
  });
126
134
  });
135
+
136
+ describe('per-mutation name and description overrides', () => {
137
+ it('should use overridden name and description when both are provided on a mutation entry', () => {
138
+ const mockAnnotations: MutationAnnotations = [
139
+ {
140
+ name: 'Group name',
141
+ description: 'Group description',
142
+ symbol: 'X',
143
+ nucleotideMutations: [
144
+ { mutation: 'A123T', name: 'Override name', description: 'Override description' },
145
+ ],
146
+ },
147
+ ];
148
+ const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A123T')!, 'nucleotide');
149
+ expect(result).toEqual([
150
+ { annotation: mockAnnotations[0], name: 'Override name', description: 'Override description' },
151
+ ]);
152
+ });
153
+
154
+ it('should fall back to group name when only description is overridden', () => {
155
+ const mockAnnotations: MutationAnnotations = [
156
+ {
157
+ name: 'Group name',
158
+ description: 'Group description',
159
+ symbol: 'X',
160
+ nucleotideMutations: [{ mutation: 'A123T', description: 'Override description' }],
161
+ },
162
+ ];
163
+ const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A123T')!, 'nucleotide');
164
+ expect(result).toEqual([
165
+ { annotation: mockAnnotations[0], name: 'Group name', description: 'Override description' },
166
+ ]);
167
+ });
168
+
169
+ it('should fall back to group description when only name is overridden', () => {
170
+ const mockAnnotations: MutationAnnotations = [
171
+ {
172
+ name: 'Group name',
173
+ description: 'Group description',
174
+ symbol: 'X',
175
+ nucleotideMutations: [{ mutation: 'A123T', name: 'Override name' }],
176
+ },
177
+ ];
178
+ const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A123T')!, 'nucleotide');
179
+ expect(result).toEqual([
180
+ { annotation: mockAnnotations[0], name: 'Override name', description: 'Group description' },
181
+ ]);
182
+ });
183
+
184
+ it('should use position-level override for position entries', () => {
185
+ const mockAnnotations: MutationAnnotations = [
186
+ {
187
+ name: 'Group name',
188
+ description: 'Group description',
189
+ symbol: 'X',
190
+ nucleotidePositions: [{ position: '123', name: 'Position override name' }],
191
+ },
192
+ ];
193
+ const result = renderAnnotationsHook(mockAnnotations)(SubstitutionClass.parse('A123T')!, 'nucleotide');
194
+ expect(result).toEqual([
195
+ { annotation: mockAnnotations[0], name: 'Position override name', description: 'Group description' },
196
+ ]);
197
+ });
198
+ });
127
199
  });
@@ -11,12 +11,18 @@ import { ErrorDisplay } from './components/error-display';
11
11
  import { ResizeContainer } from './components/resize-container';
12
12
  import { type Mutation } from '../utils/mutations';
13
13
 
14
- type MutationAnnotationPerSequenceType = {
15
- mutation: Map<string, MutationAnnotations>;
16
- position: Map<string, MutationAnnotations>;
14
+ export type ResolvedMutationAnnotation = {
15
+ annotation: MutationAnnotation;
16
+ name: string;
17
+ description: string;
17
18
  };
18
19
 
19
- type MutationAnnotationsContextValue = Record<SequenceType, MutationAnnotationPerSequenceType> & {
20
+ type AnnotationLookup = {
21
+ mutation: Map<string, ResolvedMutationAnnotation[]>;
22
+ position: Map<string, ResolvedMutationAnnotation[]>;
23
+ };
24
+
25
+ type MutationAnnotationsContextValue = Record<SequenceType, AnnotationLookup> & {
20
26
  rawAnnotations: MutationAnnotations;
21
27
  };
22
28
 
@@ -32,69 +38,113 @@ const MutationAnnotationsContext = createContext<MutationAnnotationsContextValue
32
38
  },
33
39
  });
34
40
 
41
+ /**
42
+ * Validates and provides mutation annotations to all descendant components.
43
+ * Accepts the raw MutationAnnotations config, builds the internal lookup index, and stores it in context.
44
+ * Renders an error message if the provided annotations fail schema validation.
45
+ */
35
46
  export const MutationAnnotationsContextProvider: FunctionalComponent<
36
47
  Omit<ComponentProps<typeof MutationAnnotationsContext.Provider>, 'value'> & { value: MutationAnnotations }
37
48
  > = ({ value, children }) => {
38
- const parseResult = useMemo(() => {
39
- const parseResult = mutationAnnotationsSchema.safeParse(value);
40
-
41
- if (!parseResult.success) {
42
- return parseResult;
43
- }
44
-
45
- return { success: true as const, value: getMutationAnnotationsContext(value) };
46
- }, [value]);
49
+ const parseResult = useMemo(() => mutationAnnotationsSchema.safeParse(value), [value]);
50
+ const contextValue = useMemo(
51
+ () =>
52
+ parseResult.success
53
+ ? { success: true as const, value: buildAnnotationIndex(parseResult.data) }
54
+ : { success: false as const, error: parseResult.error },
55
+ [parseResult],
56
+ );
47
57
 
48
- if (!parseResult.success) {
58
+ if (!contextValue.success) {
49
59
  return (
50
60
  <ResizeContainer size={{ width: '100%' }}>
51
- <ErrorDisplay error={parseResult.error} layout='vertical' />
61
+ <ErrorDisplay error={contextValue.error} layout='vertical' />
52
62
  </ResizeContainer>
53
63
  );
54
64
  }
55
65
 
56
66
  return (
57
- <MutationAnnotationsContext.Provider value={parseResult.value}>{children}</MutationAnnotationsContext.Provider>
67
+ <MutationAnnotationsContext.Provider value={contextValue.value}>{children}</MutationAnnotationsContext.Provider>
58
68
  );
59
69
  };
60
70
 
61
- export function getMutationAnnotationsContext(value: MutationAnnotations) {
62
- const nucleotideMap = new Map<string, MutationAnnotations>();
63
- const nucleotidePositions = new Map<string, MutationAnnotations>();
64
- const aminoAcidMap = new Map<string, MutationAnnotations>();
65
- const aminoAcidPositions = new Map<string, MutationAnnotations>();
71
+ /**
72
+ * Indexes a flat list of MutationAnnotations into fast lookup maps, resolving per-entry name/description overrides
73
+ * eagerly. Called once (memoized) when the annotations config is set on the provider.
74
+ *
75
+ * Returns two maps per sequence type — one keyed by exact mutation code, one by position string — each mapping to
76
+ * the list of ResolvedMutationAnnotations that apply to that key.
77
+ */
78
+ export function buildAnnotationIndex(value: MutationAnnotations): MutationAnnotationsContextValue {
79
+ const nucleotideMutationMap = new Map<string, ResolvedMutationAnnotation[]>();
80
+ const nucleotidePositionMap = new Map<string, ResolvedMutationAnnotation[]>();
81
+ const aminoAcidMutationMap = new Map<string, ResolvedMutationAnnotation[]>();
82
+ const aminoAcidPositionMap = new Map<string, ResolvedMutationAnnotation[]>();
66
83
 
67
84
  value.forEach((annotation) => {
68
- new Set(annotation.nucleotideMutations).forEach((code) => {
69
- addAnnotationToMap(nucleotideMap, code, annotation);
85
+ annotation.nucleotideMutations?.forEach((entry) => {
86
+ addToMap(
87
+ nucleotideMutationMap,
88
+ typeof entry === 'string' ? entry : entry.mutation,
89
+ resolve(annotation, entry),
90
+ );
70
91
  });
71
- new Set(annotation.aminoAcidMutations).forEach((code) => {
72
- addAnnotationToMap(aminoAcidMap, code, annotation);
92
+ annotation.aminoAcidMutations?.forEach((entry) => {
93
+ addToMap(
94
+ aminoAcidMutationMap,
95
+ typeof entry === 'string' ? entry : entry.mutation,
96
+ resolve(annotation, entry),
97
+ );
73
98
  });
74
- new Set(annotation.nucleotidePositions).forEach((position) => {
75
- addAnnotationToMap(nucleotidePositions, position, annotation);
99
+ annotation.nucleotidePositions?.forEach((entry) => {
100
+ addToMap(
101
+ nucleotidePositionMap,
102
+ typeof entry === 'string' ? entry : entry.position,
103
+ resolve(annotation, entry),
104
+ );
76
105
  });
77
- new Set(annotation.aminoAcidPositions).forEach((position) => {
78
- addAnnotationToMap(aminoAcidPositions, position, annotation);
106
+ annotation.aminoAcidPositions?.forEach((entry) => {
107
+ addToMap(
108
+ aminoAcidPositionMap,
109
+ typeof entry === 'string' ? entry : entry.position,
110
+ resolve(annotation, entry),
111
+ );
79
112
  });
80
113
  });
81
114
 
82
115
  return {
83
116
  rawAnnotations: value,
84
- nucleotide: { mutation: nucleotideMap, position: nucleotidePositions },
85
- 'amino acid': { mutation: aminoAcidMap, position: aminoAcidPositions },
117
+ nucleotide: { mutation: nucleotideMutationMap, position: nucleotidePositionMap },
118
+ 'amino acid': { mutation: aminoAcidMutationMap, position: aminoAcidPositionMap },
119
+ };
120
+ }
121
+
122
+ function resolve(
123
+ annotation: MutationAnnotation,
124
+ entry: string | { name?: string; description?: string },
125
+ ): ResolvedMutationAnnotation {
126
+ const overrides = typeof entry === 'object' ? entry : undefined;
127
+ return {
128
+ annotation,
129
+ name: overrides?.name ?? annotation.name,
130
+ description: overrides?.description ?? annotation.description,
86
131
  };
87
132
  }
88
133
 
89
- function addAnnotationToMap(map: Map<string, MutationAnnotations>, code: string, annotation: MutationAnnotation) {
90
- const oldAnnotations = map.get(code.toUpperCase()) ?? [];
91
- map.set(code.toUpperCase(), [...oldAnnotations, annotation]);
134
+ function addToMap(map: Map<string, ResolvedMutationAnnotation[]>, code: string, resolved: ResolvedMutationAnnotation) {
135
+ const existing = map.get(code.toUpperCase()) ?? [];
136
+ map.set(code.toUpperCase(), [...existing, resolved]);
92
137
  }
93
138
 
94
139
  export function useRawMutationAnnotations() {
95
140
  return useContext(MutationAnnotationsContext).rawAnnotations;
96
141
  }
97
142
 
143
+ /**
144
+ * Returns a lookup function `(mutation, sequenceType) => ResolvedMutationAnnotation[] | undefined` that, given a
145
+ * specific mutation, returns all annotations that apply to it with name and description already resolved.
146
+ * Returns undefined if no annotations match.
147
+ */
98
148
  export function useMutationAnnotationsProvider() {
99
149
  const mutationAnnotations = useContext(MutationAnnotationsContext);
100
150
 
@@ -108,21 +158,19 @@ export function getMutationAnnotationsProvider(mutationAnnotations: MutationAnno
108
158
  ? `${mutation.position}`
109
159
  : `${mutation.segment.toUpperCase()}:${mutation.position}`;
110
160
 
111
- const possiblePositionAnnotations = mutationAnnotations[sequenceType].position.get(position);
112
- const possibleExactAnnotations = mutationAnnotations[sequenceType].mutation.get(mutation.code.toUpperCase());
161
+ const exactMatches = mutationAnnotations[sequenceType].mutation.get(mutation.code.toUpperCase());
162
+ const positionMatches = mutationAnnotations[sequenceType].position.get(position);
113
163
 
114
- const annotations =
115
- possiblePositionAnnotations && possibleExactAnnotations
116
- ? [...possiblePositionAnnotations, ...possibleExactAnnotations]
117
- : (possiblePositionAnnotations ?? possibleExactAnnotations);
164
+ const combined =
165
+ exactMatches && positionMatches ? [...exactMatches, ...positionMatches] : (exactMatches ?? positionMatches);
118
166
 
119
- const uniqueNames = new Set<string>();
167
+ const seenNames = new Set<string>();
120
168
 
121
- return annotations?.filter((annotation) => {
122
- if (uniqueNames.has(annotation.name)) {
169
+ return combined?.filter((resolved) => {
170
+ if (seenNames.has(resolved.annotation.name)) {
123
171
  return false;
124
172
  }
125
- uniqueNames.add(annotation.name);
173
+ seenNames.add(resolved.annotation.name);
126
174
  return true;
127
175
  });
128
176
  };
@@ -128,6 +128,37 @@ export const MutationWithMultipleAnnotationEntries: StoryObj<StoryProps> = {
128
128
  },
129
129
  };
130
130
 
131
+ export const MutationWithPerMutationInfoOverride: StoryObj<StoryProps> = {
132
+ ...MutationWithoutAnnotationEntry,
133
+ args: {
134
+ ...MutationWithoutAnnotationEntry.args,
135
+ annotations: [
136
+ {
137
+ name: 'Group annotation',
138
+ description: 'Group-level description',
139
+ symbol: 'c',
140
+ nucleotideMutations: [
141
+ {
142
+ mutation: 'A23403G',
143
+ name: '3CLpro:T31C',
144
+ description: 'Per-mutation description for 3CLpro:T31C',
145
+ },
146
+ ],
147
+ },
148
+ ],
149
+ },
150
+ play: async ({ canvasElement }) => {
151
+ const canvas = within(canvasElement);
152
+
153
+ await waitFor(() => expect(canvas.getByText('A23403G')).toBeVisible());
154
+ await expect(getAnnotationIndicator(canvas)).toBeVisible();
155
+
156
+ await userEvent.click(canvas.getByText('c'));
157
+ await waitFor(() => expect(canvas.queryByText('3CLpro:T31C')).toBeVisible());
158
+ await expect(canvas.queryByText('Per-mutation description for 3CLpro:T31C')).toBeVisible();
159
+ },
160
+ };
161
+
131
162
  export const AminoAcidMutationWithAnnotationEntry: StoryObj<StoryProps> = {
132
163
  ...MutationWithoutAnnotationEntry,
133
164
  args: {
@@ -78,11 +78,11 @@ const AnnotatedMutationWithoutContext: FunctionComponent<AnnotatedMutationWithou
78
78
  const modalContent = (
79
79
  <div className='block'>
80
80
  <InfoHeadline1>Annotations for {mutation.code}</InfoHeadline1>
81
- {mutationAnnotations.map((annotation) => (
82
- <Fragment key={annotation.name}>
83
- <InfoHeadline2>{annotation.name}</InfoHeadline2>
81
+ {mutationAnnotations.map((resolved) => (
82
+ <Fragment key={resolved.annotation.name}>
83
+ <InfoHeadline2>{resolved.name}</InfoHeadline2>
84
84
  <InfoParagraph>
85
- <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(annotation.description) }} />
85
+ <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(resolved.description) }} />
86
86
  </InfoParagraph>
87
87
  </Fragment>
88
88
  ))}
@@ -99,7 +99,7 @@ const AnnotatedMutationWithoutContext: FunctionComponent<AnnotatedMutationWithou
99
99
  >
100
100
  <sup className='hover:underline focus-visible:underline decoration-red-600'>
101
101
  {mutationAnnotations
102
- .map((annotation) => annotation.symbol)
102
+ .map((resolved) => resolved.annotation.symbol)
103
103
  .map((symbol, index) => (
104
104
  <Fragment key={symbol}>
105
105
  <span className='text-red-600'>{symbol}</span>
@@ -190,50 +190,59 @@ export function DownshiftMultiCombobox<Item>({
190
190
  environment,
191
191
  });
192
192
 
193
- const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, highlightedIndex, getItemProps, closeMenu } =
194
- useCombobox({
195
- items: availableItems,
196
- itemToString(item) {
197
- return itemToString(item);
198
- },
199
- inputValue: itemsFilter,
200
- onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) {
201
- switch (type) {
202
- case useCombobox.stateChangeTypes.InputKeyDownEnter:
203
- case useCombobox.stateChangeTypes.ItemClick:
204
- if (newSelectedItem) {
205
- dispatchEvent([...selectedItems, newSelectedItem]);
206
- setItemsFilter('');
207
- }
208
- break;
209
- case useCombobox.stateChangeTypes.InputChange:
210
- setItemsFilter(newInputValue?.trim() ?? '');
211
- break;
212
- default:
213
- break;
214
- }
215
- },
216
- stateReducer(state, actionAndChanges) {
217
- const { changes, type } = actionAndChanges;
218
- switch (type) {
219
- case useCombobox.stateChangeTypes.InputKeyDownEnter:
220
- case useCombobox.stateChangeTypes.ItemClick:
221
- return {
222
- ...changes,
223
- isOpen: true,
224
- highlightedIndex: state.highlightedIndex,
225
- inputValue: '',
226
- };
227
- default:
228
- return changes;
229
- }
230
- },
231
- environment,
232
- });
193
+ const {
194
+ isOpen,
195
+ getToggleButtonProps,
196
+ getMenuProps,
197
+ getInputProps,
198
+ highlightedIndex,
199
+ getItemProps,
200
+ closeMenu,
201
+ selectItem,
202
+ } = useCombobox({
203
+ items: availableItems,
204
+ itemToString(item) {
205
+ return itemToString(item);
206
+ },
207
+ inputValue: itemsFilter,
208
+ onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) {
209
+ switch (type) {
210
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
211
+ case useCombobox.stateChangeTypes.ItemClick:
212
+ if (newSelectedItem) {
213
+ dispatchEvent([...selectedItems, newSelectedItem]);
214
+ setItemsFilter('');
215
+ }
216
+ break;
217
+ case useCombobox.stateChangeTypes.InputChange:
218
+ setItemsFilter(newInputValue?.trim() ?? '');
219
+ break;
220
+ default:
221
+ break;
222
+ }
223
+ },
224
+ stateReducer(state, actionAndChanges) {
225
+ const { changes, type } = actionAndChanges;
226
+ switch (type) {
227
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
228
+ case useCombobox.stateChangeTypes.ItemClick:
229
+ return {
230
+ ...changes,
231
+ isOpen: true,
232
+ highlightedIndex: state.highlightedIndex,
233
+ inputValue: '',
234
+ };
235
+ default:
236
+ return changes;
237
+ }
238
+ },
239
+ environment,
240
+ });
233
241
 
234
242
  const clearAll = () => {
235
243
  dispatchEvent([]);
236
244
  setItemsFilter('');
245
+ selectItem(null);
237
246
  };
238
247
 
239
248
  const buttonRef = useRef(null);
@@ -259,7 +268,10 @@ export function DownshiftMultiCombobox<Item>({
259
268
  aria-label={`remove ${itemToString(selectedItem)}`}
260
269
  className='cursor-pointer hover:text-red-600'
261
270
  type='button'
262
- onClick={() => removeSelectedItem(selectedItem)}
271
+ onClick={() => {
272
+ removeSelectedItem(selectedItem);
273
+ selectItem(null);
274
+ }}
263
275
  tabIndex={-1}
264
276
  >
265
277
  ×
@@ -422,6 +422,119 @@ export const MultiSelectClearAll: StoryObj<LineageFilterProps> = {
422
422
  },
423
423
  };
424
424
 
425
+ export const MultiSelectReselectAfterClearAll: StoryObj<LineageFilterProps> = {
426
+ render: (args) => (
427
+ <LapisUrlContextProvider value={LAPIS_URL}>
428
+ <LineageFilter {...args} />
429
+ </LapisUrlContextProvider>
430
+ ),
431
+ args: {
432
+ ...Default.args,
433
+ multiSelect: true,
434
+ value: ['A.1', 'B.1'],
435
+ placeholderText: 'Select lineages',
436
+ },
437
+ play: async ({ canvasElement, step }) => {
438
+ const canvas = within(canvasElement);
439
+ const lineageChangedListenerMock = fn();
440
+
441
+ await step('Setup event listener mock', () => {
442
+ canvasElement.addEventListener(gsEventNames.lineageFilterMultiChanged, lineageChangedListenerMock);
443
+ });
444
+
445
+ await step('multi-select filter is rendered with initial values', async () => {
446
+ await waitFor(async () => {
447
+ await expect(canvas.getByText('A.1')).toBeVisible();
448
+ await expect(canvas.getByText('B.1')).toBeVisible();
449
+ });
450
+ });
451
+
452
+ await step('clear all selections', async () => {
453
+ const clearButton = canvas.getByLabelText('clear selection');
454
+ await userEvent.click(clearButton);
455
+
456
+ await waitFor(() => {
457
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
458
+ pangoLineage: undefined,
459
+ });
460
+ });
461
+ });
462
+
463
+ await step('reselect A.1 from the dropdown', async () => {
464
+ const input = await canvas.findByPlaceholderText('Select lineages');
465
+ await userEvent.type(input, 'A.1');
466
+ await userEvent.click(canvas.getByRole('option', { name: 'A.1(2,574)' }));
467
+
468
+ await waitFor(() => {
469
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
470
+ pangoLineage: ['A.1'],
471
+ });
472
+ });
473
+ });
474
+
475
+ await step('verify A.1 is shown', async () => {
476
+ await expect(canvas.getByText('A.1')).toBeVisible();
477
+ });
478
+ },
479
+ };
480
+
481
+ export const MultiSelectReselectAfterRemove: StoryObj<LineageFilterProps> = {
482
+ render: (args) => (
483
+ <LapisUrlContextProvider value={LAPIS_URL}>
484
+ <LineageFilter {...args} />
485
+ </LapisUrlContextProvider>
486
+ ),
487
+ args: {
488
+ ...Default.args,
489
+ multiSelect: true,
490
+ value: ['A.1', 'B.1'],
491
+ placeholderText: 'Select lineages',
492
+ },
493
+ play: async ({ canvasElement, step }) => {
494
+ const canvas = within(canvasElement);
495
+ const lineageChangedListenerMock = fn();
496
+
497
+ await step('Setup event listener mock', () => {
498
+ canvasElement.addEventListener(gsEventNames.lineageFilterMultiChanged, lineageChangedListenerMock);
499
+ });
500
+
501
+ await step('multi-select filter is rendered with initial values', async () => {
502
+ await waitFor(async () => {
503
+ await expect(canvas.getByText('A.1')).toBeVisible();
504
+ await expect(canvas.getByText('B.1')).toBeVisible();
505
+ });
506
+ });
507
+
508
+ await step('remove A.1', async () => {
509
+ const removeButton = canvas.getByLabelText('remove A.1');
510
+ await userEvent.click(removeButton);
511
+
512
+ await waitFor(() => {
513
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
514
+ pangoLineage: ['B.1'],
515
+ });
516
+ });
517
+ });
518
+
519
+ await step('reselect A.1 from the dropdown', async () => {
520
+ const input = await canvas.findByPlaceholderText('Select lineages');
521
+ await userEvent.type(input, 'A.1');
522
+ await userEvent.click(canvas.getByRole('option', { name: 'A.1(2,574)' }));
523
+
524
+ await waitFor(() => {
525
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
526
+ pangoLineage: ['B.1', 'A.1'],
527
+ });
528
+ });
529
+ });
530
+
531
+ await step('verify A.1 is shown again', async () => {
532
+ await expect(canvas.getByText('A.1')).toBeVisible();
533
+ await expect(canvas.getByText('B.1')).toBeVisible();
534
+ });
535
+ },
536
+ };
537
+
425
538
  async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRenderer, unknown>) {
426
539
  const canvas = within(canvasElement);
427
540
 
@@ -4,7 +4,7 @@ import { getFilteredMutationCodes, type MutationFilter } from './getFilteredMuta
4
4
  import { type DeletionEntry, type SubstitutionEntry } from '../../types';
5
5
  import { type Deletion, type Substitution } from '../../utils/mutations';
6
6
  import { type MutationAnnotations } from '../../web-components/mutation-annotations-context';
7
- import { getMutationAnnotationsContext, getMutationAnnotationsProvider } from '../MutationAnnotationsContext';
7
+ import { buildAnnotationIndex, getMutationAnnotationsProvider } from '../MutationAnnotationsContext';
8
8
 
9
9
  describe('getFilteredMutationCodes', () => {
10
10
  it('should filter by displayed segments', () => {
@@ -129,7 +129,7 @@ describe('getFilteredMutationCodes', () => {
129
129
 
130
130
  describe('should filter by annotation', () => {
131
131
  const expectFilteredValue = (filterValue: MutationFilter, annotations: MutationAnnotations) => {
132
- const annotationProvider = getMutationAnnotationsProvider(getMutationAnnotationsContext(annotations));
132
+ const annotationProvider = getMutationAnnotationsProvider(buildAnnotationIndex(annotations));
133
133
 
134
134
  const result = getFilteredMutationCodes({
135
135
  overallMutationData: [someSubstitutionEntry, anotherSubstitutionEntry, someDeletionEntry],