@genspectrum/dashboard-components 0.11.7 → 0.12.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.
- package/custom-elements.json +16 -16
- package/dist/{dateRangeOption-Bh2p78z0.js → LocationChangedEvent-CORvQvXv.js} +11 -1
- package/dist/LocationChangedEvent-CORvQvXv.js.map +1 -0
- package/dist/components.d.ts +27 -33
- package/dist/components.js +4034 -655
- package/dist/components.js.map +1 -1
- package/dist/style.css +151 -4
- package/dist/util.d.ts +32 -26
- package/dist/util.js +2 -1
- package/package.json +2 -1
- package/src/preact/components/csv-download-button.tsx +2 -2
- package/src/preact/downshift_types.d.ts +3 -0
- package/src/preact/locationFilter/LocationChangedEvent.ts +11 -0
- package/src/preact/locationFilter/fetchAutocompletionList.spec.ts +5 -5
- package/src/preact/locationFilter/fetchAutocompletionList.ts +9 -2
- package/src/preact/locationFilter/location-filter.stories.tsx +94 -10
- package/src/preact/locationFilter/location-filter.tsx +183 -62
- package/src/preact/map/sequences-by-location-map.tsx +3 -3
- package/src/preact/mutationFilter/mutation-filter-info.tsx +73 -10
- package/src/preact/textInput/TextInputChangedEvent.ts +11 -0
- package/src/preact/textInput/fetchAutocompleteList.ts +1 -1
- package/src/preact/textInput/text-input.stories.tsx +20 -3
- package/src/preact/textInput/text-input.tsx +139 -36
- package/src/utilEntrypoint.ts +2 -0
- package/src/web-components/input/gs-location-filter.stories.ts +34 -29
- package/src/web-components/input/gs-location-filter.tsx +6 -13
- package/src/web-components/input/gs-text-input.stories.ts +30 -7
- package/standalone-bundle/dashboard-components.js +11073 -8625
- package/standalone-bundle/dashboard-components.js.map +1 -1
- package/standalone-bundle/style.css +1 -1
- package/dist/dateRangeOption-Bh2p78z0.js.map +0 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { type Meta, type StoryObj } from '@storybook/preact';
|
|
1
|
+
import { type Meta, type PreactRenderer, type StoryObj } from '@storybook/preact';
|
|
2
|
+
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
|
3
|
+
import type { StepFunction } from '@storybook/types';
|
|
2
4
|
|
|
3
5
|
import data from './__mockData__/aggregated.json';
|
|
4
6
|
import { LocationFilter, type LocationFilterProps } from './location-filter';
|
|
@@ -35,7 +37,7 @@ const meta: Meta<LocationFilterProps> = {
|
|
|
35
37
|
args: {
|
|
36
38
|
width: '100%',
|
|
37
39
|
fields: ['region', 'country', 'division', 'location'],
|
|
38
|
-
|
|
40
|
+
value: { region: 'Europe', country: undefined, division: undefined, location: undefined },
|
|
39
41
|
placeholderText: 'Enter a location',
|
|
40
42
|
},
|
|
41
43
|
argTypes: {
|
|
@@ -44,9 +46,9 @@ const meta: Meta<LocationFilterProps> = {
|
|
|
44
46
|
type: 'object',
|
|
45
47
|
},
|
|
46
48
|
},
|
|
47
|
-
|
|
49
|
+
value: {
|
|
48
50
|
control: {
|
|
49
|
-
type: '
|
|
51
|
+
type: 'object',
|
|
50
52
|
},
|
|
51
53
|
},
|
|
52
54
|
width: {
|
|
@@ -67,16 +69,98 @@ export default meta;
|
|
|
67
69
|
export const Primary: StoryObj<LocationFilterProps> = {
|
|
68
70
|
render: (args) => (
|
|
69
71
|
<LapisUrlContext.Provider value={LAPIS_URL}>
|
|
70
|
-
<LocationFilter
|
|
71
|
-
fields={args.fields}
|
|
72
|
-
initialValue={args.initialValue}
|
|
73
|
-
width={args.width}
|
|
74
|
-
placeholderText={args.placeholderText}
|
|
75
|
-
/>
|
|
72
|
+
<LocationFilter {...args} />
|
|
76
73
|
</LapisUrlContext.Provider>
|
|
77
74
|
),
|
|
75
|
+
play: async ({ canvasElement, step }) => {
|
|
76
|
+
const { canvas, locationChangedListenerMock } = await prepare(canvasElement, step);
|
|
77
|
+
|
|
78
|
+
step('change location filter value fires event', async () => {
|
|
79
|
+
const input = await inputField(canvas);
|
|
80
|
+
await userEvent.clear(input);
|
|
81
|
+
await userEvent.type(input, 'Germany');
|
|
82
|
+
await userEvent.click(canvas.getByRole('option', { name: 'Germany Europe / Germany' }));
|
|
83
|
+
|
|
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
|
+
);
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const ClearSelection: StoryObj<LocationFilterProps> = {
|
|
99
|
+
...Primary,
|
|
100
|
+
play: async ({ canvasElement, step }) => {
|
|
101
|
+
const { canvas, locationChangedListenerMock } = await prepare(canvasElement, step);
|
|
102
|
+
|
|
103
|
+
step('clear selection fires event with empty filter', async () => {
|
|
104
|
+
const clearSelectionButton = await canvas.findByLabelText('clear selection');
|
|
105
|
+
await userEvent.click(clearSelectionButton);
|
|
106
|
+
|
|
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
|
+
);
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const OnBlurInput: StoryObj<LocationFilterProps> = {
|
|
122
|
+
...Primary,
|
|
123
|
+
play: async ({ canvasElement, step }) => {
|
|
124
|
+
const { canvas, locationChangedListenerMock } = await prepare(canvasElement, step);
|
|
125
|
+
|
|
126
|
+
step('after cleared selection by hand and then blur fires event with empty filter', async () => {
|
|
127
|
+
const input = await inputField(canvas);
|
|
128
|
+
await userEvent.clear(input);
|
|
129
|
+
await userEvent.click(canvas.getByLabelText('toggle menu'));
|
|
130
|
+
|
|
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
|
+
);
|
|
141
|
+
});
|
|
142
|
+
},
|
|
78
143
|
};
|
|
79
144
|
|
|
145
|
+
const inputField = (canvas: ReturnType<typeof within>) => canvas.findByPlaceholderText('Enter a location');
|
|
146
|
+
|
|
147
|
+
async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRenderer, unknown>) {
|
|
148
|
+
const canvas = within(canvasElement);
|
|
149
|
+
|
|
150
|
+
const locationChangedListenerMock = fn();
|
|
151
|
+
step('Setup event listener mock', () => {
|
|
152
|
+
canvasElement.addEventListener('gs-location-changed', locationChangedListenerMock);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
step('location filter is rendered with value', async () => {
|
|
156
|
+
await waitFor(async () => {
|
|
157
|
+
return expect(await inputField(canvas)).toHaveValue('Europe');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { canvas, locationChangedListenerMock };
|
|
162
|
+
}
|
|
163
|
+
|
|
80
164
|
export const WithNoFields: StoryObj<LocationFilterProps> = {
|
|
81
165
|
...Primary,
|
|
82
166
|
args: {
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
+
import { useCombobox } from 'downshift/preact';
|
|
1
2
|
import { type FunctionComponent } from 'preact';
|
|
2
|
-
import { useContext, useRef, useState } from 'preact/hooks';
|
|
3
|
-
import { type JSXInternal } from 'preact/src/jsx';
|
|
3
|
+
import { useContext, useMemo, useRef, useState } from 'preact/hooks';
|
|
4
4
|
import z from 'zod';
|
|
5
5
|
|
|
6
6
|
import { fetchAutocompletionList } from './fetchAutocompletionList';
|
|
7
7
|
import { LapisUrlContext } from '../LapisUrlContext';
|
|
8
|
+
import { type LapisLocationFilter, LocationChangedEvent } from './LocationChangedEvent';
|
|
8
9
|
import { ErrorBoundary } from '../components/error-boundary';
|
|
9
10
|
import { LoadingDisplay } from '../components/loading-display';
|
|
10
11
|
import { ResizeContainer } from '../components/resize-container';
|
|
11
12
|
import { useQuery } from '../useQuery';
|
|
12
13
|
|
|
13
14
|
const lineageFilterInnerPropsSchema = z.object({
|
|
14
|
-
|
|
15
|
+
value: z.record(z.string().nullable().optional()).optional(),
|
|
15
16
|
placeholderText: z.string().optional(),
|
|
16
17
|
fields: z.array(z.string()).min(1),
|
|
17
18
|
});
|
|
@@ -36,14 +37,9 @@ export const LocationFilter: FunctionComponent<LocationFilterProps> = (props) =>
|
|
|
36
37
|
);
|
|
37
38
|
};
|
|
38
39
|
|
|
39
|
-
export const LocationFilterInner = ({
|
|
40
|
+
export const LocationFilterInner = ({ value, fields, placeholderText }: LocationFilterInnerProps) => {
|
|
40
41
|
const lapis = useContext(LapisUrlContext);
|
|
41
42
|
|
|
42
|
-
const [value, setValue] = useState(initialValue ?? '');
|
|
43
|
-
const [unknownLocation, setUnknownLocation] = useState(false);
|
|
44
|
-
|
|
45
|
-
const divRef = useRef<HTMLDivElement>(null);
|
|
46
|
-
|
|
47
43
|
const { data, error, isLoading } = useQuery(() => fetchAutocompletionList(fields, lapis), [fields, lapis]);
|
|
48
44
|
|
|
49
45
|
if (isLoading) {
|
|
@@ -53,70 +49,195 @@ export const LocationFilterInner = ({ initialValue, fields, placeholderText }: L
|
|
|
53
49
|
throw error;
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
52
|
+
return <LocationSelector fields={fields} value={value} placeholderText={placeholderText} locationData={data} />;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type SelectItem = {
|
|
56
|
+
lapisFilter: LapisLocationFilter;
|
|
57
|
+
label: string;
|
|
58
|
+
description: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const LocationSelector = ({
|
|
62
|
+
fields,
|
|
63
|
+
value,
|
|
64
|
+
placeholderText,
|
|
65
|
+
locationData,
|
|
66
|
+
}: LocationFilterInnerProps & {
|
|
67
|
+
locationData: LapisLocationFilter[];
|
|
68
|
+
}) => {
|
|
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
|
+
);
|
|
78
|
+
|
|
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 || '');
|
|
74
134
|
}
|
|
75
135
|
};
|
|
76
136
|
|
|
137
|
+
const clearInput = () => {
|
|
138
|
+
divRef.current?.dispatchEvent(new LocationChangedEvent(emptyLocationFilter(fields)));
|
|
139
|
+
selectItem(null);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const buttonRef = useRef(null);
|
|
143
|
+
|
|
77
144
|
return (
|
|
78
|
-
<div
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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>
|
|
96
199
|
</div>
|
|
97
200
|
);
|
|
98
201
|
};
|
|
99
202
|
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
return
|
|
203
|
+
function filterByInputValue(item: SelectItem, inputValue: string | undefined | null) {
|
|
204
|
+
if (inputValue === undefined || inputValue === null) {
|
|
205
|
+
return true;
|
|
103
206
|
}
|
|
104
|
-
|
|
207
|
+
return (
|
|
208
|
+
item?.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
|
209
|
+
item?.description.toLowerCase().includes(inputValue.toLowerCase())
|
|
210
|
+
);
|
|
211
|
+
}
|
|
105
212
|
|
|
106
|
-
|
|
107
|
-
|
|
213
|
+
function toSelectOption(locationFilter: LapisLocationFilter, fields: string[]) {
|
|
214
|
+
const concatenatedLocation = concatenateLocation(locationFilter, fields);
|
|
108
215
|
|
|
109
|
-
const
|
|
110
|
-
|
|
216
|
+
const lastNonUndefinedField = [...fields]
|
|
217
|
+
.reverse()
|
|
218
|
+
.find((field) => locationFilter[field] !== undefined && locationFilter[field] !== null);
|
|
111
219
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return false;
|
|
220
|
+
if (lastNonUndefinedField === undefined) {
|
|
221
|
+
return undefined;
|
|
115
222
|
}
|
|
116
223
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
224
|
+
return {
|
|
225
|
+
lapisFilter: locationFilter,
|
|
226
|
+
label: locationFilter[lastNonUndefinedField],
|
|
227
|
+
description: concatenatedLocation,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function concatenateLocation(locationFilter: LapisLocationFilter, fields: string[]) {
|
|
232
|
+
return fields
|
|
233
|
+
.map((field) => locationFilter[field])
|
|
234
|
+
.filter((value) => value !== null && value !== undefined)
|
|
235
|
+
.join(' / ');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function emptyLocationFilter(fields: string[]) {
|
|
239
|
+
return fields.reduce((acc, field) => {
|
|
240
|
+
acc[field] = undefined;
|
|
241
|
+
return acc;
|
|
242
|
+
}, {} as LapisLocationFilter);
|
|
243
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Feature, Geometry, GeometryObject } from 'geojson';
|
|
2
|
-
import
|
|
2
|
+
import { geoJson, type Layer, type LayerGroup, map } from 'leaflet';
|
|
3
3
|
import type { FunctionComponent } from 'preact';
|
|
4
4
|
import { useEffect, useRef } from 'preact/hooks';
|
|
5
5
|
|
|
@@ -42,7 +42,7 @@ export const SequencesByLocationMap: FunctionComponent<SequencesByLocationMapPro
|
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
const leafletMap =
|
|
45
|
+
const leafletMap = map(ref.current, {
|
|
46
46
|
scrollWheelZoom: enableMapNavigation,
|
|
47
47
|
zoomControl: enableMapNavigation,
|
|
48
48
|
keyboard: enableMapNavigation,
|
|
@@ -52,7 +52,7 @@ export const SequencesByLocationMap: FunctionComponent<SequencesByLocationMapPro
|
|
|
52
52
|
center: [offsetY, offsetX],
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
geoJson(locations, {
|
|
56
56
|
style: (feature: Feature<GeometryObject, EnhancedGeoJsonFeatureProperties> | undefined) => ({
|
|
57
57
|
fillColor: getColor(feature?.properties.data?.proportion),
|
|
58
58
|
fillOpacity: 1,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useContext } from 'preact/hooks';
|
|
2
|
+
import { type FC } from 'react';
|
|
2
3
|
|
|
3
4
|
import { isSingleSegmented } from '../../lapisApi/ReferenceGenome';
|
|
5
|
+
import { type SequenceType } from '../../types';
|
|
4
6
|
import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
|
|
5
7
|
import Info, { InfoHeadline1, InfoHeadline2, InfoParagraph } from '../components/info';
|
|
6
8
|
|
|
@@ -13,7 +15,38 @@ export const MutationFilterInfo = () => {
|
|
|
13
15
|
<InfoHeadline1> Mutation Filter</InfoHeadline1>
|
|
14
16
|
<InfoParagraph>This component allows you to filter for mutations at specific positions.</InfoParagraph>
|
|
15
17
|
|
|
16
|
-
<InfoHeadline2>
|
|
18
|
+
<InfoHeadline2>Quickstart</InfoHeadline2>
|
|
19
|
+
<InfoParagraph>
|
|
20
|
+
<ul className='list-disc list-inside'>
|
|
21
|
+
<li>
|
|
22
|
+
Filter for nucleotide mutations:{' '}
|
|
23
|
+
<ExampleMutation mutationType='substitution' sequenceType='nucleotide' />
|
|
24
|
+
</li>
|
|
25
|
+
<li>
|
|
26
|
+
Filter for amino acid mutations:{' '}
|
|
27
|
+
<ExampleMutation mutationType='insertion' sequenceType='nucleotide' />
|
|
28
|
+
</li>
|
|
29
|
+
<li>
|
|
30
|
+
Filter for nucleotide insertions:{' '}
|
|
31
|
+
<ExampleMutation mutationType='substitution' sequenceType='amino acid' />
|
|
32
|
+
</li>
|
|
33
|
+
<li>
|
|
34
|
+
Filter for amino acid insertions:{' '}
|
|
35
|
+
<ExampleMutation mutationType='insertion' sequenceType='amino acid' />
|
|
36
|
+
</li>
|
|
37
|
+
</ul>
|
|
38
|
+
</InfoParagraph>
|
|
39
|
+
{!isSingleSegmented(referenceGenome) && (
|
|
40
|
+
<InfoParagraph>
|
|
41
|
+
This organism has the following segments:{' '}
|
|
42
|
+
{referenceGenome.nucleotideSequences.map((gene) => gene.name).join(', ')}.
|
|
43
|
+
</InfoParagraph>
|
|
44
|
+
)}
|
|
45
|
+
<InfoParagraph>
|
|
46
|
+
This organism has the following genes: {referenceGenome.genes.map((gene) => gene.name).join(', ')}.
|
|
47
|
+
</InfoParagraph>
|
|
48
|
+
|
|
49
|
+
<InfoHeadline2>Nucleotide Mutations and Insertions</InfoHeadline2>
|
|
17
50
|
{isSingleSegmented(referenceGenome) ? (
|
|
18
51
|
<SingleSegmentedNucleotideMutationsInfo />
|
|
19
52
|
) : (
|
|
@@ -25,16 +58,13 @@ export const MutationFilterInfo = () => {
|
|
|
25
58
|
An amino acid mutation has the format <b><gene>:<position><base></b> or
|
|
26
59
|
<b><gene>:<base_ref><position><base></b>. A <b><base></b> can be one of
|
|
27
60
|
the 20 amino acid codes. It can also be <b>-</b> for deletion and <b>X</b> for unknown. Example:{' '}
|
|
28
|
-
<
|
|
61
|
+
<ExampleMutation mutationType='substitution' sequenceType='amino acid' />.
|
|
29
62
|
</InfoParagraph>
|
|
30
63
|
<InfoParagraph>
|
|
31
64
|
Insertions can be searched for in the same manner, they just need to have <b>ins_</b> appended to the
|
|
32
65
|
start of the mutation. Example: <b>ins_{firstGene}:31:N</b> would filter for sequences with an insertion
|
|
33
66
|
of N between positions 31 and 32 in the gene {firstGene}.
|
|
34
67
|
</InfoParagraph>
|
|
35
|
-
<InfoParagraph>
|
|
36
|
-
This organism has the following genes: {referenceGenome.genes.map((gene) => gene.name).join(', ')}.
|
|
37
|
-
</InfoParagraph>
|
|
38
68
|
|
|
39
69
|
<InfoHeadline2>Insertion Wildcards</InfoHeadline2>
|
|
40
70
|
<InfoParagraph>
|
|
@@ -106,12 +136,45 @@ const MultiSegmentedNucleotideMutationsInfo = () => {
|
|
|
106
136
|
</InfoParagraph>
|
|
107
137
|
<InfoParagraph>
|
|
108
138
|
Insertions can be searched for in the same manner, they just need to have <b>ins_</b> appended to the
|
|
109
|
-
start of the mutation. Example: <
|
|
139
|
+
start of the mutation. Example: <ExampleMutation mutationType='insertion' sequenceType='nucleotide' />.
|
|
110
140
|
</InfoParagraph>
|
|
111
|
-
<InfoParagraph>
|
|
112
|
-
This organism has the following segments:{' '}
|
|
113
|
-
{referenceGenome.nucleotideSequences.map((gene) => gene.name).join(', ')}.
|
|
114
|
-
</InfoParagraph>{' '}
|
|
115
141
|
</>
|
|
116
142
|
);
|
|
117
143
|
};
|
|
144
|
+
|
|
145
|
+
type ExampleMutationProps = {
|
|
146
|
+
sequenceType: SequenceType;
|
|
147
|
+
mutationType: 'substitution' | 'insertion';
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const ExampleMutation: FC<ExampleMutationProps> = ({ sequenceType, mutationType }) => {
|
|
151
|
+
const referenceGenome = useContext(ReferenceGenomeContext);
|
|
152
|
+
|
|
153
|
+
const firstSegment = referenceGenome.nucleotideSequences[0].name;
|
|
154
|
+
const firstGene = referenceGenome.genes[0].name;
|
|
155
|
+
|
|
156
|
+
if (sequenceType === 'amino acid') {
|
|
157
|
+
switch (mutationType) {
|
|
158
|
+
case 'substitution':
|
|
159
|
+
return <b>{firstGene}:57Q</b>;
|
|
160
|
+
case 'insertion':
|
|
161
|
+
return <b>ins_{firstGene}:31:N</b>;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (isSingleSegmented(referenceGenome)) {
|
|
166
|
+
switch (mutationType) {
|
|
167
|
+
case 'substitution':
|
|
168
|
+
return <b>23T</b>;
|
|
169
|
+
case 'insertion':
|
|
170
|
+
return <b>ins_1046:A</b>;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
switch (mutationType) {
|
|
175
|
+
case 'substitution':
|
|
176
|
+
return <b>{firstSegment}:23T</b>;
|
|
177
|
+
case 'insertion':
|
|
178
|
+
return <b>ins_{firstSegment}:10462:A</b>;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type LapisTextFilter = Record<string, string | null | undefined>;
|
|
2
|
+
|
|
3
|
+
export class TextInputChangedEvent extends CustomEvent<LapisTextFilter> {
|
|
4
|
+
constructor(detail: LapisTextFilter) {
|
|
5
|
+
super('gs-text-input-changed', {
|
|
6
|
+
detail,
|
|
7
|
+
bubbles: true,
|
|
8
|
+
composed: true,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Meta, type StoryObj } from '@storybook/preact';
|
|
2
|
-
import { expect, waitFor, within } from '@storybook/test';
|
|
2
|
+
import { expect, fireEvent, fn, waitFor, within } from '@storybook/test';
|
|
3
3
|
|
|
4
4
|
import data from './__mockData__/aggregated_hosts.json';
|
|
5
5
|
import { TextInput, type TextInputProps } from './text-input';
|
|
@@ -79,19 +79,36 @@ export const Default: StoryObj<TextInputProps> = {
|
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
-
export const
|
|
82
|
+
export const RemoveInitialValue: StoryObj<TextInputProps> = {
|
|
83
83
|
...Default,
|
|
84
84
|
args: {
|
|
85
85
|
...Default.args,
|
|
86
86
|
initialValue: 'Homo sapiens',
|
|
87
87
|
},
|
|
88
|
-
play: async ({ canvasElement }) => {
|
|
88
|
+
play: async ({ canvasElement, step }) => {
|
|
89
89
|
const canvas = within(canvasElement);
|
|
90
90
|
|
|
91
|
+
const changedListenerMock = fn();
|
|
92
|
+
await step('Setup event listener mock', async () => {
|
|
93
|
+
canvasElement.addEventListener('gs-text-input-changed', changedListenerMock);
|
|
94
|
+
});
|
|
95
|
+
|
|
91
96
|
await waitFor(() => {
|
|
92
97
|
const input = canvas.getByPlaceholderText('Enter a host name', { exact: false });
|
|
93
98
|
expect(input).toHaveValue('Homo sapiens');
|
|
94
99
|
});
|
|
100
|
+
|
|
101
|
+
await step('Remove initial value', async () => {
|
|
102
|
+
await fireEvent.click(canvas.getByRole('button', { name: 'clear selection' }));
|
|
103
|
+
|
|
104
|
+
await expect(changedListenerMock).toHaveBeenCalledWith(
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
detail: {
|
|
107
|
+
host: undefined,
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
95
112
|
},
|
|
96
113
|
};
|
|
97
114
|
|