Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 120 additions & 33 deletions src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,141 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { InlineField, Select, Input, Stack } from '@grafana/ui';
import { Field, Select, AsyncSelect, Input, Button, stylesFactory } from '@grafana/ui';
import { DataSource } from '../datasource';
import { FhirDataSourceOptions, FhirQuery } from '../types';
import { FhirDataSourceOptions, MyQuery, Filter } from '../types';

type Props = QueryEditorProps<DataSource, FhirQuery, FhirDataSourceOptions>;
function debouncePromise<F extends (...args: any[]) => Promise<any>>(fn: F, wait: number) {
let timer: NodeJS.Timeout | null = null;
let resolvers: Array<(value: any) => void> = [];
return ((...args: Parameters<F>): Promise<ReturnType<F>> => {
if (timer) {
clearTimeout(timer);
}
return new Promise<ReturnType<F>>((resolve) => {
resolvers.push(resolve);
timer = setTimeout(async () => {
const result = await fn(...args);
resolvers.forEach(r => r(result));
resolvers = [];
}, wait);
});
}) as F;
}

interface Props extends QueryEditorProps<DataSource, MyQuery, FhirDataSourceOptions> {}

export function buildFhirSearchString(filters: Filter[]): string {
const opMap: Record<string, string> = { '!=': ':ne', '>': ':gt', '<': ':lt', contains: ':contains', '=': '' };
return filters
.filter(f => f.resourceType && f.field && f.value)
.map(f => `${f.resourceType}?${f.field}${opMap[f.operator || '='] || ''}=${f.value}`)
.join('&');
}

const operatorOptions: Array<SelectableValue<string>> = [
{ label: '=', value: '=' },
{ label: '!=', value: '!=' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: 'contains', value: 'contains' },
];

const getStyles = stylesFactory(() => ({
row: {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px',
},
}));

