@genspectrum/dashboard-components 0.12.1 → 0.13.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 +292 -25
- package/dist/{LocationChangedEvent-CORvQvXv.js → LineageFilterChangedEvent-GedKNGFI.js} +25 -3
- package/dist/LineageFilterChangedEvent-GedKNGFI.js.map +1 -0
- package/dist/assets/mutationOverTimeWorker-B1-WrM4b.js.map +1 -0
- package/dist/components.d.ts +124 -25
- package/dist/components.js +765 -572
- package/dist/components.js.map +1 -1
- package/dist/style.css +3 -0
- package/dist/util.d.ts +48 -18
- package/dist/util.js +3 -1
- package/package.json +2 -2
- package/src/constants.ts +6 -0
- package/src/lapisApi/__mockData__/wiseReferenceGenome.json +9 -0
- package/src/lapisApi/lapisApi.ts +17 -0
- package/src/lapisApi/lapisTypes.ts +7 -1
- package/src/operator/FetchDetailsOperator.ts +28 -0
- package/src/preact/components/downshift-combobox.tsx +145 -0
- package/src/preact/components/tabs.tsx +1 -1
- package/src/preact/lineageFilter/LineageFilterChangedEvent.ts +11 -0
- package/src/preact/lineageFilter/fetchLineageAutocompleteList.spec.ts +16 -2
- package/src/preact/lineageFilter/fetchLineageAutocompleteList.ts +13 -2
- package/src/preact/lineageFilter/lineage-filter.stories.tsx +110 -9
- package/src/preact/lineageFilter/lineage-filter.tsx +40 -50
- package/src/preact/locationFilter/LocationChangedEvent.ts +1 -1
- package/src/preact/locationFilter/fetchAutocompletionList.spec.ts +6 -2
- package/src/preact/locationFilter/fetchAutocompletionList.ts +16 -6
- package/src/preact/locationFilter/location-filter.stories.tsx +33 -30
- package/src/preact/locationFilter/location-filter.tsx +47 -144
- package/src/preact/mutationsOverTime/MutationOverTimeData.ts +9 -5
- package/src/preact/mutationsOverTime/mutations-over-time-grid.tsx +5 -3
- package/src/preact/shared/sort/sortSubstitutionsAndDeletions.ts +4 -7
- package/src/preact/textInput/TextInputChangedEvent.ts +1 -1
- package/src/preact/textInput/fetchStringAutocompleteList.ts +20 -0
- package/src/preact/textInput/text-input.stories.tsx +14 -11
- package/src/preact/textInput/text-input.tsx +39 -140
- package/src/preact/wastewater/mutationsOverTime/__mockData__/details.json +88 -0
- package/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.spec.ts +159 -0
- package/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.ts +51 -0
- package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.stories.tsx +71 -0
- package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.tsx +151 -0
- package/src/query/queryMutationsOverTime.ts +6 -14
- package/src/query/queryWastewaterMutationsOverTime.spec.ts +94 -0
- package/src/query/queryWastewaterMutationsOverTime.ts +55 -0
- package/src/types.ts +3 -0
- package/src/utilEntrypoint.ts +2 -0
- package/src/utils/map2d.ts +39 -0
- package/src/web-components/index.ts +1 -0
- package/src/web-components/input/gs-lineage-filter.stories.ts +120 -31
- package/src/web-components/input/gs-lineage-filter.tsx +24 -8
- package/src/web-components/input/gs-location-filter.stories.ts +9 -0
- package/src/web-components/input/gs-location-filter.tsx +21 -3
- package/src/web-components/input/gs-text-input.stories.ts +14 -5
- package/src/web-components/input/gs-text-input.tsx +23 -7
- package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.stories.ts +82 -0
- package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.tsx +112 -0
- package/src/web-components/wastewaterVisualization/index.ts +1 -0
- package/standalone-bundle/assets/{mutationOverTimeWorker-DEybsZ5r.js.map → mutationOverTimeWorker-Cls1J0cl.js.map} +1 -1
- package/standalone-bundle/dashboard-components.js +6972 -6796
- package/standalone-bundle/dashboard-components.js.map +1 -1
- package/standalone-bundle/style.css +1 -1
- package/dist/LocationChangedEvent-CORvQvXv.js.map +0 -1
- package/dist/assets/mutationOverTimeWorker-DTv93Ere.js.map +0 -1
- package/src/preact/textInput/fetchAutocompleteList.ts +0 -9
|
@@ -1,35 +1,37 @@
|
|
|
1
|
-
import { useCombobox } from 'downshift/preact';
|
|
2
1
|
import { type FunctionComponent } from 'preact';
|
|
3
|
-
import { useContext, useMemo
|
|
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 {
|
|
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
|
|
15
|
-
value:
|
|
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
|
|
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
|
|
25
|
-
export type LocationFilterProps = z.infer<typeof
|
|
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={
|
|
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(
|
|
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
|
-
}:
|
|
71
|
+
}: LocationSelectorProps & {
|
|
67
72
|
locationData: LapisLocationFilter[];
|
|
68
73
|
}) => {
|
|
69
|
-
const allItems = useMemo(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
111
|
+
item?.label?.toLowerCase().includes(inputValue.toLowerCase()) ||
|
|
209
112
|
item?.description.toLowerCase().includes(inputValue.toLowerCase())
|
|
210
113
|
);
|
|
211
114
|
}
|
|
212
115
|
|
|
213
|
-
function
|
|
116
|
+
function toSelectItem(locationFilter: LapisLocationFilter, fields: string[]): SelectItem | undefined {
|
|
214
117
|
const concatenatedLocation = concatenateLocation(locationFilter, fields);
|
|
215
118
|
|
|
216
119
|
const lastNonUndefinedField = [...fields]
|
|
@@ -5,16 +5,20 @@ import {
|
|
|
5
5
|
} from '../../query/queryMutationsOverTime';
|
|
6
6
|
import { type Map2d, Map2dBase, type Map2DContents } from '../../utils/map2d';
|
|
7
7
|
import type { Deletion, Substitution } from '../../utils/mutations';
|
|
8
|
-
import type { Temporal } from '../../utils/temporalClass';
|
|
8
|
+
import type { Temporal, TemporalClass } from '../../utils/temporalClass';
|
|
9
9
|
|
|
10
|
-
export type MutationOverTimeDataMap
|
|
10
|
+
export type MutationOverTimeDataMap<T extends Temporal | TemporalClass = Temporal> = Map2d<
|
|
11
|
+
Substitution | Deletion,
|
|
12
|
+
T,
|
|
13
|
+
MutationOverTimeMutationValue
|
|
14
|
+
>;
|
|
11
15
|
|
|
12
|
-
export class BaseMutationOverTimeDataMap extends Map2dBase<
|
|
16
|
+
export class BaseMutationOverTimeDataMap<T extends Temporal | TemporalClass = Temporal> extends Map2dBase<
|
|
13
17
|
Substitution | Deletion,
|
|
14
|
-
|
|
18
|
+
T,
|
|
15
19
|
MutationOverTimeMutationValue
|
|
16
20
|
> {
|
|
17
|
-
constructor(initialContent?: Map2DContents<Substitution | Deletion,
|
|
21
|
+
constructor(initialContent?: Map2DContents<Substitution | Deletion, T, MutationOverTimeMutationValue>) {
|
|
18
22
|
super(serializeSubstitutionOrDeletion, serializeTemporal, initialContent);
|
|
19
23
|
}
|
|
20
24
|
}
|
|
@@ -111,9 +111,11 @@ const ProportionCell: FunctionComponent<{
|
|
|
111
111
|
) : (
|
|
112
112
|
<>
|
|
113
113
|
<p>Proportion: {formatProportion(value.proportion)}</p>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
{value.count !== null && value.totalCount !== null && (
|
|
115
|
+
<p>
|
|
116
|
+
Count: {value.count} / {value.totalCount} total
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
117
119
|
</>
|
|
118
120
|
)}
|
|
119
121
|
</div>
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Deletion, Substitution } from '../../../utils/mutations';
|
|
2
2
|
|
|
3
|
-
export const sortSubstitutionsAndDeletions = (
|
|
4
|
-
a: SubstitutionClass | DeletionClass,
|
|
5
|
-
b: SubstitutionClass | DeletionClass,
|
|
6
|
-
) => {
|
|
3
|
+
export const sortSubstitutionsAndDeletions = (a: Substitution | Deletion, b: Substitution | Deletion) => {
|
|
7
4
|
if (a.segment !== b.segment) {
|
|
8
5
|
return compareSegments(a.segment, b.segment);
|
|
9
6
|
}
|
|
@@ -12,8 +9,8 @@ export const sortSubstitutionsAndDeletions = (
|
|
|
12
9
|
return comparePositions(a.position, b.position);
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
const aIsDeletion = a
|
|
16
|
-
const bIsDeletion = b
|
|
12
|
+
const aIsDeletion = a.type === 'deletion';
|
|
13
|
+
const bIsDeletion = b.type === 'deletion';
|
|
17
14
|
|
|
18
15
|
if (aIsDeletion !== bIsDeletion) {
|
|
19
16
|
return aIsDeletion ? 1 : -1;
|
|
@@ -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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
+
value: 'Homo sapiens',
|
|
87
90
|
},
|
|
88
91
|
play: async ({ canvasElement, step }) => {
|
|
89
92
|
const canvas = within(canvasElement);
|
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
import { useCombobox } from 'downshift/preact';
|
|
2
1
|
import { type FunctionComponent } from 'preact';
|
|
3
|
-
import { useContext
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
4
3
|
import z from 'zod';
|
|
5
4
|
|
|
6
|
-
import {
|
|
5
|
+
import { fetchStringAutocompleteList } from './fetchStringAutocompleteList';
|
|
7
6
|
import { LapisUrlContext } from '../LapisUrlContext';
|
|
8
7
|
import { TextInputChangedEvent } from './TextInputChangedEvent';
|
|
8
|
+
import { lapisFilterSchema } 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 { NoDataDisplay } from '../components/no-data-display';
|
|
12
13
|
import { ResizeContainer } from '../components/resize-container';
|
|
13
14
|
import { useQuery } from '../useQuery';
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
+
const textSelectorPropsSchema = z.object({
|
|
16
17
|
lapisField: z.string().min(1),
|
|
17
18
|
placeholderText: z.string().optional(),
|
|
18
|
-
|
|
19
|
+
value: z.string().optional(),
|
|
19
20
|
});
|
|
20
|
-
|
|
21
|
+
const textInputInnerPropsSchema = textSelectorPropsSchema.extend({ lapisFilter: lapisFilterSchema });
|
|
21
22
|
const textInputPropsSchema = textInputInnerPropsSchema.extend({
|
|
22
23
|
width: z.string(),
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
export type TextInputInnerProps = z.infer<typeof textInputInnerPropsSchema>;
|
|
26
27
|
export type TextInputProps = z.infer<typeof textInputPropsSchema>;
|
|
28
|
+
type TextSelectorProps = z.infer<typeof textSelectorPropsSchema>;
|
|
27
29
|
|
|
28
30
|
export const TextInput: FunctionComponent<TextInputProps> = (props) => {
|
|
29
31
|
const { width, ...innerProps } = props;
|
|
@@ -38,10 +40,18 @@ export const TextInput: FunctionComponent<TextInputProps> = (props) => {
|
|
|
38
40
|
);
|
|
39
41
|
};
|
|
40
42
|
|
|
41
|
-
const TextInputInner: FunctionComponent<TextInputInnerProps> = ({
|
|
43
|
+
const TextInputInner: FunctionComponent<TextInputInnerProps> = ({
|
|
44
|
+
value,
|
|
45
|
+
lapisField,
|
|
46
|
+
placeholderText,
|
|
47
|
+
lapisFilter,
|
|
48
|
+
}) => {
|
|
42
49
|
const lapis = useContext(LapisUrlContext);
|
|
43
50
|
|
|
44
|
-
const { data, error, isLoading } = useQuery(
|
|
51
|
+
const { data, error, isLoading } = useQuery(
|
|
52
|
+
() => fetchStringAutocompleteList({ lapis, field: lapisField, lapisFilter }),
|
|
53
|
+
[lapisField, lapis, lapisFilter],
|
|
54
|
+
);
|
|
45
55
|
|
|
46
56
|
if (isLoading) {
|
|
47
57
|
return <LoadingDisplay />;
|
|
@@ -55,146 +65,35 @@ const TextInputInner: FunctionComponent<TextInputInnerProps> = ({ initialValue,
|
|
|
55
65
|
return <NoDataDisplay />;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
return
|
|
59
|
-
<TextSelector
|
|
60
|
-
lapisField={lapisField}
|
|
61
|
-
initialValue={initialValue}
|
|
62
|
-
placeholderText={placeholderText}
|
|
63
|
-
data={data}
|
|
64
|
-
/>
|
|
65
|
-
);
|
|
68
|
+
return <TextSelector lapisField={lapisField} value={value} placeholderText={placeholderText} data={data} />;
|
|
66
69
|
};
|
|
67
70
|
|
|
68
71
|
const TextSelector = ({
|
|
69
72
|
lapisField,
|
|
70
|
-
|
|
73
|
+
value,
|
|
71
74
|
placeholderText,
|
|
72
75
|
data,
|
|
73
|
-
}:
|
|
76
|
+
}: TextSelectorProps & {
|
|
74
77
|
data: string[];
|
|
75
78
|
}) => {
|
|
76
|
-
const [items, setItems] = useState(data.filter((item) => filterByInputValue(item, initialValue)));
|
|
77
|
-
|
|
78
|
-
const divRef = useRef<HTMLDivElement>(null);
|
|
79
|
-
|
|
80
|
-
const shadowRoot = divRef.current?.shadowRoot ?? undefined;
|
|
81
|
-
|
|
82
|
-
const environment =
|
|
83
|
-
shadowRoot !== undefined
|
|
84
|
-
? {
|
|
85
|
-
addEventListener: window.addEventListener.bind(window),
|
|
86
|
-
removeEventListener: window.removeEventListener.bind(window),
|
|
87
|
-
document: shadowRoot.ownerDocument,
|
|
88
|
-
Node: window.Node,
|
|
89
|
-
}
|
|
90
|
-
: undefined;
|
|
91
|
-
|
|
92
|
-
function filterByInputValue(item: string, inputValue: string | undefined | null) {
|
|
93
|
-
if (inputValue === undefined || inputValue === null || inputValue === '') {
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
return item?.toLowerCase().includes(inputValue?.toLowerCase() || '');
|
|
97
|
-
}
|
|
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(data.filter((item) => filterByInputValue(item, inputValue)));
|
|
114
|
-
},
|
|
115
|
-
onSelectedItemChange({ selectedItem }) {
|
|
116
|
-
if (selectedItem !== null) {
|
|
117
|
-
divRef.current?.dispatchEvent(new TextInputChangedEvent({ [lapisField]: selectedItem }));
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
items,
|
|
121
|
-
itemToString(item) {
|
|
122
|
-
return item ?? '';
|
|
123
|
-
},
|
|
124
|
-
initialSelectedItem: initialValue,
|
|
125
|
-
environment,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
const onInputBlur = () => {
|
|
129
|
-
if (inputValue === '') {
|
|
130
|
-
divRef.current?.dispatchEvent(new TextInputChangedEvent({ [lapisField]: undefined }));
|
|
131
|
-
selectItem(null);
|
|
132
|
-
} else if (inputValue !== selectedItem) {
|
|
133
|
-
setInputValue(selectedItem ?? '');
|
|
134
|
-
}
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const clearInput = () => {
|
|
138
|
-
divRef.current?.dispatchEvent(new TextInputChangedEvent({ [lapisField]: undefined }));
|
|
139
|
-
selectItem(null);
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const buttonRef = useRef(null);
|
|
143
|
-
|
|
144
79
|
return (
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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} py-2 px-3 shadow-sm flex flex-col`}
|
|
191
|
-
key={item}
|
|
192
|
-
{...getItemProps({ item, index })}
|
|
193
|
-
>
|
|
194
|
-
<span>{item}</span>
|
|
195
|
-
</li>
|
|
196
|
-
))}
|
|
197
|
-
</ul>
|
|
198
|
-
</div>
|
|
80
|
+
<DownshiftCombobox
|
|
81
|
+
allItems={data}
|
|
82
|
+
value={value}
|
|
83
|
+
filterItemsByInputValue={filterByInputValue}
|
|
84
|
+
createEvent={(item: string | null) => new TextInputChangedEvent({ [lapisField]: item ?? undefined })}
|
|
85
|
+
itemToString={(item: string | undefined | null) => item ?? ''}
|
|
86
|
+
placeholderText={placeholderText}
|
|
87
|
+
formatItemInList={(item: string) => {
|
|
88
|
+
return <span>{item}</span>;
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
199
91
|
);
|
|
200
92
|
};
|
|
93
|
+
|
|
94
|
+
function filterByInputValue(item: string, inputValue: string | undefined | null) {
|
|
95
|
+
if (inputValue === undefined || inputValue === null || inputValue === '') {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return item?.toLowerCase().includes(inputValue?.toLowerCase() || '');
|
|
99
|
+
}
|