From 49c2f9a4665ad111a669e921cfb6e000e80c36ba Mon Sep 17 00:00:00 2001 From: Jonas Kellerer <122305307+JonasKellerer@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:00:06 +0200 Subject: [PATCH] feat(components): add mutation filter component (#149) Refs: #155 --------- Co-authored-by: Fabian Engelniederhammer --- .../mutation-filter.stories.tsx | 164 +++++++++++ .../preact/mutationFilter/mutation-filter.tsx | 265 ++++++++++++++++++ .../parseAndValidateMutation.ts | 54 ++++ .../mutationFilter/parseMutation.spec.ts | 150 ++++++++++ .../sequenceTypeFromSegment.spec.ts | 66 +++++ .../mutationFilter/sequenceTypeFromSegment.ts | 20 ++ .../preact/mutations/getMutationsGridData.ts | 4 +- .../prevalence-over-time-bar-chart.tsx | 6 +- .../prevalence-over-time-bubble-chart.tsx | 6 +- .../prevalence-over-time-line-chart.tsx | 12 +- .../relative-growth-advantage-chart.tsx | 12 +- components/src/preact/shared/charts/colors.ts | 35 ++- .../src/preact/shared/icons/DeleteIcon.tsx | 17 ++ .../src/preact/shared/sort/sortInsertions.ts | 22 +- .../sort/sortSubstitutionsAndDeletions.ts | 6 +- components/src/utils/mutations.spec.ts | 42 +++ components/src/utils/mutations.ts | 118 +++++--- .../mutation-filter-component.stories.ts | 97 +++++++ .../input/mutation-filter-component.tsx | 27 ++ 19 files changed, 1038 insertions(+), 85 deletions(-) create mode 100644 components/src/preact/mutationFilter/mutation-filter.stories.tsx create mode 100644 components/src/preact/mutationFilter/mutation-filter.tsx create mode 100644 components/src/preact/mutationFilter/parseAndValidateMutation.ts create mode 100644 components/src/preact/mutationFilter/parseMutation.spec.ts create mode 100644 components/src/preact/mutationFilter/sequenceTypeFromSegment.spec.ts create mode 100644 components/src/preact/mutationFilter/sequenceTypeFromSegment.ts create mode 100644 components/src/preact/shared/icons/DeleteIcon.tsx create mode 100644 components/src/web-components/input/mutation-filter-component.stories.ts create mode 100644 components/src/web-components/input/mutation-filter-component.tsx diff --git a/components/src/preact/mutationFilter/mutation-filter.stories.tsx b/components/src/preact/mutationFilter/mutation-filter.stories.tsx new file mode 100644 index 00000000..368fb655 --- /dev/null +++ b/components/src/preact/mutationFilter/mutation-filter.stories.tsx @@ -0,0 +1,164 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import { type Meta, type StoryObj } from '@storybook/preact'; +import { expect, fireEvent, fn, userEvent, waitFor, within } from '@storybook/test'; + +import { MutationFilter, type MutationFilterProps } from './mutation-filter'; +import { LAPIS_URL } from '../../constants'; +import referenceGenome from '../../lapisApi/__mockData__/referenceGenome.json'; +import { LapisUrlContext } from '../LapisUrlContext'; +import { ReferenceGenomeContext } from '../ReferenceGenomeContext'; + +const meta: Meta = { + title: 'Input/MutationFilter', + component: MutationFilter, + parameters: { + actions: { + handles: ['gs-mutation-filter-changed', 'gs-mutation-filter-on-blur'], + }, + fetchMock: {}, + }, + decorators: [withActions], +}; + +export default meta; + +export const Default: StoryObj = { + render: () => ( + + + + + + ), +}; + +export const FiresFilterChangedEvents: StoryObj = { + ...Default, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const listenerMock = fn(); + await step('Setup event listener mock', async () => { + canvasElement.addEventListener('gs-mutation-filter-changed', listenerMock); + }); + + await step('wait until data is loaded', async () => { + await waitFor(() => { + return expect(inputField(canvas)).toBeEnabled(); + }); + }); + + await step('Enters an invalid mutation', async () => { + await submitMutation(canvas, 'notAMutation'); + await expect(listenerMock).not.toHaveBeenCalled(); + + await userEvent.type(inputField(canvas), '{backspace>12/}'); + }); + + await step('Enter a valid mutation', async () => { + await submitMutation(canvas, 'A123T'); + + await waitFor(() => + expect(listenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + nucleotideMutations: ['A123T'], + aminoAcidMutations: [], + nucleotideInsertions: [], + aminoAcidInsertions: [], + }, + }), + ), + ); + }); + + await step('Enter a second valid nucleotide mutation', async () => { + await submitMutation(canvas, 'A234-'); + + await expect(listenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + nucleotideMutations: ['A123T', 'A234-'], + aminoAcidMutations: [], + nucleotideInsertions: [], + aminoAcidInsertions: [], + }, + }), + ); + }); + + await step('Enter another valid mutation', async () => { + await submitMutation(canvas, 'ins_123:AA'); + + await expect(listenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + nucleotideMutations: ['A123T', 'A234-'], + aminoAcidMutations: [], + nucleotideInsertions: ['ins_123:AA'], + aminoAcidInsertions: [], + }, + }), + ); + }); + + await step('Remove the first mutation', async () => { + const firstMutationDeleteButton = canvas.getAllByRole('button')[0]; + await waitFor(() => fireEvent.click(firstMutationDeleteButton)); + + await expect(listenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + nucleotideMutations: ['A234-'], + aminoAcidMutations: [], + nucleotideInsertions: ['ins_123:AA'], + aminoAcidInsertions: [], + }, + }), + ); + }); + }, +}; + +export const FiresFilterOnBlurEvent: StoryObj = { + ...Default, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + const listenerMock = fn(); + await step('Setup event listener mock', async () => { + canvasElement.addEventListener('gs-mutation-filter-on-blur', listenerMock); + }); + + await step('wait until data is loaded', async () => { + await waitFor(() => { + return expect(inputField(canvas)).toBeEnabled(); + }); + }); + + await step('Move outside of input', async () => { + await submitMutation(canvas, 'A234T'); + await submitMutation(canvas, 'S:A123G'); + await submitMutation(canvas, 'ins_123:AAA'); + await submitMutation(canvas, 'ins_S:123:AAA'); + await userEvent.tab(); + + await expect(listenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + nucleotideMutations: ['A234T'], + aminoAcidMutations: ['S:A123G'], + nucleotideInsertions: ['ins_123:AAA'], + aminoAcidInsertions: ['ins_S:123:AAA'], + }, + }), + ); + }); + }, +}; + +const submitMutation = async (canvas: ReturnType, mutation: string) => { + await userEvent.type(inputField(canvas), mutation); + await waitFor(() => submitButton(canvas).click()); +}; +const inputField = (canvas: ReturnType) => canvas.getByPlaceholderText('Enter a mutation'); +const submitButton = (canvas: ReturnType) => canvas.getByRole('button', { name: '+' }); diff --git a/components/src/preact/mutationFilter/mutation-filter.tsx b/components/src/preact/mutationFilter/mutation-filter.tsx new file mode 100644 index 00000000..8a1da722 --- /dev/null +++ b/components/src/preact/mutationFilter/mutation-filter.tsx @@ -0,0 +1,265 @@ +import { type FunctionComponent } from 'preact'; +import { useContext, useRef, useState } from 'preact/hooks'; + +import { parseAndValidateMutation } from './parseAndValidateMutation'; +import { type Deletion, type Insertion, type Mutation, type Substitution } from '../../utils/mutations'; +import { ReferenceGenomeContext } from '../ReferenceGenomeContext'; +import { singleGraphColorRGBByName } from '../shared/charts/colors'; +import { DeleteIcon } from '../shared/icons/DeleteIcon'; + +export type MutationFilterProps = {}; + +export type SelectedFilters = { + nucleotideMutations: (Substitution | Deletion)[]; + aminoAcidMutations: (Substitution | Deletion)[]; + nucleotideInsertions: Insertion[]; + aminoAcidInsertions: Insertion[]; +}; + +export type SelectedMutationFilterStrings = { + [Key in keyof SelectedFilters]: string[]; +}; + +export const MutationFilter: FunctionComponent = () => { + const referenceGenome = useContext(ReferenceGenomeContext); + const [selectedFilters, setSelectedFilters] = useState({ + nucleotideMutations: [], + aminoAcidMutations: [], + nucleotideInsertions: [], + aminoAcidInsertions: [], + }); + const [inputValue, setInputValue] = useState(''); + const formRef = useRef(null); + + const handleSubmit = (event: Event) => { + event.preventDefault(); + + const parsedMutation = parseAndValidateMutation(inputValue, referenceGenome); + + if (parsedMutation === null) { + return; + } + + const newSelectedValues = { + ...selectedFilters, + [parsedMutation.type]: [...selectedFilters[parsedMutation.type], parsedMutation.value], + }; + + setSelectedFilters(newSelectedValues); + fireChangeEvent(newSelectedValues); + setInputValue(''); + }; + + const fireChangeEvent = (selectedFilters: SelectedFilters) => { + const detail = mapToMutationFilterStrings(selectedFilters); + + formRef.current?.dispatchEvent( + new CustomEvent('gs-mutation-filter-changed', { + detail, + bubbles: true, + composed: true, + }), + ); + }; + + const handleOnBlur = () => { + const detail = mapToMutationFilterStrings(selectedFilters); + + formRef.current?.dispatchEvent( + new CustomEvent('gs-mutation-filter-on-blur', { + detail, + bubbles: true, + composed: true, + }), + ); + }; + + const handleInputChange = (event: Event) => { + setInputValue((event.target as HTMLInputElement).value); + }; + + return ( +
+ + +
+ +
+
+ ); +}; + +const SelectedMutationDisplay: FunctionComponent<{ + selectedFilters: SelectedFilters; + setSelectedFilters: (selectedFilters: SelectedFilters) => void; + fireChangeEvent: (selectedFilters: SelectedFilters) => void; +}> = ({ selectedFilters, setSelectedFilters, fireChangeEvent }) => { + const onSelectedRemoved = ( + mutation: SelectedFilters[MutationType][number], + key: MutationType, + ) => { + const newSelectedValues = { + ...selectedFilters, + [key]: selectedFilters[key].filter((i) => !mutation.equals(i)), + }; + + setSelectedFilters(newSelectedValues); + + fireChangeEvent(newSelectedValues); + }; + + return ( +
    + {selectedFilters.nucleotideMutations.map((mutation) => ( +
  • + + onSelectedRemoved(mutation, 'nucleotideMutations') + } + /> +
  • + ))} + {selectedFilters.aminoAcidMutations.map((mutation) => ( +
  • + + onSelectedRemoved(mutation, 'aminoAcidMutations') + } + /> +
  • + ))} + {selectedFilters.nucleotideInsertions.map((insertion) => ( +
  • + onSelectedRemoved(insertion, 'nucleotideInsertions')} + /> +
  • + ))} + {selectedFilters.aminoAcidInsertions.map((insertion) => ( +
  • + onSelectedRemoved(insertion, 'aminoAcidInsertions')} + /> +
  • + ))} +