export function QueryEditor({ query, datasource, onChange, onRunQuery }: Props) {
export default function QueryEditor({ query, datasource, onChange, onRunQuery }: Props) {
const styles = getStyles();
const [resources, setResources] = useState<Array<SelectableValue<string>>>([]);
const operatorOptions = [
{ label: '==', value: '==' },
{ label: '!=', value: '!=' },
];
const fieldCache = useRef<Record<string, Array<SelectableValue<string>>>>({});

useEffect(() => {
datasource.getResourceTypes().then((types) => setResources(types));
datasource.getResourceTypes().then(setResources);
}, [datasource]);

const onResourceChange = (v: SelectableValue<string>) => {
onChange({ ...query, resourceType: v.value || '' });
onRunQuery();
useEffect(() => {
if (!query.filters || query.filters.length === 0) {
onChange({ ...query, filters: [{}] });
}
}, [query, onChange]);

const loadFields = async (rt: string) => {
if (fieldCache.current[rt]) {
return fieldCache.current[rt];
}
const opts = await datasource.getFields(rt);
fieldCache.current[rt] = opts;
return opts;
};

const onParamChange = (v: React.ChangeEvent<HTMLInputElement>) => {
onChange({ ...query, searchParam: v.target.value });
const debouncedLoad = useRef(debouncePromise(loadFields, 300)).current;

const updateFilter = (idx: number, patch: Partial<Filter>) => {
const filters = query.filters.map((f, i) => (i === idx ? { ...f, ...patch } : f));
onChange({ ...query, filters });
onRunQuery();
};

const onOperatorChange = (v: SelectableValue<string>) => {
onChange({ ...query, operator: v.value || '==' });
const addFilter = () => {
onChange({ ...query, filters: [...query.filters, {}] });
onRunQuery();
};

const onValueChange = (v: React.ChangeEvent<HTMLInputElement>) => {
onChange({ ...query, searchValue: v.target.value });
const removeFilter = (idx: number) => {
const filters = query.filters.filter((_, i) => i !== idx);
onChange({ ...query, filters: filters.length > 0 ? filters : [{}] });
onRunQuery();
};

return (
<Stack gap={1} wrap="nowrap" direction="row">
<InlineField label="Resource">
<Select options={resources} value={query.resourceType} onChange={onResourceChange} width={20} />
</InlineField>
<InlineField label="Search">
<Input width={20} value={query.searchParam || ''} onChange={onParamChange} placeholder="code" />
</InlineField>
<InlineField label="Op">
<Select options={operatorOptions} value={query.operator} onChange={onOperatorChange} width={8} />
</InlineField>
<InlineField label="Value">
<Input width={20} value={query.searchValue || ''} onChange={onValueChange} placeholder="*" />
</InlineField>
</Stack>
<div>
{query.filters.map((f, i) => (
<div className={styles.row} key={i}>
<Field label="Resource Type" horizontal>
<Select
options={resources}
value={f.resourceType}
onChange={v => updateFilter(i, { resourceType: v.value, field: undefined })}
width={20}
/>
</Field>
<Field label="Field" horizontal>
<AsyncSelect
isDisabled={!f.resourceType}
loadOptions={(value) => (f.resourceType ? debouncedLoad(f.resourceType) : Promise.resolve([]))}
onChange={v => updateFilter(i, { field: v.value })}
value={f.field}
width={20}
/>
</Field>
<Field label="Operator" horizontal>
<Select
options={operatorOptions}
value={f.operator}
onChange={v => updateFilter(i, { operator: v.value })}
isDisabled={!f.field}
width={12}
/>
</Field>
<Field label="Value" horizontal>
<Input
value={f.value || ''}
onChange={e => updateFilter(i, { value: e.target.value }, true)}
disabled={!f.operator}
width={20}
/>
</Field>
<Button variant="secondary" icon="trash-alt" onClick={() => removeFilter(i)} />
</div>
))}
<Button variant="secondary" onClick={addFilter} icon="plus">
+ Add filter
</Button>
</div>
);
}
56 changes: 44 additions & 12 deletions src/datasource.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { DataSourceApi, DataSourceInstanceSettings, DataQueryRequest, DataQueryResponse, MutableDataFrame, FieldType } from '@grafana/data';
import { DataSourceApi, DataSourceInstanceSettings, DataQueryRequest, DataQueryResponse, MutableDataFrame, FieldType, SelectableValue } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { firstValueFrom } from 'rxjs';
import { FhirQuery, FhirDataSourceOptions, DEFAULT_QUERY } from './types';
import { MyQuery, Filter, FhirDataSourceOptions, DEFAULT_QUERY } from './types';

export class DataSource extends DataSourceApi<FhirQuery, FhirDataSourceOptions> {
export class DataSource extends DataSourceApi<MyQuery, FhirDataSourceOptions> {
instanceSettings: DataSourceInstanceSettings<FhirDataSourceOptions>;

constructor(instanceSettings: DataSourceInstanceSettings<FhirDataSourceOptions>) {
Expand All @@ -29,29 +29,35 @@ export class DataSource extends DataSourceApi<FhirQuery, FhirDataSourceOptions>
return DEFAULT_QUERY;
}

async query(options: DataQueryRequest<FhirQuery>): Promise<DataQueryResponse> {
const promises = options.targets.map(t => this.fetchSeries(t));
async query(options: DataQueryRequest<MyQuery>): Promise<DataQueryResponse> {
const promises = options.targets.flatMap(target =>
target.filters.map(f => this.fetchSeries({ ...f, refId: target.refId }))
);
const data = await Promise.all(promises);
return { data };
}

async fetchSeries(query: FhirQuery) {
async fetchSeries(filter: Filter & { refId?: string }) {
if (!filter.resourceType) {
return new MutableDataFrame({ refId: filter.refId, fields: [] });
}
let params = '';
if (query.searchParam && query.searchValue) {
const prefix = query.operator === '!=' ? ':ne' : '';
params = `?${encodeURIComponent(query.searchParam)}${prefix}=${encodeURIComponent(query.searchValue)}`;
if (filter.field && filter.value) {
const opMap: Record<string, string> = { '!=': ':ne', '>': ':gt', '<': ':lt', contains: ':contains', '=': '' };
const op = opMap[filter.operator || '='] || '';
params = `?${encodeURIComponent(filter.field)}${op}=${encodeURIComponent(filter.value)}`;
}
const url = `${this.getBaseUrl()}/${query.resourceType}${params}`;
const url = `${this.getBaseUrl()}/${filter.resourceType}${params}`;
const res = await firstValueFrom(getBackendSrv().fetch<any>({ url }));

const resources = (res.data.entry || []).map((e: any) => e.resource || {});
if (resources.length === 0) {
return new MutableDataFrame({ refId: query.refId, fields: [] });
return new MutableDataFrame({ refId: filter.refId, fields: [] });
}

const columns: string[] = Array.from(new Set(resources.flatMap((r: any) => Object.keys(r)))) as string[];
const fields = columns.map((name: string) => ({ name, type: FieldType.string }));
const frame = new MutableDataFrame({ refId: query.refId, fields });
const frame = new MutableDataFrame({ refId: filter.refId, fields });

resources.forEach((r: any) => {
const row: Record<string, any> = {};
Expand Down Expand Up @@ -93,4 +99,30 @@ export class DataSource extends DataSourceApi<FhirQuery, FhirDataSourceOptions>
throw err;
}
}

async getFields(resourceType: string): Promise<Array<SelectableValue<string>>> {
const base = this.getBaseUrl();
try {
const res = await firstValueFrom(
getBackendSrv().fetch<any>({ url: `${base}/SearchParameter?base=${resourceType}` })
);
const entries = res.data.entry || [];
if (entries.length > 0) {
return entries.map((e: any) => ({ label: e.resource?.code, value: e.resource?.code }));
}
} catch (err) {
console.error('SearchParameter fetch failed', err);
}

try {
const res = await firstValueFrom(
getBackendSrv().fetch<any>({ url: `${base}/StructureDefinition/${resourceType}` })
);
const elements = res.data.snapshot?.element || [];
return elements.map((e: any) => ({ label: e.path, value: e.path }));
} catch (err) {
console.error('Failed to fetch fields', err);
return [];
}
}
}
6 changes: 3 additions & 3 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { DataSourcePlugin } from '@grafana/data';
import { DataSource } from './datasource';
import { ConfigEditor } from './components/ConfigEditor';
import { QueryEditor } from './components/QueryEditor';
import { FhirQuery, FhirDataSourceOptions } from './types';
import QueryEditor from './components/QueryEditor';
import { MyQuery, FhirDataSourceOptions } from './types';

export const plugin = new DataSourcePlugin<DataSource, FhirQuery, FhirDataSourceOptions>(DataSource)
export const plugin = new DataSourcePlugin<DataSource, MyQuery, FhirDataSourceOptions>(DataSource)
.setConfigEditor(ConfigEditor)
.setQueryEditor(QueryEditor);
18 changes: 11 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { DataQuery, DataSourceJsonData } from '@grafana/data';

export interface FhirQuery extends DataQuery {
resourceType: string;
searchParam?: string;
export interface Filter {
resourceType?: string;
field?: string;
operator?: string;
searchValue?: string;
value?: string;
}

export const DEFAULT_QUERY: Partial<FhirQuery> = {
resourceType: 'Observation',
operator: '==',
export interface MyQuery extends DataQuery {
filters: Filter[];
}

export const DEFAULT_QUERY: MyQuery = {
refId: '',
filters: [{}],
};

export interface FhirDataSourceOptions extends DataSourceJsonData {
Expand Down