diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 022889a..ef512b6 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -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; +function debouncePromise Promise>(fn: F, wait: number) { + let timer: NodeJS.Timeout | null = null; + let resolvers: Array<(value: any) => void> = []; + return ((...args: Parameters): Promise> => { + if (timer) { + clearTimeout(timer); + } + return new Promise>((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 {} + +export function buildFhirSearchString(filters: Filter[]): string { + const opMap: Record = { '!=': ':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> = [ + { 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>>([]); - const operatorOptions = [ - { label: '==', value: '==' }, - { label: '!=', value: '!=' }, - ]; + const fieldCache = useRef>>>({}); useEffect(() => { - datasource.getResourceTypes().then((types) => setResources(types)); + datasource.getResourceTypes().then(setResources); }, [datasource]); - const onResourceChange = (v: SelectableValue) => { - 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) => { - onChange({ ...query, searchParam: v.target.value }); + const debouncedLoad = useRef(debouncePromise(loadFields, 300)).current; + + const updateFilter = (idx: number, patch: Partial) => { + const filters = query.filters.map((f, i) => (i === idx ? { ...f, ...patch } : f)); + onChange({ ...query, filters }); + onRunQuery(); }; - const onOperatorChange = (v: SelectableValue) => { - onChange({ ...query, operator: v.value || '==' }); + const addFilter = () => { + onChange({ ...query, filters: [...query.filters, {}] }); + onRunQuery(); }; - const onValueChange = (v: React.ChangeEvent) => { - 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 ( - - - - - - - - +
+ {query.filters.map((f, i) => ( +
+ + updateFilter(i, { operator: v.value })} + isDisabled={!f.field} + width={12} + /> + + + updateFilter(i, { value: e.target.value }, true)} + disabled={!f.operator} + width={20} + /> + +
+ ))} + +
); } diff --git a/src/datasource.ts b/src/datasource.ts index b71b2fc..21807b9 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -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 { +export class DataSource extends DataSourceApi { instanceSettings: DataSourceInstanceSettings; constructor(instanceSettings: DataSourceInstanceSettings) { @@ -29,29 +29,35 @@ export class DataSource extends DataSourceApi return DEFAULT_QUERY; } - async query(options: DataQueryRequest): Promise { - const promises = options.targets.map(t => this.fetchSeries(t)); + async query(options: DataQueryRequest): Promise { + 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 = { '!=': ':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({ 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 = {}; @@ -93,4 +99,30 @@ export class DataSource extends DataSourceApi throw err; } } + + async getFields(resourceType: string): Promise>> { + const base = this.getBaseUrl(); + try { + const res = await firstValueFrom( + getBackendSrv().fetch({ 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({ 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 []; + } + } } diff --git a/src/module.ts b/src/module.ts index 84a9234..91e358d 100644 --- a/src/module.ts +++ b/src/module.ts @@ -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) +export const plugin = new DataSourcePlugin(DataSource) .setConfigEditor(ConfigEditor) .setQueryEditor(QueryEditor); diff --git a/src/types.ts b/src/types.ts index 345cb3f..c534041 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { - resourceType: 'Observation', - operator: '==', +export interface MyQuery extends DataQuery { + filters: Filter[]; +} + +export const DEFAULT_QUERY: MyQuery = { + refId: '', + filters: [{}], }; export interface FhirDataSourceOptions extends DataSourceJsonData {