@genspectrum/dashboard-components 0.1.1 → 0.1.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.
@@ -0,0 +1,98 @@
1
+ import { type FunctionComponent } from 'preact';
2
+ import { useContext } from 'preact/hooks';
3
+
4
+ import { AggregateTable } from './aggregate-table';
5
+ import { type AggregateData, queryAggregateData } from '../../query/queryAggregateData';
6
+ import { type LapisFilter } from '../../types';
7
+ import { LapisUrlContext } from '../LapisUrlContext';
8
+ import { CsvDownloadButton } from '../components/csv-download-button';
9
+ import { ErrorDisplay } from '../components/error-display';
10
+ import Headline from '../components/headline';
11
+ import Info from '../components/info';
12
+ import { LoadingDisplay } from '../components/loading-display';
13
+ import { NoDataDisplay } from '../components/no-data-display';
14
+ import Tabs from '../components/tabs';
15
+ import { useQuery } from '../useQuery';
16
+
17
+ export type View = 'table';
18
+
19
+ export interface AggregateProps {
20
+ filter: LapisFilter;
21
+ fields: string[];
22
+ views: View[];
23
+ }
24
+
25
+ export const Aggregate: FunctionComponent<AggregateProps> = ({ fields, views, filter }) => {
26
+ const lapis = useContext(LapisUrlContext);
27
+
28
+ const { data, error, isLoading } = useQuery(async () => {
29
+ return queryAggregateData(filter, fields, lapis);
30
+ }, [filter, fields, lapis]);
31
+
32
+ const headline = 'Aggregate';
33
+
34
+ if (isLoading) {
35
+ return (
36
+ <Headline heading={headline}>
37
+ <LoadingDisplay />
38
+ </Headline>
39
+ );
40
+ }
41
+
42
+ if (error !== null) {
43
+ return (
44
+ <Headline heading={headline}>
45
+ <ErrorDisplay error={error} />
46
+ </Headline>
47
+ );
48
+ }
49
+
50
+ if (data === null) {
51
+ return (
52
+ <Headline heading={headline}>
53
+ <NoDataDisplay />
54
+ </Headline>
55
+ );
56
+ }
57
+
58
+ return (
59
+ <Headline heading={headline}>
60
+ <AggregatedDataTabs data={data} views={views} fields={fields} />
61
+ </Headline>
62
+ );
63
+ };
64
+
65
+ type AggregatedDataTabsProps = {
66
+ data: AggregateData;
67
+ fields: string[];
68
+ views: View[];
69
+ };
70
+
71
+ const AggregatedDataTabs: FunctionComponent<AggregatedDataTabsProps> = ({ data, views, fields }) => {
72
+ const getTab = (view: View) => {
73
+ switch (view) {
74
+ case 'table':
75
+ return {
76
+ title: 'Table',
77
+ content: <AggregateTable data={data} fields={fields} />,
78
+ };
79
+ }
80
+ };
81
+
82
+ const tabs = views.map((view) => getTab(view));
83
+
84
+ return <Tabs tabs={tabs} toolbar={<Toolbar data={data} />} />;
85
+ };
86
+
87
+ type ToolbarProps = {
88
+ data: AggregateData;
89
+ };
90
+
91
+ const Toolbar: FunctionComponent<ToolbarProps> = ({ data }) => {
92
+ return (
93
+ <div class='flex flex-row'>
94
+ <CsvDownloadButton className='mx-1 btn btn-xs' getData={() => data} filename='aggregate.csv' />
95
+ <Info className='mx-1' content='Info for aggregate' />
96
+ </div>
97
+ );
98
+ };
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { queryAggregateData } from './queryAggregateData';
4
+ import { DUMMY_LAPIS_URL, lapisRequestMocks } from '../../vitest.setup';
5
+
6
+ describe('queryAggregateData', () => {
7
+ test('should fetch aggregate data', async () => {
8
+ const fields = ['division', 'host'];
9
+ const filter = { country: 'USA' };
10
+
11
+ lapisRequestMocks.aggregated(
12
+ { fields, ...filter },
13
+ {
14
+ data: [
15
+ { count: 4, region: 'region1', host: 'host1' },
16
+ { count: 4, region: 'region1', host: 'host2' },
17
+ { count: 8, region: 'region2', host: 'host1' },
18
+ { count: 16, region: 'region2', host: 'host2' },
19
+ ],
20
+ },
21
+ );
22
+
23
+ const result = await queryAggregateData(filter, fields, DUMMY_LAPIS_URL);
24
+
25
+ expect(result).to.deep.equal([
26
+ { proportion: 0.125, count: 4, region: 'region1', host: 'host1' },
27
+ { proportion: 0.125, count: 4, region: 'region1', host: 'host2' },
28
+ { proportion: 0.25, count: 8, region: 'region2', host: 'host1' },
29
+ { proportion: 0.5, count: 16, region: 'region2', host: 'host2' },
30
+ ]);
31
+ });
32
+ });
@@ -0,0 +1,25 @@
1
+ import { FetchAggregatedOperator } from '../operator/FetchAggregatedOperator';
2
+ import { type LapisFilter } from '../types';
3
+
4
+ export type AggregateData = (Record<string, string | null | number | boolean> & {
5
+ count: number;
6
+ proportion: number;
7
+ })[];
8
+
9
+ export async function queryAggregateData(variant: LapisFilter, fields: string[], lapis: string, signal?: AbortSignal) {
10
+ const fetchData = new FetchAggregatedOperator<Record<string, string | null | number>>(variant, fields);
11
+ const data = (await fetchData.evaluate(lapis, signal)).content;
12
+
13
+ const total = data.reduce((acc, row) => acc + row.count, 0);
14
+
15
+ return data.map(
16
+ (row) =>
17
+ ({
18
+ ...row,
19
+ proportion: row.count / total,
20
+ }) as Record<string, string | null | number | boolean> & {
21
+ count: number;
22
+ proportion: number;
23
+ },
24
+ );
25
+ }
@@ -0,0 +1,25 @@
1
+ import { ArgTypes, Description, Meta, Story, Title, Source } from '@storybook/blocks';
2
+
3
+ import * as AggregateComponentStories from './aggregate-component.stories.ts';
4
+
5
+ <Meta of={AggregateComponentStories} name='Docs' />
6
+
7
+ <Title of={AggregateComponentStories} />
8
+
9
+ <Description of={AggregateComponentStories} />
10
+
11
+ ## Specification
12
+
13
+ <ArgTypes of={AggregateComponentStories} />
14
+
15
+ ## Example
16
+
17
+ <Source
18
+ code={`<gs-aggregate-component fields="['division', 'host']" filter='{"country": "USA"}'></gs-aggregate-component>`}
19
+ />
20
+
21
+ ### Live Example
22
+
23
+ [See here](?path=/story/visualization-aggregate--template)
24
+
25
+ <Story of={AggregateComponentStories.Table} />
@@ -0,0 +1,63 @@
1
+ import { type Meta, type StoryObj } from '@storybook/web-components';
2
+ import { html } from 'lit';
3
+
4
+ import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
5
+ import aggregatedData from '../../preact/aggregatedData/__mockData__/aggregated.json';
6
+ import type { AggregateProps } from '../../preact/aggregatedData/aggregate';
7
+ import './aggregate-component';
8
+ import '../app';
9
+
10
+ const meta: Meta<AggregateProps> = {
11
+ title: 'Visualization/Aggregate',
12
+ component: 'gs-aggregate-component',
13
+ argTypes: {
14
+ fields: [{ control: 'object' }],
15
+ views: {
16
+ options: ['table'],
17
+ control: { type: 'check' },
18
+ },
19
+ },
20
+ parameters: {
21
+ fetchMock: {
22
+ mocks: [
23
+ {
24
+ matcher: {
25
+ name: 'aggregatedData',
26
+ url: AGGREGATED_ENDPOINT,
27
+ body: {
28
+ fields: ['division', 'host'],
29
+ country: 'USA',
30
+ },
31
+ },
32
+ response: {
33
+ status: 200,
34
+ body: aggregatedData,
35
+ },
36
+ },
37
+ ],
38
+ },
39
+ },
40
+ };
41
+
42
+ export default meta;
43
+
44
+ export const Table: StoryObj<AggregateProps> = {
45
+ render: (args) => html`
46
+ <div class="w-11/12 h-11/12">
47
+ <gs-app lapis="${LAPIS_URL}">
48
+ <gs-aggregate-component
49
+ .fields=${args.fields}
50
+ .filter=${args.filter}
51
+ .views=${args.views}
52
+ ></gs-aggregate-component>
53
+ </gs-app>
54
+ </div>
55
+ `,
56
+ args: {
57
+ fields: ['division', 'host'],
58
+ views: ['table'],
59
+ filter: {
60
+ country: 'USA',
61
+ },
62
+ },
63
+ };
@@ -0,0 +1,51 @@
1
+ import { customElement, property } from 'lit/decorators.js';
2
+
3
+ import { Aggregate, type View } from '../../preact/aggregatedData/aggregate';
4
+ import { type LapisFilter } from '../../types';
5
+ import { PreactLitAdapterWithGridJsStyles } from '../PreactLitAdapterWithGridJsStyles';
6
+
7
+ /**
8
+ * ## Tag
9
+ *
10
+ * `gs-aggregate-component`
11
+ *
12
+ * ## Context
13
+ *
14
+ * This component displays aggregated data, which can provide an overview of the underlying data.
15
+ *
16
+ * It expects a list of fields to aggregate by and a filter to apply to the data.
17
+ */
18
+ @customElement('gs-aggregate-component')
19
+ export class AggregateComponent extends PreactLitAdapterWithGridJsStyles {
20
+ /**
21
+ * The fields to aggregate by.
22
+ */
23
+ @property({ type: Array })
24
+ fields: string[] = [];
25
+
26
+ /**
27
+ * The views are used to display the aggregated data.
28
+ * In the table view, the data is presented in a table format where each field is a column,
29
+ * along with the aggregated value and its proportion.
30
+ * The proportion represents the ratio of the aggregated value to the total count of the data
31
+ * (considering the applied filter).
32
+ */
33
+ @property({ type: Array })
34
+ views: View[] = ['table'];
35
+
36
+ /**
37
+ * The filter to apply to the data.
38
+ */
39
+ @property({ type: Object })
40
+ filter: LapisFilter = {};
41
+
42
+ override render() {
43
+ return <Aggregate fields={this.fields} views={this.views} filter={this.filter} />;
44
+ }
45
+ }
46
+
47
+ declare global {
48
+ interface HTMLElementTagNameMap {
49
+ 'gs-aggregate-component': AggregateComponent;
50
+ }
51
+ }
@@ -2,3 +2,4 @@ export { MutationComparisonComponent } from './mutation-comparison-component';
2
2
  export { MutationsComponent } from './mutations-component';
3
3
  export { PrevalenceOverTimeComponent } from './prevalence-over-time-component';
4
4
  export { RelativeGrowthAdvantageComponent } from './relative-growth-advantage-component';
5
+ export { AggregateComponent } from './aggregate-component';
@@ -97,7 +97,7 @@ export const FetchingLocationsFails: StoryObj<LocationFilterProps> = {
97
97
  {
98
98
  matcher: aggregatedEndpointMatcher,
99
99
  response: {
100
- status: 500,
100
+ status: 400,
101
101
  body: { error: 'no data' },
102
102
  },
103
103
  },
@@ -108,9 +108,7 @@ export const FetchingLocationsFails: StoryObj<LocationFilterProps> = {
108
108
  const canvas = await withinShadowRoot(canvasElement, 'gs-location-filter');
109
109
 
110
110
  await waitFor(() =>
111
- expect(
112
- canvas.getByText('Internal Server Error: {"error":"no data"} ', { exact: false }),
113
- ).toBeInTheDocument(),
111
+ expect(canvas.getByText('Bad Request: {"error":"no data"} ', { exact: false })).toBeInTheDocument(),
114
112
  );
115
113
  },
116
114
  };