@genspectrum/dashboard-components 0.8.3 → 0.8.5

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.
@@ -1,15 +1,10 @@
1
1
  import { type FunctionComponent } from 'preact';
2
- import { useContext, useRef, useState } from 'preact/hooks';
2
+ import { useContext, useState, useRef } from 'preact/hooks';
3
3
 
4
4
  import { MutationFilterInfo } from './mutation-filter-info';
5
- import { parseAndValidateMutation } from './parseAndValidateMutation';
5
+ import { parseAndValidateMutation, type ParsedMutationFilter } from './parseAndValidateMutation';
6
6
  import { type ReferenceGenome } from '../../lapisApi/ReferenceGenome';
7
- import {
8
- type DeletionClass,
9
- type InsertionClass,
10
- type MutationClass,
11
- type SubstitutionClass,
12
- } from '../../utils/mutations';
7
+ import { type DeletionClass, type InsertionClass, type SubstitutionClass } from '../../utils/mutations';
13
8
  import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
14
9
  import { ErrorBoundary } from '../components/error-boundary';
15
10
  import { singleGraphColorRGBByName } from '../shared/charts/colors';
@@ -48,37 +43,23 @@ export const MutationFilterInner: FunctionComponent<MutationFilterInnerProps> =
48
43
  const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>(
49
44
  getInitialState(initialValue, referenceGenome),
50
45
  );
