@genspectrum/dashboard-components 0.16.1 → 0.16.3

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.
Files changed (41) hide show
  1. package/custom-elements.json +72 -7
  2. package/dist/assets/mutationOverTimeWorker-DJcZmEH9.js.map +1 -0
  3. package/dist/components.d.ts +63 -25
  4. package/dist/components.js +310 -151
  5. package/dist/components.js.map +1 -1
  6. package/dist/style.css +16 -0
  7. package/dist/util.d.ts +25 -25
  8. package/package.json +4 -2
  9. package/src/preact/MutationAnnotationsContext.spec.tsx +58 -0
  10. package/src/preact/MutationAnnotationsContext.tsx +72 -0
  11. package/src/preact/components/annotated-mutation.stories.tsx +163 -0
  12. package/src/preact/components/annotated-mutation.tsx +80 -0
  13. package/src/preact/components/downshift-combobox.tsx +6 -4
  14. package/src/preact/components/error-display.tsx +9 -9
  15. package/src/preact/components/info.tsx +6 -13
  16. package/src/preact/components/modal.stories.tsx +7 -19
  17. package/src/preact/components/modal.tsx +35 -4
  18. package/src/preact/mutations/mutations-table.tsx +14 -2
  19. package/src/preact/mutations/mutations.stories.tsx +40 -2
  20. package/src/preact/mutations/mutations.tsx +1 -0
  21. package/src/preact/mutationsOverTime/mutations-over-time-grid.tsx +19 -8
  22. package/src/preact/mutationsOverTime/mutations-over-time.stories.tsx +34 -5
  23. package/src/preact/mutationsOverTime/mutations-over-time.tsx +13 -1
  24. package/src/preact/sequencesByLocation/sequences-by-location-map.tsx +28 -30
  25. package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.tsx +7 -2
  26. package/src/web-components/gs-app.spec-d.ts +10 -0
  27. package/src/web-components/gs-app.stories.ts +24 -6
  28. package/src/web-components/gs-app.ts +17 -0
  29. package/src/web-components/mutation-annotations-context.ts +16 -0
  30. package/src/web-components/visualization/gs-mutations-over-time.stories.ts +18 -1
  31. package/src/web-components/visualization/gs-mutations-over-time.tsx +22 -11
  32. package/src/web-components/visualization/gs-mutations.stories.ts +18 -1
  33. package/src/web-components/visualization/gs-mutations.tsx +20 -9
  34. package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.stories.ts +11 -1
  35. package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.tsx +18 -7
  36. package/standalone-bundle/assets/mutationOverTimeWorker-CERZSdcA.js.map +1 -0
  37. package/standalone-bundle/dashboard-components.js +8094 -7963
  38. package/standalone-bundle/dashboard-components.js.map +1 -1
  39. package/standalone-bundle/style.css +1 -1
  40. package/dist/assets/mutationOverTimeWorker-BL50C-yi.js.map +0 -1
  41. package/standalone-bundle/assets/mutationOverTimeWorker-CFB5-Mdk.js.map +0 -1
package/dist/style.css CHANGED
@@ -1073,6 +1073,12 @@ html {
1073
1073
  --tw-bg-opacity: 1;
1074
1074
  background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));
1075
1075
  }
1076
+
1077
+ .table-zebra tr.hover:hover,
1078
+ .table-zebra tr.hover:nth-child(even):hover {
1079
+ --tw-bg-opacity: 1;
1080
+ background-color: var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));
1081
+ }
1076
1082
  }
1077
1083
  .btn {
1078
1084
  display: inline-flex;
@@ -3187,6 +3193,9 @@ input.tab:checked + .tab-content,
3187
3193
  .mt-4 {
3188
3194
  margin-top: 1rem;
3189
3195
  }
3196
+ .block {
3197
+ display: block;
3198
+ }
3190
3199
  .inline {
3191
3200
  display: inline;
3192
3201
  }
@@ -3492,6 +3501,9 @@ input.tab:checked + .tab-content,
3492
3501
  .font-medium {
3493
3502
  font-weight: 500;
3494
3503
  }
3504
+ .font-normal {
3505
+ font-weight: 400;
3506
+ }
3495
3507
  .font-semibold {
3496
3508
  font-weight: 600;
3497
3509
  }
@@ -3522,6 +3534,10 @@ input.tab:checked + .tab-content,
3522
3534
  --tw-text-opacity: 1;
3523
3535
  color: rgb(115 115 115 / var(--tw-text-opacity, 1));
3524
3536
  }
