@genspectrum/dashboard-components 0.19.0 → 0.19.2

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/dist/util.d.ts CHANGED
@@ -918,7 +918,7 @@ declare global {
918
918
 
919
919
  declare global {
920
920
  interface HTMLElementTagNameMap {
921
- 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
921
+ 'gs-mutations-component': MutationsComponent;
922
922
  }
923
923
  }
924
924
 
@@ -926,7 +926,7 @@ declare global {
926
926
  declare global {
927
927
  namespace JSX {
928
928
  interface IntrinsicElements {
929
- 'gs-prevalence-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
929
+ 'gs-mutations-component': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
930
930
  }
931
931
  }
932
932
  }
@@ -934,7 +934,7 @@ declare global {
934
934
 
935
935
  declare global {
936
936
  interface HTMLElementTagNameMap {
937
- 'gs-mutations-component': MutationsComponent;
937
+ 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
938
938
  }
939
939
  }
940
940
 
@@ -942,7 +942,7 @@ declare global {
942
942
  declare global {
943
943
  namespace JSX {
944
944
  interface IntrinsicElements {
945
- 'gs-mutations-component': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
945
+ 'gs-prevalence-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
946
946
  }
947
947
  }
948
948
  }
@@ -1081,10 +1081,11 @@ declare global {
1081
1081
 
1082
1082
  declare global {
1083
1083
  interface HTMLElementTagNameMap {
1084
- 'gs-text-filter': TextFilterComponent;
1084
+ 'gs-date-range-filter': DateRangeFilterComponent;
1085
1085
  }
1086
1086
  interface HTMLElementEventMap {
1087
- 'gs-text-filter-changed': TextFilterChangedEvent;
1087
+ 'gs-date-range-filter-changed': CustomEvent<Record<string, string>>;
1088
+ 'gs-date-range-option-changed': DateRangeOptionChangedEvent;
1088
1089
  }
1089
1090
  }
1090
1091
 
@@ -1092,7 +1093,7 @@ declare global {
1092
1093
  declare global {
1093
1094
  namespace JSX {
1094
1095
  interface IntrinsicElements {
1095
- 'gs-text-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1096
+ 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1096
1097
  }
1097
1098
  }
1098
1099
  }
@@ -1100,11 +1101,10 @@ declare global {
1100
1101
 
1101
1102
  declare global {
1102
1103
  interface HTMLElementTagNameMap {
1103
- 'gs-date-range-filter': DateRangeFilterComponent;
1104
+ 'gs-text-filter': TextFilterComponent;
1104
1105
  }
1105
1106
  interface HTMLElementEventMap {
1106
- 'gs-date-range-filter-changed': CustomEvent<Record<string, string>>;
1107
- 'gs-date-range-option-changed': DateRangeOptionChangedEvent;
1107
+ 'gs-text-filter-changed': TextFilterChangedEvent;
1108
1108
  }
1109
1109
  }
1110
1110
 
@@ -1112,7 +1112,7 @@ declare global {
1112
1112
  declare global {
1113
1113
  namespace JSX {
1114
1114
  interface IntrinsicElements {
1115
- 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1115
+ 'gs-text-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1116
1116
  }
1117
1117
  }
1118
1118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genspectrum/dashboard-components",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "GenSpectrum web components for building dashboards",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -1,6 +1,6 @@
1
1
  import { useCombobox } from 'downshift/preact';
2
2
  import { type ComponentChild } from 'preact';
3
- import { useMemo, useRef, useState } from 'preact/hooks';
3
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
4
4
 
5
5
  import { DeleteIcon } from '../shared/icons/DeleteIcon';
6
6
 
@@ -15,7 +15,7 @@ export function DownshiftCombobox<Item>({
15
15
  inputClassName = '',
16
16
  }: {
17
17
  allItems: Item[];
18
- value?: Item | null;
18
+ value: Item | null;
19
19
  filterItemsByInputValue: (item: Item, value: string) => boolean;
20
20
  createEvent: (item: Item | null) => CustomEvent;
21
21
  itemToString: (item: Item | undefined | null) => string;
@@ -23,12 +23,25 @@ export function DownshiftCombobox<Item>({
23
23
  formatItemInList: (item: Item) => ComponentChild;
24
24
  inputClassName?: string;
25
25
  }) {
26
- const [itemsFilter, setItemsFilter] = useState(itemToString(value));
26
+ const [selectedItem, setSelectedItem] = useState<Item | null>(() => value);
27
+ const [itemsFilter, setItemsFilter] = useState(() => itemToString(selectedItem));
28
+
29
+ useEffect(() => {
30
+ setSelectedItem(value);
31
+ setItemsFilter(itemToString(value));
32
+ }, [itemToString, value]);
33
+
27
34
  const items = useMemo(
28
35
  () => allItems.filter((item) => filterItemsByInputValue(item, itemsFilter)),
29
36
  [allItems, filterItemsByInputValue, itemsFilter],
30
37
  );
31
38
  const divRef = useRef<HTMLDivElement>(null);
39
+ const [inputIsInvalid, setInputIsInvalid] = useState(false);
40
+
41
+ const selectItem = (item: Item | null) => {
42
+ setSelectedItem(item);
43
+ divRef.current?.dispatchEvent(createEvent(item));
44
+ };
32
45
 
33
46
  const shadowRoot = divRef.current?.shadowRoot ?? undefined;
34
47
 
@@ -49,39 +62,41 @@ export function DownshiftCombobox<Item>({
49
62
  getInputProps,
50
63
  highlightedIndex,
51
64
  getItemProps,
52
- selectedItem,
53
65
  inputValue,
54
- selectItem,
55
- setInputValue,
56
66
  closeMenu,
57
67
  } = useCombobox({
58
68
  onInputValueChange({ inputValue }) {
59
- setItemsFilter(inputValue);
69
+ setInputIsInvalid(false);
70
+ setItemsFilter(inputValue.trim());
60
71
  },
61
72
  onSelectedItemChange({ selectedItem }) {
62
- if (selectedItem !== null) {
63
- divRef.current?.dispatchEvent(createEvent(selectedItem));
64
- }
73
+ selectItem(selectedItem);
65
74
  },
66
75
  items,
67
76
  itemToString(item) {
68
77
  return itemToString(item);
69
78
  },
70
- selectedItem: value,
79
+ selectedItem,
71
80
  environment,
72
81
  });
73
82
 
74
83
  const onInputBlur = () => {
75
84
  if (inputValue === '') {
76
- divRef.current?.dispatchEvent(createEvent(null));
77
85
  selectItem(null);
78
- } else if (inputValue !== itemToString(selectedItem)) {
79
- setInputValue(itemToString(selectedItem) || '');
86
+ return;
87
+ }
88
+
89
+ const trimmedInput = inputValue.trim();
90
+ const matchingItem = items.find((item) => itemToString(item) === trimmedInput);
91
+ if (matchingItem !== undefined) {
92
+ selectItem(matchingItem);
93
+ return;
80
94
  }
95
+
96
+ setInputIsInvalid(true);
81
97
  };
82
98
 
83
99
  const clearInput = () => {
84
- divRef.current?.dispatchEvent(createEvent(null));
85
100
  selectItem(null);
86
101
  };
87
102
 
@@ -91,7 +106,7 @@ export function DownshiftCombobox<Item>({
91
106
  <div ref={divRef} className={'relative w-full'}>
92
107
  <div className='w-full flex flex-col gap-1'>
93
108
  <div
94
- className={`flex gap-0.5 input min-w-32 w-full ${inputClassName}`}
109
+ className={`flex gap-0.5 input min-w-32 w-full ${inputClassName} ${inputIsInvalid ? 'input-error' : ''}`}
95
110
  onBlur={(event) => {
96
111
  if (event.relatedTarget != buttonRef.current) {
97
112
  closeMenu();
@@ -77,16 +77,16 @@ const LineageSelector = ({
77
77
  allItems={data}
78
78
  value={value}
79
79
  filterItemsByInputValue={filterByInputValue}
80
- createEvent={(item: string | null) => new LineageFilterChangedEvent({ [lapisField]: item ?? undefined })}
81
- itemToString={(item: string | undefined | null) => item ?? ''}
80
+ createEvent={(item) => new LineageFilterChangedEvent({ [lapisField]: item ?? undefined })}
81
+ itemToString={(item) => item ?? ''}
82
82
  placeholderText={placeholderText}
83
83
  formatItemInList={(item: string) => item}
84
84
  />
85
85
  );
86
86
  };
87
87
 
88
- function filterByInputValue(item: string, inputValue: string | undefined | null) {
89
- if (inputValue === undefined || inputValue === null || inputValue === '') {
88
+ function filterByInputValue(item: string, inputValue: string | null) {
89
+ if (inputValue === null || inputValue === '') {
90
90
  return true;
91
91
  }
92
92
  return item?.toLowerCase().includes(inputValue?.toLowerCase() || '');
@@ -87,12 +87,10 @@ const LocationSelector = ({
87
87
  return (
88
88
  <DownshiftCombobox
89
89
  allItems={allItems}
90
- value={selectedItem}
90
+ value={selectedItem ?? null}
91
91
  filterItemsByInputValue={filterByInputValue}
92
- createEvent={(item: SelectItem | null) =>
93
- new LocationChangedEvent(item?.lapisFilter ?? emptyLocationFilter(fields))
94
- }
95
- itemToString={(item: SelectItem | undefined | null) => item?.label ?? ''}
92
+ createEvent={(item) => new LocationChangedEvent(item?.lapisFilter ?? emptyLocationFilter(fields))}
93
+ itemToString={(item) => item?.label ?? ''}
96
94
  placeholderText={placeholderText}
97
95
  formatItemInList={(item: SelectItem) => (
98
96
  <>
@@ -107,8 +105,8 @@ const LocationSelector = ({
107
105
  );
108
106
  };
109
107
 
110
- function filterByInputValue(item: SelectItem, inputValue: string | undefined | null) {
111
- if (inputValue === undefined || inputValue === null) {
108
+ function filterByInputValue(item: SelectItem, inputValue: string | null) {
109
+ if (inputValue === null) {
112
110
  return true;
113
111
  }
114
112
  return (
@@ -47,7 +47,7 @@ export function getFilteredMutationOverTimeData({
47
47
  return true;
48
48
  }
49
49
 
50
- if (displayMutationsSet !== null && !displayMutationsSet.has(entry.mutation.code)) {
50
+ if (displayMutationsSet !== null && !displayMutationsSet.has(entry.mutation.code.toUpperCase())) {
51
51
  return true;
52
52
  }
53
53
 
@@ -1,5 +1,5 @@
1
1
  import { type Meta, type StoryObj } from '@storybook/preact';
2
- import { expect, fireEvent, fn, waitFor, within } from '@storybook/test';
2
+ import { expect, fireEvent, fn, userEvent, waitFor, within } from '@storybook/test';
3
3
 
4
4
  import data from './__mockData__/aggregated_hosts.json';
5
5
  import { TextFilter, type TextFilterProps } from './text-filter';
@@ -104,13 +104,73 @@ export const RemoveInitialValue: StoryObj<TextFilterProps> = {
104
104
  await step('Remove initial value', async () => {
105
105
  await fireEvent.click(canvas.getByRole('button', { name: 'clear selection' }));
106
106
 
107
- await expect(changedListenerMock).toHaveBeenCalledWith(
108
- expect.objectContaining({
109
- detail: {
110
- host: undefined,
111
- },
112
- }),
113
- );
107
+ await waitFor(async () => {
108
+ await expect(changedListenerMock).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ detail: {
111
+ host: undefined,
112
+ },
113
+ }),
114
+ );
115
+ });
116
+ });
117
+ },
118
+ };
119
+
120
+ export const KeepsPartialInputInInputField: StoryObj<TextFilterProps> = {
121
+ ...Default,
122
+ render: (args) => (
123
+ <>
124
+ <button data-testid='focusHelper'>Focus helper</button>
125
+ <LapisUrlContextProvider value={LAPIS_URL}>
126
+ <TextFilter {...args} />
127
+ </LapisUrlContextProvider>
128
+ </>
129
+ ),
130
+ args: {
131
+ ...Default.args,
132
+ value: '',
133
+ },
134
+ play: async ({ canvasElement, step }) => {
135
+ const canvas = within(canvasElement);
136
+
137
+ const changedListenerMock = fn();
138
+ await step('Setup event listener mock', () => {
139
+ canvasElement.addEventListener('gs-text-filter-changed', changedListenerMock);
140
+ });
141
+ const inputField = () => canvas.getByPlaceholderText('Enter a host name', { exact: false });
142
+ async function typeAndBlur(input: string) {
143
+ await userEvent.click(inputField());
144
+ await userEvent.type(inputField(), input);
145
+ await userEvent.click(canvas.getByTestId('focusHelper'));
146
+ }
147
+
148
+ await waitFor(async () => {
149
+ await expect(inputField()).toHaveValue('');
150
+ });
151
+
152
+ await step('Type a value that it not valid yet and focus out', async () => {
153
+ await typeAndBlur('Homo sapi');
154
+ await waitFor(async () => {
155
+ await expect(inputField()).toHaveValue('Homo sapi');
156
+ });
157
+ await expect(changedListenerMock).not.toHaveBeenCalled();
158
+ });
159
+
160
+ await step('Complete the input value and expect an event', async () => {
161
+ await typeAndBlur('ens');
162
+ await waitFor(async () => {
163
+ await expect(inputField()).toHaveValue('Homo sapiens');
164
+ });
165
+ await waitFor(async () => {
166
+ await expect(changedListenerMock).toHaveBeenLastCalledWith(
167
+ expect.objectContaining({
168
+ detail: {
169
+ host: 'Homo sapiens',
170
+ },
171
+ }),
172
+ );
173
+ });
114
174
  });
115
175
  },
116
176
  };
@@ -85,12 +85,10 @@ const TextSelector = ({
85
85
  return (
86
86
  <DownshiftCombobox
87
87
  allItems={data}
88
- value={initialSelectedItem}
88
+ value={initialSelectedItem ?? null}
89
89
  filterItemsByInputValue={filterByInputValue}
90
- createEvent={(item: SelectItem | null) =>
91
- new TextFilterChangedEvent({ [lapisField]: item?.value ?? undefined })
92
- }
93
- itemToString={(item: SelectItem | undefined | null) => item?.value ?? ''}
90
+ createEvent={(item) => new TextFilterChangedEvent({ [lapisField]: item?.value ?? undefined })}
91
+ itemToString={(item) => item?.value ?? ''}
94
92
  placeholderText={placeholderText}
95
93
  formatItemInList={(item: SelectItem) => {
96
94
  return (
@@ -104,8 +102,8 @@ const TextSelector = ({
104
102
  );
105
103
  };
106
104
 
107
- function filterByInputValue(item: SelectItem, inputValue: string | undefined | null) {
108
- if (inputValue === undefined || inputValue === null || inputValue === '') {
105
+ function filterByInputValue(item: SelectItem, inputValue: string | null) {
106
+ if (inputValue === null || inputValue === '') {
109
107
  return true;
110
108
  }
111
109
  return item.value?.toLowerCase().includes(inputValue?.toLowerCase() || '');
@@ -1,4 +1,4 @@
1
- import { expect, fireEvent, fn, userEvent, waitFor } from '@storybook/test';
1
+ import { expect, fn, userEvent, waitFor } from '@storybook/test';
2
2
  import type { Meta, StoryObj } from '@storybook/web-components';
3
3
  import { html } from 'lit';
4
4
 
@@ -152,9 +152,9 @@ export const FiresEvents: StoryObj<Required<TextFilterProps>> = {
152
152
  });
153
153
 
154
154
  await step('Remove initial value', async () => {
155
- await fireEvent.click(canvas.getByRole('button', { name: 'clear selection' }));
155
+ await userEvent.click(canvas.getByRole('button', { name: 'clear selection' }));
156
156
 
157
- await expect(listenerMock).toHaveBeenCalledWith(
157
+ await expect(listenerMock).toHaveBeenLastCalledWith(
158
158
  expect.objectContaining({
159
159
  detail: {
160
160
  host: undefined,
@@ -162,13 +162,6 @@ export const FiresEvents: StoryObj<Required<TextFilterProps>> = {
162
162
  }),
163
163
  );
164
164
  });
165
-
166
- await step('Empty input', async () => {
167
- inputField().blur();
168
- await expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
169
- host: undefined,
170
- });
171
- });
172
165
  },
173
166
  args: {
174
167
  ...Default.args,