51
- const [inputValue, setInputValue] = useState('');
52
- const [isError, setIsError] = useState(false);
53
- const formRef = useRef<HTMLFormElement>(null);
54
-
55
- const handleSubmit = (event: Event) => {
56
- event.preventDefault();
57
- if (inputValue === '') {
58
- return;
59
- }
60
46
 
61
- const parsedMutation = parseAndValidateMutation(inputValue, referenceGenome);
47
+ const filterRef = useRef<HTMLDivElement>(null);
62
48
 
63
- if (parsedMutation === null) {
64
- setIsError(true);
65
- return;
66
- }
67
-
68
- const newSelectedValues = {
49
+ const handleRemoveValue = (option: ParsedMutationFilter) => {
50
+ const newSelectedFilters = {
69
51
  ...selectedFilters,
70
- [parsedMutation.type]: [...selectedFilters[parsedMutation.type], parsedMutation.value],
52
+ [option.type]: selectedFilters[option.type].filter((i) => option.value.toString() != i.toString()),
71
53
  };
72
54
 
73
- setSelectedFilters(newSelectedValues);
74
- fireChangeEvent(newSelectedValues);
75
- setInputValue('');
55
+ setSelectedFilters(newSelectedFilters);
56
+ fireChangeEvent(newSelectedFilters);
76
57
  };
77
58
 
78
59
  const fireChangeEvent = (selectedFilters: SelectedFilters) => {
79
60
  const detail = mapToMutationFilterStrings(selectedFilters);
80
61
 
81
- formRef.current?.dispatchEvent(
62
+ filterRef.current?.dispatchEvent(
82
63
  new CustomEvent<SelectedMutationFilterStrings>('gs-mutation-filter-changed', {
83
64
  detail,
84
65
  bubbles: true,
@@ -87,38 +68,27 @@ export const MutationFilterInner: FunctionComponent<MutationFilterInnerProps> =
87
68
  );
88
69
  };
89
70
 
90
- const handleInputChange = (event: Event) => {
91
- setInputValue((event.target as HTMLInputElement).value);
92
- setIsError(false);
93
- };
94
-
95
71
  return (
96
- <form className='w-full border boder-gray-300 rounded-md relative' onSubmit={handleSubmit} ref={formRef}>
97
- <div className='absolute -top-3 -right-3'>
72
+ <div className='w-full border border-gray-300 rounded-md relative' ref={filterRef}>
73
+ <div className='absolute -top-3 -right-3 z-10'>
98
74
  <MutationFilterInfo />
99
75
  </div>
100
- <div className='w-full flex p-2 flex-wrap items-center'>
101
- <SelectedMutationDisplay
76
+
77
+ <div className='relative w-full p-1'>
78
+ <MutationFilterSelector
79
+ referenceGenome={referenceGenome}
80
+ setSelectedFilters={(newSelectedFilters) => {
81
+ setSelectedFilters(newSelectedFilters);
82
+ fireChangeEvent(newSelectedFilters);
83
+ }}
102
84
  selectedFilters={selectedFilters}
103
- setSelectedFilters={setSelectedFilters}
104
- fireChangeEvent={fireChangeEvent}
105
85
  />
106
- <div
107
- className={`w-full flex border ${isError ? 'border-red-500' : 'border-gray-300'} border-solid m-2 text-sm focus-within:border-gray-400 `}
108
- >
109
- <input
110
- className='grow flex-1 p-1 border-none focus:outline-none focus:ring-0'
111
- type='text'
112
- value={inputValue}
113
- onInput={handleInputChange}
114
- placeholder={getPlaceholder(referenceGenome)}
115
- />
116
- <button type='submit' className='btn btn-xs m-1'>
117
- +
118
- </button>
119
- </div>
86
+ <SelectedMutationFilterDisplay
87
+ selectedFilters={selectedFilters}
88
+ handleRemoveValue={handleRemoveValue}
89
+ />
120
90
  </div>
121
- </form>
91
+ </div>
122
92
  );
123
93
  };
124
94
 
@@ -158,6 +128,115 @@ function getInitialState(
158
128
  );
159
129
  }
160
130
 
131
+ const MutationFilterSelector: FunctionComponent<{
132
+ referenceGenome: ReferenceGenome;
133
+ setSelectedFilters: (option: SelectedFilters) => void;
134
+ selectedFilters: SelectedFilters;
135
+ }> = ({ referenceGenome, setSelectedFilters, selectedFilters }) => {
136
+ const [option, setOption] = useState<ParsedMutationFilter | null>(null);
137
+ const [inputValue, setInputValue] = useState('');
138
+
139
+ const selectorRef = useRef<HTMLDivElement>(null);
140
+
141
+ const handleInputChange = (newValue: string) => {
142
+ if (newValue.includes(',') || newValue.includes(';')) {
143
+ handleCommaSeparatedInput(newValue);
144
+ } else {
145
+ setInputValue(newValue);
146
+ const result = parseAndValidateMutation(newValue, referenceGenome);
147
+ setOption(result);
148
+ }
149
+ };
150
+
151
+ const handleCommaSeparatedInput = (inputValue: string) => {
152
+ const inputValues = inputValue.split(/[,;]/);
153
+ let newSelectedOptions = selectedFilters;
154
+ let updated: boolean = false;
155
+ const invalidQueries: string[] = [];
156
+ for (const value of inputValues) {
157
+ const trimmedValue = value.trim();
158
+ const parsedMutation = parseAndValidateMutation(trimmedValue, referenceGenome);
159
+ if (parsedMutation) {
160
+ const type = parsedMutation.type;
161
+ if (!selectedFilters[type].some((i) => parsedMutation.value.toString() === i.toString())) {
162
+ newSelectedOptions = {
163
+ ...newSelectedOptions,
164
+ [parsedMutation.type]: [...newSelectedOptions[parsedMutation.type], parsedMutation.value],
165
+ };
166
+ updated = true;
167
+ }
168
+ } else {
169
+ invalidQueries.push(trimmedValue);
170
+ }
171
+ }
172
+
173
+ setInputValue(invalidQueries.join(','));
174
+
175
+ if (updated) {
176
+ setSelectedFilters(newSelectedOptions);
177
+ setOption(null);
178
+ }
179
+ };
180
+
181
+ const handleOptionClick = () => {
182
+ if (option === null) {
183
+ return;
184
+ }
185
+
186
+ const type = option.type;
187
+
188
+ if (!selectedFilters[type].some((i) => option.value.toString() === i.toString())) {
189
+ const newSelectedValues = {
190
+ ...selectedFilters,
191
+ [option?.type]: [...selectedFilters[option?.type], option?.value],
192
+ };
193
+ setSelectedFilters(newSelectedValues);
194
+ }
195
+
196
+ setInputValue('');
197
+ setOption(null);
198
+ };
199
+
200
+ const handleEnterPress = (event: KeyboardEvent) => {
201
+ if (event.key === 'Enter' && option !== null) {
202
+ handleOptionClick();
203
+ }
204
+ };
205
+
206
+ const handleBlur = (event: FocusEvent) => {
207
+ // Check if click was inside the selector
208
+ if (!selectorRef.current?.contains(event.relatedTarget as Node)) {
209
+ setOption(null);
210
+ }
211
+ };
212
+
213
+ return (
214
+ <div ref={selectorRef} tabIndex={-1}>
215
+ <input
216
+ type='text'
217
+ className='w-full p-2 border-gray-300 border rounded-md'
218
+ placeholder={getPlaceholder(referenceGenome)}
219
+ value={inputValue}
220
+ onInput={(e: Event) => {
221
+ handleInputChange((e.target as HTMLInputElement).value);
222
+ }}
223
+ onKeyDown={(e) => handleEnterPress(e)}
224
+ onFocus={() => handleInputChange(inputValue)}
225
+ onBlur={handleBlur}
226
+ />
227
+ {option != null && (
228
+ <div
229
+ role='option'
230
+ className='hover:bg-gray-300 absolute cursor-pointer p-2 border-1 border-slate-500 bg-slate-200'
231
+ onClick={() => handleOptionClick()}
232
+ >
233
+ {option.value.toString()}
234
+ </div>
235
+ )}
236
+ </div>
237
+ );
238
+ };
239
+
161
240
  function getPlaceholder(referenceGenome: ReferenceGenome) {
162
241
  const segmentPrefix =
163
242
  referenceGenome.nucleotideSequences.length > 1 ? `${referenceGenome.nucleotideSequences[0].name}:` : '';
@@ -166,149 +245,73 @@ function getPlaceholder(referenceGenome: ReferenceGenome) {
166
245
  return `Enter a mutation (e.g. ${segmentPrefix}A123T, ins_${segmentPrefix}123:AT, ${firstGene}:M123E, ins_${firstGene}:123:ME)`;
167
246
  }
168
247
 
169
- const SelectedMutationDisplay: FunctionComponent<{
170
- selectedFilters: SelectedFilters;
171
- setSelectedFilters: (selectedFilters: SelectedFilters) => void;
172
- fireChangeEvent: (selectedFilters: SelectedFilters) => void;
173
- }> = ({ selectedFilters, setSelectedFilters, fireChangeEvent }) => {
174
- const onSelectedRemoved = <MutationType extends keyof SelectedFilters>(
175
- mutation: SelectedFilters[MutationType][number],
176
- key: MutationType,
177
- ) => {
178
- const newSelectedValues = {
179
- ...selectedFilters,
180
- [key]: selectedFilters[key].filter((i) => !mutation.equals(i)),
181
- };
182
-
183
- setSelectedFilters(newSelectedValues);
248
+ const backgroundColor: { [key in keyof SelectedFilters]: string } = {
249
+ aminoAcidMutations: singleGraphColorRGBByName('teal', 0.4),
250
+ nucleotideMutations: singleGraphColorRGBByName('green', 0.4),
251
+ aminoAcidInsertions: singleGraphColorRGBByName('purple', 0.4),
252
+ nucleotideInsertions: singleGraphColorRGBByName('indigo', 0.4),
253
+ };
184
254
 
185
- fireChangeEvent(newSelectedValues);
186
- };
255
+ const backgroundColorMap = (data: ParsedMutationFilter) => {
256
+ return backgroundColor[data.type] || 'lightgray';
257
+ };
187
258
 
259
+ const SelectedMutationFilterDisplay: FunctionComponent<{
260
+ selectedFilters: SelectedFilters;
261
+ handleRemoveValue: (option: ParsedMutationFilter) => void;
262
+ }> = ({ selectedFilters, handleRemoveValue }) => {
188
263
  return (
189
- <>
264
+ <div className='flex flex-wrap'>
190
265
  {selectedFilters.nucleotideMutations.map((mutation) => (
191
- <SelectedNucleotideMutation
266
+ <SelectedFilter
192
267
  key={mutation.toString()}
193
- mutation={mutation}
194
- onDelete={(mutation: SubstitutionClass | DeletionClass) =>
195
- onSelectedRemoved(mutation, 'nucleotideMutations')
196
- }
268
+ handleRemoveValue={handleRemoveValue}
269
+ mutationFilter={{ type: 'nucleotideMutations', value: mutation }}
197
270
  />
198
271
  ))}
199
272
  {selectedFilters.aminoAcidMutations.map((mutation) => (
200
- <SelectedAminoAcidMutation
273
+ <SelectedFilter
201
274
  key={mutation.toString()}
202
- mutation={mutation}
203
- onDelete={(mutation: SubstitutionClass | DeletionClass) =>
204
- onSelectedRemoved(mutation, 'aminoAcidMutations')
205
- }
275
+ handleRemoveValue={handleRemoveValue}
276
+ mutationFilter={{ type: 'aminoAcidMutations', value: mutation }}
206
277
  />
207
278
  ))}
208
- {selectedFilters.nucleotideInsertions.map((insertion) => (
209
- <SelectedNucleotideInsertion
210
- key={insertion.toString()}
211
- insertion={insertion}
212
- onDelete={(insertion) => onSelectedRemoved(insertion, 'nucleotideInsertions')}
279
+ {selectedFilters.nucleotideInsertions.map((mutation) => (
280
+ <SelectedFilter
281
+ key={mutation.toString()}
282
+ handleRemoveValue={handleRemoveValue}
283
+ mutationFilter={{ type: 'nucleotideInsertions', value: mutation }}
213
284
  />
214
285
  ))}
215
- {selectedFilters.aminoAcidInsertions.map((insertion) => (
216
- <SelectedAminoAcidInsertion
217
- key={insertion.toString()}
218
- insertion={insertion}
219
- onDelete={(insertion: InsertionClass) => onSelectedRemoved(insertion, 'aminoAcidInsertions')}
286
+ {selectedFilters.aminoAcidInsertions.map((mutation) => (
287
+ <SelectedFilter
288
+ key={mutation.toString()}
289
+ handleRemoveValue={handleRemoveValue}
290
+ mutationFilter={{ type: 'aminoAcidInsertions', value: mutation }}
220
291
  />
221
292
  ))}
222
- </>
223
- );
224
- };
225
-
226
- const SelectedAminoAcidInsertion: FunctionComponent<{
227
- insertion: InsertionClass;
228
- onDelete: (insertion: InsertionClass) => void;
229
- }> = ({ insertion, onDelete }) => {
230
- const backgroundColor = singleGraphColorRGBByName('teal', 0.3);
231
- const textColor = singleGraphColorRGBByName('teal', 1);
232
- return (
233
- <SelectedFilter
234
- mutation={insertion}
235
- onDelete={onDelete}
236
- backgroundColor={backgroundColor}
237
- textColor={textColor}
238
- />
239
- );
240
- };
241
-
242
- const SelectedAminoAcidMutation: FunctionComponent<{
243
- mutation: SubstitutionClass | DeletionClass;
244
- onDelete: (mutation: SubstitutionClass | DeletionClass) => void;
245
- }> = ({ mutation, onDelete }) => {
246
- const backgroundColor = singleGraphColorRGBByName('rose', 0.3);
247
- const textColor = singleGraphColorRGBByName('rose', 1);
248
- return (
249
- <SelectedFilter
250
- mutation={mutation}
251
- onDelete={onDelete}
252
- backgroundColor={backgroundColor}
253
- textColor={textColor}
254
- />
255
- );
256
- };
257
-
258
- const SelectedNucleotideMutation: FunctionComponent<{
259
- mutation: SubstitutionClass | DeletionClass;
260
- onDelete: (insertion: SubstitutionClass | DeletionClass) => void;
261
- }> = ({ mutation, onDelete }) => {
262
- const backgroundColor = singleGraphColorRGBByName('indigo', 0.3);
263
- const textColor = singleGraphColorRGBByName('indigo', 1);
264
- return (
265
- <SelectedFilter
266
- mutation={mutation}
267
- onDelete={onDelete}
268
- backgroundColor={backgroundColor}
269
- textColor={textColor}
270
- />
271
- );
272
- };
273
-
274
- const SelectedNucleotideInsertion: FunctionComponent<{
275
- insertion: InsertionClass;
276
- onDelete: (insertion: InsertionClass) => void;
277
- }> = ({ insertion, onDelete }) => {
278
- const backgroundColor = singleGraphColorRGBByName('green', 0.3);
279
- const textColor = singleGraphColorRGBByName('green', 1);
280
-
281
- return (
282
- <SelectedFilter
283
- mutation={insertion}
284
- onDelete={onDelete}
285
- backgroundColor={backgroundColor}
286
- textColor={textColor}
287
- />
293
+ </div>
288
294
  );
289
295
  };
290
296
 
291
- type SelectedFilterProps<MutationType extends MutationClass> = {
292
- mutation: MutationType;
293
- onDelete: (mutation: MutationType) => void;
294
- backgroundColor: string;
295
- textColor: string;
297
+ type SelectedFilterProps = {
298
+ handleRemoveValue: (mutation: ParsedMutationFilter) => void;
299
+ mutationFilter: ParsedMutationFilter;
296
300
  };
297
301
 
298
- const SelectedFilter = <MutationType extends MutationClass>({
299
- mutation,
300
- onDelete,
301
- backgroundColor,
302
- textColor,
303
- }: SelectedFilterProps<MutationType>) => {
302
+ const SelectedFilter = ({ handleRemoveValue, mutationFilter }: SelectedFilterProps) => {
304
303
  return (
305
304
  <span
306
- class='inline-block mx-1 px-2 py-1 font-medium text-xs rounded-full'
307
- style={{ backgroundColor, color: textColor }}
305
+ key={mutationFilter.value.toString()}
306
+ name={mutationFilter.value.toString()}
307
+ className='center p-2 m-1 inline-flex text-black rounded-md'
308
+ style={{
309
+ backgroundColor: backgroundColorMap(mutationFilter),
310
+ }}
308
311
  >
309
- {mutation.toString()}
310
- <button className='ml-1' type='button' onClick={() => onDelete(mutation)}>
311
-
312
+ {mutationFilter.value.toString()}
313
+ <button className='ml-1' onClick={() => handleRemoveValue(mutationFilter)}>
314
+ ×
312
315
  </button>
313
316
  </span>
314
317
  );
@@ -3,7 +3,7 @@ import { sequenceTypeFromSegment } from './sequenceTypeFromSegment';
3
3
  import type { ReferenceGenome } from '../../lapisApi/ReferenceGenome';
4
4
  import { DeletionClass, InsertionClass, SubstitutionClass } from '../../utils/mutations';
5
5
 
6
- type ParsedMutationFilter = {
6
+ export type ParsedMutationFilter = {
7
7
  [MutationType in keyof SelectedFilters]: { type: MutationType; value: SelectedFilters[MutationType][number] };
8
8
  }[keyof SelectedFilters];
9
9
 
@@ -1,31 +1,53 @@
1
1
  import { useEffect, useState } from 'preact/hooks';
2
2
 
3
+ import { UserFacingError } from '../components/error-display';
4
+
5
+ export type LoadingWorkerStatus = {
6
+ status: 'loading';
7
+ };
8
+ export type SuccessWorkerStatus<Response> = {
9
+ status: 'success';
10
+ data: Response;
11
+ };
12
+ export type ErrorWorkerStatus =
13
+ | {
14
+ status: 'error';
15
+ userFacing: false;
16
+ error: Error;
17
+ }
18
+ | {
19
+ status: 'error';
20
+ userFacing: true;
21
+ headline: string;
22
+ error: Error;
23
+ };
24
+ export type WorkerStatus<Response> = LoadingWorkerStatus | SuccessWorkerStatus<Response> | ErrorWorkerStatus;
25
+
3
26
  export function useWebWorker<Request, Response>(messageToWorker: Request, worker: Worker) {
4
27
  const [data, setData] = useState<Response | undefined>(undefined);
5
28
  const [error, setError] = useState<Error | undefined>(undefined);
6
29
  const [isLoading, setIsLoading] = useState(true);
7
30
 
8
31
  useEffect(() => {
9
- worker.onmessage = (
10
- event: MessageEvent<{
11
- status: 'loading' | 'success' | 'error';
12
- data?: Response;
13
- error?: Error;
14
- }>,
15
- ) => {
16
- const { status, data, error } = event.data;
32
+ worker.onmessage = (event: MessageEvent<WorkerStatus<Response>>) => {
33
+ const eventData = event.data;
34
+ const status = eventData.status;
17
35
 
18
36
  switch (status) {
19
37
  case 'loading':
20
38
  setIsLoading(true);
21
39
  break;
22
40
  case 'success':
23
- setData(data);
41
+ setData(eventData.data);
24
42
  setError(undefined);
25
43
  setIsLoading(false);
26
44
  break;
27
45
  case 'error':
28
- setError(error);
46
+ setError(
47
+ eventData.userFacing
48
+ ? new UserFacingError(eventData.headline, eventData.error.message)
49
+ : eventData.error,
50
+ );
29
51
  setIsLoading(false);
30
52
  break;
31
53
  default:
@@ -1,14 +1,30 @@
1
+ import { type ErrorWorkerStatus, type LoadingWorkerStatus, type SuccessWorkerStatus } from './useWebWorker';
2
+ import { UserFacingError } from '../components/error-display';
3
+
1
4
  export async function workerFunction<R>(queryFunction: () => R) {
2
5
  try {
3
- postMessage({ status: 'loading' });
6
+ postMessage({ status: 'loading' } satisfies LoadingWorkerStatus);
4
7
 
5
8
  const workerResponse = await queryFunction();
6
9
 
7
10
  postMessage({
8
11
  status: 'success',
9
12
  data: workerResponse,
10
- });
13
+ } satisfies SuccessWorkerStatus<R>);
11
14
  } catch (error) {
12
- postMessage({ status: 'error', error });
15
+ postMessage(
16
+ (error instanceof UserFacingError
17
+ ? {
18
+ status: 'error',
19
+ userFacing: true,
20
+ headline: error.headline,
21
+ error,
22
+ }
23
+ : {
24
+ status: 'error',
25
+ userFacing: false,
26
+ error: error instanceof Error ? error : new Error(`${error}`),
27
+ }) satisfies ErrorWorkerStatus,
28
+ );
13
29
  }
14
30
  }
@@ -70,7 +70,6 @@ export const FiresFilterChangedEvent: StoryObj<MutationFilterProps> = {
70
70
  const canvas = await withinShadowRoot(canvasElement, 'gs-mutation-filter');
71
71
 
72
72
  const inputField = () => canvas.getByPlaceholderText('Enter a mutation', { exact: false });
73
- const submitButton = () => canvas.getByRole('button', { name: '+' });
74
73
  const listenerMock = fn();
75
74
  await step('Setup event listener mock', async () => {
76
75
  canvasElement.addEventListener('gs-mutation-filter-changed', listenerMock);
@@ -84,7 +83,8 @@ export const FiresFilterChangedEvent: StoryObj<MutationFilterProps> = {
84
83
 
85
84
  await step('Enter a valid mutation', async () => {
86
85
  await userEvent.type(inputField(), 'A123T');
87
- await waitFor(() => submitButton().click());
86
+ const option = await canvas.findByRole('option');
87
+ await userEvent.click(option);
88
88
 
89
89
  await waitFor(() =>
90
90
  expect(listenerMock).toHaveBeenCalledWith(
@@ -150,6 +150,16 @@ export const MultiSegmentedReferenceGenomes: StoryObj<MutationFilterProps> = {
150
150
  play: async ({ canvasElement }) => {
151
151
  const canvas = await withinShadowRoot(canvasElement, 'gs-mutation-filter');
152
152
 
153
+ const inputField = () => canvas.getByPlaceholderText('Enter a mutation', { exact: false });
154
+
155
+ await waitFor(() => {
156
+ const placeholderText = inputField().getAttribute('placeholder');
157
+
158
+ expect(placeholderText).toEqual(
159
+ 'Enter a mutation (e.g. seg1:A123T, ins_seg1:123:AT, gene1:M123E, ins_gene1:123:ME)',
160
+ );
161
+ });
162
+
153
163
  await waitFor(() => {
154
164
  expect(canvas.getByText('seg1:123T')).toBeVisible();
155
165
  expect(canvas.getByText('gene2:56')).toBeVisible();
@@ -14,8 +14,9 @@ import { PreactLitAdapter } from '../PreactLitAdapter';
14
14
  * ## Context
15
15
  * This component provides an input field to specify filters for nucleotide and amino acid mutations and insertions.
16
16
  *
17
- * Input values have to be provided one at a time and submitted by pressing the Enter key or by clicking the '+' button.
18
- * After submission, the component validates the input and fires an event with the selected mutations.
17
+ * Input values have to be provided one at a time and submitted by pressing the Enter key or by selecting an option from the dropdown.
18
+ * Alternatively, they can be provided as a string of comma-separated values, which will be directly parsed and validated.
19
+ * After submission (after pressing Enter or pasting a comma-separated string) an event is fired with the selected mutations.
19
20
  * All previously selected mutations are displayed at the input field and added to the event.
20
21
  * Users can remove a mutation by clicking the 'x' button next to the mutation.
21
22
  *