3537
+ .text-red-600 {
3538
+ --tw-text-opacity: 1;
3539
+ color: rgb(220 38 38 / var(--tw-text-opacity, 1));
3540
+ }
3525
3541
  .text-red-700 {
3526
3542
  --tw-text-opacity: 1;
3527
3543
  color: rgb(185 28 28 / var(--tw-text-opacity, 1));
package/dist/util.d.ts CHANGED
@@ -880,7 +880,7 @@ declare global {
880
880
 
881
881
  declare global {
882
882
  interface HTMLElementTagNameMap {
883
- 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
883
+ 'gs-relative-growth-advantage': RelativeGrowthAdvantageComponent;
884
884
  }
885
885
  }
886
886
 
@@ -888,7 +888,7 @@ declare global {
888
888
  declare global {
889
889
  namespace JSX {
890
890
  interface IntrinsicElements {
891
- 'gs-prevalence-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
891
+ 'gs-relative-growth-advantage': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
892
892
  }
893
893
  }
894
894
  }
@@ -896,7 +896,7 @@ declare global {
896
896
 
897
897
  declare global {
898
898
  interface HTMLElementTagNameMap {
899
- 'gs-aggregate': AggregateComponent;
899
+ 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
900
900
  }
901
901
  }
902
902
 
@@ -904,7 +904,7 @@ declare global {
904
904
  declare global {
905
905
  namespace JSX {
906
906
  interface IntrinsicElements {
907
- 'gs-aggregate': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
907
+ 'gs-prevalence-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
908
908
  }
909
909
  }
910
910
  }
@@ -912,7 +912,7 @@ declare global {
912
912
 
913
913
  declare global {
914
914
  interface HTMLElementTagNameMap {
915
- 'gs-relative-growth-advantage': RelativeGrowthAdvantageComponent;
915
+ 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
916
916
  }
917
917
  }
918
918
 
@@ -920,7 +920,7 @@ declare global {
920
920
  declare global {
921
921
  namespace JSX {
922
922
  interface IntrinsicElements {
923
- 'gs-relative-growth-advantage': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
923
+ 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
924
924
  }
925
925
  }
926
926
  }
@@ -928,7 +928,7 @@ declare global {
928
928
 
929
929
  declare global {
930
930
  interface HTMLElementTagNameMap {
931
- 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
931
+ 'gs-mutations-over-time': MutationsOverTimeComponent;
932
932
  }
933
933
  }
934
934
 
@@ -936,7 +936,7 @@ declare global {
936
936
  declare global {
937
937
  namespace JSX {
938
938
  interface IntrinsicElements {
939
- 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
939
+ 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
940
940
  }
941
941
  }
942
942
  }
@@ -944,7 +944,7 @@ declare global {
944
944
 
945
945
  declare global {
946
946
  interface HTMLElementTagNameMap {
947
- 'gs-mutations-over-time': MutationsOverTimeComponent;
947
+ 'gs-aggregate': AggregateComponent;
948
948
  }
949
949
  }
950
950
 
@@ -952,7 +952,7 @@ declare global {
952
952
  declare global {
953
953
  namespace JSX {
954
954
  interface IntrinsicElements {
955
- 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
955
+ 'gs-aggregate': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
956
956
  }
957
957
  }
958
958
  }
@@ -960,7 +960,7 @@ declare global {
960
960
 
961
961
  declare global {
962
962
  interface HTMLElementTagNameMap {
963
- 'gs-sequences-by-location': SequencesByLocationComponent;
963
+ 'gs-statistics': StatisticsComponent;
964
964
  }
965
965
  }
966
966
 
@@ -968,7 +968,7 @@ declare global {
968
968
  declare global {
969
969
  namespace JSX {
970
970
  interface IntrinsicElements {
971
- 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
971
+ 'gs-statistics': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
972
972
  }
973
973
  }
974
974
  }
@@ -976,7 +976,10 @@ declare global {
976
976
 
977
977
  declare global {
978
978
  interface HTMLElementTagNameMap {
979
- 'gs-statistics': StatisticsComponent;
979
+ 'gs-location-filter': LocationFilterComponent;
980
+ }
981
+ interface HTMLElementEventMap {
982
+ 'gs-location-changed': LocationChangedEvent;
980
983
  }
981
984
  }
982
985
 
@@ -984,7 +987,7 @@ declare global {
984
987
  declare global {
985
988
  namespace JSX {
986
989
  interface IntrinsicElements {
987
- 'gs-statistics': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
990
+ 'gs-location-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
988
991
  }
989
992
  }
990
993
  }
@@ -992,7 +995,7 @@ declare global {
992
995
 
993
996
  declare global {
994
997
  interface HTMLElementTagNameMap {
995
- 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
998
+ 'gs-sequences-by-location': SequencesByLocationComponent;
996
999
  }
997
1000
  }
998
1001
 
@@ -1000,7 +1003,7 @@ declare global {
1000
1003
  declare global {
1001
1004
  namespace JSX {
1002
1005
  interface IntrinsicElements {
1003
- 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1006
+ 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1004
1007
  }
1005
1008
  }
1006
1009
  }
@@ -1008,11 +1011,7 @@ declare global {
1008
1011
 
1009
1012
  declare global {
1010
1013
  interface HTMLElementTagNameMap {
1011
- 'gs-date-range-filter': DateRangeFilterComponent;
1012
- }
1013
- interface HTMLElementEventMap {
1014
- 'gs-date-range-filter-changed': CustomEvent<Record<string, string>>;
1015
- 'gs-date-range-option-changed': DateRangeOptionChangedEvent;
1014
+ 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1016
1015
  }
1017
1016
  }
1018
1017
 
@@ -1020,7 +1019,7 @@ declare global {
1020
1019
  declare global {
1021
1020
  namespace JSX {
1022
1021
  interface IntrinsicElements {
1023
- 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1022
+ 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1024
1023
  }
1025
1024
  }
1026
1025
  }
@@ -1028,10 +1027,11 @@ declare global {
1028
1027
 
1029
1028
  declare global {
1030
1029
  interface HTMLElementTagNameMap {
1031
- 'gs-location-filter': LocationFilterComponent;
1030
+ 'gs-date-range-filter': DateRangeFilterComponent;
1032
1031
  }
1033
1032
  interface HTMLElementEventMap {
1034
- 'gs-location-changed': LocationChangedEvent;
1033
+ 'gs-date-range-filter-changed': CustomEvent<Record<string, string>>;
1034
+ 'gs-date-range-option-changed': DateRangeOptionChangedEvent;
1035
1035
  }
1036
1036
  }
1037
1037
 
@@ -1039,7 +1039,7 @@ declare global {
1039
1039
  declare global {
1040
1040
  namespace JSX {
1041
1041
  interface IntrinsicElements {
1042
- 'gs-location-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1042
+ 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1043
1043
  }
1044
1044
  }
1045
1045
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genspectrum/dashboard-components",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "GenSpectrum web components for building dashboards",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -51,7 +51,7 @@
51
51
  "scripts": {
52
52
  "build": "vite --config vite.release.config.ts build && npm run generate-manifest && vite --config vite.release-standalone.config.ts build",
53
53
  "build-and-pack": "npm run build && npm pack | xargs -I {} cp {} genspectrum-dashboard-components.tgz",
54
- "test": "vitest",
54
+ "test": "vitest --typecheck",
55
55
  "lint": "npm run lint:lit-analyzer && npm run lint:eslint",
56
56
  "lint:eslint": "eslint 'src/**/*.{ts,tsx}'",
57
57
  "lint:lit-analyzer": "lit-analyzer",
@@ -115,6 +115,7 @@
115
115
  "@storybook/web-components": "^8.0.9",
116
116
  "@storybook/web-components-vite": "^8.0.9",
117
117
  "@tailwindcss/container-queries": "^0.1.1",
118
+ "@testing-library/preact": "^3.2.4",
118
119
  "@types/geojson": "^7946.0.15",
119
120
  "@types/leaflet": "^1.9.15",
120
121
  "@types/node": "^22.0.0",
@@ -130,6 +131,7 @@
130
131
  "eslint-plugin-import": "^2.29.1",
131
132
  "eslint-plugin-jest": "^28.2.0",
132
133
  "eslint-plugin-storybook": "^0.11.0",
134
+ "happy-dom": "^17.1.1",
133
135
  "http-server": "^14.1.1",
134
136
  "lit-analyzer": "^2.0.3",
135
137
  "msw": "^2.2.14",
@@ -0,0 +1,58 @@
1
+ import { renderHook } from '@testing-library/preact';
2
+ import { type FunctionalComponent } from 'preact';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { MutationAnnotationsContextProvider, useMutationAnnotationsProvider } from './MutationAnnotationsContext';
6
+ import { type MutationAnnotations } from '../web-components/mutation-annotations-context';
7
+
8
+ describe('useMutationAnnotation', () => {
9
+ const mockAnnotations: MutationAnnotations = [
10
+ {
11
+ name: 'Annotation 1',
12
+ description: 'Description 1',
13
+ symbol: 'A1',
14
+ nucleotideMutations: ['A123', 'A456'],
15
+ aminoAcidMutations: ['B123'],
16
+ },
17
+ {
18
+ name: 'Annotation 2',
19
+ description: 'Description 2',
20
+ symbol: 'A2',
21
+ nucleotideMutations: ['A456', 'A789'],
22
+ aminoAcidMutations: ['B456', 'B789'],
23
+ },
24
+ ];
25
+
26
+ const wrapper: FunctionalComponent = ({ children }) => (
27
+ <MutationAnnotationsContextProvider value={mockAnnotations}>{children}</MutationAnnotationsContextProvider>
28
+ );
29
+
30
+ function renderAnnotationsHook() {
31
+ const { result } = renderHook(() => useMutationAnnotationsProvider(), { wrapper });
32
+ return result.current;
33
+ }
34
+
35
+ it('should return the correct annotation for a given nucleotide mutation', () => {
36
+ const result = renderAnnotationsHook()('A123', 'nucleotide');
37
+
38
+ expect(result).toEqual([mockAnnotations[0]]);
39
+ });
40
+
41
+ it('should return the correct annotations if multiple contain a mutation', () => {
42
+ const result = renderAnnotationsHook()('A456', 'nucleotide');
43
+
44
+ expect(result).toEqual([mockAnnotations[0], mockAnnotations[1]]);
45
+ });
46
+
47
+ it('should return undefined for a non-existent mutation code', () => {
48
+ const result = renderAnnotationsHook()('NON_EXISTENT', 'nucleotide');
49
+
50
+ expect(result).toBeUndefined();
51
+ });
52
+
53
+ it('should return the correct mutation annotation for amino acid mutations', () => {
54
+ const result = renderAnnotationsHook()('B456', 'amino acid');
55
+
56
+ expect(result).toEqual([mockAnnotations[1]]);
57
+ });
58
+ });
@@ -0,0 +1,72 @@
1
+ import { type ComponentProps, createContext, type FunctionalComponent } from 'preact';
2
+ import { useContext, useMemo } from 'preact/hooks';
3
+
4
+ import { type SequenceType } from '../types';
5
+ import {
6
+ type MutationAnnotation,
7
+ type MutationAnnotations,
8
+ mutationAnnotationsSchema,
9
+ } from '../web-components/mutation-annotations-context';
10
+ import { ErrorDisplay } from './components/error-display';
11
+ import { ResizeContainer } from './components/resize-container';
12
+
13
+ const MutationAnnotationsContext = createContext<Record<SequenceType, Map<string, MutationAnnotations>>>({
14
+ nucleotide: new Map(),
15
+ 'amino acid': new Map(),
16
+ });
17
+
18
+ export const MutationAnnotationsContextProvider: FunctionalComponent<
19
+ Omit<ComponentProps<typeof MutationAnnotationsContext.Provider>, 'value'> & { value: MutationAnnotations }
20
+ > = ({ value, children }) => {
21
+ const parseResult = useMemo(() => {
22
+ const parseResult = mutationAnnotationsSchema.safeParse(value);
23
+
24
+ if (!parseResult.success) {
25
+ return parseResult;
26
+ }
27
+
28
+ const nucleotideMap = new Map<string, MutationAnnotations>();
29
+ const aminoAcidMap = new Map<string, MutationAnnotations>();
30
+
31
+ value.forEach((annotation) => {
32
+ new Set(annotation.nucleotideMutations).forEach((code) => {
33
+ addAnnotationToMap(nucleotideMap, code, annotation);
34
+ });
35
+ new Set(annotation.aminoAcidMutations).forEach((code) => {
36
+ addAnnotationToMap(aminoAcidMap, code, annotation);
37
+ });
38
+ });
39
+
40
+ return {
41
+ success: true as const,
42
+ value: {
43
+ nucleotide: nucleotideMap,
44
+ 'amino acid': aminoAcidMap,
45
+ },
46
+ };
47
+ }, [value]);
48
+
49
+ if (!parseResult.success) {
50
+ return (
51
+ <ResizeContainer size={{ width: '100%' }}>
52
+ <ErrorDisplay error={parseResult.error} layout='vertical' />
53
+ </ResizeContainer>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <MutationAnnotationsContext.Provider value={parseResult.value}>{children}</MutationAnnotationsContext.Provider>
59
+ );
60
+ };
61
+
62
+ function addAnnotationToMap(map: Map<string, MutationAnnotations>, code: string, annotation: MutationAnnotation) {
63
+ const oldAnnotations = map.get(code.toUpperCase()) ?? [];
64
+ map.set(code.toUpperCase(), [...oldAnnotations, annotation]);
65
+ }
66
+
67
+ export function useMutationAnnotationsProvider() {
68
+ const mutationAnnotations = useContext(MutationAnnotationsContext);
69
+
70
+ return (mutationCode: string, sequenceType: SequenceType) =>
71
+ mutationAnnotations[sequenceType].get(mutationCode.toUpperCase());
72
+ }
@@ -0,0 +1,163 @@
1
+ import { type Meta, type StoryObj } from '@storybook/preact';
2
+ import { expect, userEvent, waitFor, within } from '@storybook/test';
3
+
4
+ import { AnnotatedMutation, type AnnotatedMutationProps } from './annotated-mutation';
5
+ import { type MutationAnnotations } from '../../web-components/mutation-annotations-context';
6
+ import { MutationAnnotationsContextProvider } from '../MutationAnnotationsContext';
7
+
8
+ const meta: Meta<AnnotatedMutationProps & { annotations: MutationAnnotations }> = {
9
+ title: 'Component/Annotated Mutation',
10
+ component: AnnotatedMutation,
11
+ parameters: { fetchMock: {} },
12
+ argTypes: {
13
+ annotations: { control: { type: 'object' } },
14
+ mutation: { control: { type: 'object' } },
15
+ sequenceType: {
16
+ options: ['nucleotide', 'amino acid'],
17
+ control: { type: 'radio' },
18
+ },
19
+ },
20
+ };
21
+
22
+ export default meta;
23
+
24
+ export const MutationWithoutAnnotationEntry: StoryObj<AnnotatedMutationProps & { annotations: MutationAnnotations }> = {
25
+ render: (args) => {
26
+ const { annotations, ...annotatedMutationsArgs } = args;
27
+
28
+ return (
29
+ <MutationAnnotationsContextProvider value={annotations}>
30
+ <AnnotatedMutation {...annotatedMutationsArgs} />
31
+ </MutationAnnotationsContextProvider>
32
+ );
33
+ },
34
+ args: {
35
+ mutation: {
36
+ type: 'substitution',
37
+ code: 'A23403G',
38
+ position: 23403,
39
+ valueAtReference: 'A',
40
+ substitutionValue: 'G',
41
+ },
42
+ sequenceType: 'nucleotide',
43
+ annotations: [
44
+ {
45
+ name: 'Test annotation',
46
+ description: 'This is a test annotation',
47
+ symbol: '*',
48
+ nucleotideMutations: ['123T'],
49
+ aminoAcidMutations: [],
50
+ },
51
+ ],
52
+ },
53
+ play: async ({ canvasElement }) => {
54
+ const canvas = within(canvasElement);
55
+
56
+ await waitFor(() => expect(canvas.getByText('A23403G')).toBeVisible());
57
+ await expect(getAnnotationIndicator(canvas)).not.toBeInTheDocument();
58
+ await expect(getAnnotationName(canvas)).not.toBeInTheDocument();
59
+ },
60
+ };
61
+
62
+ export const MutationWithAnnotationEntry: StoryObj<AnnotatedMutationProps & { annotations: MutationAnnotations }> = {
63
+ ...MutationWithoutAnnotationEntry,
64
+ args: {
65
+ ...MutationWithoutAnnotationEntry.args,
66
+ annotations: [
67
+ {
68
+ name: 'Test annotation',
69
+ description: 'This is a test annotation',
70
+ symbol: '*',
71
+ nucleotideMutations: ['A23403G'],
72
+ aminoAcidMutations: [],
73
+ },
74
+ ],
75
+ },
76
+ play: async ({ canvasElement }) => {
77
+ const canvas = within(canvasElement);
78
+
79
+ await waitFor(() => expect(canvas.getByText('A23403G')).toBeVisible());
80
+ await expect(getAnnotationIndicator(canvas)).toBeVisible();
81
+
82
+ await userEvent.click(canvas.getByText('A23403G'));
83
+ await waitFor(() => expect(getAnnotationName(canvas)).toBeVisible());
84
+ },
85
+ };
86
+
87
+ export const MutationWithMultipleAnnotationEntries: StoryObj<
88
+ AnnotatedMutationProps & { annotations: MutationAnnotations }
89
+ > = {
90
+ ...MutationWithoutAnnotationEntry,
91
+ args: {
92
+ ...MutationWithoutAnnotationEntry.args,
93
+ annotations: [
94
+ {
95
+ name: 'Test annotation',
96
+ description: 'This is a test annotation',
97
+ symbol: '*',
98
+ nucleotideMutations: ['A23403G'],
99
+ aminoAcidMutations: [],
100
+ },
101
+ {
102
+ name: 'Another test annotation',
103
+ description: 'This is a test annotation',
104
+ symbol: '+',
105
+ nucleotideMutations: ['A23403G'],
106
+ aminoAcidMutations: [],
107
+ },
108
+ ],
109
+ },
110
+ play: async ({ canvasElement }) => {
111
+ const canvas = within(canvasElement);
112
+
113
+ await waitFor(() => expect(canvas.getByText('A23403G')).toBeVisible());
114
+ await expect(getAnnotationIndicator(canvas)).toBeVisible();
115
+ await expect(canvas.queryByText('+')).toBeVisible();
116
+
117
+ await userEvent.click(canvas.getByText('A23403G'));
118
+ await waitFor(() => expect(getAnnotationName(canvas)).toBeVisible());
119
+ await expect(canvas.queryByText('Another test annotation')).toBeVisible();
120
+ },
121
+ };
122
+
123
+ export const AminoAcidMutationWithAnnotationEntry: StoryObj<
124
+ AnnotatedMutationProps & { annotations: MutationAnnotations }
125
+ > = {
126
+ ...MutationWithoutAnnotationEntry,
127
+ args: {
128
+ mutation: {
129
+ type: 'substitution',
130
+ code: 'S:A501G',
131
+ position: 501,
132
+ valueAtReference: 'A',
133
+ substitutionValue: 'G',
134
+ },
135
+ sequenceType: 'amino acid',
136
+ annotations: [
137
+ {
138
+ name: 'Test annotation',
139
+ description: 'This is a test annotation',
140
+ symbol: '*',
141
+ nucleotideMutations: [],
142
+ aminoAcidMutations: ['S:A501G'],
143
+ },
144
+ ],
145
+ },
146
+ play: async ({ canvasElement }) => {
147
+ const canvas = within(canvasElement);
148
+
149
+ await waitFor(() => expect(canvas.getByText('S:A501G')).toBeVisible());
150
+ await expect(getAnnotationIndicator(canvas)).toBeVisible();
151
+
152
+ await userEvent.click(canvas.getByText('S:A501G'));
153
+ await waitFor(() => expect(getAnnotationName(canvas)).toBeVisible());
154
+ },
155
+ };
156
+
157
+ function getAnnotationIndicator(canvas: ReturnType<typeof within>) {
158
+ return canvas.queryByText('*');
159
+ }
160
+
161
+ function getAnnotationName(canvas: ReturnType<typeof within>) {
162
+ return canvas.queryByText('Test annotation');
163
+ }
@@ -0,0 +1,80 @@
1
+ import { useRef } from 'gridjs';
2
+ import { Fragment, type FunctionComponent, type RefObject } from 'preact';
3
+
4
+ import type { SequenceType } from '../../types';
5
+ import type { Deletion, Substitution } from '../../utils/mutations';
6
+ import { useMutationAnnotationsProvider } from '../MutationAnnotationsContext';
7
+ import { InfoHeadline1, InfoHeadline2, InfoParagraph } from './info';
8
+ import { ButtonWithModalDialog, useModalRef } from './modal';
9
+
10
+ export type AnnotatedMutationProps = {
11
+ mutation: Substitution | Deletion;
12
+ sequenceType: SequenceType;
13
+ };
14
+
15
+ export const AnnotatedMutation: FunctionComponent<AnnotatedMutationProps> = (props) => {
16
+ const annotationsProvider = useMutationAnnotationsProvider();
17
+ const modalRef = useModalRef();
18
+
19
+ return <AnnotatedMutationWithoutContext {...props} annotationsProvider={annotationsProvider} modalRef={modalRef} />;
20
+ };
21
+
22
+ type GridJsAnnotatedMutationProps = AnnotatedMutationProps & {
23
+ annotationsProvider: ReturnType<typeof useMutationAnnotationsProvider>;
24
+ };
25
+
26
+ /**
27
+ * GridJS internally also uses Preact, but it uses its own Preact instance:
28
+ * - Our Preact contexts are not available in GridJS. We need to inject context content as long as we're in our Preact instance.
29
+ * - We must use the GridJS re-exports of the Preact hooks. I'm not sure why.
30
+ */
31
+ export const GridJsAnnotatedMutation: FunctionComponent<GridJsAnnotatedMutationProps> = (props) => {
32
+ const modalRef = useRef<HTMLDialogElement>(null);
33
+
34
+ return <AnnotatedMutationWithoutContext {...props} modalRef={modalRef} />;
35
+ };
36
+
37
+ type AnnotatedMutationWithoutContextProps = GridJsAnnotatedMutationProps & {
38
+ modalRef: RefObject<HTMLDialogElement>;
39
+ };
40
+
41
+ const AnnotatedMutationWithoutContext: FunctionComponent<AnnotatedMutationWithoutContextProps> = ({
42
+ mutation,
43
+ sequenceType,
44
+ annotationsProvider,
45
+ modalRef,
46
+ }) => {
47
+ const mutationAnnotations = annotationsProvider(mutation.code, sequenceType);
48
+
49
+ if (mutationAnnotations === undefined || mutationAnnotations.length === 0) {
50
+ return mutation.code;
51
+ }
52
+
53
+ const modalContent = (
54
+ <div className='block'>
55
+ <InfoHeadline1>Annotations for {mutation.code}</InfoHeadline1>
56
+ {mutationAnnotations.map((annotation) => (
57
+ <Fragment key={annotation.name}>
58
+ <InfoHeadline2>{annotation.name}</InfoHeadline2>
59
+ <InfoParagraph>{annotation.description}</InfoParagraph>
60
+ </Fragment>
61
+ ))}
62
+ </div>
63
+ );
64
+
65
+ return (
66
+ <ButtonWithModalDialog modalContent={modalContent} modalRef={modalRef}>
67
+ {mutation.code}
68
+ <sup>
69
+ {mutationAnnotations
70
+ .map((annotation) => annotation.symbol)
71
+ .map((symbol, index) => (
72
+ <Fragment key={symbol}>
73
+ <span className='text-red-600'>{symbol}</span>
74
+ {index !== mutationAnnotations.length - 1 && ','}
75
+ </Fragment>
76
+ ))}
77
+ </sup>
78
+ </ButtonWithModalDialog>
79
+ );
80
+ };
@@ -1,6 +1,6 @@
1
1
  import { useCombobox } from 'downshift/preact';
2
2
  import { type ComponentChild } from 'preact';
3
- import { useRef, useState } from 'preact/hooks';
3
+ import { useMemo, useRef, useState } from 'preact/hooks';
4
4
 
5
5
  export function DownshiftCombobox<Item>({
6
6
  allItems,
@@ -21,8 +21,10 @@ export function DownshiftCombobox<Item>({
21
21
  }) {
22
22
  const initialSelectedItem = value ?? null;
23
23
 
24
- const [items, setItems] = useState(
25
- allItems.filter((item) => filterItemsByInputValue(item, itemToString(initialSelectedItem))),
24
+ const [itemsFilter, setItemsFilter] = useState(itemToString(initialSelectedItem));
25
+ const items = useMemo(
26
+ () => allItems.filter((item) => filterItemsByInputValue(item, itemsFilter)),
27
+ [allItems, filterItemsByInputValue, itemsFilter],
26
28
  );
27
29
  const divRef = useRef<HTMLDivElement>(null);
28
30
 
@@ -52,7 +54,7 @@ export function DownshiftCombobox<Item>({
52
54
  closeMenu,
53
55
  } = useCombobox({
54
56
  onInputValueChange({ inputValue }) {
55
- setItems(allItems.filter((item) => filterItemsByInputValue(item, inputValue)));
57
+ setItemsFilter(inputValue);
56
58
  },
57
59
  onSelectedItemChange({ selectedItem }) {
58
60
  if (selectedItem !== null) {