+ ); +}; + +const SelectedAminoAcidInsertion: FunctionComponent<{ + insertion: Insertion; + onDelete: (insertion: Insertion) => void; +}> = ({ insertion, onDelete }) => { + const backgroundColor = singleGraphColorRGBByName('teal', 0.3); + const textColor = singleGraphColorRGBByName('teal', 1); + return ( + + ); +}; + +const SelectedAminoAcidMutation: FunctionComponent<{ + mutation: Substitution | Deletion; + onDelete: (mutation: Substitution | Deletion) => void; +}> = ({ mutation, onDelete }) => { + const backgroundColor = singleGraphColorRGBByName('rose', 0.3); + const textColor = singleGraphColorRGBByName('rose', 1); + return ( + + ); +}; + +const SelectedNucleotideMutation: FunctionComponent<{ + mutation: Substitution | Deletion; + onDelete: (insertion: Substitution | Deletion) => void; +}> = ({ mutation, onDelete }) => { + const backgroundColor = singleGraphColorRGBByName('indigo', 0.3); + const textColor = singleGraphColorRGBByName('indigo', 1); + return ( + + ); +}; + +const SelectedNucleotideInsertion: FunctionComponent<{ + insertion: Insertion; + onDelete: (insertion: Insertion) => void; +}> = ({ insertion, onDelete }) => { + const backgroundColor = singleGraphColorRGBByName('green', 0.3); + const textColor = singleGraphColorRGBByName('green', 1); + + return ( + + ); +}; + +type SelectedFilterProps = { + mutation: MutationType; + onDelete: (mutation: MutationType) => void; + backgroundColor: string; + textColor: string; +}; + +const SelectedFilter = ({ + mutation, + onDelete, + backgroundColor, + textColor, +}: SelectedFilterProps) => { + return ( +
+
{mutation.toString()}
+ +
+ ); +}; + +function mapToMutationFilterStrings(selectedFilters: SelectedFilters) { + return { + aminoAcidMutations: selectedFilters.aminoAcidMutations.map((mutation) => mutation.toString()), + nucleotideMutations: selectedFilters.nucleotideMutations.map((mutation) => mutation.toString()), + aminoAcidInsertions: selectedFilters.aminoAcidInsertions.map((insertion) => insertion.toString()), + nucleotideInsertions: selectedFilters.nucleotideInsertions.map((insertion) => insertion.toString()), + }; +} diff --git a/components/src/preact/mutationFilter/parseAndValidateMutation.ts b/components/src/preact/mutationFilter/parseAndValidateMutation.ts new file mode 100644 index 00000000..5d4bff0e --- /dev/null +++ b/components/src/preact/mutationFilter/parseAndValidateMutation.ts @@ -0,0 +1,54 @@ +import { type SelectedFilters } from './mutation-filter'; +import { sequenceTypeFromSegment } from './sequenceTypeFromSegment'; +import type { ReferenceGenome } from '../../lapisApi/ReferenceGenome'; +import { Deletion, Insertion, Substitution } from '../../utils/mutations'; + +type ParsedMutationFilter = { + [MutationType in keyof SelectedFilters]: { type: MutationType; value: SelectedFilters[MutationType][number] }; +}[keyof SelectedFilters]; + +export const parseAndValidateMutation = ( + value: string, + referenceGenome: ReferenceGenome, +): ParsedMutationFilter | null => { + const possibleInsertion = Insertion.parse(value); + if (possibleInsertion !== null) { + const sequenceType = sequenceTypeFromSegment(possibleInsertion.segment, referenceGenome); + switch (sequenceType) { + case 'nucleotide': + return { type: 'nucleotideInsertions', value: possibleInsertion }; + case 'amino acid': + return { type: 'aminoAcidInsertions', value: possibleInsertion }; + case undefined: + return null; + } + } + + const possibleDeletion = Deletion.parse(value); + if (possibleDeletion !== null) { + const sequenceType = sequenceTypeFromSegment(possibleDeletion.segment, referenceGenome); + switch (sequenceType) { + case 'nucleotide': + return { type: 'nucleotideMutations', value: possibleDeletion }; + case 'amino acid': + return { type: 'aminoAcidMutations', value: possibleDeletion }; + case undefined: + return null; + } + } + + const possibleSubstitution = Substitution.parse(value); + if (possibleSubstitution !== null) { + const sequenceType = sequenceTypeFromSegment(possibleSubstitution.segment, referenceGenome); + switch (sequenceType) { + case 'nucleotide': + return { type: 'nucleotideMutations', value: possibleSubstitution }; + case 'amino acid': + return { type: 'aminoAcidMutations', value: possibleSubstitution }; + case undefined: + return null; + } + } + + return null; +}; diff --git a/components/src/preact/mutationFilter/parseMutation.spec.ts b/components/src/preact/mutationFilter/parseMutation.spec.ts new file mode 100644 index 00000000..5a830cbb --- /dev/null +++ b/components/src/preact/mutationFilter/parseMutation.spec.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; + +import { parseAndValidateMutation } from './parseAndValidateMutation'; +import { Deletion, Insertion, Substitution } from '../../utils/mutations'; + +describe('parseMutation', () => { + const singleSegmentedReferenceGenome = { + nucleotideSequences: [ + { + name: 'nuc1', + sequence: 'ACGT', + }, + ], + genes: [ + { + name: 'gene1', + sequence: 'ACGT', + }, + { + name: 'gene2', + sequence: 'ACGT', + }, + ], + }; + + const testCases = { + insertions: [ + { + name: 'should parse nucleotide insertions', + input: 'ins_10:ACGT', + expected: { type: 'nucleotideInsertions', value: new Insertion(undefined, 10, 'ACGT') }, + }, + { + name: 'should parse amino acid insertions', + input: 'ins_gene1:10:ACGT', + expected: { type: 'aminoAcidInsertions', value: new Insertion('gene1', 10, 'ACGT') }, + }, + { + name: 'should parse amino acid insertion with LAPIS-style wildcard', + input: 'ins_gene1:10:?AC?GT', + expected: { type: 'aminoAcidInsertions', value: new Insertion('gene1', 10, '?AC?GT') }, + }, + { + name: 'should parse amino acid insertion with SILO-style wildcard', + input: 'ins_gene1:10:.*AC.*GT', + expected: { type: 'aminoAcidInsertions', value: new Insertion('gene1', 10, '.*AC.*GT') }, + }, + { + name: 'should return null for insertion with segment not in reference genome', + input: 'INS_notInReferenceGenome:10:ACGT', + expected: null, + }, + { name: 'should return null for insertion with missing position', input: 'ins_gene1:ACGT', expected: null }, + ], + deletions: [ + { + name: 'should parse nucleotide deletion in single segmented reference genome, when no segment is given', + input: 'A123-', + expected: { type: 'nucleotideMutations', value: new Deletion(undefined, 'A', 123) }, + }, + { + name: 'should parse nucleotide deletion without valueAtReference when no segment is given', + input: '123-', + expected: { type: 'nucleotideMutations', value: new Deletion(undefined, undefined, 123) }, + }, + { + name: 'should parse nucleotide deletion', + input: 'nuc1:A123-', + expected: { type: 'nucleotideMutations', value: new Deletion('nuc1', 'A', 123) }, + }, + { + name: 'should parse nucleotide deletion without valueAtReference', + input: 'nuc1:123-', + expected: { type: 'nucleotideMutations', value: new Deletion('nuc1', undefined, 123) }, + }, + { + name: 'should parse amino acid deletion', + input: 'gene1:A123-', + expected: { type: 'aminoAcidMutations', value: new Deletion('gene1', 'A', 123) }, + }, + { + name: 'should parse amino acid deletion without valueAtReference', + input: 'gene1:123-', + expected: { type: 'aminoAcidMutations', value: new Deletion('gene1', undefined, 123) }, + }, + { + name: 'should return null for deletion with segment not in reference genome', + input: 'notInReferenceGenome:A123-', + expected: null, + }, + ], + substitutions: [ + { + name: 'should parse nucleotide substitution in single segmented reference genome, when no segment is given', + input: 'A123T', + expected: { type: 'nucleotideMutations', value: new Substitution(undefined, 'A', 'T', 123) }, + }, + { + name: 'should parse substitution without valueAtReference', + input: '123T', + expected: { type: 'nucleotideMutations', value: new Substitution(undefined, undefined, 'T', 123) }, + }, + { + name: 'should parse substitution with neither valueAtReference not substitutionValue', + input: '123', + expected: { + type: 'nucleotideMutations', + value: new Substitution(undefined, undefined, undefined, 123), + }, + }, + { + name: 'should parse a "no mutation" substitution', + input: '123.', + expected: { type: 'nucleotideMutations', value: new Substitution(undefined, undefined, '.', 123) }, + }, + { + name: 'should parse nucleotide substitution', + input: 'nuc1:A123T', + expected: { type: 'nucleotideMutations', value: new Substitution('nuc1', 'A', 'T', 123) }, + }, + { + name: 'should parse amino acid substitution', + input: 'gene1:A123T', + expected: { type: 'aminoAcidMutations', value: new Substitution('gene1', 'A', 'T', 123) }, + }, + { + name: 'should return null for substitution with segment not in reference genome', + input: 'notInReferenceGenome:A123T', + expected: null, + }, + ], + }; + + Object.entries(testCases).forEach(([type, cases]) => { + describe(type, () => { + cases.forEach(({ name, input, expected }) => { + it(name, () => { + const result = parseAndValidateMutation(input, singleSegmentedReferenceGenome); + + expect(result).deep.equals(expected); + }); + }); + }); + }); + + it('should return null for invalid mutation', () => { + const result = parseAndValidateMutation('invalidMutation', singleSegmentedReferenceGenome); + expect(result).toBe(null); + }); +}); diff --git a/components/src/preact/mutationFilter/sequenceTypeFromSegment.spec.ts b/components/src/preact/mutationFilter/sequenceTypeFromSegment.spec.ts new file mode 100644 index 00000000..fb11fd60 --- /dev/null +++ b/components/src/preact/mutationFilter/sequenceTypeFromSegment.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { sequenceTypeFromSegment } from './sequenceTypeFromSegment'; + +describe('getSequenceType', () => { + const singleSegmentedReferenceGenome = { + nucleotideSequences: [ + { + name: 'nuc1', + sequence: 'ACGT', + }, + ], + genes: [ + { + name: 'gene1', + sequence: 'ACGT', + }, + { + name: 'gene2', + sequence: 'ACGT', + }, + ], + }; + + const multiSegmentedReferenceGenome = { + nucleotideSequences: [ + { + name: 'nuc1', + sequence: 'ACGT', + }, + { + name: 'nuc2', + sequence: 'ACGT', + }, + ], + genes: [ + { + name: 'gene1', + sequence: 'ACGT', + }, + { + name: 'gene2', + sequence: 'ACGT', + }, + ], + }; + + it('should return nucleotide when the segment is undefined for singe segmented genome', () => { + expect(sequenceTypeFromSegment('nuc1', singleSegmentedReferenceGenome)).toBe('nucleotide'); + expect(sequenceTypeFromSegment(undefined, singleSegmentedReferenceGenome)).toBe('nucleotide'); + }); + + it('should return undefined when the segment is undefined for multi segmented genome', () => { + expect(sequenceTypeFromSegment('nuc1', multiSegmentedReferenceGenome)).toBe('nucleotide'); + expect(sequenceTypeFromSegment(undefined, multiSegmentedReferenceGenome)).toBe(undefined); + }); + + it('should return amino acid when the segment is a gene', () => { + expect(sequenceTypeFromSegment('gene1', singleSegmentedReferenceGenome)).toBe('amino acid'); + expect(sequenceTypeFromSegment('gene2', singleSegmentedReferenceGenome)).toBe('amino acid'); + }); + + it('should return undefined when the segment is not found in the reference genome', () => { + expect(sequenceTypeFromSegment('not-existing', singleSegmentedReferenceGenome)).toBe(undefined); + }); +}); diff --git a/components/src/preact/mutationFilter/sequenceTypeFromSegment.ts b/components/src/preact/mutationFilter/sequenceTypeFromSegment.ts new file mode 100644 index 00000000..26057559 --- /dev/null +++ b/components/src/preact/mutationFilter/sequenceTypeFromSegment.ts @@ -0,0 +1,20 @@ +import type { ReferenceGenome } from '../../lapisApi/ReferenceGenome'; +import type { SequenceType } from '../../types'; + +export const sequenceTypeFromSegment = ( + possibleSegment: string | undefined, + referenceGenome: ReferenceGenome, +): SequenceType | undefined => { + if (possibleSegment === undefined) { + return referenceGenome.nucleotideSequences.length === 1 ? 'nucleotide' : undefined; + } + + if (referenceGenome.nucleotideSequences.some((sequence) => sequence.name === possibleSegment)) { + return 'nucleotide'; + } + + if (referenceGenome.genes.some((gene) => gene.name === possibleSegment)) { + return 'amino acid'; + } + return undefined; +}; diff --git a/components/src/preact/mutations/getMutationsGridData.ts b/components/src/preact/mutations/getMutationsGridData.ts index 4e8a71ef..7801d21f 100644 --- a/components/src/preact/mutations/getMutationsGridData.ts +++ b/components/src/preact/mutations/getMutationsGridData.ts @@ -14,8 +14,8 @@ export const getMutationsGridData = ( const accumulateByPosition = (data: SubstitutionOrDeletionEntry[], sequenceType: SequenceType) => { const basesOfView = bases[sequenceType]; - const positionsToProportionAtBase = new Map>(); - const referenceBases = new Map(); + const positionsToProportionAtBase = new Map>(); + const referenceBases = new Map(); for (const mutationEntry of data) { const position = diff --git a/components/src/preact/prevalenceOverTime/prevalence-over-time-bar-chart.tsx b/components/src/preact/prevalenceOverTime/prevalence-over-time-bar-chart.tsx index 1b611c97..65f2d56f 100644 --- a/components/src/preact/prevalenceOverTime/prevalence-over-time-bar-chart.tsx +++ b/components/src/preact/prevalenceOverTime/prevalence-over-time-bar-chart.tsx @@ -4,7 +4,7 @@ import { BarWithErrorBar, BarWithErrorBarsController } from 'chartjs-chart-error import { type PrevalenceOverTimeData, type PrevalenceOverTimeVariantData } from '../../query/queryPrevalenceOverTime'; import GsChart from '../components/chart'; import { LogitScale } from '../shared/charts/LogitScale'; -import { singleGraphColorRGBA } from '../shared/charts/colors'; +import { singleGraphColorRGBAById } from '../shared/charts/colors'; import { type ConfidenceIntervalMethod, wilson95PercentConfidenceInterval } from '../shared/charts/confideceInterval'; import { getYAxisScale, type ScaleType } from '../shared/charts/getYAxisScale'; @@ -53,8 +53,8 @@ const datasets = ( borderWidth: 1, pointRadius: 0, label: prevalenceOverTimeVariant.displayName, - backgroundColor: singleGraphColorRGBA(index, 0.3), - borderColor: singleGraphColorRGBA(index), + backgroundColor: singleGraphColorRGBAById(index, 0.3), + borderColor: singleGraphColorRGBAById(index), }; switch (confidenceIntervalMethod) { diff --git a/components/src/preact/prevalenceOverTime/prevalence-over-time-bubble-chart.tsx b/components/src/preact/prevalenceOverTime/prevalence-over-time-bubble-chart.tsx index a443f86e..bf4193ef 100644 --- a/components/src/preact/prevalenceOverTime/prevalence-over-time-bubble-chart.tsx +++ b/components/src/preact/prevalenceOverTime/prevalence-over-time-bubble-chart.tsx @@ -5,7 +5,7 @@ import { addUnit, minusTemporal } from '../../utils/temporal'; import { getMinMaxNumber } from '../../utils/utils'; import GsChart from '../components/chart'; import { LogitScale } from '../shared/charts/LogitScale'; -import { singleGraphColorRGBA } from '../shared/charts/colors'; +import { singleGraphColorRGBAById } from '../shared/charts/colors'; import { getYAxisScale, type ScaleType } from '../shared/charts/getYAxisScale'; interface PrevalenceOverTimeBubbleChartProps { @@ -37,8 +37,8 @@ const PrevalenceOverTimeBubbleChart = ({ data, yAxisScaleType }: PrevalenceOverT })), borderWidth: 1, pointRadius: 0, - backgroundColor: singleGraphColorRGBA(index, 0.3), - borderColor: singleGraphColorRGBA(index), + backgroundColor: singleGraphColorRGBAById(index, 0.3), + borderColor: singleGraphColorRGBAById(index), })), }, options: { diff --git a/components/src/preact/prevalenceOverTime/prevalence-over-time-line-chart.tsx b/components/src/preact/prevalenceOverTime/prevalence-over-time-line-chart.tsx index 3a27e7ea..91ed7b3d 100644 --- a/components/src/preact/prevalenceOverTime/prevalence-over-time-line-chart.tsx +++ b/components/src/preact/prevalenceOverTime/prevalence-over-time-line-chart.tsx @@ -4,10 +4,10 @@ import { type TooltipItem } from 'chart.js/dist/types'; import { type PrevalenceOverTimeData, type PrevalenceOverTimeVariantData } from '../../query/queryPrevalenceOverTime'; import GsChart from '../components/chart'; import { LogitScale } from '../shared/charts/LogitScale'; -import { singleGraphColorRGBA } from '../shared/charts/colors'; +import { singleGraphColorRGBAById } from '../shared/charts/colors'; import { - type ConfidenceIntervalMethod, confidenceIntervalDataLabel, + type ConfidenceIntervalMethod, wilson95PercentConfidenceInterval, } from '../shared/charts/confideceInterval'; import { getYAxisScale, type ScaleType } from '../shared/charts/getYAxisScale'; @@ -76,7 +76,7 @@ const getDatasetCIUpper = (prevalenceOverTimeVariant: PrevalenceOverTimeVariantD borderWidth: 0, pointRadius: 0, fill: '+1', - backgroundColor: singleGraphColorRGBA(dataIndex, 0.3), + backgroundColor: singleGraphColorRGBAById(dataIndex, 0.3), }); const getDatasetCILower = (prevalenceOverTimeVariant: PrevalenceOverTimeVariantData, dataIndex: number) => ({ @@ -87,7 +87,7 @@ const getDatasetCILower = (prevalenceOverTimeVariant: PrevalenceOverTimeVariantD borderWidth: 0, pointRadius: 0, fill: '-1', - backgroundColor: singleGraphColorRGBA(dataIndex, 0.3), + backgroundColor: singleGraphColorRGBAById(dataIndex, 0.3), }); const getDatasetLine = (prevalenceOverTimeVariant: PrevalenceOverTimeVariantData, dataIndex: number) => ({ @@ -95,8 +95,8 @@ const getDatasetLine = (prevalenceOverTimeVariant: PrevalenceOverTimeVariantData data: prevalenceOverTimeVariant.content.map((dataPoint) => dataPoint.prevalence), borderWidth: 1, pointRadius: 0, - borderColor: singleGraphColorRGBA(dataIndex), - backgroundColor: singleGraphColorRGBA(dataIndex), + borderColor: singleGraphColorRGBAById(dataIndex), + backgroundColor: singleGraphColorRGBAById(dataIndex), }); const tooltip = (confidenceIntervalMethod?: ConfidenceIntervalMethod) => { diff --git a/components/src/preact/relativeGrowthAdvantage/relative-growth-advantage-chart.tsx b/components/src/preact/relativeGrowthAdvantage/relative-growth-advantage-chart.tsx index 2c6f53d1..2ab9ebe6 100644 --- a/components/src/preact/relativeGrowthAdvantage/relative-growth-advantage-chart.tsx +++ b/components/src/preact/relativeGrowthAdvantage/relative-growth-advantage-chart.tsx @@ -3,7 +3,7 @@ import { Chart, type ChartConfiguration, registerables, type TooltipItem } from import { type YearMonthDay } from '../../utils/temporal'; import GsChart from '../components/chart'; import { LogitScale } from '../shared/charts/LogitScale'; -import { singleGraphColorRGBA } from '../shared/charts/colors'; +import { singleGraphColorRGBByName } from '../shared/charts/colors'; import { confidenceIntervalDataLabel } from '../shared/charts/confideceInterval'; import { getYAxisScale, type ScaleType } from '../shared/charts/getYAxisScale'; @@ -69,8 +69,8 @@ const datasets = (data: RelativeGrowthAdvantageChartData) => { data: data.proportion, borderWidth: 1, pointRadius: 0, - borderColor: singleGraphColorRGBA(0), - backgroundColor: singleGraphColorRGBA(0), + borderColor: singleGraphColorRGBByName('indigo'), + backgroundColor: singleGraphColorRGBByName('indigo'), }, { type: 'line' as const, @@ -79,7 +79,7 @@ const datasets = (data: RelativeGrowthAdvantageChartData) => { borderWidth: 1, pointRadius: 0, fill: '+1', - backgroundColor: singleGraphColorRGBA(0, 0.3), + backgroundColor: singleGraphColorRGBByName('indigo', 0.3), }, { type: 'line' as const, @@ -88,13 +88,13 @@ const datasets = (data: RelativeGrowthAdvantageChartData) => { borderWidth: 1, pointRadius: 0, fill: '-1', - backgroundColor: singleGraphColorRGBA(0, 0.3), + backgroundColor: singleGraphColorRGBByName('indigo', 0.3), }, { type: 'scatter' as const, label: 'Observed', data: data.observed, - pointBackgroundColor: singleGraphColorRGBA(1), + pointBackgroundColor: singleGraphColorRGBByName('green'), pointRadius: 2, }, ]; diff --git a/components/src/preact/shared/charts/colors.ts b/components/src/preact/shared/charts/colors.ts index a9c6cc3b..537a5dac 100644 --- a/components/src/preact/shared/charts/colors.ts +++ b/components/src/preact/shared/charts/colors.ts @@ -1,17 +1,26 @@ // colorblind friendly colors taken from https://personal.sron.nl/~pault/ -const ColorsRGB = [ - [51, 34, 136], - [17, 119, 51], - [136, 204, 238], - [68, 170, 153], - [153, 153, 51], - [221, 204, 119], - [204, 102, 119], - [136, 34, 85], - [170, 68, 153], -]; +const ColorsRGB = { + indigo: [51, 34, 136], + green: [17, 119, 51], + cyan: [136, 204, 238], + teal: [68, 170, 153], + olive: [153, 153, 51], + sand: [221, 204, 119], + rose: [204, 102, 119], + wine: [136, 34, 85], + purple: [170, 68, 153], +}; + +type GraphColor = keyof typeof ColorsRGB; + +export const singleGraphColorRGBAById = (id: number, alpha = 1) => { + const keys = Object.keys(ColorsRGB) as GraphColor[]; + const key = keys[id % keys.length]; + + return `rgba(${ColorsRGB[key].join(',')},${alpha})`; +}; -export const singleGraphColorRGBA = (id: number, alpha = 1) => { - return `rgba(${ColorsRGB[id % ColorsRGB.length].join(',')},${alpha})`; +export const singleGraphColorRGBByName = (name: GraphColor, alpha = 1) => { + return `rgba(${ColorsRGB[name].join(',')},${alpha})`; }; diff --git a/components/src/preact/shared/icons/DeleteIcon.tsx b/components/src/preact/shared/icons/DeleteIcon.tsx new file mode 100644 index 00000000..bd3e716e --- /dev/null +++ b/components/src/preact/shared/icons/DeleteIcon.tsx @@ -0,0 +1,17 @@ +import type { FunctionComponent } from 'preact'; + +export const DeleteIcon: FunctionComponent = () => { + return ( + + + + ); +}; diff --git a/components/src/preact/shared/sort/sortInsertions.ts b/components/src/preact/shared/sort/sortInsertions.ts index fd41f0e7..32289d83 100644 --- a/components/src/preact/shared/sort/sortInsertions.ts +++ b/components/src/preact/shared/sort/sortInsertions.ts @@ -1,17 +1,21 @@ -const pattern = /^ins_(?:([A-Za-z0-9]+):)?(\d+):([A-Za-z]+|\*)/; +import { Insertion } from '../../../utils/mutations'; export const sortInsertions = (a: string, b: string) => { - const aMatch = a.match(pattern); - const bMatch = b.match(pattern); + const insertionA = Insertion.parse(a); + const insertionB = Insertion.parse(b); - if (aMatch && bMatch) { - if (aMatch[1] !== bMatch[1]) { - return aMatch[1].localeCompare(bMatch[1]); + if (insertionA && insertionB) { + const segmentA = insertionA.segment; + const segmentB = insertionB.segment; + if (segmentA !== undefined && segmentB !== undefined && segmentA !== segmentB) { + return segmentA.localeCompare(segmentB); } - if (aMatch[2] !== bMatch[2]) { - return parseInt(aMatch[2], 10) - parseInt(bMatch[2], 10); + const positionA = insertionA.position; + const positionB = insertionB.position; + if (positionA !== positionB) { + return positionA - positionB; } - return aMatch[3].localeCompare(bMatch[3]); + return insertionA.insertedSymbols.localeCompare(insertionB.insertedSymbols); } throw new Error(`Invalid insertion: ${a} or ${b}`); }; diff --git a/components/src/preact/shared/sort/sortSubstitutionsAndDeletions.ts b/components/src/preact/shared/sort/sortSubstitutionsAndDeletions.ts index aee7b41a..3925dcf5 100644 --- a/components/src/preact/shared/sort/sortSubstitutionsAndDeletions.ts +++ b/components/src/preact/shared/sort/sortSubstitutionsAndDeletions.ts @@ -1,8 +1,8 @@ -const pattern = /(?:([A-Za-z0-9]+):)?([A-Za-z])(\d+)([A-Za-z]|-|\*)/; +export const substitutionAndDeletionRegex = /(?:([A-Za-z0-9]+):)?([A-Za-z])(\d+)([A-Za-z]|-|\*)/; export const sortSubstitutionsAndDeletions = (a: string, b: string) => { - const aMatch = a.match(pattern); - const bMatch = b.match(pattern); + const aMatch = a.match(substitutionAndDeletionRegex); + const bMatch = b.match(substitutionAndDeletionRegex); if (aMatch && bMatch) { if (aMatch[1] !== bMatch[1]) { diff --git a/components/src/utils/mutations.spec.ts b/components/src/utils/mutations.spec.ts index 78f3aebd..c204d4fd 100644 --- a/components/src/utils/mutations.spec.ts +++ b/components/src/utils/mutations.spec.ts @@ -7,16 +7,58 @@ describe('Substitution', () => { expect(Substitution.parse('A1T')).deep.equal(new Substitution(undefined, 'A', 'T', 1)); expect(Substitution.parse('seg1:A1T')).deep.equal(new Substitution('seg1', 'A', 'T', 1)); }); + + it('should render to string correctly', () => { + const substitutions = [ + { + substitution: new Substitution(undefined, 'A', 'T', 1), + expected: 'A1T', + }, + { substitution: new Substitution('segment', 'A', 'T', 1), expected: 'segment:A1T' }, + { substitution: new Substitution(undefined, undefined, undefined, 1), expected: '1' }, + ]; + + for (const { substitution, expected } of substitutions) { + expect(substitution.toString()).to.equal(expected); + } + }); }); + describe('Deletion', () => { it('should be parsed from string', () => { expect(Deletion.parse('A1-')).deep.equal(new Deletion(undefined, 'A', 1)); expect(Deletion.parse('seg1:A1-')).deep.equal(new Deletion('seg1', 'A', 1)); }); + + it('should render to string correctly', () => { + const substitutions = [ + { + deletion: new Deletion(undefined, 'A', 1), + expected: 'A1-', + }, + { deletion: new Deletion('segment', 'A', 1), expected: 'segment:A1-' }, + { deletion: new Deletion(undefined, undefined, 1), expected: '1-' }, + ]; + + for (const { deletion, expected } of substitutions) { + expect(deletion.toString()).to.equal(expected); + } + }); }); + describe('Insertion', () => { it('should be parsed from string', () => { expect(Insertion.parse('ins_1:A')).deep.equal(new Insertion(undefined, 1, 'A')); expect(Insertion.parse('ins_seg1:1:A')).deep.equal(new Insertion('seg1', 1, 'A')); }); + + it('should be parsed with case insensitive ins prefix', () => { + expect(Insertion.parse('INS_1:A')).deep.equal(new Insertion(undefined, 1, 'A')); + expect(Insertion.parse('iNs_1:A')).deep.equal(new Insertion(undefined, 1, 'A')); + }); + + it('should be parsed with the other parts not case insensitive', () => { + expect(Insertion.parse('ins_geNe1:1:A')).deep.equal(new Insertion('geNe1', 1, 'A')); + expect(Insertion.parse('ins_1:aA')).deep.equal(new Insertion(undefined, 1, 'aA')); + }); }); diff --git a/components/src/utils/mutations.ts b/components/src/utils/mutations.ts index 1f5a3334..6fd02870 100644 --- a/components/src/utils/mutations.ts +++ b/components/src/utils/mutations.ts @@ -4,69 +4,103 @@ export interface Mutation { readonly segment: string | undefined; readonly position: number; readonly code: string; + + equals(other: Mutation): boolean; + + toString(): string; } +export const substitutionRegex = + /^((?[A-Za-z0-9_-]+)(?=:):)?(?[A-Za-z])?(?\d+)(?[A-Za-z.])?$/; + export class Substitution implements Mutation { readonly code; constructor( readonly segment: string | undefined, - readonly valueAtReference: string, - readonly substitutionValue: string, + readonly valueAtReference: string | undefined, + readonly substitutionValue: string | undefined, readonly position: number, ) { - this.code = `${this.segment ? `${this.segment}:` : ''}${this.valueAtReference}${this.position}${this.substitutionValue}`; + const segmentString = this.segment ? `${this.segment}:` : ''; + const valueAtReferenceString = this.valueAtReference ? `${this.valueAtReference}` : ''; + const substitutionValueString = this.substitutionValue ? `${this.substitutionValue}` : ''; + this.code = `${segmentString}${valueAtReferenceString}${this.position}${substitutionValueString}`; + } + + equals(other: Mutation): boolean { + if (!(other instanceof Substitution)) { + return false; + } + return ( + this.segment === other.segment && + this.valueAtReference === other.valueAtReference && + this.substitutionValue === other.substitutionValue && + this.position === other.position + ); } toString() { return this.code; } - static parse(mutationStr: string): Substitution { - const parts = mutationStr.split(':'); - if (parts.length === 1) { - return new Substitution( - undefined, - parts[0].charAt(0), - parts[0].charAt(parts[0].length - 1), - parseInt(parts[0].slice(1, -1), 10), - ); - } else if (parts.length === 2) { - return new Substitution( - parts[0], - parts[1].charAt(0), - parts[1].charAt(parts[1].length - 1), - parseInt(parts[1].slice(1, -1), 10), - ); + static parse(mutationStr: string): Substitution | null { + const match = mutationStr.match(substitutionRegex); + if (match === null || match.groups === undefined) { + return null; } - throw Error(`Invalid substitution: ${mutationStr}`); + return new Substitution( + match.groups.segment, + match.groups.valueAtReference, + match.groups.substitutionValue, + parseInt(match.groups.position, 10), + ); } } +export const deletionRegex = /^((?[A-Za-z0-9_-]+)(?=:):)?(?[A-Za-z])?(?\d+)(-)$/; + export class Deletion implements Mutation { readonly code; constructor( readonly segment: string | undefined, - readonly valueAtReference: string, + readonly valueAtReference: string | undefined, readonly position: number, ) { - this.code = `${this.segment ? `${this.segment}:` : ''}${this.valueAtReference}${this.position}-`; + const segmentString = this.segment ? `${this.segment}:` : ''; + const valueAtReferenceString = this.valueAtReference ? `${this.valueAtReference}` : ''; + this.code = `${segmentString}${valueAtReferenceString}${this.position}-`; + } + + equals(other: Mutation): boolean { + if (!(other instanceof Deletion)) { + return false; + } + return ( + this.segment === other.segment && + this.valueAtReference === other.valueAtReference && + this.position === other.position + ); } toString() { return this.code; } - static parse(mutationStr: string): Deletion { - const substitution = Substitution.parse(mutationStr); - if (substitution.substitutionValue !== '-') { - throw Error(`Invalid deletion: ${mutationStr}`); + static parse(mutationStr: string): Deletion | null { + const match = mutationStr.match(deletionRegex); + if (match === null || match.groups === undefined) { + return null; } - return new Deletion(substitution.segment, substitution.valueAtReference, substitution.position); + + return new Deletion(match.groups.segment, match.groups.valueAtReference, parseInt(match.groups.position, 10)); } } +export const insertionRegexp = + /^ins_((?[A-Za-z0-9_-]+)(?=:):)?(?\d+):(?(([A-Za-z?]|(\.\*))+))$/i; + export class Insertion implements Mutation { readonly code; @@ -78,27 +112,31 @@ export class Insertion implements Mutation { this.code = `ins_${this.segment ? `${this.segment}:` : ''}${this.position}:${this.insertedSymbols}`; } + equals(other: Mutation): boolean { + if (!(other instanceof Insertion)) { + return false; + } + return ( + this.segment === other.segment && + this.insertedSymbols === other.insertedSymbols && + this.position === other.position + ); + } + toString() { return this.code; } - static parse(mutationStr: string): Insertion { - const insertionStr = mutationStr.slice(4); - const parts = insertionStr.split(':'); - if (parts.length === 2) { - return new Insertion(undefined, parseInt(parts[0], 10), parts[1]); - } else if (parts.length === 3) { - return new Insertion(parts[0], parseInt(parts[1], 10), parts[2]); + static parse(mutationStr: string): Insertion | null { + const match = mutationStr.match(insertionRegexp); + if (match === null || match.groups === undefined) { + return null; } - throw Error(`Invalid insertion: ${mutationStr}`); + + return new Insertion(match.groups.segment, parseInt(match.groups.position, 10), match.groups.insertedSymbols); } } -export const segmentName: { [P in SequenceType]: string } = { - nucleotide: 'Segment', - 'amino acid': 'Gene', -}; - export const bases: { [P in SequenceType]: string[] } = { nucleotide: ['A', 'C', 'G', 'T', '-'], 'amino acid': [ diff --git a/components/src/web-components/input/mutation-filter-component.stories.ts b/components/src/web-components/input/mutation-filter-component.stories.ts new file mode 100644 index 00000000..3d67817a --- /dev/null +++ b/components/src/web-components/input/mutation-filter-component.stories.ts @@ -0,0 +1,97 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import { expect, fn, userEvent, waitFor } from '@storybook/test'; +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +import { LAPIS_URL } from '../../constants'; +import '../app'; +import { withinShadowRoot } from '../withinShadowRoot.story'; +import './mutation-filter-component'; + +const meta: Meta = { + title: 'Input/Mutation filter', + component: 'gs-mutation-filter', + parameters: { + actions: { + handles: ['gs-mutation-filter-changed', 'gs-mutation-filter-on-blur'], + }, + fetchMock: {}, + }, + decorators: [withActions], +}; + +export default meta; + +export const Default: StoryObj<{ lapisField: string; placeholderText: string }> = { + render: () => { + return html` +
+ +
+
`; + }, +}; + +export const FiresFilterChangedEvent: StoryObj<{ lapisField: string; placeholderText: string }> = { + ...Default, + play: async ({ canvasElement, step }) => { + const canvas = await withinShadowRoot(canvasElement, 'gs-mutation-filter'); + + const inputField = () => canvas.getByPlaceholderText('Enter a mutation'); + const submitButton = () => canvas.getByRole('button', { name: '+' }); + const listenerMock = fn(); + await step('Setup event listener mock', async () => { + canvasElement.addEventListener('gs-mutation-filter-changed', listenerMock); + }); + + await step('wait until data is loaded', async () => { + await waitFor(() => { + return expect(inputField()).toBeEnabled(); + }); + }); + + await step('Enter a valid mutation', async () => { + await userEvent.type(inputField(), 'A123T'); + await waitFor(() => submitButton().click()); + + await waitFor(() => + expect(listenerMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + nucleotideMutations: ['A123T'], + aminoAcidMutations: [], + nucleotideInsertions: [], + aminoAcidInsertions: [], + }, + }), + ), + ); + }); + }, +}; + +export const FiresFilterOnBlurEvent: StoryObj<{ lapisField: string; placeholderText: string }> = { + ...Default, + play: async ({ canvasElement, step }) => { + const canvas = await withinShadowRoot(canvasElement, 'gs-mutation-filter'); + + const inputField = () => canvas.getByPlaceholderText('Enter a mutation'); + const listenerMock = fn(); + await step('Setup event listener mock', async () => { + canvasElement.addEventListener('gs-mutation-filter-on-blur', listenerMock); + }); + + await step('wait until data is loaded', async () => { + await waitFor(() => { + return expect(inputField()).toBeEnabled(); + }); + }); + + await step('Move outside of input', async () => { + await userEvent.type(inputField(), 'A123T'); + await userEvent.tab(); + + await expect(listenerMock).toHaveBeenCalled(); + }); + }, +}; diff --git a/components/src/web-components/input/mutation-filter-component.tsx b/components/src/web-components/input/mutation-filter-component.tsx new file mode 100644 index 00000000..d35ccd31 --- /dev/null +++ b/components/src/web-components/input/mutation-filter-component.tsx @@ -0,0 +1,27 @@ +import { customElement } from 'lit/decorators.js'; + +import { type TextInputComponent } from './text-input-component'; +import { MutationFilter, type SelectedMutationFilterStrings } from '../../preact/mutationFilter/mutation-filter'; +import { PreactLitAdapter } from '../PreactLitAdapter'; + +/** + * @fires {CustomEvent} gs-mutation-filter-changed - When the mutation filter values have changed + * @fires {CustomEvent} gs-mutation-filter-on-blur - When the mutation filter has lost focus + */ +@customElement('gs-mutation-filter') +export class MutationFilterComponent extends PreactLitAdapter { + override render() { + return ; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gs-mutation-filter': TextInputComponent; + } + + interface HTMLElementEventMap { + 'gs-mutation-filter-changed': CustomEvent; + 'gs-mutation-filter-on-blur': CustomEvent; + } +}