From 96c2b3ee9f80e3b77fd61684298fc90fa973bf1d Mon Sep 17 00:00:00 2001 From: richie Date: Thu, 9 Jan 2025 14:53:53 +0530 Subject: [PATCH 01/37] Added basic control in the query editor --- README.md | 1 + provisioning/example.yaml | 4 + src/datasources/product/ProductDataSource.ts | 71 ++++++++++++++ src/datasources/product/README.md | 6 ++ .../product/components/ProductQueryEditor.tsx | 95 +++++++++++++++++++ src/datasources/product/img/logo-ni.svg | 11 +++ src/datasources/product/module.ts | 8 ++ src/datasources/product/plugin.json | 15 +++ src/datasources/product/types.ts | 84 ++++++++++++++++ 9 files changed, 295 insertions(+) create mode 100644 src/datasources/product/ProductDataSource.ts create mode 100644 src/datasources/product/README.md create mode 100644 src/datasources/product/components/ProductQueryEditor.tsx create mode 100644 src/datasources/product/img/logo-ni.svg create mode 100644 src/datasources/product/module.ts create mode 100644 src/datasources/product/plugin.json create mode 100644 src/datasources/product/types.ts diff --git a/README.md b/README.md index 7614ec38..a0a478f5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Tools](https://grafana.github.io/plugin-tools/). - [Systems](src/datasources/system/) - [Tags](src/datasources/tag/) - [Assets](src/datasources/asset/) +- [Products](src/datasources/product/) ### Panels diff --git a/provisioning/example.yaml b/provisioning/example.yaml index 3fce34e1..568733f0 100644 --- a/provisioning/example.yaml +++ b/provisioning/example.yaml @@ -41,3 +41,7 @@ datasources: type: ni-slassetcalibration-datasource uid: assetcalibration <<: *config + - name: SystemLink Products + type: ni-slproducts-datasource + uid: products + <<: *config diff --git a/src/datasources/product/ProductDataSource.ts b/src/datasources/product/ProductDataSource.ts new file mode 100644 index 00000000..223c94a4 --- /dev/null +++ b/src/datasources/product/ProductDataSource.ts @@ -0,0 +1,71 @@ +import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, FieldType, TestDataSourceResponse } from '@grafana/data'; +import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime'; +import { DataSourceBase } from 'core/DataSourceBase'; +import { ProductQuery, ProductsProperties, Properties, PropertiesOptions, QueryProductResponse } from './types'; + +export class ProductDataSource extends DataSourceBase { + constructor( + readonly instanceSettings: DataSourceInstanceSettings, + readonly backendSrv: BackendSrv = getBackendSrv(), + readonly templateSrv: TemplateSrv = getTemplateSrv() + ) { + super(instanceSettings, backendSrv, templateSrv); + } + + baseUrl = this.instanceSettings.url; + + defaultQuery = { + properties: [ + PropertiesOptions.ID, + PropertiesOptions.PART_NUMBER, + PropertiesOptions.NAME, + PropertiesOptions.FAMILY, + PropertiesOptions.UPDATEDAT, + PropertiesOptions.WORKSPACE + ] as Properties[], + orderBy: '', + descending: false, + recordCount: 1000 + }; + + async queryProducts(filter?: string, orderBy?: string, projection?: Properties[], recordCount = 1000, descending = false, returnCount = false): Promise { + const response = await this.post(this.baseUrl + '/nitestmonitor/v2/query-products', { + filter: filter, + orderBy: orderBy, + descending: descending, + projection: projection ? projection : undefined, + take: recordCount, + returnCount: returnCount + }); + return response; + } + + async runQuery(query: ProductQuery, options: DataQueryRequest): Promise { + const responseData = (await this.queryProducts(undefined, query.orderBy, query.properties!, query.recordCount, query.descending)).products; + const filteredFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; + const fields = filteredFields.map((field) => { + const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; + const values = responseData.map(data => data[field as unknown as keyof ProductsProperties]); + + if (field === PropertiesOptions.PROPERTIES) { + return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; + } + return { name: field, values, type: fieldType }; + }); + + return { + refId: query.refId, + fields: fields + }; + } + + shouldRunQuery(query: ProductQuery): boolean { + return true; + } + + async testDatasource(): Promise { + // TODO: Implement a health and authentication check + await this.backendSrv.get(this.baseUrl + '/bar'); + return { status: 'success', message: 'Data source connected and authentication successful!' }; + } +} diff --git a/src/datasources/product/README.md b/src/datasources/product/README.md new file mode 100644 index 00000000..60eff55a --- /dev/null +++ b/src/datasources/product/README.md @@ -0,0 +1,6 @@ +# Systemlink Product data source + + diff --git a/src/datasources/product/components/ProductQueryEditor.tsx b/src/datasources/product/components/ProductQueryEditor.tsx new file mode 100644 index 00000000..14de9712 --- /dev/null +++ b/src/datasources/product/components/ProductQueryEditor.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { AutoSizeInput, HorizontalGroup, InlineSwitch, MultiSelect, Select, VerticalGroup } from '@grafana/ui'; +import { QueryEditorProps, SelectableValue } from '@grafana/data'; +import { InlineField } from 'core/components/InlineField'; +import { ProductDataSource } from '../ProductDataSource'; +import { OrderBy, ProductQuery, Properties } from '../types'; + +type Props = QueryEditorProps; + +export function ProductQueryEditor({ query, onChange, onRunQuery, datasource }: Props) { + query = datasource.prepareQuery(query); + + const onPropertiesChange = (items: Array>) => { + if (items !== undefined) { + onChange({ ...query, properties: items.map(i => i.value as Properties) }); + onRunQuery(); + } + }; + + const onOrderByChange = (item: SelectableValue) => { + onChange({ ...query, orderBy: item.value }); + onRunQuery(); + } + + const onDescendingChange = (isDescendingChecked: boolean) => { + onChange({ ...query, descending: isDescendingChecked }); + onRunQuery(); + } + + const recordCountChange = (event: React.FormEvent) => { + const value = parseInt((event.target as HTMLInputElement).value, 10); + onChange({ ...query, recordCount: value }); + onRunQuery(); + } + + return ( + <> + + + + ({ label: value, value })) as SelectableValue[]} + onChange={onPropertiesChange} + value={query.properties} + defaultValue={query.properties!} + maxVisibleValues={5} + allowCustomValue={false} + closeMenuOnSelect={false} + /> + +
+
+ + @@ -77,6 +77,7 @@ export function ProductQueryEditor({ query, onChange, onRunQuery, datasource }: maxWidth={40} defaultValue={query.recordCount} onCommitChange={recordCountChange} + placeholder='Enter record count' />
diff --git a/src/datasources/product/components/productQueryEditor.test.tsx b/src/datasources/product/components/productQueryEditor.test.tsx index ca737455..0f085dbf 100644 --- a/src/datasources/product/components/productQueryEditor.test.tsx +++ b/src/datasources/product/components/productQueryEditor.test.tsx @@ -3,14 +3,71 @@ import { ProductDataSource } from "../ProductDataSource"; import { ProductQueryEditor } from "./ProductQueryEditor"; import { screen, waitFor } from "@testing-library/react"; import { ProductQuery } from "../types"; +import { select } from "react-select-event"; +import userEvent from "@testing-library/user-event"; const render = setupRenderer(ProductQueryEditor, ProductDataSource); +let onChange: jest.Mock +let properties: HTMLElement +let orderBy: HTMLElement +let descending: HTMLElement +let recordCount: HTMLElement -it('renders with query default controls', async () => { - render({} as ProductQuery); +describe('ProductQueryEditor', () => { + beforeEach(async() => { + [onChange] = render({ refId: '', properties: [], orderBy: undefined} as ProductQuery); + await waitFor(() => properties = screen.getAllByRole('combobox')[0]); + orderBy = screen.getAllByRole('combobox')[1]; + descending = screen.getByRole('checkbox'); + recordCount = screen.getByRole('textbox'); + }); + + it('renders with query default ', async () => { + expect(properties).toBeInTheDocument(); + expect(properties).not.toBeNull(); + + expect(orderBy).toBeInTheDocument(); + expect(orderBy).toHaveAccessibleDescription('Select field to order by'); + + expect(descending).toBeInTheDocument(); + expect(descending).not.toBeChecked(); + + expect(recordCount).toBeInTheDocument(); + expect(recordCount).toHaveValue('1000'); + }); + + it('updates when user makes changes', async () => { + //User changes properties + await select(properties, "id", { container: document.body }); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ properties: ["id"] }) + ) + }); + + //User changes order by + await select(orderBy, "ID", { container: document.body }); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: "ID" }) + ) + }); - await waitFor(() => expect(screen.getAllByText('Properties').length).toBe(1)); - await waitFor(() => expect(screen.getAllByText('Records to Query').length).toBe(1)); - await waitFor(() => expect(screen.getAllByText('Descending').length).toBe(1)); - await waitFor(() => expect(screen.getAllByText('OrderBy').length).toBe(1)); + //User changes descending checkbox + await userEvent.click(descending); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ descending: true }) + ) + }); + + //User changes record count + await userEvent.clear(recordCount); + await userEvent.type(recordCount, '500{Enter}'); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ recordCount: 500 }) + ) + }); + }); }); \ No newline at end of file From 49e0b088616bb0cc2c5398e93ca36257ec3ca780 Mon Sep 17 00:00:00 2001 From: richie Date: Wed, 15 Jan 2025 15:43:09 +0530 Subject: [PATCH 07/37] fix lint error --- .../product/components/productQueryEditor.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datasources/product/components/productQueryEditor.test.tsx b/src/datasources/product/components/productQueryEditor.test.tsx index 0f085dbf..e4a33a6b 100644 --- a/src/datasources/product/components/productQueryEditor.test.tsx +++ b/src/datasources/product/components/productQueryEditor.test.tsx @@ -22,7 +22,7 @@ describe('ProductQueryEditor', () => { recordCount = screen.getByRole('textbox'); }); - it('renders with query default ', async () => { + it('renders with query default', async () => { expect(properties).toBeInTheDocument(); expect(properties).not.toBeNull(); @@ -70,4 +70,4 @@ describe('ProductQueryEditor', () => { ) }); }); -}); \ No newline at end of file +}); From c2eb22c666e064f6a6c68aafe78d0decb121397f Mon Sep 17 00:00:00 2001 From: richie Date: Wed, 15 Jan 2025 16:07:17 +0530 Subject: [PATCH 08/37] fix: renamed datsaouce to products --- README.md | 2 +- src/datasources/product/module.ts | 8 -------- .../ProductsDataSource.test.ts} | 8 ++++---- .../ProductsDataSource.ts} | 7 ++++--- src/datasources/{product => products}/README.md | 0 .../components/ProductsQueryEditor.test.tsx} | 14 +++++++------- .../components/ProductsQueryEditor.tsx} | 6 +++--- .../{product => products}/img/logo-ni.svg | 0 src/datasources/products/module.ts | 8 ++++++++ src/datasources/{product => products}/plugin.json | 2 +- src/datasources/{product => products}/types.ts | 0 11 files changed, 28 insertions(+), 27 deletions(-) delete mode 100644 src/datasources/product/module.ts rename src/datasources/{product/ProductDataSource.test.ts => products/ProductsDataSource.test.ts} (95%) rename src/datasources/{product/ProductDataSource.ts => products/ProductsDataSource.ts} (93%) rename src/datasources/{product => products}/README.md (100%) rename src/datasources/{product/components/productQueryEditor.test.tsx => products/components/ProductsQueryEditor.test.tsx} (85%) rename src/datasources/{product/components/ProductQueryEditor.tsx => products/components/ProductsQueryEditor.tsx} (94%) rename src/datasources/{product => products}/img/logo-ni.svg (100%) create mode 100644 src/datasources/products/module.ts rename src/datasources/{product => products}/plugin.json (86%) rename src/datasources/{product => products}/types.ts (100%) diff --git a/README.md b/README.md index a0a478f5..b7690eec 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Tools](https://grafana.github.io/plugin-tools/). - [Systems](src/datasources/system/) - [Tags](src/datasources/tag/) - [Assets](src/datasources/asset/) -- [Products](src/datasources/product/) +- [Products](src/datasources/products/) ### Panels diff --git a/src/datasources/product/module.ts b/src/datasources/product/module.ts deleted file mode 100644 index 00ea0c59..00000000 --- a/src/datasources/product/module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DataSourcePlugin } from '@grafana/data'; -import { ProductDataSource } from './ProductDataSource'; -import { ProductQueryEditor } from './components/ProductQueryEditor'; -import { HttpConfigEditor } from 'core/components/HttpConfigEditor'; - -export const plugin = new DataSourcePlugin(ProductDataSource) - .setConfigEditor(HttpConfigEditor) - .setQueryEditor(ProductQueryEditor); diff --git a/src/datasources/product/ProductDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts similarity index 95% rename from src/datasources/product/ProductDataSource.test.ts rename to src/datasources/products/ProductsDataSource.test.ts index 60701dc5..a6d010cb 100644 --- a/src/datasources/product/ProductDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -1,5 +1,5 @@ import { BackendSrvRequest, FetchResponse } from '@grafana/runtime'; -import { ProductDataSource } from './ProductDataSource'; +import { ProductsDataSource } from './ProductsDataSource'; import { ProductQuery, Properties, PropertiesOptions, QueryProductResponse } from './types'; import { Observable, of } from 'rxjs'; import { DataQueryRequest, DataSourceInstanceSettings, dateTime, Field } from '@grafana/data'; @@ -19,7 +19,7 @@ const mockQueryProductResponse: QueryProductResponse = { totalCount: 2 }; -let ds: ProductDataSource; +let ds: ProductsDataSource; beforeEach(() => { jest.clearAllMocks(); @@ -27,11 +27,11 @@ beforeEach(() => { url: '_', name: 'SystemLink Product', }; - ds = new ProductDataSource(instanceSettings as DataSourceInstanceSettings); + ds = new ProductsDataSource(instanceSettings as DataSourceInstanceSettings); setupFetchMock(); }); -describe('ProductDataSource', () => { +describe('ProductsDataSource', () => { describe('queryProducts', () => { it('should call api with correct parameters', async () => { const orderBy = 'name'; diff --git a/src/datasources/product/ProductDataSource.ts b/src/datasources/products/ProductsDataSource.ts similarity index 93% rename from src/datasources/product/ProductDataSource.ts rename to src/datasources/products/ProductsDataSource.ts index 2f8cd363..c9b37e1f 100644 --- a/src/datasources/product/ProductDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -3,7 +3,7 @@ import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana import { DataSourceBase } from 'core/DataSourceBase'; import { ProductQuery, ProductResponseProperties, Properties, PropertiesOptions, QueryProductResponse } from './types'; -export class ProductDataSource extends DataSourceBase { +export class ProductsDataSource extends DataSourceBase { constructor( readonly instanceSettings: DataSourceInstanceSettings, readonly backendSrv: BackendSrv = getBackendSrv(), @@ -40,8 +40,9 @@ export class ProductDataSource extends DataSourceBase { async runQuery(query: ProductQuery, options: DataQueryRequest): Promise { const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.recordCount, query.descending)).products; - const filteredFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; - const fields = filteredFields.map((field) => { + + const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; + const fields = selectedFields.map((field) => { const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); diff --git a/src/datasources/product/README.md b/src/datasources/products/README.md similarity index 100% rename from src/datasources/product/README.md rename to src/datasources/products/README.md diff --git a/src/datasources/product/components/productQueryEditor.test.tsx b/src/datasources/products/components/ProductsQueryEditor.test.tsx similarity index 85% rename from src/datasources/product/components/productQueryEditor.test.tsx rename to src/datasources/products/components/ProductsQueryEditor.test.tsx index e4a33a6b..0d8d8d5a 100644 --- a/src/datasources/product/components/productQueryEditor.test.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.test.tsx @@ -1,19 +1,19 @@ import { setupRenderer } from "test/fixtures"; -import { ProductDataSource } from "../ProductDataSource"; -import { ProductQueryEditor } from "./ProductQueryEditor"; +import { ProductsDataSource } from "../ProductsDataSource"; +import { ProductsQueryEditor } from "./ProductsQueryEditor"; import { screen, waitFor } from "@testing-library/react"; import { ProductQuery } from "../types"; import { select } from "react-select-event"; import userEvent from "@testing-library/user-event"; -const render = setupRenderer(ProductQueryEditor, ProductDataSource); +const render = setupRenderer(ProductsQueryEditor, ProductsDataSource); let onChange: jest.Mock let properties: HTMLElement let orderBy: HTMLElement let descending: HTMLElement let recordCount: HTMLElement -describe('ProductQueryEditor', () => { +describe('ProductsQueryEditor', () => { beforeEach(async() => { [onChange] = render({ refId: '', properties: [], orderBy: undefined} as ProductQuery); await waitFor(() => properties = screen.getAllByRole('combobox')[0]); @@ -22,9 +22,9 @@ describe('ProductQueryEditor', () => { recordCount = screen.getByRole('textbox'); }); - it('renders with query default', async () => { + it('renders with default query', async () => { expect(properties).toBeInTheDocument(); - expect(properties).not.toBeNull(); + expect(properties).toHaveDisplayValue(''); expect(orderBy).toBeInTheDocument(); expect(orderBy).toHaveAccessibleDescription('Select field to order by'); @@ -37,7 +37,7 @@ describe('ProductQueryEditor', () => { }); it('updates when user makes changes', async () => { - //User changes properties + //User adds a properties await select(properties, "id", { container: document.body }); await waitFor(() => { expect(onChange).toHaveBeenCalledWith( diff --git a/src/datasources/product/components/ProductQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx similarity index 94% rename from src/datasources/product/components/ProductQueryEditor.tsx rename to src/datasources/products/components/ProductsQueryEditor.tsx index 8e331a90..84d002f4 100644 --- a/src/datasources/product/components/ProductQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -2,12 +2,12 @@ import React, { useCallback } from 'react'; import { AutoSizeInput, HorizontalGroup, InlineSwitch, MultiSelect, Select, VerticalGroup } from '@grafana/ui'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { InlineField } from 'core/components/InlineField'; -import { ProductDataSource } from '../ProductDataSource'; +import { ProductsDataSource } from '../ProductsDataSource'; import { OrderBy, ProductQuery, Properties } from '../types'; -type Props = QueryEditorProps; +type Props = QueryEditorProps; -export function ProductQueryEditor({ query, onChange, onRunQuery, datasource }: Props) { +export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: Props) { query = datasource.prepareQuery(query); const handleQueryChange = useCallback((query: ProductQuery, runQuery = true): void => { diff --git a/src/datasources/product/img/logo-ni.svg b/src/datasources/products/img/logo-ni.svg similarity index 100% rename from src/datasources/product/img/logo-ni.svg rename to src/datasources/products/img/logo-ni.svg diff --git a/src/datasources/products/module.ts b/src/datasources/products/module.ts new file mode 100644 index 00000000..fe1815b7 --- /dev/null +++ b/src/datasources/products/module.ts @@ -0,0 +1,8 @@ +import { DataSourcePlugin } from '@grafana/data'; +import { ProductsDataSource } from './ProductsDataSource'; +import { ProductsQueryEditor } from './components/ProductsQueryEditor'; +import { HttpConfigEditor } from 'core/components/HttpConfigEditor'; + +export const plugin = new DataSourcePlugin(ProductsDataSource) + .setConfigEditor(HttpConfigEditor) + .setQueryEditor(ProductsQueryEditor); diff --git a/src/datasources/product/plugin.json b/src/datasources/products/plugin.json similarity index 86% rename from src/datasources/product/plugin.json rename to src/datasources/products/plugin.json index 2d1d4b30..748b62cf 100644 --- a/src/datasources/product/plugin.json +++ b/src/datasources/products/plugin.json @@ -1,7 +1,7 @@ { "type": "datasource", "name": "SystemLink Products", - "id": "ni-slproduct-datasource", + "id": "ni-slproducts-datasource", "metrics": true, "info": { "author": { diff --git a/src/datasources/product/types.ts b/src/datasources/products/types.ts similarity index 100% rename from src/datasources/product/types.ts rename to src/datasources/products/types.ts From 4fde1b1eb804e8cfd778bc5a5fd3e583153beff4 Mon Sep 17 00:00:00 2001 From: richie Date: Wed, 15 Jan 2025 18:08:55 +0530 Subject: [PATCH 09/37] feat(ProductsQueryEditor): add queryBy parameter and workspace handling; enhance query builder fields --- src/core/query-builder.constants.ts | 67 +++++++++ .../products/ProductsDataSource.test.ts | 2 +- .../products/ProductsDataSource.ts | 46 +++++- .../components/ProductsQueryEditor.tsx | 44 +++++- .../query-builder/ProductsQueryBuilder.tsx | 134 ++++++++++++++++++ .../ProductsQueryBuilder.constants.ts | 105 ++++++++++++++ src/datasources/products/types.ts | 12 ++ 7 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx create mode 100644 src/datasources/products/constants/ProductsQueryBuilder.constants.ts diff --git a/src/core/query-builder.constants.ts b/src/core/query-builder.constants.ts index 5bba89e0..875fd1de 100644 --- a/src/core/query-builder.constants.ts +++ b/src/core/query-builder.constants.ts @@ -167,8 +167,75 @@ export const QueryBuilderOperations = { expressionTemplate: '!string.IsNullOrEmpty(properties["{0}"])', hideValue: true, }, + KEY_VALUE_MATCHES: { + label: 'matches', + name: 'key_value_matches', + expressionTemplate: '{0}["{1}"] = "{2}"', + hideValue: true, + } } +// function labeledEditorTemplate(keyPlaceholder: string, valuePlaceholder: string, value: any): HTMLElement { +// const template = ` +//
+//
    +//
  • +// +// +//
  • +//
  • +// +// +//
  • +//
+//
`; + +// const templateBody = new DOMParser().parseFromString(template, 'text/html').body; +// return templateBody.querySelector('#sl-query-builder-key-value-editor')!; +// } + +// function valueTemplate(editor: HTMLElement | null | undefined, value: { key: string; value: string | number;}): string { +// if (value) { +// const keyValuePair = value as { key: string; value: string | number;}; +// return `${keyValuePair.key} : ${keyValuePair.value}`; +// } +// if (editor) { +// const keyInput = editor.querySelector('.key-input'); +// const valueInput = editor.querySelector('.value-input'); +// if (keyInput && valueInput) { +// return `${keyInput.value} : ${valueInput.value}`; +// } +// } +// return ''; +// } + +// function retrieveKeyValueInputs(editor: HTMLElement | null | undefined): { key: string, value: string } { +// let pair = { key: '', value: '' }; +// if (editor) { +// const keyInput = editor.querySelector('.key-input'); +// const valueInput = editor.querySelector('.value-input'); +// if (keyInput && valueInput) { +// pair = { +// key: keyInput.value, +// value: valueInput.value +// }; +// } +// } +// return pair; +// } + +// function handleStringValue(editor: HTMLElement | null | undefined): any { +// const inputs = retrieveKeyValueInputs(editor); +// return { +// label: inputs, +// value: inputs +// }; +// } + export const customOperations: QueryBuilderCustomOperation[] = [ QueryBuilderOperations.EQUALS, QueryBuilderOperations.DOES_NOT_EQUAL, diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index a6d010cb..65f61513 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -40,7 +40,7 @@ describe('ProductsDataSource', () => { const descending = true; const returnCount = true; - const response = await ds.queryProducts(orderBy, projection, recordCount, descending, returnCount); + const response = await ds.queryProducts(orderBy, projection, '', recordCount, descending, returnCount); expect(response).toEqual(mockQueryProductResponse); }); diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index c9b37e1f..e3479a30 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -2,6 +2,9 @@ import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, FieldType, import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { DataSourceBase } from 'core/DataSourceBase'; import { ProductQuery, ProductResponseProperties, Properties, PropertiesOptions, QueryProductResponse } from './types'; +import { QueryBuilderOption, Workspace } from 'core/types'; +import { parseErrorMessage } from 'core/errors'; +import { getVariableOptions } from 'core/utils'; export class ProductsDataSource extends DataSourceBase { constructor( @@ -10,8 +13,16 @@ export class ProductsDataSource extends DataSourceBase { readonly templateSrv: TemplateSrv = getTemplateSrv() ) { super(instanceSettings, backendSrv, templateSrv); + this.workspaceLoadedPromise = this.loadWorkspaces(); } + private workspaceLoadedPromise: Promise; + private workspacesLoaded!: () => void; + + readonly workspacesCache = new Map([]); + areWorkspacesLoaded$ = new Promise(resolve => this.workspacesLoaded = resolve); + error = ''; + baseUrl = this.instanceSettings.url + '/nitestmonitor'; queryProductsUrl = this.baseUrl + '/v2/query-products'; @@ -24,11 +35,13 @@ export class ProductsDataSource extends DataSourceBase { ] as Properties[], orderBy: undefined, descending: false, - recordCount: 1000 + recordCount: 1000, + queryBy: '' }; - async queryProducts(orderBy: string, projection: Properties[], recordCount = 1000, descending = false, returnCount = false): Promise { + async queryProducts(orderBy: string, projection: Properties[], filter?: string, recordCount = 1000, descending = false, returnCount = false): Promise { const response = await this.post(this.queryProductsUrl, { + filter: filter, orderBy: orderBy, descending: descending, projection: projection, @@ -39,13 +52,18 @@ export class ProductsDataSource extends DataSourceBase { } async runQuery(query: ProductQuery, options: DataQueryRequest): Promise { - const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.recordCount, query.descending)).products; - + await this.workspaceLoadedPromise; + if (query.queryBy) { + query.queryBy = this.templateSrv.replace(query.queryBy, options.scopedVars); + } + + const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.queryBy, query.recordCount, query.descending)).products; + const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; const fields = selectedFields.map((field) => { const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); - + if (field === PropertiesOptions.PROPERTIES) { return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; } @@ -58,6 +76,9 @@ export class ProductsDataSource extends DataSourceBase { }; } + public readonly globalVariableOptions = (): QueryBuilderOption[] => getVariableOptions(this); + + shouldRunQuery(query: ProductQuery): boolean { return true; } @@ -67,4 +88,19 @@ export class ProductsDataSource extends DataSourceBase { await this.backendSrv.get(this.baseUrl + '/'); return { status: 'success', message: 'Data source connected and authentication successful!' }; } + + private async loadWorkspaces(): Promise { + if (this.workspacesCache.size > 0) { + return; + } + + const workspaces = await this.getWorkspaces() + .catch(error => { + this.error = parseErrorMessage(error)!; + }); + + workspaces?.forEach(workspace => this.workspacesCache.set(workspace.id, workspace)); + + this.workspacesLoaded(); + } } diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index 84d002f4..f5a10686 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -1,21 +1,32 @@ -import React, { useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { AutoSizeInput, HorizontalGroup, InlineSwitch, MultiSelect, Select, VerticalGroup } from '@grafana/ui'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { InlineField } from 'core/components/InlineField'; import { ProductsDataSource } from '../ProductsDataSource'; import { OrderBy, ProductQuery, Properties } from '../types'; +import { Workspace } from 'core/types'; +import { ProductsQueryBuilder } from 'datasources/products/components/query-builder/ProductsQueryBuilder'; + type Props = QueryEditorProps; export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: Props) { query = datasource.prepareQuery(query); + const [workspaces, setWorkspaces] = useState([]); + + useEffect(() => { + Promise.all([datasource.areWorkspacesLoaded$]).then(() => { + setWorkspaces(Array.from(datasource.workspacesCache.values())); + }); + }, [datasource]); + const handleQueryChange = useCallback((query: ProductQuery, runQuery = true): void => { - onChange(query); - if (runQuery) { - onRunQuery(); - } - }, [onChange, onRunQuery]); + onChange(query); + if (runQuery) { + onRunQuery(); + } + }, [onChange, onRunQuery]); const onPropertiesChange = (items: Array>) => { if (items !== undefined) { @@ -37,6 +48,14 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: handleQueryChange({ ...query, recordCount: value }); } + const onParameterChange = (event: CustomEvent) => { + if (query.queryBy !== event.detail.linq) { + query.queryBy = event.detail.linq; + onChange({...query}); + onRunQuery(); + } + } + return ( <> @@ -82,6 +101,16 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }:
+ + + onParameterChange(event)} + > + +
); @@ -91,5 +120,6 @@ const tooltips = { properties: 'Select the properties fields to query', recordCount: 'Enter the number of records to query', orderBy: 'Select the field to order the results by', - descending: 'Select to order the results in descending order' + descending: 'Select to order the results in descending order', + queryBy: 'Enter the query to filter the results', } diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx new file mode 100644 index 00000000..18494922 --- /dev/null +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx @@ -0,0 +1,134 @@ +import { useTheme2 } from "@grafana/ui"; +import { queryBuilderMessages, QueryBuilderOperations } from "core/query-builder.constants"; +import { expressionBuilderCallback, expressionReaderCallback } from "core/query-builder.utils"; +import { Workspace, QueryBuilderOption } from "core/types"; +import { filterXSSField, filterXSSLINQExpression } from "core/utils"; +import { ProductsQueryBuilderStaticFields, ProductsQueryBuilderFields } from "datasources/products/constants/ProductsQueryBuilder.constants"; +import { QBField } from "datasources/products/types"; +import React, { useState, useEffect, useMemo } from "react"; +import QueryBuilder, { QueryBuilderCustomOperation, QueryBuilderProps } from "smart-webcomponents-react/querybuilder"; + +type ProductsQueryBuilderProps = QueryBuilderProps & React.HTMLAttributes & { + filter?: string; + workspaces: Workspace[]; + globalVariableOptions: QueryBuilderOption[]; +}; + +export const ProductsQueryBuilder: React.FC = ({ + filter, + onChange, + workspaces, + globalVariableOptions +}) => { + const theme = useTheme2(); + document.body.setAttribute('theme', theme.isDark ? 'dark-orange' : 'orange'); + + const [fields, setFields] = useState([]); + const [operations, setOperations] = useState([]); + + const sanitizedFilter = useMemo(() => { + return filterXSSLINQExpression(filter); + }, [filter]) + + const workspaceField = useMemo(() => { + const workspaceField = ProductsQueryBuilderFields.WORKSPACE; + + return { + ...workspaceField, + lookup: { + ...workspaceField.lookup, + dataSource: [ + ...(workspaceField.lookup?.dataSource || []), + ...workspaces.map(({ id, name }) => ({ label: name, value: id })), + ], + }, + }; + }, [workspaces]) + + const updatedAtField = useMemo(() => { + const updatedField = ProductsQueryBuilderFields.UPDATEDAT; + return { + ...updatedField, + lookup: { + ...updatedField.lookup, + dataSource: [ + ...(updatedField.lookup?.dataSource || []), + { label: 'From', value: '${__from:date}' }, + { label: 'To', value: '${__to:date}' }, + { label: 'Now', value: '${__now:date}' }, + ] + } + } + }, []); + + useEffect(() => { + const fields = [...ProductsQueryBuilderStaticFields, updatedAtField, workspaceField] + .map((field) => { + if (field.lookup?.dataSource) { + return { + ...field, + lookup: { + dataSource: [...globalVariableOptions.map(filterXSSField), ...field.lookup.dataSource.map(filterXSSField)], + }, + } + } + + return field; + }); + + setFields(fields); + + const options = Object.values(fields).reduce((accumulator, fieldConfig) => { + if (fieldConfig.lookup) { + accumulator[fieldConfig.dataField!] = fieldConfig.lookup.dataSource; + } + + return accumulator; + }, {} as Record); + + const callbacks = { + expressionBuilderCallback: expressionBuilderCallback(options), + expressionReaderCallback: expressionReaderCallback(options), + }; + + setOperations([ + { + ...QueryBuilderOperations.EQUALS, + ...callbacks, + }, + { + ...QueryBuilderOperations.DOES_NOT_EQUAL, + ...callbacks, + }, + QueryBuilderOperations.CONTAINS, + QueryBuilderOperations.DOES_NOT_CONTAIN, + { + ...QueryBuilderOperations.LESS_THAN, + ...callbacks, + }, + { + ...QueryBuilderOperations.LESS_THAN_OR_EQUAL_TO, + ...callbacks, + }, + { + ...QueryBuilderOperations.GREATER_THAN, + ...callbacks, + }, + { + ...QueryBuilderOperations.GREATER_THAN_OR_EQUAL_TO, + ...callbacks, + } + ]); + + }, [workspaceField, updatedAtField, globalVariableOptions]); + + return ( + + ); +} diff --git a/src/datasources/products/constants/ProductsQueryBuilder.constants.ts b/src/datasources/products/constants/ProductsQueryBuilder.constants.ts new file mode 100644 index 00000000..11bb3742 --- /dev/null +++ b/src/datasources/products/constants/ProductsQueryBuilder.constants.ts @@ -0,0 +1,105 @@ +import { QueryBuilderOperations } from "core/query-builder.constants"; +import { QBField } from "datasources/products/types"; + +export enum ProductsQueryBuilderFieldNames { + PART_NUMBER = "PartNumber", + FAMILY = "Family", + NAME = "Name", + PROPERTIES = "Properties", + UPDATED_AT = "UpdatedAt", + WORKSPACE = "Workspace" +} + +export const ProductsQueryBuilderFields: Record = { + PARTNUMBER: { + label: 'Part Number', + dataField: ProductsQueryBuilderFieldNames.PART_NUMBER, + filterOperations: [ + QueryBuilderOperations.EQUALS.name, + QueryBuilderOperations.DOES_NOT_EQUAL.name, + QueryBuilderOperations.STARTS_WITH.name, + QueryBuilderOperations.ENDS_WITH.name, + QueryBuilderOperations.CONTAINS.name, + QueryBuilderOperations.DOES_NOT_CONTAIN.name, + QueryBuilderOperations.IS_BLANK.name, + QueryBuilderOperations.IS_NOT_BLANK.name + ], + lookup: { + dataSource: [] + } + }, + FAMILY: { + label: 'Family', + dataField: ProductsQueryBuilderFieldNames.FAMILY, + filterOperations: [ + QueryBuilderOperations.EQUALS.name, + QueryBuilderOperations.DOES_NOT_EQUAL.name, + QueryBuilderOperations.CONTAINS.name, + QueryBuilderOperations.DOES_NOT_CONTAIN.name, + QueryBuilderOperations.IS_BLANK.name, + QueryBuilderOperations.IS_NOT_BLANK.name + ], + lookup: { + dataSource: [] + } + }, + NAME: { + label: 'Name', + dataField: ProductsQueryBuilderFieldNames.NAME, + filterOperations: [ + QueryBuilderOperations.EQUALS.name, + QueryBuilderOperations.DOES_NOT_EQUAL.name, + QueryBuilderOperations.CONTAINS.name, + QueryBuilderOperations.DOES_NOT_CONTAIN.name, + QueryBuilderOperations.IS_BLANK.name, + QueryBuilderOperations.IS_NOT_BLANK.name + ], + lookup: { + dataSource: [] + } + }, + PROPERTIES: { + label: 'Properties', + dataField: ProductsQueryBuilderFieldNames.PROPERTIES, + dataType: 'object', + filterOperations: [ + QueryBuilderOperations.EQUALS.name, + ], + lookup: { + dataSource: [] + } + }, + UPDATEDAT: { + label: 'Updated At', + dataField: ProductsQueryBuilderFieldNames.UPDATED_AT, + filterOperations: [ + QueryBuilderOperations.EQUALS.name, + QueryBuilderOperations.DOES_NOT_EQUAL.name, + QueryBuilderOperations.GREATER_THAN.name, + QueryBuilderOperations.GREATER_THAN_OR_EQUAL_TO.name, + QueryBuilderOperations.LESS_THAN.name, + QueryBuilderOperations.LESS_THAN_OR_EQUAL_TO.name + ], + lookup: { + dataSource: [] + } + }, + WORKSPACE: { + label: 'Workspace', + dataField: ProductsQueryBuilderFieldNames.WORKSPACE, + filterOperations: [ + QueryBuilderOperations.EQUALS.name, + QueryBuilderOperations.DOES_NOT_EQUAL.name, + ], + lookup: { + dataSource: [] + } + } +} + +export const ProductsQueryBuilderStaticFields = [ + ProductsQueryBuilderFields.PARTNUMBER, + ProductsQueryBuilderFields.FAMILY, + ProductsQueryBuilderFields.NAME, + ProductsQueryBuilderFields.PROPERTIES, +]; diff --git a/src/datasources/products/types.ts b/src/datasources/products/types.ts index a5c0816a..96a69db5 100644 --- a/src/datasources/products/types.ts +++ b/src/datasources/products/types.ts @@ -1,10 +1,12 @@ import { DataQuery } from '@grafana/schema' +import { QueryBuilderField } from 'smart-webcomponents-react'; export interface ProductQuery extends DataQuery { properties?: Properties[]; orderBy?: string; descending?: boolean; recordCount?: number; + queryBy?: string; } export enum Properties { @@ -82,3 +84,13 @@ export interface ProductResponseProperties { fileIds?: string[]; returnCount?: number; } + +export interface QBField extends QueryBuilderField { + lookup?: { + readonly?: boolean; + dataSource: Array<{ + label: string, + value: string + }>; + }, +} From 4a2c7d04409286764fe0a5668f41671b359db7a2 Mon Sep 17 00:00:00 2001 From: richie Date: Thu, 16 Jan 2025 10:22:03 +0530 Subject: [PATCH 10/37] updated the alignments --- .../products/ProductsDataSource.test.ts | 41 ++++++-- .../products/ProductsDataSource.ts | 4 +- .../components/ProductsQueryEditor.tsx | 94 +++++++++---------- 3 files changed, 81 insertions(+), 58 deletions(-) diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index a6d010cb..fc3c0ecd 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -12,8 +12,24 @@ jest.mock('@grafana/runtime', () => ({ const fetchMock = jest.fn, [BackendSrvRequest]>(); const mockQueryProductResponse: QueryProductResponse = { products: [ - { id: '1', name: 'Product 1', partNumber: '123', family: 'Family 1', workspace: 'Workspace 1' }, - { id: '2', name: 'Product 2', partNumber: '456', family: 'Family 2', workspace: 'Workspace 2' }, + { + id: '1', + name: 'Product 1', + partNumber: '123', + family: 'Family 1', + workspace: 'Workspace 1', + updatedAt: '2021-08-01T00:00:00Z', + properties: { prop1: 'value1' } + }, + { + id: '2', + name: 'Product 2', + partNumber: '456', + family: 'Family 2', + workspace: 'Workspace 2', + updatedAt: '2021-08-02T00:00:00Z', + properties: { prop2: 'value2' } + }, ], continuationToken: '', totalCount: 2 @@ -52,30 +68,39 @@ describe('ProductsDataSource', () => { { refId: 'A', properties: [PropertiesOptions.PART_NUMBER, PropertiesOptions.FAMILY, PropertiesOptions.NAME, PropertiesOptions.WORKSPACE] as Properties[], orderBy: undefined }, // initial state when creating a panel { refId: 'B', properties: [PropertiesOptions.PART_NUMBER, PropertiesOptions.FAMILY, PropertiesOptions.NAME, PropertiesOptions.WORKSPACE] as Properties[], orderBy: PropertiesOptions.ID }, // state after orderby is selected ]); - + const response = await ds.query(query); - + expect(response.data).toHaveLength(2); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledWith(expect.objectContaining({ url: '_/nitestmonitor/v2/query-products' })); }); - + it('should convert properties to Grafana fields', async () => { const query = buildQuery([ { refId: 'A', - properties: [PropertiesOptions.PART_NUMBER, PropertiesOptions.FAMILY, PropertiesOptions.NAME, PropertiesOptions.WORKSPACE] as Properties[], orderBy: undefined + properties: [ + PropertiesOptions.PART_NUMBER, + PropertiesOptions.FAMILY, + PropertiesOptions.NAME, + PropertiesOptions.WORKSPACE, + PropertiesOptions.UPDATEDAT, + PropertiesOptions.PROPERTIES + ] as Properties[], orderBy: undefined }, ]); - + const response = await ds.query(query); - + const fields = response.data[0].fields as Field[]; expect(fields).toEqual([ { name: 'partNumber', values: ['123', '456'], type: 'string' }, { name: 'family', values: ['Family 1', 'Family 2'], type: 'string' }, { name: 'name', values: ['Product 1', 'Product 2'], type: 'string' }, { name: 'workspace', values: ['Workspace 1', 'Workspace 2'], type: 'string' }, + { name: 'updatedAt', values: ['2021-08-01T00:00:00Z', '2021-08-02T00:00:00Z'], type: 'time' }, + { name: 'properties', values: ['{"prop1":"value1"}', '{"prop2":"value2"}'], type: 'string' }, ]); }); }); diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index c9b37e1f..b811163b 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -40,10 +40,10 @@ export class ProductsDataSource extends DataSourceBase { async runQuery(query: ProductQuery, options: DataQueryRequest): Promise { const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.recordCount, query.descending)).products; - + const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; const fields = selectedFields.map((field) => { - const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; + const fieldType = field === PropertiesOptions.UPDATEDAT ? FieldType.time : FieldType.string; const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); if (field === PropertiesOptions.PROPERTIES) { diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index 84d002f4..734e698d 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { AutoSizeInput, HorizontalGroup, InlineSwitch, MultiSelect, Select, VerticalGroup } from '@grafana/ui'; +import { AutoSizeInput, InlineSwitch, MultiSelect, Select, VerticalGroup } from '@grafana/ui'; import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { InlineField } from 'core/components/InlineField'; import { ProductsDataSource } from '../ProductsDataSource'; @@ -11,11 +11,11 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: query = datasource.prepareQuery(query); const handleQueryChange = useCallback((query: ProductQuery, runQuery = true): void => { - onChange(query); - if (runQuery) { - onRunQuery(); - } - }, [onChange, onRunQuery]); + onChange(query); + if (runQuery) { + onRunQuery(); + } + }, [onChange, onRunQuery]); const onPropertiesChange = (items: Array>) => { if (items !== undefined) { @@ -29,7 +29,7 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: const onDescendingChange = (isDescendingChecked: boolean) => { handleQueryChange({ ...query, descending: isDescendingChecked }); - + } const recordCountChange = (event: React.FormEvent) => { @@ -39,50 +39,48 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: return ( <> - - - - ({ label: value, value })) as SelectableValue[]} - onChange={onPropertiesChange} - value={query.properties} - defaultValue={query.properties!} - maxVisibleValues={5} - allowCustomValue={false} - closeMenuOnSelect={false} - /> - -
-
- - + + + onDescendingChange(event.currentTarget.checked)} + value={query.descending} />
- - + + + +
+
); } From 39bd65dbde0145b6db72058266dc71f417fe4bc5 Mon Sep 17 00:00:00 2001 From: richie Date: Thu, 16 Jan 2025 21:03:23 +0530 Subject: [PATCH 11/37] added properties to the query builder --- src/core/query-builder.constants.ts | 258 +++++++++++------- .../products/ProductsDataSource.ts | 33 ++- .../components/ProductsQueryEditor.tsx | 23 +- .../query-builder/ProductsQueryBuilder.tsx | 44 ++- .../query-builder/keyValueOperation.ts | 154 +++++++++++ .../ProductsQueryBuilder.constants.ts | 31 +-- 6 files changed, 417 insertions(+), 126 deletions(-) create mode 100644 src/datasources/products/components/query-builder/keyValueOperation.ts diff --git a/src/core/query-builder.constants.ts b/src/core/query-builder.constants.ts index 875fd1de..301143a3 100644 --- a/src/core/query-builder.constants.ts +++ b/src/core/query-builder.constants.ts @@ -1,3 +1,4 @@ +import { KeyValueOperationTemplate } from "datasources/products/components/query-builder/keyValueOperation"; import { QueryBuilderCustomOperation } from "smart-webcomponents-react"; export const queryBuilderMessages = { @@ -40,6 +41,32 @@ export const queryBuilderMessages = { }, }; +export enum FilterOperations { + KeyValueMatch = 'key_value_matches', + KeyValueDoesNotMatch = 'key_value_not_matches', + KeyValueContains = 'key_value_contains', + KeyValueDoesNotContains = 'key_value_not_contains', + KeyValueIsGreaterThan = 'key_value_>', + KeyValueIsGreaterThanOrEqual = 'key_value_>=', + KeyValueIsLessThan = 'key_value_<', + KeyValueIsLessThanOrEqual = 'key_value_<=', + KeyValueIsNumericallyEqual = 'key_value_===', + KeyValueIsNumericallyNotEqual = 'key_value_!==', +} + +export enum FilterExpressions { + KeyValueMatches = '{0}["{1}"] = "{2}"', + KeyValueNotMatches = '{0}["{1}"] != "{2}"', + KeyValueContains = '{0}["{1}"].Contains("{2}")', + KeyValueNotContains = '!{0}["{1}"].Contains("{2}")', + KeyValueIsGreaterThan = 'SafeConvert.ToDecimal({0}["{1}"]) > {2}', + KeyValueIsGreaterThanOrEqual = 'SafeConvert.ToDecimal({0}["{1}"]) >= {2}', + KeyValueIsLessThan = 'SafeConvert.ToDecimal({0}["{1}"]) < {2}', + KeyValueIsLessThanOrEqual = 'SafeConvert.ToDecimal({0}["{1}"]) <= {2}', + KeyValueIsNumericallyEqual = 'SafeConvert.ToDecimal({0}["{1}"]) = {2}', + KeyValueIsNumericallyNotEqual = 'SafeConvert.ToDecimal({0}["{1}"]) != {2}', +} + export const QueryBuilderOperations = { EQUALS: { label: 'Equals', @@ -167,98 +194,145 @@ export const QueryBuilderOperations = { expressionTemplate: '!string.IsNullOrEmpty(properties["{0}"])', hideValue: true, }, - KEY_VALUE_MATCHES: { - label: 'matches', - name: 'key_value_matches', - expressionTemplate: '{0}["{1}"] = "{2}"', - hideValue: true, + KEY_VALUE_MATCH: { + label: `matches`, + name: FilterOperations.KeyValueMatch, + expressionTemplate: FilterExpressions.KeyValueMatches, + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleStringValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValuePair + }, + KEY_VALUE_DOES_NOT_MATCH: { + label: `does not match`, + name: FilterOperations.KeyValueDoesNotMatch, + expressionTemplate: FilterExpressions.KeyValueNotMatches, + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleStringValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKey + }, + KEY_VALUE_DOES_NOT_CONTAINS: { + label: `does not contain`, + name: FilterOperations.KeyValueDoesNotContains, + expressionTemplate: FilterExpressions.KeyValueNotContains, + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleStringValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKey + }, + KEY_VALUE_CONTAINS: { + label: `contains`, + name: FilterOperations.KeyValueContains, + expressionTemplate: FilterExpressions.KeyValueContains, + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleStringValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKey + }, + KEY_VALUE_IS_GREATER_THAN: { + label: `> (numeric)`, + name: FilterOperations.KeyValueIsGreaterThan, + expressionTemplate: FilterExpressions.KeyValueIsGreaterThan, + editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), + valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), + handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + }, + KEY_VALUE_IS_GREATER_THAN_OR_EQUAL: { + label: `≥ (numeric)`, + name: FilterOperations.KeyValueIsGreaterThanOrEqual, + expressionTemplate: FilterExpressions.KeyValueIsGreaterThanOrEqual, + editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), + valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), + handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + }, + KEY_VALUE_IS_LESS_THAN: { + label: `< (numeric)`, + name: FilterOperations.KeyValueIsLessThan, + expressionTemplate: FilterExpressions.KeyValueIsLessThan, + editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), + valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), + handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + }, + KEY_VALUE_IS_LESS_THAN_OR_EQUAL: { + label: `≤ (numeric)`, + name: FilterOperations.KeyValueIsLessThanOrEqual, + expressionTemplate: FilterExpressions.KeyValueIsLessThanOrEqual, + editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), + valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), + handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + }, + KEY_VALUE_IS_NUMERICAL_EQUAL: { + label: `= (numeric)`, + name: FilterOperations.KeyValueIsNumericallyEqual, + expressionTemplate: FilterExpressions.KeyValueIsNumericallyEqual, + editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), + valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), + handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + }, + KEY_VALUE_IS_NUMERICAL_NOT_EQUAL: { + label: `≠ (numeric)`, + name: FilterOperations.KeyValueIsNumericallyNotEqual, + expressionTemplate: FilterExpressions.KeyValueIsNumericallyNotEqual, + editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), + valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), + handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) } -} - -// function labeledEditorTemplate(keyPlaceholder: string, valuePlaceholder: string, value: any): HTMLElement { -// const template = ` -//
-//
    -//
  • -// -// -//
  • -//
  • -// -// -//
  • -//
-//
`; - -// const templateBody = new DOMParser().parseFromString(template, 'text/html').body; -// return templateBody.querySelector('#sl-query-builder-key-value-editor')!; -// } - -// function valueTemplate(editor: HTMLElement | null | undefined, value: { key: string; value: string | number;}): string { -// if (value) { -// const keyValuePair = value as { key: string; value: string | number;}; -// return `${keyValuePair.key} : ${keyValuePair.value}`; -// } -// if (editor) { -// const keyInput = editor.querySelector('.key-input'); -// const valueInput = editor.querySelector('.value-input'); -// if (keyInput && valueInput) { -// return `${keyInput.value} : ${valueInput.value}`; -// } -// } -// return ''; -// } - -// function retrieveKeyValueInputs(editor: HTMLElement | null | undefined): { key: string, value: string } { -// let pair = { key: '', value: '' }; -// if (editor) { -// const keyInput = editor.querySelector('.key-input'); -// const valueInput = editor.querySelector('.value-input'); -// if (keyInput && valueInput) { -// pair = { -// key: keyInput.value, -// value: valueInput.value -// }; -// } -// } -// return pair; -// } - -// function handleStringValue(editor: HTMLElement | null | undefined): any { -// const inputs = retrieveKeyValueInputs(editor); -// return { -// label: inputs, -// value: inputs -// }; -// } +}; export const customOperations: QueryBuilderCustomOperation[] = [ - QueryBuilderOperations.EQUALS, - QueryBuilderOperations.DOES_NOT_EQUAL, - QueryBuilderOperations.STARTS_WITH, - QueryBuilderOperations.ENDS_WITH, - QueryBuilderOperations.CONTAINS, - QueryBuilderOperations.DOES_NOT_CONTAIN, - QueryBuilderOperations.IS_BLANK, - QueryBuilderOperations.IS_NOT_BLANK, - QueryBuilderOperations.GREATER_THAN, - QueryBuilderOperations.GREATER_THAN_OR_EQUAL_TO, - QueryBuilderOperations.LESS_THAN, - QueryBuilderOperations.LESS_THAN_OR_EQUAL_TO, - QueryBuilderOperations.LIST_EQUALS, - QueryBuilderOperations.LIST_DOES_NOT_EQUAL, - QueryBuilderOperations.LIST_CONTAINS, - QueryBuilderOperations.LIST_DOES_NOT_CONTAIN, - QueryBuilderOperations.PROPERTY_EQUALS, - QueryBuilderOperations.PROPERTY_DOES_NOT_EQUAL, - QueryBuilderOperations.PROPERTY_STARTS_WITH, - QueryBuilderOperations.PROPERTY_ENDS_WITH, - QueryBuilderOperations.PROPERTY_CONTAINS, - QueryBuilderOperations.PROPERTY_DOES_NOT_CONTAIN, - QueryBuilderOperations.PROPERTY_IS_BLANK, - QueryBuilderOperations.PROPERTY_IS_NOT_BLANK, -]; + QueryBuilderOperations.EQUALS, + QueryBuilderOperations.DOES_NOT_EQUAL, + QueryBuilderOperations.STARTS_WITH, + QueryBuilderOperations.ENDS_WITH, + QueryBuilderOperations.CONTAINS, + QueryBuilderOperations.DOES_NOT_CONTAIN, + QueryBuilderOperations.IS_BLANK, + QueryBuilderOperations.IS_NOT_BLANK, + QueryBuilderOperations.GREATER_THAN, + QueryBuilderOperations.GREATER_THAN_OR_EQUAL_TO, + QueryBuilderOperations.LESS_THAN, + QueryBuilderOperations.LESS_THAN_OR_EQUAL_TO, + QueryBuilderOperations.LIST_EQUALS, + QueryBuilderOperations.LIST_DOES_NOT_EQUAL, + QueryBuilderOperations.LIST_CONTAINS, + QueryBuilderOperations.LIST_DOES_NOT_CONTAIN, + QueryBuilderOperations.PROPERTY_EQUALS, + QueryBuilderOperations.PROPERTY_DOES_NOT_EQUAL, + QueryBuilderOperations.PROPERTY_STARTS_WITH, + QueryBuilderOperations.PROPERTY_ENDS_WITH, + QueryBuilderOperations.PROPERTY_CONTAINS, + QueryBuilderOperations.PROPERTY_DOES_NOT_CONTAIN, + QueryBuilderOperations.PROPERTY_IS_BLANK, + QueryBuilderOperations.PROPERTY_IS_NOT_BLANK, + QueryBuilderOperations.KEY_VALUE_MATCH, + QueryBuilderOperations.KEY_VALUE_DOES_NOT_MATCH, + QueryBuilderOperations.KEY_VALUE_CONTAINS, + QueryBuilderOperations.KEY_VALUE_DOES_NOT_CONTAINS + ]; diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index e3479a30..b8d8473d 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -14,17 +14,25 @@ export class ProductsDataSource extends DataSourceBase { ) { super(instanceSettings, backendSrv, templateSrv); this.workspaceLoadedPromise = this.loadWorkspaces(); + this.partNumberLoadedPromise = this.getProductPartNumbers(); } private workspaceLoadedPromise: Promise; + private partNumberLoadedPromise: Promise; + private workspacesLoaded!: () => void; + private partNumberLoaded!: () => void; readonly workspacesCache = new Map([]); + readonly partNumbersCache = new Map([]); + areWorkspacesLoaded$ = new Promise(resolve => this.workspacesLoaded = resolve); + arePartNumberLoaded$ = new Promise(resolve => this.partNumberLoaded = resolve); error = ''; baseUrl = this.instanceSettings.url + '/nitestmonitor'; queryProductsUrl = this.baseUrl + '/v2/query-products'; + queryProductValuesUrl = this.baseUrl + '/v2/query-product-values'; defaultQuery = { properties: [ @@ -51,8 +59,17 @@ export class ProductsDataSource extends DataSourceBase { return response; } + async queryProductValues(fieldName: string): Promise { + const response = await this.post(this.queryProductValuesUrl, { + field: fieldName + }); + return response; + } + async runQuery(query: ProductQuery, options: DataQueryRequest): Promise { await this.workspaceLoadedPromise; + await this.partNumberLoadedPromise; + if (query.queryBy) { query.queryBy = this.templateSrv.replace(query.queryBy, options.scopedVars); } @@ -63,7 +80,7 @@ export class ProductsDataSource extends DataSourceBase { const fields = selectedFields.map((field) => { const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); - + if (field === PropertiesOptions.PROPERTIES) { return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; } @@ -103,4 +120,18 @@ export class ProductsDataSource extends DataSourceBase { this.workspacesLoaded(); } + + private async getProductPartNumbers(): Promise { + if (this.partNumbersCache.size > 0) { + return; + } + const partNumbers = await this.queryProductValues(PropertiesOptions.PART_NUMBER) + .catch(error => { + this.error = parseErrorMessage(error)!; + }); + + partNumbers?.forEach(partNumber => this.partNumbersCache.set(partNumber, partNumber)); + + this.partNumberLoaded(); + } } diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index f5a10686..4a9c3862 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -14,11 +14,15 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: query = datasource.prepareQuery(query); const [workspaces, setWorkspaces] = useState([]); - - useEffect(() => { + const [partNumbers, setPartNumbers] = useState([]); + + useEffect(() => { Promise.all([datasource.areWorkspacesLoaded$]).then(() => { setWorkspaces(Array.from(datasource.workspacesCache.values())); }); + Promise.all([datasource.arePartNumberLoaded$]).then(() => { + setPartNumbers(Array.from(datasource.partNumbersCache.values())); + }); }, [datasource]); const handleQueryChange = useCallback((query: ProductQuery, runQuery = true): void => { @@ -40,7 +44,7 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: const onDescendingChange = (isDescendingChecked: boolean) => { handleQueryChange({ ...query, descending: isDescendingChecked }); - + } const recordCountChange = (event: React.FormEvent) => { @@ -48,12 +52,8 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: handleQueryChange({ ...query, recordCount: value }); } - const onParameterChange = (event: CustomEvent) => { - if (query.queryBy !== event.detail.linq) { - query.queryBy = event.detail.linq; - onChange({...query}); - onRunQuery(); - } + const onParameterChange = (value: string) => { + handleQueryChange({ ...query, queryBy:value}); } return ( @@ -100,14 +100,15 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: /> - + onParameterChange(event)} + onChange={(event: any) => onParameterChange(event.detail.linq)} > diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx index 18494922..ca56c937 100644 --- a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx @@ -11,6 +11,7 @@ import QueryBuilder, { QueryBuilderCustomOperation, QueryBuilderProps } from "sm type ProductsQueryBuilderProps = QueryBuilderProps & React.HTMLAttributes & { filter?: string; workspaces: Workspace[]; + partNumbers: string[]; globalVariableOptions: QueryBuilderOption[]; }; @@ -18,6 +19,7 @@ export const ProductsQueryBuilder: React.FC = ({ filter, onChange, workspaces, + partNumbers, globalVariableOptions }) => { const theme = useTheme2(); @@ -61,14 +63,30 @@ export const ProductsQueryBuilder: React.FC = ({ } }, []); + const partNumberField = useMemo(() => { + const partNumberField = ProductsQueryBuilderFields.PARTNUMBER; + + return { + ...partNumberField, + lookup: { + ...partNumberField.lookup, + dataSource: [ + ...(partNumberField.lookup?.dataSource || []), + ...partNumbers.map((partNumber) => ({ label: partNumber, value: partNumber })) + ] + } + } + }, [partNumbers]); + + useEffect(() => { - const fields = [...ProductsQueryBuilderStaticFields, updatedAtField, workspaceField] + const fields = [partNumberField, ...ProductsQueryBuilderStaticFields, updatedAtField, workspaceField] .map((field) => { if (field.lookup?.dataSource) { return { ...field, lookup: { - dataSource: [...globalVariableOptions.map(filterXSSField), ...field.lookup.dataSource.map(filterXSSField)], + dataSource: [...globalVariableOptions.map(filterXSSField), ...field.lookup!.dataSource.map(filterXSSField)], }, } } @@ -117,10 +135,28 @@ export const ProductsQueryBuilder: React.FC = ({ { ...QueryBuilderOperations.GREATER_THAN_OR_EQUAL_TO, ...callbacks, - } + }, + { + ...QueryBuilderOperations.IS_BLANK, + ...callbacks, + }, + { + ...QueryBuilderOperations.IS_NOT_BLANK, + ...callbacks, + }, + QueryBuilderOperations.KEY_VALUE_MATCH, + QueryBuilderOperations.KEY_VALUE_DOES_NOT_MATCH, + QueryBuilderOperations.KEY_VALUE_CONTAINS, + QueryBuilderOperations.KEY_VALUE_DOES_NOT_CONTAINS, + QueryBuilderOperations.KEY_VALUE_IS_GREATER_THAN, + QueryBuilderOperations.KEY_VALUE_IS_GREATER_THAN_OR_EQUAL, + QueryBuilderOperations.KEY_VALUE_IS_LESS_THAN, + QueryBuilderOperations.KEY_VALUE_IS_LESS_THAN_OR_EQUAL, + QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_EQUAL, + QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_NOT_EQUAL ]); - }, [workspaceField, updatedAtField, globalVariableOptions]); + }, [workspaceField, updatedAtField, partNumberField, globalVariableOptions]); return ( +
    +
  • + + +
  • +
  • + + +
  • +
+ `; + + const templateBody = new DOMParser().parseFromString(template, 'text/html').body; + return templateBody.querySelector('#sl-query-builder-key-value-editor')!; + } + + public static valueTemplate(editor: HTMLElement | null | undefined, value: any): string { + if (value) { + const keyValuePair = value as { key: string; value: string | number; }; + return `${keyValuePair.key} : ${keyValuePair.value}`; + } + if (editor) { + const keyInput = editor.querySelector('.key-input'); + const valueInput = editor.querySelector('.value-input'); + if (keyInput && valueInput) { + return `${keyInput.value} : ${valueInput.value}`; + } + } + return ''; + } + + public static handleStringValue(editor: HTMLElement | null | undefined): any { + const inputs = KeyValueOperationTemplate.retrieveKeyValueInputs(editor); + return { + label: inputs, + value: inputs + }; + } + + public static handleNumberValue(editor: HTMLElement | null | undefined): any { + const inputs = KeyValueOperationTemplate.retrieveKeyValueInputs(editor); + const normalizedInputs = { key: inputs.key, value: KeyValueOperationTemplate.normalizeNumericValue(inputs.value) }; + return { + label: normalizedInputs, + value: normalizedInputs + }; + } + + public static keyValueExpressionBuilderCallback(dataField: string, operation: string, keyValuePair: any): string { + let expressionTemplate = ''; + switch (operation) { + case FilterOperations.KeyValueMatch: + expressionTemplate = FilterExpressions.KeyValueMatches; + break; + case FilterOperations.KeyValueDoesNotMatch: + expressionTemplate = FilterExpressions.KeyValueNotMatches; + break; + case FilterOperations.KeyValueContains: + expressionTemplate = FilterExpressions.KeyValueContains; + break; + case FilterOperations.KeyValueDoesNotContains: + expressionTemplate = FilterExpressions.KeyValueNotContains; + break; + case FilterOperations.KeyValueIsGreaterThan: + expressionTemplate = FilterExpressions.KeyValueIsGreaterThan; + break; + case FilterOperations.KeyValueIsGreaterThanOrEqual: + expressionTemplate = FilterExpressions.KeyValueIsGreaterThanOrEqual; + break; + case FilterOperations.KeyValueIsLessThan: + expressionTemplate = FilterExpressions.KeyValueIsLessThan; + break; + case FilterOperations.KeyValueIsLessThanOrEqual: + expressionTemplate = FilterExpressions.KeyValueIsLessThanOrEqual; + break; + case FilterOperations.KeyValueIsNumericallyEqual: + expressionTemplate = FilterExpressions.KeyValueIsNumericallyEqual; + break; + case FilterOperations.KeyValueIsNumericallyNotEqual: + expressionTemplate = FilterExpressions.KeyValueIsNumericallyNotEqual; + break; + default: + return ''; + } + return expressionTemplate.replace('{0}', dataField).replace('{1}', keyValuePair.key).replace('{2}', keyValuePair.value); + } + + public static stringKeyValueExpressionReaderCallback(expression: string, bindings: string[]): any { + + // Handle the case where the value is a string with spaces + const matches = expression.match(/"([^"]*)"/g)?.map(m => m.slice(1, -1)) ?? []; + if (matches.length < 2) { + return { fieldName: bindings[0], value: { key: '', value: '' } }; + } + return { fieldName: bindings[0], value: { key: matches[0], value: matches[1] } }; + } + + public static numericKeyValueExpressionReaderCallback(_expression: string, bindings: string[]): any { + const normalizedNumberValue = KeyValueOperationTemplate.normalizeNumericValue(bindings[2]); + return { fieldName: bindings[0], value: { key: bindings[1], value: normalizedNumberValue } }; + } + + public static validateNotEmptyKeyValuePair(keyValuePair: any): boolean { + return keyValuePair?.key?.length > 0 + && keyValuePair?.value?.length > 0; + } + + public static validateNotEmptyKey(keyValuePair: any): boolean { + return keyValuePair?.key?.length > 0; + } + + public static validateNotEmptyKeyValueAndValueIsNumber(keyValuePair: any): boolean { + return keyValuePair?.key?.length > 0 + && !isNaN(Number(keyValuePair?.value)); + } + + private static retrieveKeyValueInputs(editor: HTMLElement | null | undefined): { key: string, value: string } { + let pair = { key: '', value: '' }; + if (editor) { + const keyInput = editor.querySelector('.key-input'); + const valueInput = editor.querySelector('.value-input'); + if (keyInput && valueInput) { + pair = { + key: keyInput.value, + value: valueInput.value + }; + } + } + return pair; + } + + private static normalizeNumericValue(value: string): number | string { + const convertedNumberValue = Number(value); + return isNaN(convertedNumberValue) + ? value + : convertedNumberValue; + } +} diff --git a/src/datasources/products/constants/ProductsQueryBuilder.constants.ts b/src/datasources/products/constants/ProductsQueryBuilder.constants.ts index 11bb3742..b370af01 100644 --- a/src/datasources/products/constants/ProductsQueryBuilder.constants.ts +++ b/src/datasources/products/constants/ProductsQueryBuilder.constants.ts @@ -17,10 +17,6 @@ export const ProductsQueryBuilderFields: Record = { filterOperations: [ QueryBuilderOperations.EQUALS.name, QueryBuilderOperations.DOES_NOT_EQUAL.name, - QueryBuilderOperations.STARTS_WITH.name, - QueryBuilderOperations.ENDS_WITH.name, - QueryBuilderOperations.CONTAINS.name, - QueryBuilderOperations.DOES_NOT_CONTAIN.name, QueryBuilderOperations.IS_BLANK.name, QueryBuilderOperations.IS_NOT_BLANK.name ], @@ -38,10 +34,7 @@ export const ProductsQueryBuilderFields: Record = { QueryBuilderOperations.DOES_NOT_CONTAIN.name, QueryBuilderOperations.IS_BLANK.name, QueryBuilderOperations.IS_NOT_BLANK.name - ], - lookup: { - dataSource: [] - } + ] }, NAME: { label: 'Name', @@ -53,21 +46,24 @@ export const ProductsQueryBuilderFields: Record = { QueryBuilderOperations.DOES_NOT_CONTAIN.name, QueryBuilderOperations.IS_BLANK.name, QueryBuilderOperations.IS_NOT_BLANK.name - ], - lookup: { - dataSource: [] - } + ] }, PROPERTIES: { label: 'Properties', dataField: ProductsQueryBuilderFieldNames.PROPERTIES, dataType: 'object', filterOperations: [ - QueryBuilderOperations.EQUALS.name, - ], - lookup: { - dataSource: [] - } + QueryBuilderOperations.KEY_VALUE_MATCH.name, + QueryBuilderOperations.KEY_VALUE_DOES_NOT_MATCH.name, + QueryBuilderOperations.KEY_VALUE_CONTAINS.name, + QueryBuilderOperations.KEY_VALUE_DOES_NOT_CONTAINS.name, + QueryBuilderOperations.KEY_VALUE_IS_GREATER_THAN.name, + QueryBuilderOperations.KEY_VALUE_IS_GREATER_THAN_OR_EQUAL.name, + QueryBuilderOperations.KEY_VALUE_IS_LESS_THAN.name, + QueryBuilderOperations.KEY_VALUE_IS_LESS_THAN_OR_EQUAL.name, + QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_EQUAL.name, + QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_NOT_EQUAL.name + ] }, UPDATEDAT: { label: 'Updated At', @@ -98,7 +94,6 @@ export const ProductsQueryBuilderFields: Record = { } export const ProductsQueryBuilderStaticFields = [ - ProductsQueryBuilderFields.PARTNUMBER, ProductsQueryBuilderFields.FAMILY, ProductsQueryBuilderFields.NAME, ProductsQueryBuilderFields.PROPERTIES, From d06b3739b909c2c9270c59aeaa02fdabe59669c3 Mon Sep 17 00:00:00 2001 From: richie Date: Fri, 17 Jan 2025 10:42:26 +0530 Subject: [PATCH 12/37] resimplify method bindings and improve readability --- src/core/query-builder.constants.ts | 72 +++++++++---------- .../components/ProductsQueryEditor.tsx | 4 +- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/core/query-builder.constants.ts b/src/core/query-builder.constants.ts index 301143a3..4fefbee6 100644 --- a/src/core/query-builder.constants.ts +++ b/src/core/query-builder.constants.ts @@ -242,67 +242,67 @@ export const QueryBuilderOperations = { label: `> (numeric)`, name: FilterOperations.KeyValueIsGreaterThan, expressionTemplate: FilterExpressions.KeyValueIsGreaterThan, - editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), - valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), - handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), - expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), - expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleNumberValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_GREATER_THAN_OR_EQUAL: { label: `≥ (numeric)`, name: FilterOperations.KeyValueIsGreaterThanOrEqual, expressionTemplate: FilterExpressions.KeyValueIsGreaterThanOrEqual, - editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), - valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), - handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), - expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), - expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleNumberValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_LESS_THAN: { label: `< (numeric)`, name: FilterOperations.KeyValueIsLessThan, expressionTemplate: FilterExpressions.KeyValueIsLessThan, - editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), - valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), - handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), - expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), - expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleNumberValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_LESS_THAN_OR_EQUAL: { label: `≤ (numeric)`, name: FilterOperations.KeyValueIsLessThanOrEqual, expressionTemplate: FilterExpressions.KeyValueIsLessThanOrEqual, - editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), - valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), - handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), - expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), - expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleNumberValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_NUMERICAL_EQUAL: { label: `= (numeric)`, name: FilterOperations.KeyValueIsNumericallyEqual, expressionTemplate: FilterExpressions.KeyValueIsNumericallyEqual, - editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), - valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), - handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), - expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), - expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleNumberValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_NUMERICAL_NOT_EQUAL: { label: `≠ (numeric)`, name: FilterOperations.KeyValueIsNumericallyNotEqual, expressionTemplate: FilterExpressions.KeyValueIsNumericallyNotEqual, - editorTemplate: KeyValueOperationTemplate.editorTemplate.bind(this), - valueTemplate: KeyValueOperationTemplate.valueTemplate.bind(this), - handleValue: KeyValueOperationTemplate.handleNumberValue.bind(this), - expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback.bind(this), - expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback.bind(this), - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber.bind(this) + editorTemplate: KeyValueOperationTemplate.editorTemplate, + valueTemplate: KeyValueOperationTemplate.valueTemplate, + handleValue: KeyValueOperationTemplate.handleNumberValue, + expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, + expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, + validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber } }; diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index 4a9c3862..ce07c634 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -53,7 +53,9 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: } const onParameterChange = (value: string) => { - handleQueryChange({ ...query, queryBy:value}); + if (query.queryBy !== value) { + handleQueryChange({ ...query, queryBy: value }); + } } return ( From d342aff6fc3895c08acabd164178378e133d97ef Mon Sep 17 00:00:00 2001 From: richie Date: Fri, 17 Jan 2025 17:13:33 +0530 Subject: [PATCH 13/37] added all fields to query builder --- src/core/query-builder.constants.ts | 10 -- .../products/ProductsDataSource.ts | 101 ++++++++++++++---- .../components/ProductsQueryEditor.tsx | 4 +- .../query-builder/ProductsQueryBuilder.tsx | 1 - .../query-builder/keyValueOperation.ts | 55 +++------- src/datasources/products/types.ts | 19 ++-- 6 files changed, 110 insertions(+), 80 deletions(-) diff --git a/src/core/query-builder.constants.ts b/src/core/query-builder.constants.ts index 4fefbee6..e3dd8215 100644 --- a/src/core/query-builder.constants.ts +++ b/src/core/query-builder.constants.ts @@ -203,7 +203,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleStringValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValuePair }, KEY_VALUE_DOES_NOT_MATCH: { label: `does not match`, @@ -214,7 +213,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleStringValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKey }, KEY_VALUE_DOES_NOT_CONTAINS: { label: `does not contain`, @@ -225,7 +223,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleStringValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKey }, KEY_VALUE_CONTAINS: { label: `contains`, @@ -236,7 +233,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleStringValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.stringKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKey }, KEY_VALUE_IS_GREATER_THAN: { label: `> (numeric)`, @@ -247,7 +243,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleNumberValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_GREATER_THAN_OR_EQUAL: { label: `≥ (numeric)`, @@ -258,7 +253,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleNumberValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_LESS_THAN: { label: `< (numeric)`, @@ -269,7 +263,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleNumberValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_LESS_THAN_OR_EQUAL: { label: `≤ (numeric)`, @@ -280,7 +273,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleNumberValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_NUMERICAL_EQUAL: { label: `= (numeric)`, @@ -291,7 +283,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleNumberValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber }, KEY_VALUE_IS_NUMERICAL_NOT_EQUAL: { label: `≠ (numeric)`, @@ -302,7 +293,6 @@ export const QueryBuilderOperations = { handleValue: KeyValueOperationTemplate.handleNumberValue, expressionBuilderCallback: KeyValueOperationTemplate.keyValueExpressionBuilderCallback, expressionReaderCallback: KeyValueOperationTemplate.numericKeyValueExpressionReaderCallback, - validateValue: KeyValueOperationTemplate.validateNotEmptyKeyValueAndValueIsNumber } }; diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index b8d8473d..80825754 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -5,6 +5,9 @@ import { ProductQuery, ProductResponseProperties, Properties, PropertiesOptions, import { QueryBuilderOption, Workspace } from 'core/types'; import { parseErrorMessage } from 'core/errors'; import { getVariableOptions } from 'core/utils'; +import { ExpressionTransformFunction, transformComputedFieldsQuery } from 'core/query-builder.utils'; +import { QueryBuilderOperations } from 'core/query-builder.constants'; +import { ProductsQueryBuilderFieldNames } from './constants/ProductsQueryBuilder.constants'; export class ProductsDataSource extends DataSourceBase { constructor( @@ -48,15 +51,20 @@ export class ProductsDataSource extends DataSourceBase { }; async queryProducts(orderBy: string, projection: Properties[], filter?: string, recordCount = 1000, descending = false, returnCount = false): Promise { - const response = await this.post(this.queryProductsUrl, { - filter: filter, - orderBy: orderBy, - descending: descending, - projection: projection, - take: recordCount, - returnCount: returnCount - }); - return response; + try { + const response = await this.post(this.queryProductsUrl, { + filter: filter, + orderBy: orderBy, + descending: descending, + projection: projection, + take: recordCount, + returnCount: returnCount + }); + return response; + } catch (error) { + this.error = parseErrorMessage(error as Error)!; + throw new Error(`An error occurred while querying products: ${this.error}`); + } } async queryProductValues(fieldName: string): Promise { @@ -71,31 +79,78 @@ export class ProductsDataSource extends DataSourceBase { await this.partNumberLoadedPromise; if (query.queryBy) { + query.queryBy = transformComputedFieldsQuery( + this.templateSrv.replace(query.queryBy, options.scopedVars), + this.productsComputedDataFields, + ); query.queryBy = this.templateSrv.replace(query.queryBy, options.scopedVars); } const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.queryBy, query.recordCount, query.descending)).products; - const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; - const fields = selectedFields.map((field) => { - const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; - const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); - - if (field === PropertiesOptions.PROPERTIES) { - return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; - } - return { name: field, values, type: fieldType }; - }); + if (responseData.length > 0) { + const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; + const fields = selectedFields.map((field) => { + const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; + const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); + if (field === PropertiesOptions.PROPERTIES) { + return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; + } + return { name: field, values, type: fieldType }; + }); + return { + refId: query.refId, + fields: fields + }; + } return { refId: query.refId, - fields: fields - }; + fields: [] + } + } public readonly globalVariableOptions = (): QueryBuilderOption[] => getVariableOptions(this); + public readonly productsComputedDataFields = new Map([ + ...Object.values(ProductsQueryBuilderFieldNames).map(field => [field, this.multipleValuesQuery(field)] as [string, ExpressionTransformFunction]), + [ + ProductsQueryBuilderFieldNames.UPDATED_AT, + (value: string, operation: string, options?: Map) => { + if (value === '${__now:date}') { + return `${ProductsQueryBuilderFieldNames.UPDATED_AT} ${operation} "${new Date().toISOString()}"`; + } + + return `${ProductsQueryBuilderFieldNames.UPDATED_AT} ${operation} "${value}"`; + }]]); + + protected multipleValuesQuery(field: string): ExpressionTransformFunction { + return (value: string, operation: string, _options?: any) => { + if (this.isMultiSelectValue(value)) { + const query = this.getMultipleValuesArray(value) + .map(val => `${field} ${operation} "${val}"`) + .join(` ${this.getLocicalOperator(operation)} `); + return `(${query})`; + } + + return `${field} ${operation} "${value}"` + } + } + + private isMultiSelectValue(value: string): boolean { + return value.startsWith('{') && value.endsWith('}'); + } + + private getMultipleValuesArray(value: string): string[] { + return value.replace(/({|})/g, '').split(','); + } + + private getLocicalOperator(operation: string): string { + return operation === QueryBuilderOperations.EQUALS.name ? '||' : '&&'; + } + shouldRunQuery(query: ProductQuery): boolean { return true; } @@ -113,7 +168,7 @@ export class ProductsDataSource extends DataSourceBase { const workspaces = await this.getWorkspaces() .catch(error => { - this.error = parseErrorMessage(error)!; + this.error = parseErrorMessage(error.message)!; }); workspaces?.forEach(workspace => this.workspacesCache.set(workspace.id, workspace)); @@ -131,7 +186,7 @@ export class ProductsDataSource extends DataSourceBase { }); partNumbers?.forEach(partNumber => this.partNumbersCache.set(partNumber, partNumber)); - + this.partNumberLoaded(); } } diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index ce07c634..c47dc184 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -6,6 +6,7 @@ import { ProductsDataSource } from '../ProductsDataSource'; import { OrderBy, ProductQuery, Properties } from '../types'; import { Workspace } from 'core/types'; import { ProductsQueryBuilder } from 'datasources/products/components/query-builder/ProductsQueryBuilder'; +import { FloatingError } from 'core/errors'; type Props = QueryEditorProps; @@ -60,7 +61,7 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: return ( <> - + + ); } diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx index ca56c937..4e134c9b 100644 --- a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx @@ -90,7 +90,6 @@ export const ProductsQueryBuilder: React.FC = ({ }, } } - return field; }); diff --git a/src/datasources/products/components/query-builder/keyValueOperation.ts b/src/datasources/products/components/query-builder/keyValueOperation.ts index 74e4e3db..bf853619 100644 --- a/src/datasources/products/components/query-builder/keyValueOperation.ts +++ b/src/datasources/products/components/query-builder/keyValueOperation.ts @@ -1,13 +1,14 @@ import { FilterExpressions, FilterOperations } from "core/query-builder.constants"; +import { propertyFieldKeyValuePair } from "datasources/products/types"; export class KeyValueOperationTemplate { - public static editorTemplate(_: string, value: any): HTMLElement { - const localizedKey = `Key`; - const localizedValue = `Value`; - return KeyValueOperationTemplate.labeledEditorTemplate(localizedKey, localizedValue, _, value); + public static editorTemplate(_: string, value: propertyFieldKeyValuePair): HTMLElement { + const keyPlaceHolder = `Key`; + const valuePlaceHolder = `Value`; + return KeyValueOperationTemplate.labeledEditorTemplate(keyPlaceHolder, valuePlaceHolder, value); } - public static labeledEditorTemplate(keyPlaceholder: string, valuePlaceholder: string, _: string, value: any): HTMLElement { + public static labeledEditorTemplate(keyPlaceholder: string, valuePlaceholder: string, value: propertyFieldKeyValuePair): HTMLElement { const template = `
    @@ -30,7 +31,7 @@ export class KeyValueOperationTemplate { return templateBody.querySelector('#sl-query-builder-key-value-editor')!; } - public static valueTemplate(editor: HTMLElement | null | undefined, value: any): string { + public static valueTemplate(editor: HTMLElement | null | undefined, value: propertyFieldKeyValuePair): string { if (value) { const keyValuePair = value as { key: string; value: string | number; }; return `${keyValuePair.key} : ${keyValuePair.value}`; @@ -45,7 +46,7 @@ export class KeyValueOperationTemplate { return ''; } - public static handleStringValue(editor: HTMLElement | null | undefined): any { + public static handleStringValue(editor: HTMLElement | null | undefined): { label: propertyFieldKeyValuePair; value: propertyFieldKeyValuePair; } { const inputs = KeyValueOperationTemplate.retrieveKeyValueInputs(editor); return { label: inputs, @@ -53,16 +54,16 @@ export class KeyValueOperationTemplate { }; } - public static handleNumberValue(editor: HTMLElement | null | undefined): any { + public static handleNumberValue(editor: HTMLElement | null | undefined): { label: propertyFieldKeyValuePair; value: propertyFieldKeyValuePair; } { const inputs = KeyValueOperationTemplate.retrieveKeyValueInputs(editor); - const normalizedInputs = { key: inputs.key, value: KeyValueOperationTemplate.normalizeNumericValue(inputs.value) }; + const normalizedInputs = { key: inputs.key, value: inputs.value }; return { label: normalizedInputs, value: normalizedInputs }; } - public static keyValueExpressionBuilderCallback(dataField: string, operation: string, keyValuePair: any): string { + public static keyValueExpressionBuilderCallback(dataField: string, operation: string, keyValuePair: propertyFieldKeyValuePair): string { let expressionTemplate = ''; switch (operation) { case FilterOperations.KeyValueMatch: @@ -98,12 +99,12 @@ export class KeyValueOperationTemplate { default: return ''; } - return expressionTemplate.replace('{0}', dataField).replace('{1}', keyValuePair.key).replace('{2}', keyValuePair.value); + return expressionTemplate.replace('{0}', dataField).replace('{1}', keyValuePair.key).replace('{2}', String(keyValuePair.value)); } - public static stringKeyValueExpressionReaderCallback(expression: string, bindings: string[]): any { + public static stringKeyValueExpressionReaderCallback(expression: string, bindings: string[]): { fieldName: string; value: propertyFieldKeyValuePair; } { - // Handle the case where the value is a string with spaces + // Handle the case where the value is equal to the key const matches = expression.match(/"([^"]*)"/g)?.map(m => m.slice(1, -1)) ?? []; if (matches.length < 2) { return { fieldName: bindings[0], value: { key: '', value: '' } }; @@ -111,26 +112,11 @@ export class KeyValueOperationTemplate { return { fieldName: bindings[0], value: { key: matches[0], value: matches[1] } }; } - public static numericKeyValueExpressionReaderCallback(_expression: string, bindings: string[]): any { - const normalizedNumberValue = KeyValueOperationTemplate.normalizeNumericValue(bindings[2]); - return { fieldName: bindings[0], value: { key: bindings[1], value: normalizedNumberValue } }; + public static numericKeyValueExpressionReaderCallback(_expression: string, bindings: string[]): { fieldName: string; value: propertyFieldKeyValuePair; } { + return { fieldName: bindings[0], value: { key: bindings[1], value: bindings[2] } }; } - public static validateNotEmptyKeyValuePair(keyValuePair: any): boolean { - return keyValuePair?.key?.length > 0 - && keyValuePair?.value?.length > 0; - } - - public static validateNotEmptyKey(keyValuePair: any): boolean { - return keyValuePair?.key?.length > 0; - } - - public static validateNotEmptyKeyValueAndValueIsNumber(keyValuePair: any): boolean { - return keyValuePair?.key?.length > 0 - && !isNaN(Number(keyValuePair?.value)); - } - - private static retrieveKeyValueInputs(editor: HTMLElement | null | undefined): { key: string, value: string } { + private static retrieveKeyValueInputs(editor: HTMLElement | null | undefined): propertyFieldKeyValuePair{ let pair = { key: '', value: '' }; if (editor) { const keyInput = editor.querySelector('.key-input'); @@ -144,11 +130,4 @@ export class KeyValueOperationTemplate { } return pair; } - - private static normalizeNumericValue(value: string): number | string { - const convertedNumberValue = Number(value); - return isNaN(convertedNumberValue) - ? value - : convertedNumberValue; - } } diff --git a/src/datasources/products/types.ts b/src/datasources/products/types.ts index 96a69db5..9ac52965 100644 --- a/src/datasources/products/types.ts +++ b/src/datasources/products/types.ts @@ -86,11 +86,16 @@ export interface ProductResponseProperties { } export interface QBField extends QueryBuilderField { - lookup?: { - readonly?: boolean; - dataSource: Array<{ - label: string, - value: string - }>; - }, + lookup?: { + readonly?: boolean; + dataSource: Array<{ + label: string, + value: string + }>; + }, } + +export interface propertyFieldKeyValuePair { + key: string; + value: string | number; +}; From 72e4e89e74b0aa3998207ffc50ced31b3526eb45 Mon Sep 17 00:00:00 2001 From: richie Date: Fri, 17 Jan 2025 17:39:16 +0530 Subject: [PATCH 14/37] enhance error handling in queryProducts and ensure proper field selection in runQuery --- .../products/ProductsDataSource.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index b811163b..1b462b76 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -1,4 +1,4 @@ -import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, FieldType, TestDataSourceResponse } from '@grafana/data'; +import { DataFrameDTO, DataSourceInstanceSettings, FieldType, TestDataSourceResponse } from '@grafana/data'; import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { DataSourceBase } from 'core/DataSourceBase'; import { ProductQuery, ProductResponseProperties, Properties, PropertiesOptions, QueryProductResponse } from './types'; @@ -27,35 +27,44 @@ export class ProductsDataSource extends DataSourceBase { recordCount: 1000 }; - async queryProducts(orderBy: string, projection: Properties[], recordCount = 1000, descending = false, returnCount = false): Promise { - const response = await this.post(this.queryProductsUrl, { - orderBy: orderBy, - descending: descending, - projection: projection, - take: recordCount, - returnCount: returnCount - }); - return response; + async queryProducts(orderBy: string, projection: Properties[], recordCount?: number, descending?: boolean, returnCount = false): Promise { + try { + const response = await this.post(this.queryProductsUrl, { + orderBy: orderBy, + descending: descending, + projection: projection, + take: recordCount, + returnCount: returnCount + }); + return response; + } catch (error) { + throw new Error(`An error occurred while querying products: ${error}`); + } } - async runQuery(query: ProductQuery, options: DataQueryRequest): Promise { - const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.recordCount, query.descending)).products; + async runQuery(query: ProductQuery): Promise { + const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.recordCount!, query.descending!)).products; - const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; - const fields = selectedFields.map((field) => { - const fieldType = field === PropertiesOptions.UPDATEDAT ? FieldType.time : FieldType.string; - const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); - - if (field === PropertiesOptions.PROPERTIES) { - return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; - } - return { name: field, values, type: fieldType }; - }); + if (responseData.length > 0) { + const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; + const fields = selectedFields.map((field) => { + const fieldType = field === PropertiesOptions.UPDATEDAT ? FieldType.time : FieldType.string; + const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); + if (field === PropertiesOptions.PROPERTIES) { + return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; + } + return { name: field, values, type: fieldType }; + }); + return { + refId: query.refId, + fields: fields + }; + } return { refId: query.refId, - fields: fields - }; + fields: [] + } } shouldRunQuery(query: ProductQuery): boolean { From 9df0d3447076aae8ca1fd37a13437a8935f7a464 Mon Sep 17 00:00:00 2001 From: richie Date: Mon, 20 Jan 2025 11:03:58 +0530 Subject: [PATCH 15/37] refactor: standardize PropertyFieldKeyValuePair naming and improve queryProducts test parameters --- .../products/ProductsDataSource.test.ts | 5 +++-- .../products/ProductsDataSource.ts | 2 +- .../query-builder/keyValueOperation.ts | 20 +++++++++---------- src/datasources/products/types.ts | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index 6292f78e..a93c3cd4 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -51,12 +51,13 @@ describe('ProductsDataSource', () => { describe('queryProducts', () => { it('should call api with correct parameters', async () => { const orderBy = 'name'; + const filter = ''; const projection = [PropertiesOptions.ID, PropertiesOptions.NAME] as Properties[]; const recordCount = 500; const descending = true; const returnCount = true; - const response = await ds.queryProducts(orderBy, projection, '', recordCount, descending, returnCount); + const response = await ds.queryProducts(orderBy, projection, filter, recordCount, descending, returnCount); expect(response).toEqual(mockQueryProductResponse); }); @@ -72,7 +73,7 @@ describe('ProductsDataSource', () => { const response = await ds.query(query); expect(response.data).toHaveLength(2); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledWith(expect.objectContaining({ url: '_/nitestmonitor/v2/query-products' })); }); diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index 80825754..237146f8 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -91,7 +91,7 @@ export class ProductsDataSource extends DataSourceBase { if (responseData.length > 0) { const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; const fields = selectedFields.map((field) => { - const fieldType = field === Properties.updatedAt ? FieldType.time : FieldType.string; + const fieldType = field === PropertiesOptions.UPDATEDAT ? FieldType.time : FieldType.string; const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); if (field === PropertiesOptions.PROPERTIES) { diff --git a/src/datasources/products/components/query-builder/keyValueOperation.ts b/src/datasources/products/components/query-builder/keyValueOperation.ts index bf853619..2b9fdaa6 100644 --- a/src/datasources/products/components/query-builder/keyValueOperation.ts +++ b/src/datasources/products/components/query-builder/keyValueOperation.ts @@ -1,14 +1,14 @@ import { FilterExpressions, FilterOperations } from "core/query-builder.constants"; -import { propertyFieldKeyValuePair } from "datasources/products/types"; +import { PropertyFieldKeyValuePair } from "datasources/products/types"; export class KeyValueOperationTemplate { - public static editorTemplate(_: string, value: propertyFieldKeyValuePair): HTMLElement { + public static editorTemplate(_: string, value: PropertyFieldKeyValuePair): HTMLElement { const keyPlaceHolder = `Key`; const valuePlaceHolder = `Value`; return KeyValueOperationTemplate.labeledEditorTemplate(keyPlaceHolder, valuePlaceHolder, value); } - public static labeledEditorTemplate(keyPlaceholder: string, valuePlaceholder: string, value: propertyFieldKeyValuePair): HTMLElement { + public static labeledEditorTemplate(keyPlaceholder: string, valuePlaceholder: string, value: PropertyFieldKeyValuePair): HTMLElement { const template = `
      @@ -31,7 +31,7 @@ export class KeyValueOperationTemplate { return templateBody.querySelector('#sl-query-builder-key-value-editor')!; } - public static valueTemplate(editor: HTMLElement | null | undefined, value: propertyFieldKeyValuePair): string { + public static valueTemplate(editor: HTMLElement | null | undefined, value: PropertyFieldKeyValuePair): string { if (value) { const keyValuePair = value as { key: string; value: string | number; }; return `${keyValuePair.key} : ${keyValuePair.value}`; @@ -46,7 +46,7 @@ export class KeyValueOperationTemplate { return ''; } - public static handleStringValue(editor: HTMLElement | null | undefined): { label: propertyFieldKeyValuePair; value: propertyFieldKeyValuePair; } { + public static handleStringValue(editor: HTMLElement | null | undefined): { label: PropertyFieldKeyValuePair; value: PropertyFieldKeyValuePair; } { const inputs = KeyValueOperationTemplate.retrieveKeyValueInputs(editor); return { label: inputs, @@ -54,7 +54,7 @@ export class KeyValueOperationTemplate { }; } - public static handleNumberValue(editor: HTMLElement | null | undefined): { label: propertyFieldKeyValuePair; value: propertyFieldKeyValuePair; } { + public static handleNumberValue(editor: HTMLElement | null | undefined): { label: PropertyFieldKeyValuePair; value: PropertyFieldKeyValuePair; } { const inputs = KeyValueOperationTemplate.retrieveKeyValueInputs(editor); const normalizedInputs = { key: inputs.key, value: inputs.value }; return { @@ -63,7 +63,7 @@ export class KeyValueOperationTemplate { }; } - public static keyValueExpressionBuilderCallback(dataField: string, operation: string, keyValuePair: propertyFieldKeyValuePair): string { + public static keyValueExpressionBuilderCallback(dataField: string, operation: string, keyValuePair: PropertyFieldKeyValuePair): string { let expressionTemplate = ''; switch (operation) { case FilterOperations.KeyValueMatch: @@ -102,7 +102,7 @@ export class KeyValueOperationTemplate { return expressionTemplate.replace('{0}', dataField).replace('{1}', keyValuePair.key).replace('{2}', String(keyValuePair.value)); } - public static stringKeyValueExpressionReaderCallback(expression: string, bindings: string[]): { fieldName: string; value: propertyFieldKeyValuePair; } { + public static stringKeyValueExpressionReaderCallback(expression: string, bindings: string[]): { fieldName: string; value: PropertyFieldKeyValuePair; } { // Handle the case where the value is equal to the key const matches = expression.match(/"([^"]*)"/g)?.map(m => m.slice(1, -1)) ?? []; @@ -112,11 +112,11 @@ export class KeyValueOperationTemplate { return { fieldName: bindings[0], value: { key: matches[0], value: matches[1] } }; } - public static numericKeyValueExpressionReaderCallback(_expression: string, bindings: string[]): { fieldName: string; value: propertyFieldKeyValuePair; } { + public static numericKeyValueExpressionReaderCallback(_expression: string, bindings: string[]): { fieldName: string; value: PropertyFieldKeyValuePair; } { return { fieldName: bindings[0], value: { key: bindings[1], value: bindings[2] } }; } - private static retrieveKeyValueInputs(editor: HTMLElement | null | undefined): propertyFieldKeyValuePair{ + private static retrieveKeyValueInputs(editor: HTMLElement | null | undefined): PropertyFieldKeyValuePair{ let pair = { key: '', value: '' }; if (editor) { const keyInput = editor.querySelector('.key-input'); diff --git a/src/datasources/products/types.ts b/src/datasources/products/types.ts index 9ac52965..77ae3533 100644 --- a/src/datasources/products/types.ts +++ b/src/datasources/products/types.ts @@ -95,7 +95,7 @@ export interface QBField extends QueryBuilderField { }, } -export interface propertyFieldKeyValuePair { +export interface PropertyFieldKeyValuePair { key: string; value: string | number; }; From 3097c63f3693f559eec31db509dee99ed3462e92 Mon Sep 17 00:00:00 2001 From: richie Date: Mon, 20 Jan 2025 14:30:34 +0530 Subject: [PATCH 16/37] test: enhance ProductsDataSource tests with error handling and variable replacement scenarios --- .../products/ProductsDataSource.test.ts | 69 ++++++++++++++++++- .../products/ProductsDataSource.ts | 1 - 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index a93c3cd4..f59004b5 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -3,13 +3,23 @@ import { ProductsDataSource } from './ProductsDataSource'; import { ProductQuery, Properties, PropertiesOptions, QueryProductResponse } from './types'; import { Observable, of } from 'rxjs'; import { DataQueryRequest, DataSourceInstanceSettings, dateTime, Field } from '@grafana/data'; +import { createFetchError } from 'test/fixtures'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => ({ fetch: fetchMock }), + getTemplateSrv: () => ({ replace: replaceMock, containsTemplate: containsTemplateMock, getVariables: getVariablesMock }), })); +const mockVariables = [ + { name: 'partNumber', current: { value: '123' } }, + { name: 'workspace', current: { value: 'Workspace 1, Workspace 2, Workspace 3' } }, +] + const fetchMock = jest.fn, [BackendSrvRequest]>(); +const replaceMock = jest.fn((a: string, ...rest: any) => a); +const containsTemplateMock = jest.fn((a: string) => mockVariables.map(v => `$${v.name}`).includes(a)); +const getVariablesMock = jest.fn(() => mockVariables); const mockQueryProductResponse: QueryProductResponse = { products: [ { @@ -61,7 +71,6 @@ describe('ProductsDataSource', () => { expect(response).toEqual(mockQueryProductResponse); }); - }); it('should return data when there are valid queries', async () => { @@ -75,6 +84,56 @@ describe('ProductsDataSource', () => { expect(response.data).toHaveLength(2); expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledWith(expect.objectContaining({ url: '_/nitestmonitor/v2/query-products' })); + expect(fetchMock).toHaveBeenCalledWith(expect.objectContaining({ url: '_/nitestmonitor/v2/query-product-values' })); + }); + + it('should return error when queryProducts fails', async () => { + fetchMock.mockReturnValueOnce((createFetchError(400))); + + const query = buildQuery([ + { refId: 'A', properties: [PropertiesOptions.PART_NUMBER, PropertiesOptions.FAMILY, PropertiesOptions.NAME, PropertiesOptions.WORKSPACE] as Properties[], orderBy: undefined }, + ]); + + await expect(ds.query(query)).rejects.toThrow('An error occurred while querying products: Request to url \"_/nitestmonitor/v2/query-products\" failed with status code: 400. Error message: \"Error\"'); + }); + + it('should return empty data when there are no products', async () => { + fetchMock.mockReturnValueOnce(of(createFetchResponse(emptyProductsResponseMock()))); + + const query = buildQuery([ + { refId: 'A', properties: [PropertiesOptions.PART_NUMBER, PropertiesOptions.FAMILY, PropertiesOptions.NAME, PropertiesOptions.WORKSPACE] as Properties[], orderBy: undefined }, + ]); + + const response = await ds.query(query); + + expect(response.data).toHaveLength(1); + expect(response.data[0].fields).toHaveLength(0); + }); + + it('should replace variables with values when queryBy exists', async () => { + replaceMock.mockReturnValueOnce('123'); + const query = buildQuery([ + { refId: 'A', properties: [PropertiesOptions.PART_NUMBER, PropertiesOptions.FAMILY, PropertiesOptions.NAME, PropertiesOptions.WORKSPACE] as Properties[], orderBy: undefined, queryBy: "PartNumber = '$partNumber'" }, // initial state when creating a panel + ]); + + await ds.query(query); + + expect(replaceMock).toHaveBeenCalledTimes(1); + expect(replaceMock).toHaveBeenCalledWith(query.targets[0].queryBy, expect.anything()); + expect(fetchMock).toHaveBeenCalledWith(expect.objectContaining({ data: { } })); + }); + + it('should replace multi value variables with values when queryBy exists', async () => { + replaceMock.mockReturnValueOnce('Workspace 1, Workspace 2'); + + const query = buildQuery([ + { refId: 'A', properties: [PropertiesOptions.PART_NUMBER, PropertiesOptions.FAMILY, PropertiesOptions.NAME, PropertiesOptions.WORKSPACE] as Properties[], orderBy: undefined, queryBy: "Workspace IN ('$workspace')" }, // initial state when creating a panel + ]); + + await ds.query(query); + + expect(replaceMock).toHaveBeenCalledTimes(1); + expect(replaceMock).toHaveBeenCalledWith(query.targets[0].queryBy, expect.anything()); }); it('should convert properties to Grafana fields', async () => { @@ -136,6 +195,14 @@ const createFetchResponse = (data: T): FetchResponse => { }; }; +const emptyProductsResponseMock = (): QueryProductResponse => { + return { + products: [], + continuationToken: '', + totalCount: 0 + } +} + const defaultQuery: DataQueryRequest = { requestId: '1', dashboardUID: '1', diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index 237146f8..93d2ede8 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -83,7 +83,6 @@ export class ProductsDataSource extends DataSourceBase { this.templateSrv.replace(query.queryBy, options.scopedVars), this.productsComputedDataFields, ); - query.queryBy = this.templateSrv.replace(query.queryBy, options.scopedVars); } const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.queryBy, query.recordCount, query.descending)).products; From 3625b89aca668b4684aab97e240294a6b31debc9 Mon Sep 17 00:00:00 2001 From: richie Date: Mon, 20 Jan 2025 15:27:50 +0530 Subject: [PATCH 17/37] addressed PR comments --- .../products/ProductsDataSource.ts | 47 +++++++++++++------ .../components/ProductsQueryEditor.test.tsx | 8 ++-- .../components/ProductsQueryEditor.tsx | 14 +++--- src/datasources/products/types.ts | 4 +- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index 1b462b76..9448ce21 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -27,14 +27,20 @@ export class ProductsDataSource extends DataSourceBase { recordCount: 1000 }; - async queryProducts(orderBy: string, projection: Properties[], recordCount?: number, descending?: boolean, returnCount = false): Promise { + async queryProducts( + orderBy?: string, + projection?: Properties[], + take?: number, + descending?: boolean, + returnCount = false + ): Promise { try { const response = await this.post(this.queryProductsUrl, { - orderBy: orderBy, - descending: descending, - projection: projection, - take: recordCount, - returnCount: returnCount + orderBy, + descending, + projection, + take, + returnCount }); return response; } catch (error) { @@ -43,18 +49,29 @@ export class ProductsDataSource extends DataSourceBase { } async runQuery(query: ProductQuery): Promise { - const responseData = (await this.queryProducts(query.orderBy!, query.properties!, query.recordCount!, query.descending!)).products; + const products = ( + await this.queryProducts( + query.orderBy, + query.properties, + query.recordCount, + query.descending + )).products; - if (responseData.length > 0) { - const selectedFields = query.properties?.filter((field: Properties) => Object.keys(responseData[0]).includes(field)) || []; + if (products.length > 0) { + const selectedFields = query.properties?.filter( + (field: Properties) => Object.keys(products[0]).includes(field)) || []; const fields = selectedFields.map((field) => { - const fieldType = field === PropertiesOptions.UPDATEDAT ? FieldType.time : FieldType.string; - const values = responseData.map(data => data[field as unknown as keyof ProductResponseProperties]); + const isTimeField = field === PropertiesOptions.UPDATEDAT; + const fieldType = isTimeField + ? FieldType.time + : FieldType.string; + const values = products.map(data => data[field as unknown as keyof ProductResponseProperties]); - if (field === PropertiesOptions.PROPERTIES) { - return { name: field, values: values.map(value => JSON.stringify(value)), type: fieldType }; - } - return { name: field, values, type: fieldType }; + return { name: field, values: field === PropertiesOptions.PROPERTIES + ? values.map(value => { + return value != null ? JSON.stringify(value) : ''; + }) + : values , type: fieldType }; }); return { refId: query.refId, diff --git a/src/datasources/products/components/ProductsQueryEditor.test.tsx b/src/datasources/products/components/ProductsQueryEditor.test.tsx index 0d8d8d5a..aa5b0ce3 100644 --- a/src/datasources/products/components/ProductsQueryEditor.test.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.test.tsx @@ -36,7 +36,7 @@ describe('ProductsQueryEditor', () => { expect(recordCount).toHaveValue('1000'); }); - it('updates when user makes changes', async () => { + it('updates when user makes changes', async () => { //User adds a properties await select(properties, "id", { container: document.body }); await waitFor(() => { @@ -44,7 +44,7 @@ describe('ProductsQueryEditor', () => { expect.objectContaining({ properties: ["id"] }) ) }); - + //User changes order by await select(orderBy, "ID", { container: document.body }); await waitFor(() => { @@ -52,7 +52,7 @@ describe('ProductsQueryEditor', () => { expect.objectContaining({ orderBy: "ID" }) ) }); - + //User changes descending checkbox await userEvent.click(descending); await waitFor(() => { @@ -60,7 +60,7 @@ describe('ProductsQueryEditor', () => { expect.objectContaining({ descending: true }) ) }); - + //User changes record count await userEvent.clear(recordCount); await userEvent.type(recordCount, '500{Enter}'); diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index 734e698d..2755b6a1 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -42,7 +42,7 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: ({ label: value, value })) as SelectableValue[]} onChange={onPropertiesChange} value={query.properties} @@ -57,7 +57,7 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: Date: Tue, 21 Jan 2025 10:29:16 +0530 Subject: [PATCH 19/37] removed the snapshots --- .../ProductsDataSource.test.ts.snap | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap diff --git a/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap b/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap deleted file mode 100644 index 6ef3b020..00000000 --- a/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap +++ /dev/null @@ -1,77 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`query returns data when there are valid queries 1`] = ` -[ - { - "fields": [ - { - "name": "partNumber", - "type": "string", - "values": [ - "123", - ], - }, - { - "name": "family", - "type": "string", - "values": [ - "Family 1", - ], - }, - { - "name": "name", - "type": "string", - "values": [ - "Product 1", - ], - }, - { - "name": "workspace", - "type": "string", - "values": [ - "Workspace 1", - ], - }, - ], - "refId": "A", - }, -] -`; - -exports[`query returns no data when API returns no data 1`] = ` -[ - { - "fields": [], - "refId": "A", - }, -] -`; - -exports[`query returns no data when Query Products returns no data 1`] = ` -[ - { - "fields": [], - "refId": "A", - }, -] -`; - -exports[`queryProducts returns data when there are valid queries 1`] = ` -{ - "continuationToken": "", - "products": [ - { - "family": "Family 1", - "id": "1", - "name": "Product 1", - "partNumber": "123", - "properties": { - "prop1": "value1", - }, - "updatedAt": "2021-08-01T00:00:00Z", - "workspace": "Workspace 1", - }, - ], - "totalCount": 2, -} -`; From 6371242cc3d0fb2e9d3684eadcb4acac0ae6b9a9 Mon Sep 17 00:00:00 2001 From: richie Date: Tue, 21 Jan 2025 10:31:46 +0530 Subject: [PATCH 20/37] added new snapshots --- .../ProductsDataSource.test.ts.snap | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap diff --git a/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap b/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap new file mode 100644 index 00000000..c39b3687 --- /dev/null +++ b/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`query returns data when there are valid queries 1`] = ` +[ + { + "fields": [ + { + "name": "partNumber", + "type": "string", + "values": [ + "123", + ], + }, + { + "name": "family", + "type": "string", + "values": [ + "Family 1", + ], + }, + { + "name": "name", + "type": "string", + "values": [ + "Product 1", + ], + }, + { + "name": "workspace", + "type": "string", + "values": [ + "Workspace 1", + ], + }, + ], + "refId": "A", + }, +] +`; + +exports[`query returns no data when Query Products returns no data 1`] = ` +[ + { + "fields": [], + "refId": "A", + }, +] +`; + +exports[`queryProducts returns data when there are valid queries 1`] = ` +{ + "continuationToken": "", + "products": [ + { + "family": "Family 1", + "id": "1", + "name": "Product 1", + "partNumber": "123", + "properties": { + "prop1": "value1", + }, + "updatedAt": "2021-08-01T00:00:00Z", + "workspace": "Workspace 1", + }, + ], + "totalCount": 2, +} +`; From d4f3c5362c5d01ee50f0503a3264eadd1ddaf04d Mon Sep 17 00:00:00 2001 From: richie Date: Tue, 21 Jan 2025 22:34:57 +0530 Subject: [PATCH 21/37] refactor: rename KeyValueOperationTemplate import and remove unused keyValueOperation file --- src/core/query-builder.constants.ts | 2 +- ...ueOperation.ts => keyValueOperationTemplate.ts} | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) rename src/datasources/products/components/query-builder/{keyValueOperation.ts => keyValueOperationTemplate.ts} (91%) diff --git a/src/core/query-builder.constants.ts b/src/core/query-builder.constants.ts index e3dd8215..bd1cf8a8 100644 --- a/src/core/query-builder.constants.ts +++ b/src/core/query-builder.constants.ts @@ -1,4 +1,4 @@ -import { KeyValueOperationTemplate } from "datasources/products/components/query-builder/keyValueOperation"; +import { KeyValueOperationTemplate } from "datasources/products/components/query-builder/keyValueOperationTemplate"; import { QueryBuilderCustomOperation } from "smart-webcomponents-react"; export const queryBuilderMessages = { diff --git a/src/datasources/products/components/query-builder/keyValueOperation.ts b/src/datasources/products/components/query-builder/keyValueOperationTemplate.ts similarity index 91% rename from src/datasources/products/components/query-builder/keyValueOperation.ts rename to src/datasources/products/components/query-builder/keyValueOperationTemplate.ts index 2b9fdaa6..fd8adcfa 100644 --- a/src/datasources/products/components/query-builder/keyValueOperation.ts +++ b/src/datasources/products/components/query-builder/keyValueOperationTemplate.ts @@ -56,7 +56,7 @@ export class KeyValueOperationTemplate { public static handleNumberValue(editor: HTMLElement | null | undefined): { label: PropertyFieldKeyValuePair; value: PropertyFieldKeyValuePair; } { const inputs = KeyValueOperationTemplate.retrieveKeyValueInputs(editor); - const normalizedInputs = { key: inputs.key, value: inputs.value }; + const normalizedInputs = { key: inputs.key, value: KeyValueOperationTemplate.normalizeNumericValue(inputs.value) }; return { label: normalizedInputs, value: normalizedInputs @@ -113,10 +113,11 @@ export class KeyValueOperationTemplate { } public static numericKeyValueExpressionReaderCallback(_expression: string, bindings: string[]): { fieldName: string; value: PropertyFieldKeyValuePair; } { - return { fieldName: bindings[0], value: { key: bindings[1], value: bindings[2] } }; + const normalizedNumberValue = KeyValueOperationTemplate.normalizeNumericValue(bindings[2]); + return { fieldName: bindings[0], value: { key: bindings[1], value: normalizedNumberValue } }; } - private static retrieveKeyValueInputs(editor: HTMLElement | null | undefined): PropertyFieldKeyValuePair{ + private static retrieveKeyValueInputs(editor: HTMLElement | null | undefined): { key: string, value: string }{ let pair = { key: '', value: '' }; if (editor) { const keyInput = editor.querySelector('.key-input'); @@ -130,4 +131,11 @@ export class KeyValueOperationTemplate { } return pair; } + + private static normalizeNumericValue(value: string): number | string { + const convertedNumberValue = Number(value); + return isNaN(convertedNumberValue) + ? value + : convertedNumberValue; + } } From b91c0792d5a0a99c6b59a526fa8eaf8a045dbf25 Mon Sep 17 00:00:00 2001 From: richie Date: Tue, 21 Jan 2025 23:16:50 +0530 Subject: [PATCH 22/37] refactor: simplify query building and update test snapshots --- .../products/ProductsDataSource.test.ts | 136 ++++++++---------- .../ProductsDataSource.test.ts.snap | 9 +- 2 files changed, 57 insertions(+), 88 deletions(-) diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index 40114e69..b99ea883 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -1,8 +1,8 @@ import { BackendSrv } from '@grafana/runtime'; import { ProductsDataSource } from './ProductsDataSource'; import { ProductQuery, Properties, PropertiesOptions, QueryProductResponse } from './types'; -import { DataQueryRequest, dateTime, Field } from '@grafana/data'; -import { createFetchError, createFetchResponse, requestMatching, setupDataSource } from 'test/fixtures'; +import { Field } from '@grafana/data'; +import { createFetchError, createFetchResponse, getQueryBuilder, requestMatching, setupDataSource } from 'test/fixtures'; import { MockProxy } from 'jest-mock-extended'; const mockQueryProductResponse: QueryProductResponse = { @@ -29,7 +29,7 @@ beforeEach(() => { backendServer.fetch .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-products' })) .mockReturnValue( - createFetchResponse(mockQueryProductResponse) + createFetchResponse(mockQueryProductResponse) ); }); @@ -47,7 +47,7 @@ describe('testDatasource', () => { test('bubbles up exception', async () => { backendServer.fetch .calledWith(requestMatching({ url: '/nitestmonitor/v2/products?take=1' })) - .mockReturnValue(createFetchError(400)); + .mockReturnValue(createFetchError(400)); await expect(datastore.testDatasource()) .rejects @@ -75,20 +75,20 @@ describe('queryProducts', () => { describe('query', () => { test('returns data when there are valid queries', async () => { - const query = buildQuery([ - { - refId: 'A', + const query = buildQuery( + { + refId: 'A', properties: [ - PropertiesOptions.PART_NUMBER, - PropertiesOptions.FAMILY, - PropertiesOptions.NAME, - PropertiesOptions.WORKSPACE - ] as Properties[], - orderBy: PropertiesOptions.ID , - descending: false, + PropertiesOptions.PART_NUMBER, + PropertiesOptions.FAMILY, + PropertiesOptions.NAME, + PropertiesOptions.WORKSPACE + ] as Properties[], + orderBy: PropertiesOptions.ID, + descending: false, recordCount: 1 - }, - ]); + }, + ); const response = await datastore.query(query); @@ -101,23 +101,13 @@ describe('query', () => { .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-products' })) .mockReturnValue( createFetchResponse( - { - products: [], - continuationToken: null, - totalCount: 0 - } as unknown as QueryProductResponse)); - - const query = buildQuery([ - { - refId: 'A', - properties: [ - PropertiesOptions.PART_NUMBER, - PropertiesOptions.FAMILY, - PropertiesOptions.NAME, - PropertiesOptions.WORKSPACE - ] as Properties[], orderBy: undefined - }, - ]); + { + products: [], + continuationToken: null, + totalCount: 0 + } as unknown as QueryProductResponse)); + + const query = buildQuery(); const response = await datastore.query(query); @@ -129,17 +119,17 @@ describe('query', () => { .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-products' })) .mockReturnValue(createFetchError(400)); - const query = buildQuery([ + const query = buildQuery( { refId: 'A', properties: [ - PropertiesOptions.PART_NUMBER, - PropertiesOptions.FAMILY, - PropertiesOptions.NAME, - PropertiesOptions.WORKSPACE + PropertiesOptions.PART_NUMBER, + PropertiesOptions.FAMILY, + PropertiesOptions.NAME, + PropertiesOptions.WORKSPACE ] as Properties[], orderBy: undefined }, - ]); + ); await expect(datastore.query(query)) .rejects @@ -147,19 +137,19 @@ describe('query', () => { }); it('should convert properties to Grafana fields', async () => { - const query = buildQuery([ + const query = buildQuery( { refId: 'A', properties: [ - PropertiesOptions.PART_NUMBER, - PropertiesOptions.FAMILY, - PropertiesOptions.NAME, - PropertiesOptions.WORKSPACE, - PropertiesOptions.UPDATEDAT, - PropertiesOptions.PROPERTIES + PropertiesOptions.PART_NUMBER, + PropertiesOptions.FAMILY, + PropertiesOptions.NAME, + PropertiesOptions.WORKSPACE, + PropertiesOptions.UPDATEDAT, + PropertiesOptions.PROPERTIES ] as Properties[], orderBy: undefined }, - ]); + ); const response = await datastore.query(query); @@ -179,22 +169,22 @@ describe('query', () => { .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-products' })) .mockReturnValue(createFetchResponse({ products: [ - { - id: '1', - name: 'Product 1', - properties: null - } + { + id: '1', + name: 'Product 1', + properties: null + } ], continuationToken: null, totalCount: 0 - } as unknown as QueryProductResponse)); + } as unknown as QueryProductResponse)); - const query = buildQuery([ + const query = buildQuery( { refId: 'A', properties: [ - PropertiesOptions.PROPERTIES + PropertiesOptions.PROPERTIES ] as Properties[], orderBy: undefined }, - ]); + ); const response = await datastore.query(query); const fields = response.data[0].fields as Field[]; @@ -204,27 +194,13 @@ describe('query', () => { }); }); -const buildQuery = (targets: ProductQuery[]): DataQueryRequest => { - return { - ...defaultQuery, - targets, - }; -}; - -const defaultQuery: DataQueryRequest = { - requestId: '1', - dashboardUID: '1', - interval: '0', - intervalMs: 10, - panelId: 0, - scopedVars: {}, - range: { - from: dateTime().subtract(1, 'h'), - to: dateTime(), - raw: { from: '1h', to: 'now' }, - }, - timezone: 'browser', - app: 'explore', - startTime: 0, - targets: [], -}; +const buildQuery = getQueryBuilder()({ + refId: 'A', + properties: [ + PropertiesOptions.PART_NUMBER, + PropertiesOptions.FAMILY, + PropertiesOptions.NAME, + PropertiesOptions.WORKSPACE + ] as Properties[], + orderBy: undefined +}); \ No newline at end of file diff --git a/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap b/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap index c39b3687..7982306e 100644 --- a/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap +++ b/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap @@ -38,14 +38,7 @@ exports[`query returns data when there are valid queries 1`] = ` ] `; -exports[`query returns no data when Query Products returns no data 1`] = ` -[ - { - "fields": [], - "refId": "A", - }, -] -`; +exports[`query returns no data when Query Products returns no data 1`] = `[]`; exports[`queryProducts returns data when there are valid queries 1`] = ` { From 7bdef54291d8018938814703c2d1fe4177d82edb Mon Sep 17 00:00:00 2001 From: richie Date: Wed, 22 Jan 2025 00:36:26 +0530 Subject: [PATCH 23/37] test: add queryProductValues tests and improve error handling --- .../products/ProductsDataSource.test.ts | 107 +++++++++++++++++- .../products/ProductsDataSource.ts | 34 +++--- .../ProductsDataSource.test.ts.snap | 6 + .../ProductsQueryBuilder.test.tsx | 70 ++++++++++++ 4 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index ca62125d..573618fa 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -4,6 +4,7 @@ import { ProductQuery, Properties, PropertiesOptions, QueryProductResponse } fro import { Field } from '@grafana/data'; import { createFetchError, createFetchResponse, getQueryBuilder, requestMatching, setupDataSource } from 'test/fixtures'; import { MockProxy } from 'jest-mock-extended'; +import { ProductsQueryBuilderFieldNames } from './constants/ProductsQueryBuilder.constants'; const mockQueryProductResponse: QueryProductResponse = { products: [ @@ -62,7 +63,7 @@ describe('queryProducts', () => { expect(response).toMatchSnapshot(); }); - test('raises an error returns API fails', async () => { + test('raises an error when API fails', async () => { backendServer.fetch .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-products' })) .mockReturnValue(createFetchError(400)); @@ -73,6 +74,28 @@ describe('queryProducts', () => { }); }); +describe('queryProductValues', () => { + test('returns data when there are valid queries', async () => { + backendServer.fetch + .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-product-values' })) + .mockReturnValue(createFetchResponse(['value1'])); + + const response = await datastore.queryProductValues(ProductsQueryBuilderFieldNames.PART_NUMBER); + + expect(response).toMatchSnapshot(); + }); + + test('raises an error when API fails', async () => { + backendServer.fetch + .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-product-values' })) + .mockReturnValue(createFetchError(400)); + + await expect(datastore.queryProductValues(ProductsQueryBuilderFieldNames.PART_NUMBER)) + .rejects + .toThrow('Request to url "/nitestmonitor/v2/query-product-values" failed with status code: 400. Error message: "Error"'); + }); +}); + describe('query', () => { test('returns data when there are valid queries', async () => { const query = buildQuery( @@ -84,6 +107,7 @@ describe('query', () => { PropertiesOptions.NAME, PropertiesOptions.WORKSPACE ] as Properties[], + queryBy: `${ProductsQueryBuilderFieldNames.PART_NUMBER} = "123"`, orderBy: PropertiesOptions.ID, descending: false, recordCount: 1 @@ -192,6 +216,87 @@ describe('query', () => { { name: 'properties', values: [''], type: 'string' }, ]); }); + + test('should not query product values if cache exists', async () => { + backendServer.fetch + .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-product-values' })) + .mockReturnValue(createFetchResponse(['value1'])); + datastore.partNumbersCache.set('partNumber', 'value1'); + backendServer.fetch.mockClear(); + + await datastore.query(buildQuery()) + + expect(backendServer.fetch).not.toHaveBeenCalled(); + }); + + test('should not query workspace values if cache exists', async () => { + backendServer.fetch + .calledWith(requestMatching({ url: '/niauth/v1/user' })) + .mockReturnValue(createFetchResponse(['workspace1'])); + datastore.workspacesCache.set('workspace', {id: 'workspace1', name: 'workspace1', default: false, enabled: true}); + backendServer.fetch.mockClear(); + + await datastore.query(buildQuery()) + + expect(backendServer.fetch).not.toHaveBeenCalled(); + }); + + describe('Query builder queries', () => { + test('should transform fields with single value', async () => { + const query = buildQuery( + { + refId: 'A', + properties: [ + PropertiesOptions.PART_NUMBER + ] as Properties[], + orderBy: undefined, + queryBy: `${PropertiesOptions.PART_NUMBER} = '123'` + }, + ); + + await datastore.query(query); + + expect(backendServer.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + descending: false, + filter: "partNumber = '123'", + orderBy: undefined, + projection: ["partNumber"], + returnCount: false, take: 1000 + } + }) + ); + }); + + test('should transform fields with multiple values', async () => { + const query = buildQuery( + { + refId: 'A', + properties: [ + PropertiesOptions.PART_NUMBER + ] as Properties[], + orderBy: undefined, + queryBy: `${ProductsQueryBuilderFieldNames.PART_NUMBER} = "{partNumber1,partNumber2}"` + }, + ); + + await datastore.query(query); + + expect(backendServer.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + descending: false, + filter: "(PartNumber = \"partNumber1\" || PartNumber = \"partNumber2\")", + orderBy: undefined, + projection: ["partNumber"], + returnCount: false, take: 1000 + } + }) + ); + }); + + }); }); const buildQuery = getQueryBuilder()({ diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index ac4cb91f..0f874483 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -52,7 +52,7 @@ export class ProductsDataSource extends DataSourceBase { async queryProducts( orderBy?: string, - projection?: Properties[], + projection?: Properties[], filter?: string, take?: number, descending = false, @@ -75,10 +75,14 @@ export class ProductsDataSource extends DataSourceBase { } async queryProductValues(fieldName: string): Promise { - const response = await this.post(this.queryProductValuesUrl, { - field: fieldName - }); - return response; + try { + return await this.post(this.queryProductValuesUrl, { + field: fieldName + }); + } catch (error) { + this.error = parseErrorMessage(error as Error)!; + throw new Error(`An error occurred while querying product values: ${this.error}`); + } } async runQuery(query: ProductQuery, options: DataQueryRequest): Promise { @@ -107,20 +111,20 @@ export class ProductsDataSource extends DataSourceBase { const fields = selectedFields.map((field) => { const isTimeField = field === PropertiesOptions.UPDATEDAT; const fieldType = isTimeField - ? FieldType.time - : FieldType.string; + ? FieldType.time + : FieldType.string; const values = products .map(data => data[field as unknown as keyof ProductResponseProperties]); - return { - name: field, - values: values.map(value => value != null - ? (field === PropertiesOptions.PROPERTIES - ? JSON.stringify(value) - : value) - : ''), - type: fieldType + return { + name: field, + values: values.map(value => value != null + ? (field === PropertiesOptions.PROPERTIES + ? JSON.stringify(value) + : value) + : ''), + type: fieldType }; }); return { diff --git a/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap b/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap index 7982306e..cca38e79 100644 --- a/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap +++ b/src/datasources/products/__snapshots__/ProductsDataSource.test.ts.snap @@ -40,6 +40,12 @@ exports[`query returns data when there are valid queries 1`] = ` exports[`query returns no data when Query Products returns no data 1`] = `[]`; +exports[`queryProductValues returns data when there are valid queries 1`] = ` +[ + "value1", +] +`; + exports[`queryProducts returns data when there are valid queries 1`] = ` { "continuationToken": "", diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx new file mode 100644 index 00000000..61a92345 --- /dev/null +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx @@ -0,0 +1,70 @@ +import { QueryBuilderOption, Workspace } from "core/types"; +import React, { ReactNode } from "react"; +import { ProductsQueryBuilder } from "./ProductsQueryBuilder"; +import { render } from "@testing-library/react"; + +describe('ProductsQueryBuilder', () => { + describe('useEffects', () => { + let reactNode: ReactNode + + const containerClass = 'smart-filter-group-condition-container'; + const workspace = { id: '1', name: 'Selected workspace' } as Workspace; + const partNumber = ['partNumber1', 'partNumber2']; + + function renderElement(workspaces: Workspace[], partNumbers: string[], filter?: string, globalVariableOptions: QueryBuilderOption[] = []) { + reactNode = React.createElement(ProductsQueryBuilder, { filter, workspaces, partNumbers, globalVariableOptions, onChange: jest.fn(), }); + const renderResult = render(reactNode); + return { + renderResult, + conditionsContainer: renderResult.container.getElementsByClassName(`${containerClass}`) + }; + } + + it('should render empty query builder', () => { + const { renderResult, conditionsContainer } = renderElement([], [], ''); + + expect(conditionsContainer.length).toBe(1); + expect(renderResult.findByLabelText('Empty condition row')).toBeTruthy(); + }) + + it('should select workspace in query builder', () => { + const { conditionsContainer } = renderElement([workspace], partNumber, 'Workspace = "1" && PartNumber = "partNumber1"'); + + expect(conditionsContainer?.length).toBe(2); + expect(conditionsContainer.item(0)?.textContent).toContain(workspace.name); + expect(conditionsContainer.item(1)?.textContent).toContain("partNumber1"); + }) + + it('should select part number in query builder', () => { + const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = "partNumber1"'); + + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.textContent).toContain("partNumber1"); + }); + + it('should select global variable option', () => { + const globalVariableOption = { label: 'Global variable', value: 'global_variable' }; + const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = \"global_variable\"', [globalVariableOption]); + + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.textContent).toContain(globalVariableOption.label); + }); + + [['${__from:date}', 'From'], ['${__to:date}', 'To'], ['${__now:date}', 'Now']].forEach(([value, label]) => { + it(`should select user friendly value for updated date`, () => { + + const { conditionsContainer } = renderElement([workspace], partNumber, `UpdatedAt > \"${value}\"`); + + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.textContent).toContain(label); + }); + }); + + it('should sanitize fields in query builder', () => { + const { conditionsContainer } = renderElement([workspace], partNumber, 'Family = ""'); + + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.innerHTML).not.toContain('alert(\'Family\')'); + }) + }); +}); \ No newline at end of file From 0419b0c81757047e0f6bd66552e3121a953b3208 Mon Sep 17 00:00:00 2001 From: richie Date: Wed, 22 Jan 2025 01:04:45 +0530 Subject: [PATCH 24/37] refactor: enhance ProductsQueryEditor layout and add styling --- .../components/ProductsQueryEditor.scss | 8 +++++ .../components/ProductsQueryEditor.tsx | 33 ++++++++++--------- 2 files changed, 25 insertions(+), 16 deletions(-) create mode 100644 src/datasources/products/components/ProductsQueryEditor.scss diff --git a/src/datasources/products/components/ProductsQueryEditor.scss b/src/datasources/products/components/ProductsQueryEditor.scss new file mode 100644 index 00000000..27c079c0 --- /dev/null +++ b/src/datasources/products/components/ProductsQueryEditor.scss @@ -0,0 +1,8 @@ +.right-query-controls { + padding-top: 45px; +} + +.horizontal-control-group { + display: flex ; + align-items: center ; +} \ No newline at end of file diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index adaedc3a..63d227a3 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -7,6 +7,7 @@ import { OrderBy, ProductQuery, Properties } from '../types'; import { Workspace } from 'core/types'; import { ProductsQueryBuilder } from 'datasources/products/components/query-builder/ProductsQueryBuilder'; import { FloatingError } from 'core/errors'; +import './ProductsQueryEditor.scss'; type Props = QueryEditorProps; @@ -67,18 +68,29 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: ({ label: value, value })) as SelectableValue[]} + .map(value => ({ label: value, value })) as SelectableValue[]} onChange={onPropertiesChange} value={query.properties} defaultValue={query.properties!} maxVisibleValues={5} - width={60} - allowCustomValue={false} + width={65} + allowCustomValue={false} closeMenuOnSelect={false} /> -
      -
      + + onParameterChange(event.detail.linq)} + > + + + +
      +
      + + `; + const result = valueTemplate(editor, { key: 'testKey', value: 'testValue' }); + expect(result).toBe('testKey : testValue'); + }); + + it('should return empty colons if no value and editor inputs are provided', () => { + const result = valueTemplate(null, { key: '', value: '' }); + expect(result).toBe(' : '); + }); + }); + + describe('handleStringValue', () => { + it('should return key-value pair from editor inputs', () => { + const editor = document.createElement('div'); + editor.innerHTML = ` + + + `; + const result = handleStringValue(editor); + expect(result).toEqual({ label: { key: 'testKey', value: 'testValue' }, value: { key: 'testKey', value: 'testValue' } }); + }); + }); + + describe('handleNumberValue', () => { + it('should return normalized key-value pair from editor inputs', () => { + const editor = document.createElement('div'); + editor.innerHTML = ` + + + `; + const result = handleNumberValue(editor); + expect(result).toEqual({ label: { key: 'testKey', value: 123 }, value: { key: 'testKey', value: 123 } }); + }); + + it('should return string value if input is not a number', () => { + const editor = document.createElement('div'); + editor.innerHTML = ` + + + `; + const result = handleNumberValue(editor); + expect(result).toEqual({ label: { key: 'testKey', value: 'testValue' }, value: { key: 'testKey', value: 'testValue' } }); + }); + }); + + describe('keyValueExpressionBuilderCallback', () => { + it('should return correct expression for valid operation', () => { + const result = keyValueExpressionBuilderCallback('dataField', FilterOperations.KeyValueMatch, { key: 'testKey', value: 'testValue' }); + expect(result).toBe('dataField[\"testKey\"] = \"testValue\"'); + }); + + it('should return empty string for unknown operation', () => { + const result = keyValueExpressionBuilderCallback('dataField', 'unknownOperation', { key: 'testKey', value: 'testValue' }); + expect(result).toBe(''); + }); + }); + + describe('stringKeyValueExpressionReaderCallback', () => { + it('should return field name and key-value pair from expression', () => { + const result = stringKeyValueExpressionReaderCallback('"testKey" : "testValue"', ['dataField']); + expect(result).toEqual({ fieldName: 'dataField', value: { key: 'testKey', value: 'testValue' } }); + }); + + it('should return empty key-value pair if expression is invalid', () => { + const result = stringKeyValueExpressionReaderCallback('invalidExpression', ['dataField']); + expect(result).toEqual({ fieldName: 'dataField', value: { key: '', value: '' } }); + }); + }); + + describe('numericKeyValueExpressionReaderCallback', () => { + it('should return field name and normalized key-value pair from bindings', () => { + const result = numericKeyValueExpressionReaderCallback('', ['dataField', 'testKey', '123']); + expect(result).toEqual({ fieldName: 'dataField', value: { key: 'testKey', value: 123 } }); + }); + + it('should return string value if binding is not a number', () => { + const result = numericKeyValueExpressionReaderCallback('', ['dataField', 'testKey', 'testValue']); + expect(result).toEqual({ fieldName: 'dataField', value: { key: 'testKey', value: 'testValue' } }); + }); + }); +}); \ No newline at end of file diff --git a/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.ts b/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.ts new file mode 100644 index 00000000..3d724379 --- /dev/null +++ b/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.ts @@ -0,0 +1,136 @@ +import { FilterExpressions, FilterOperations } from "core/query-builder.constants"; +import { PropertyFieldKeyValuePair } from "datasources/products/types"; + + +export function editorTemplate(_: string, value: PropertyFieldKeyValuePair): HTMLElement { + const keyPlaceholder = `Key`; + const valuePlaceholder = `Value`; + + const template = ` +
      +
        +
      • + + +
      • +
      • + + +
      • +
      +
      `; + + const templateBody = new DOMParser().parseFromString(template, 'text/html').body; + return templateBody.querySelector('#sl-query-builder-key-value-editor')!; +} + +export function valueTemplate(editor: HTMLElement | null | undefined, value: PropertyFieldKeyValuePair): string { + if (value) { + const keyValuePair = value as { key: string; value: string | number; }; + return `${keyValuePair.key} : ${keyValuePair.value}`; + } + if (editor) { + const keyInput = editor.querySelector('.key-input'); + const valueInput = editor.querySelector('.value-input'); + if (keyInput && valueInput) { + return `${keyInput.value} : ${valueInput.value}`; + } + } + return ''; +} + +export function handleStringValue(editor: HTMLElement | null | undefined): { label: PropertyFieldKeyValuePair; value: PropertyFieldKeyValuePair; } { + const inputs = retrieveKeyValueInputs(editor); + return { + label: inputs, + value: inputs + }; +} + +export function handleNumberValue(editor: HTMLElement | null | undefined): { label: PropertyFieldKeyValuePair; value: PropertyFieldKeyValuePair; } { + const inputs = retrieveKeyValueInputs(editor); + const normalizedInputs = { key: inputs.key, value: normalizeNumericValue(inputs.value) }; + return { + label: normalizedInputs, + value: normalizedInputs + }; +} + +export function keyValueExpressionBuilderCallback(dataField: string, operation: string, keyValuePair: PropertyFieldKeyValuePair): string { + let expressionTemplate = ''; + switch (operation) { + case FilterOperations.KeyValueMatch: + expressionTemplate = FilterExpressions.KeyValueMatches; + break; + case FilterOperations.KeyValueDoesNotMatch: + expressionTemplate = FilterExpressions.KeyValueNotMatches; + break; + case FilterOperations.KeyValueContains: + expressionTemplate = FilterExpressions.KeyValueContains; + break; + case FilterOperations.KeyValueDoesNotContains: + expressionTemplate = FilterExpressions.KeyValueNotContains; + break; + case FilterOperations.KeyValueIsGreaterThan: + expressionTemplate = FilterExpressions.KeyValueIsGreaterThan; + break; + case FilterOperations.KeyValueIsGreaterThanOrEqual: + expressionTemplate = FilterExpressions.KeyValueIsGreaterThanOrEqual; + break; + case FilterOperations.KeyValueIsLessThan: + expressionTemplate = FilterExpressions.KeyValueIsLessThan; + break; + case FilterOperations.KeyValueIsLessThanOrEqual: + expressionTemplate = FilterExpressions.KeyValueIsLessThanOrEqual; + break; + case FilterOperations.KeyValueIsNumericallyEqual: + expressionTemplate = FilterExpressions.KeyValueIsNumericallyEqual; + break; + case FilterOperations.KeyValueIsNumericallyNotEqual: + expressionTemplate = FilterExpressions.KeyValueIsNumericallyNotEqual; + break; + default: + return ''; + } + return expressionTemplate.replace('{0}', dataField).replace('{1}', keyValuePair.key).replace('{2}', String(keyValuePair.value)); +} + +export function stringKeyValueExpressionReaderCallback(expression: string, bindings: string[]): { fieldName: string; value: PropertyFieldKeyValuePair; } { + const matches = expression.match(/"([^"]*)"/g)?.map(m => m.slice(1, -1)) ?? []; + if (matches.length < 2) { + return { fieldName: bindings[0], value: { key: '', value: '' } }; + } + return { fieldName: bindings[0], value: { key: matches[0], value: matches[1] } }; +} + +export function numericKeyValueExpressionReaderCallback(_expression: string, bindings: string[]): { fieldName: string; value: PropertyFieldKeyValuePair; } { + const normalizedNumberValue = normalizeNumericValue(bindings[2]); + return { fieldName: bindings[0], value: { key: bindings[1], value: normalizedNumberValue } }; +} + +function retrieveKeyValueInputs(editor: HTMLElement | null | undefined): { key: string, value: string } { + let pair = { key: '', value: '' }; + if (editor) { + const keyInput = editor.querySelector('.key-input'); + const valueInput = editor.querySelector('.value-input'); + if (keyInput && valueInput) { + pair = { + key: keyInput.value, + value: valueInput.value + }; + } + } + return pair; +} + +function normalizeNumericValue(value: string): number | string { + const convertedNumberValue = Number(value); + return isNaN(convertedNumberValue) + ? value + : convertedNumberValue; +} + From fc634ef06ba466c7e5fdb34dd90e3b12e8b48692 Mon Sep 17 00:00:00 2001 From: richie Date: Thu, 23 Jan 2025 14:01:17 +0530 Subject: [PATCH 30/37] fixed lint error --- .../components/query-builder/ProductsQueryBuilder.test.tsx | 2 +- .../query-builder/utils/keyValueOperationUtils.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx index 61a92345..dd13f6c7 100644 --- a/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx @@ -67,4 +67,4 @@ describe('ProductsQueryBuilder', () => { expect(conditionsContainer.item(0)?.innerHTML).not.toContain('alert(\'Family\')'); }) }); -}); \ No newline at end of file +}); diff --git a/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.test.ts b/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.test.ts index 56d83923..338b00b7 100644 --- a/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.test.ts +++ b/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.test.ts @@ -110,4 +110,4 @@ describe('keyValueOperationTemplate', () => { expect(result).toEqual({ fieldName: 'dataField', value: { key: 'testKey', value: 'testValue' } }); }); }); -}); \ No newline at end of file +}); From 420edd7ec02f42219bc69c4791866aad7a9dd0c7 Mon Sep 17 00:00:00 2001 From: richie Date: Thu, 23 Jan 2025 15:14:12 +0530 Subject: [PATCH 31/37] refactor: simplify type casting in keyValueOperationUtils --- .../components/query-builder/utils/keyValueOperationUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.ts b/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.ts index 3d724379..a379b0c9 100644 --- a/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.ts +++ b/src/datasources/products/components/query-builder/utils/keyValueOperationUtils.ts @@ -30,7 +30,7 @@ export function editorTemplate(_: string, value: PropertyFieldKeyValuePair): HTM export function valueTemplate(editor: HTMLElement | null | undefined, value: PropertyFieldKeyValuePair): string { if (value) { - const keyValuePair = value as { key: string; value: string | number; }; + const keyValuePair = value; return `${keyValuePair.key} : ${keyValuePair.value}`; } if (editor) { @@ -48,7 +48,7 @@ export function handleStringValue(editor: HTMLElement | null | undefined): { lab return { label: inputs, value: inputs - }; + }; } export function handleNumberValue(editor: HTMLElement | null | undefined): { label: PropertyFieldKeyValuePair; value: PropertyFieldKeyValuePair; } { From 33ad95d6af098a75c6d6dc5027c2de17ed19fd9e Mon Sep 17 00:00:00 2001 From: richie Date: Mon, 27 Jan 2025 16:54:37 +0530 Subject: [PATCH 32/37] feat(products): add variable query editor and enhance ProductsDataSource with metricFindQuery --- .../products/ProductsDataSource.ts | 17 +++- .../components/ProductsQueryEditor.tsx | 2 + .../ProductsVariableQueryEditor.tsx | 80 +++++++++++++++++++ .../query-builder/ProductsQueryBuilder.tsx | 10 ++- src/datasources/products/module.ts | 4 +- src/datasources/products/types.ts | 4 + 6 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 src/datasources/products/components/ProductsVariableQueryEditor.tsx diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index 0f874483..b4e592e0 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -1,7 +1,7 @@ -import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, FieldType, TestDataSourceResponse } from '@grafana/data'; +import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, FieldType, MetricFindValue, TestDataSourceResponse } from '@grafana/data'; import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { DataSourceBase } from 'core/DataSourceBase'; -import { ProductQuery, ProductResponseProperties, Properties, PropertiesOptions, QueryProductResponse } from './types'; +import { ProductQuery, ProductResponseProperties, ProductVariableQuery, Properties, PropertiesOptions, QueryProductResponse } from './types'; import { QueryBuilderOption, Workspace } from 'core/types'; import { parseErrorMessage } from 'core/errors'; import { getVariableOptions } from 'core/utils'; @@ -154,6 +154,19 @@ export class ProductsDataSource extends DataSourceBase { return `${ProductsQueryBuilderFieldNames.UPDATED_AT} ${operation} "${value}"`; }]]); + async metricFindQuery(query: ProductVariableQuery, options: DataQueryRequest): Promise { + if (query.queryBy) { + const filter = this.templateSrv.replace(query.queryBy, options.scopedVars) + const metadata = (await this.queryProducts( + PropertiesOptions.PART_NUMBER, + [Properties.partNumber, Properties.family], + filter + )).products; + return metadata.map(frame => ({ text: `${frame.partNumber}(${frame.family})`, value: frame.partNumber })); + } + return []; + } + protected multipleValuesQuery(field: string): ExpressionTransformFunction { return (value: string, operation: string, _options?: any) => { if (this.isMultiSelectValue(value)) { diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index 9e828bd1..a85c76f5 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -8,6 +8,7 @@ import { Workspace } from 'core/types'; import { ProductsQueryBuilder } from 'datasources/products/components/query-builder/ProductsQueryBuilder'; import { FloatingError } from 'core/errors'; import './ProductsQueryEditor.scss'; +import { ProductsQueryBuilderStaticFields } from '../constants/ProductsQueryBuilder.constants'; type Props = QueryEditorProps; @@ -85,6 +86,7 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: partNumbers={partNumbers} globalVariableOptions={datasource.globalVariableOptions()} onChange={(event: any) => onParameterChange(event.detail.linq)} + staticFields={ProductsQueryBuilderStaticFields} >
      diff --git a/src/datasources/products/components/ProductsVariableQueryEditor.tsx b/src/datasources/products/components/ProductsVariableQueryEditor.tsx new file mode 100644 index 00000000..54a60c9b --- /dev/null +++ b/src/datasources/products/components/ProductsVariableQueryEditor.tsx @@ -0,0 +1,80 @@ +import { QueryEditorProps } from "@grafana/data"; +import { ProductsDataSource } from "../ProductsDataSource"; +import { ProductVariableQuery, PropertiesOptions, QBField } from "../types"; +import { InlineField } from 'core/components/InlineField'; +import { ProductsQueryBuilder } from "./query-builder/ProductsQueryBuilder"; +import { Workspace } from "core/types"; +import React, { useState, useEffect, useMemo } from "react"; +import { ProductsQueryBuilderFields } from "../constants/ProductsQueryBuilder.constants"; +import { FloatingError, parseErrorMessage } from "core/errors"; + +type Props = QueryEditorProps; + +export function ProductsVariableQueryEditor({ query, onChange, datasource }: Props) { + const familyNamesCache = new Map([]); + + const [workspaces, setWorkspaces] = useState([]); + const [partNumbers, setPartNumbers] = useState([]); + const [familyNames, setFamilyNames] = useState([]); + + useEffect(() => { + Promise.all([datasource.areWorkspacesLoaded$, datasource.arePartNumberLoaded$, getFamilyNames()]).then(() => { + setWorkspaces(Array.from(datasource.workspacesCache.values())); + setPartNumbers(Array.from(datasource.partNumbersCache.values())); + setFamilyNames(Array.from(familyNamesCache.values())); + }); + }, [datasource]); + + const onQueryByChange = (value: string) => { + onChange({ ...query, queryBy: value }); + }; + + async function getFamilyNames(): Promise { + if (familyNamesCache.size > 0) { + return; + } + + const familyNames = await datasource.queryProductValues(PropertiesOptions.FAMILY). + catch(error => { + datasource.error = parseErrorMessage(error)!; + }); + + familyNames?.forEach(familyName => familyNamesCache.set(familyName, familyName)); + } + + const FamilyField = useMemo(() => { + const familyField = ProductsQueryBuilderFields.FAMILY; + return { + ...familyField, + lookup: { + ...familyField.lookup, + dataSource: [ + ...familyNames.map(family => ({ label: family, value: family })) + ], + minLength: 1 + } + } + }, [familyNames]); + + const productsVariableQueryStaticFields: QBField[] = [ + FamilyField, + ProductsQueryBuilderFields.NAME, + ProductsQueryBuilderFields.PROPERTIES + ]; + + return ( + <> + + onQueryByChange(event.detail.linq)} + workspaces={workspaces} + partNumbers={partNumbers} + globalVariableOptions={datasource.globalVariableOptions()} + staticFields={productsVariableQueryStaticFields} + > + + + + ); +}; diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx index f43b8d1e..350871ec 100644 --- a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx @@ -3,7 +3,7 @@ import { queryBuilderMessages, QueryBuilderOperations } from "core/query-builder import { expressionBuilderCallback, expressionReaderCallback } from "core/query-builder.utils"; import { Workspace, QueryBuilderOption } from "core/types"; import { filterXSSField, filterXSSLINQExpression } from "core/utils"; -import { ProductsQueryBuilderStaticFields, ProductsQueryBuilderFields } from "datasources/products/constants/ProductsQueryBuilder.constants"; +import { ProductsQueryBuilderFields } from "datasources/products/constants/ProductsQueryBuilder.constants"; import { QBField } from "datasources/products/types"; import React, { useState, useEffect, useMemo } from "react"; import QueryBuilder, { QueryBuilderCustomOperation, QueryBuilderProps } from "smart-webcomponents-react/querybuilder"; @@ -13,6 +13,7 @@ type ProductsQueryBuilderProps = QueryBuilderProps & React.HTMLAttributes = ({ @@ -20,7 +21,8 @@ export const ProductsQueryBuilder: React.FC = ({ onChange, workspaces, partNumbers, - globalVariableOptions + globalVariableOptions, + staticFields }) => { const theme = useTheme2(); document.body.setAttribute('theme', theme.isDark ? 'dark-orange' : 'orange'); @@ -80,7 +82,7 @@ export const ProductsQueryBuilder: React.FC = ({ useEffect(() => { - const fields = [partNumberField, ...ProductsQueryBuilderStaticFields, updatedAtField, workspaceField] + const fields = [partNumberField, ...staticFields!, updatedAtField, workspaceField] .map((field) => { if (field.lookup?.dataSource) { return { @@ -157,7 +159,7 @@ export const ProductsQueryBuilder: React.FC = ({ QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_NOT_EQUAL ]); - }, [workspaceField, updatedAtField, partNumberField, globalVariableOptions]); + }, [workspaceField, updatedAtField, partNumberField, staticFields, globalVariableOptions]); return ( Date: Mon, 27 Jan 2025 16:57:19 +0530 Subject: [PATCH 33/37] Revert "feat(products): add variable query editor and enhance ProductsDataSource with metricFindQuery" This reverts commit 33ad95d6af098a75c6d6dc5027c2de17ed19fd9e. --- .../products/ProductsDataSource.ts | 17 +--- .../components/ProductsQueryEditor.tsx | 2 - .../ProductsVariableQueryEditor.tsx | 80 ------------------- .../query-builder/ProductsQueryBuilder.tsx | 10 +-- src/datasources/products/module.ts | 4 +- src/datasources/products/types.ts | 4 - 6 files changed, 7 insertions(+), 110 deletions(-) delete mode 100644 src/datasources/products/components/ProductsVariableQueryEditor.tsx diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index b4e592e0..0f874483 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -1,7 +1,7 @@ -import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, FieldType, MetricFindValue, TestDataSourceResponse } from '@grafana/data'; +import { DataFrameDTO, DataQueryRequest, DataSourceInstanceSettings, FieldType, TestDataSourceResponse } from '@grafana/data'; import { BackendSrv, TemplateSrv, getBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { DataSourceBase } from 'core/DataSourceBase'; -import { ProductQuery, ProductResponseProperties, ProductVariableQuery, Properties, PropertiesOptions, QueryProductResponse } from './types'; +import { ProductQuery, ProductResponseProperties, Properties, PropertiesOptions, QueryProductResponse } from './types'; import { QueryBuilderOption, Workspace } from 'core/types'; import { parseErrorMessage } from 'core/errors'; import { getVariableOptions } from 'core/utils'; @@ -154,19 +154,6 @@ export class ProductsDataSource extends DataSourceBase { return `${ProductsQueryBuilderFieldNames.UPDATED_AT} ${operation} "${value}"`; }]]); - async metricFindQuery(query: ProductVariableQuery, options: DataQueryRequest): Promise { - if (query.queryBy) { - const filter = this.templateSrv.replace(query.queryBy, options.scopedVars) - const metadata = (await this.queryProducts( - PropertiesOptions.PART_NUMBER, - [Properties.partNumber, Properties.family], - filter - )).products; - return metadata.map(frame => ({ text: `${frame.partNumber}(${frame.family})`, value: frame.partNumber })); - } - return []; - } - protected multipleValuesQuery(field: string): ExpressionTransformFunction { return (value: string, operation: string, _options?: any) => { if (this.isMultiSelectValue(value)) { diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index a85c76f5..9e828bd1 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -8,7 +8,6 @@ import { Workspace } from 'core/types'; import { ProductsQueryBuilder } from 'datasources/products/components/query-builder/ProductsQueryBuilder'; import { FloatingError } from 'core/errors'; import './ProductsQueryEditor.scss'; -import { ProductsQueryBuilderStaticFields } from '../constants/ProductsQueryBuilder.constants'; type Props = QueryEditorProps; @@ -86,7 +85,6 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: partNumbers={partNumbers} globalVariableOptions={datasource.globalVariableOptions()} onChange={(event: any) => onParameterChange(event.detail.linq)} - staticFields={ProductsQueryBuilderStaticFields} > diff --git a/src/datasources/products/components/ProductsVariableQueryEditor.tsx b/src/datasources/products/components/ProductsVariableQueryEditor.tsx deleted file mode 100644 index 54a60c9b..00000000 --- a/src/datasources/products/components/ProductsVariableQueryEditor.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { QueryEditorProps } from "@grafana/data"; -import { ProductsDataSource } from "../ProductsDataSource"; -import { ProductVariableQuery, PropertiesOptions, QBField } from "../types"; -import { InlineField } from 'core/components/InlineField'; -import { ProductsQueryBuilder } from "./query-builder/ProductsQueryBuilder"; -import { Workspace } from "core/types"; -import React, { useState, useEffect, useMemo } from "react"; -import { ProductsQueryBuilderFields } from "../constants/ProductsQueryBuilder.constants"; -import { FloatingError, parseErrorMessage } from "core/errors"; - -type Props = QueryEditorProps; - -export function ProductsVariableQueryEditor({ query, onChange, datasource }: Props) { - const familyNamesCache = new Map([]); - - const [workspaces, setWorkspaces] = useState([]); - const [partNumbers, setPartNumbers] = useState([]); - const [familyNames, setFamilyNames] = useState([]); - - useEffect(() => { - Promise.all([datasource.areWorkspacesLoaded$, datasource.arePartNumberLoaded$, getFamilyNames()]).then(() => { - setWorkspaces(Array.from(datasource.workspacesCache.values())); - setPartNumbers(Array.from(datasource.partNumbersCache.values())); - setFamilyNames(Array.from(familyNamesCache.values())); - }); - }, [datasource]); - - const onQueryByChange = (value: string) => { - onChange({ ...query, queryBy: value }); - }; - - async function getFamilyNames(): Promise { - if (familyNamesCache.size > 0) { - return; - } - - const familyNames = await datasource.queryProductValues(PropertiesOptions.FAMILY). - catch(error => { - datasource.error = parseErrorMessage(error)!; - }); - - familyNames?.forEach(familyName => familyNamesCache.set(familyName, familyName)); - } - - const FamilyField = useMemo(() => { - const familyField = ProductsQueryBuilderFields.FAMILY; - return { - ...familyField, - lookup: { - ...familyField.lookup, - dataSource: [ - ...familyNames.map(family => ({ label: family, value: family })) - ], - minLength: 1 - } - } - }, [familyNames]); - - const productsVariableQueryStaticFields: QBField[] = [ - FamilyField, - ProductsQueryBuilderFields.NAME, - ProductsQueryBuilderFields.PROPERTIES - ]; - - return ( - <> - - onQueryByChange(event.detail.linq)} - workspaces={workspaces} - partNumbers={partNumbers} - globalVariableOptions={datasource.globalVariableOptions()} - staticFields={productsVariableQueryStaticFields} - > - - - - ); -}; diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx index 350871ec..f43b8d1e 100644 --- a/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.tsx @@ -3,7 +3,7 @@ import { queryBuilderMessages, QueryBuilderOperations } from "core/query-builder import { expressionBuilderCallback, expressionReaderCallback } from "core/query-builder.utils"; import { Workspace, QueryBuilderOption } from "core/types"; import { filterXSSField, filterXSSLINQExpression } from "core/utils"; -import { ProductsQueryBuilderFields } from "datasources/products/constants/ProductsQueryBuilder.constants"; +import { ProductsQueryBuilderStaticFields, ProductsQueryBuilderFields } from "datasources/products/constants/ProductsQueryBuilder.constants"; import { QBField } from "datasources/products/types"; import React, { useState, useEffect, useMemo } from "react"; import QueryBuilder, { QueryBuilderCustomOperation, QueryBuilderProps } from "smart-webcomponents-react/querybuilder"; @@ -13,7 +13,6 @@ type ProductsQueryBuilderProps = QueryBuilderProps & React.HTMLAttributes = ({ @@ -21,8 +20,7 @@ export const ProductsQueryBuilder: React.FC = ({ onChange, workspaces, partNumbers, - globalVariableOptions, - staticFields + globalVariableOptions }) => { const theme = useTheme2(); document.body.setAttribute('theme', theme.isDark ? 'dark-orange' : 'orange'); @@ -82,7 +80,7 @@ export const ProductsQueryBuilder: React.FC = ({ useEffect(() => { - const fields = [partNumberField, ...staticFields!, updatedAtField, workspaceField] + const fields = [partNumberField, ...ProductsQueryBuilderStaticFields, updatedAtField, workspaceField] .map((field) => { if (field.lookup?.dataSource) { return { @@ -159,7 +157,7 @@ export const ProductsQueryBuilder: React.FC = ({ QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_NOT_EQUAL ]); - }, [workspaceField, updatedAtField, partNumberField, staticFields, globalVariableOptions]); + }, [workspaceField, updatedAtField, partNumberField, globalVariableOptions]); return ( Date: Wed, 29 Jan 2025 13:10:34 +0530 Subject: [PATCH 34/37] refactor(products): clean up code structure and improve query handling in ProductsDataSource --- .../products/ProductsDataSource.test.ts | 24 ++-- .../products/ProductsDataSource.ts | 39 +++---- .../components/ProductsQueryEditor.tsx | 4 +- .../ProductsQueryBuilder.test.tsx | 109 ++++++++---------- 4 files changed, 80 insertions(+), 96 deletions(-) diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index 573618fa..1cab3d62 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -79,9 +79,9 @@ describe('queryProductValues', () => { backendServer.fetch .calledWith(requestMatching({ url: '/nitestmonitor/v2/query-product-values' })) .mockReturnValue(createFetchResponse(['value1'])); - + const response = await datastore.queryProductValues(ProductsQueryBuilderFieldNames.PART_NUMBER); - + expect(response).toMatchSnapshot(); }); @@ -233,9 +233,9 @@ describe('query', () => { backendServer.fetch .calledWith(requestMatching({ url: '/niauth/v1/user' })) .mockReturnValue(createFetchResponse(['workspace1'])); - datastore.workspacesCache.set('workspace', {id: 'workspace1', name: 'workspace1', default: false, enabled: true}); + datastore.workspacesCache.set('workspace', { id: 'workspace1', name: 'workspace1', default: false, enabled: true }); backendServer.fetch.mockClear(); - + await datastore.query(buildQuery()) expect(backendServer.fetch).not.toHaveBeenCalled(); @@ -259,10 +259,10 @@ describe('query', () => { expect(backendServer.fetch).toHaveBeenCalledWith( expect.objectContaining({ data: { - descending: false, - filter: "partNumber = '123'", - orderBy: undefined, - projection: ["partNumber"], + descending: false, + filter: "partNumber = '123'", + orderBy: undefined, + projection: ["partNumber"], returnCount: false, take: 1000 } }) @@ -286,10 +286,10 @@ describe('query', () => { expect(backendServer.fetch).toHaveBeenCalledWith( expect.objectContaining({ data: { - descending: false, - filter: "(PartNumber = \"partNumber1\" || PartNumber = \"partNumber2\")", - orderBy: undefined, - projection: ["partNumber"], + descending: false, + filter: "(PartNumber = \"partNumber1\" || PartNumber = \"partNumber2\")", + orderBy: undefined, + projection: ["partNumber"], returnCount: false, take: 1000 } }) diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index 0f874483..2bd85cef 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -20,15 +20,6 @@ export class ProductsDataSource extends DataSourceBase { this.partNumberLoadedPromise = this.getProductPartNumbers(); } - private workspaceLoadedPromise: Promise; - private partNumberLoadedPromise: Promise; - - private workspacesLoaded!: () => void; - private partNumberLoaded!: () => void; - - readonly workspacesCache = new Map([]); - readonly partNumbersCache = new Map([]); - areWorkspacesLoaded$ = new Promise(resolve => this.workspacesLoaded = resolve); arePartNumberLoaded$ = new Promise(resolve => this.partNumberLoaded = resolve); error = ''; @@ -50,6 +41,17 @@ export class ProductsDataSource extends DataSourceBase { queryBy: '' }; + readonly workspacesCache = new Map([]); + readonly partNumbersCache = new Map([]); + + readonly globalVariableOptions = (): QueryBuilderOption[] => getVariableOptions(this); + + private workspaceLoadedPromise: Promise; + private partNumberLoadedPromise: Promise; + + private workspacesLoaded!: () => void; + private partNumberLoaded!: () => void; + async queryProducts( orderBy?: string, projection?: Properties[], @@ -139,10 +141,16 @@ export class ProductsDataSource extends DataSourceBase { } - public readonly globalVariableOptions = (): QueryBuilderOption[] => getVariableOptions(this); + shouldRunQuery(query: ProductQuery): boolean { + return true; + } + async testDatasource(): Promise { + await this.get(this.baseUrl + '/v2/products?take=1'); + return { status: 'success', message: 'Data source connected and authentication successful!' }; + } - public readonly productsComputedDataFields = new Map([ + readonly productsComputedDataFields = new Map([ ...Object.values(ProductsQueryBuilderFieldNames).map(field => [field, this.multipleValuesQuery(field)] as [string, ExpressionTransformFunction]), [ ProductsQueryBuilderFieldNames.UPDATED_AT, @@ -179,15 +187,6 @@ export class ProductsDataSource extends DataSourceBase { return operation === QueryBuilderOperations.EQUALS.name ? '||' : '&&'; } - shouldRunQuery(query: ProductQuery): boolean { - return true; - } - - async testDatasource(): Promise { - await this.get(this.baseUrl + '/v2/products?take=1'); - return { status: 'success', message: 'Data source connected and authentication successful!' }; - } - private async loadWorkspaces(): Promise { if (this.workspacesCache.size > 0) { return; diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index 9e828bd1..8237281f 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -19,10 +19,8 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: const [partNumbers, setPartNumbers] = useState([]); useEffect(() => { - Promise.all([datasource.areWorkspacesLoaded$]).then(() => { + Promise.all([datasource.areWorkspacesLoaded$, datasource.arePartNumberLoaded$]).then(() => { setWorkspaces(Array.from(datasource.workspacesCache.values())); - }); - Promise.all([datasource.arePartNumberLoaded$]).then(() => { setPartNumbers(Array.from(datasource.partNumbersCache.values())); }); }, [datasource]); diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx index dd13f6c7..92a8db60 100644 --- a/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx @@ -4,67 +4,54 @@ import { ProductsQueryBuilder } from "./ProductsQueryBuilder"; import { render } from "@testing-library/react"; describe('ProductsQueryBuilder', () => { - describe('useEffects', () => { - let reactNode: ReactNode - - const containerClass = 'smart-filter-group-condition-container'; - const workspace = { id: '1', name: 'Selected workspace' } as Workspace; - const partNumber = ['partNumber1', 'partNumber2']; - - function renderElement(workspaces: Workspace[], partNumbers: string[], filter?: string, globalVariableOptions: QueryBuilderOption[] = []) { - reactNode = React.createElement(ProductsQueryBuilder, { filter, workspaces, partNumbers, globalVariableOptions, onChange: jest.fn(), }); - const renderResult = render(reactNode); - return { - renderResult, - conditionsContainer: renderResult.container.getElementsByClassName(`${containerClass}`) - }; - } - - it('should render empty query builder', () => { - const { renderResult, conditionsContainer } = renderElement([], [], ''); - - expect(conditionsContainer.length).toBe(1); - expect(renderResult.findByLabelText('Empty condition row')).toBeTruthy(); - }) - - it('should select workspace in query builder', () => { - const { conditionsContainer } = renderElement([workspace], partNumber, 'Workspace = "1" && PartNumber = "partNumber1"'); - - expect(conditionsContainer?.length).toBe(2); - expect(conditionsContainer.item(0)?.textContent).toContain(workspace.name); - expect(conditionsContainer.item(1)?.textContent).toContain("partNumber1"); - }) - - it('should select part number in query builder', () => { - const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = "partNumber1"'); - - expect(conditionsContainer?.length).toBe(1); - expect(conditionsContainer.item(0)?.textContent).toContain("partNumber1"); - }); - - it('should select global variable option', () => { - const globalVariableOption = { label: 'Global variable', value: 'global_variable' }; - const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = \"global_variable\"', [globalVariableOption]); - - expect(conditionsContainer?.length).toBe(1); - expect(conditionsContainer.item(0)?.textContent).toContain(globalVariableOption.label); - }); - - [['${__from:date}', 'From'], ['${__to:date}', 'To'], ['${__now:date}', 'Now']].forEach(([value, label]) => { - it(`should select user friendly value for updated date`, () => { - - const { conditionsContainer } = renderElement([workspace], partNumber, `UpdatedAt > \"${value}\"`); - - expect(conditionsContainer?.length).toBe(1); - expect(conditionsContainer.item(0)?.textContent).toContain(label); - }); - }); - - it('should sanitize fields in query builder', () => { - const { conditionsContainer } = renderElement([workspace], partNumber, 'Family = ""'); + describe('useEffects', () => { + let reactNode: ReactNode + const containerClass = 'smart-filter-group-condition-container'; + const workspace = { id: '1', name: 'Selected workspace' } as Workspace; + const partNumber = ['partNumber1', 'partNumber2']; + function renderElement(workspaces: Workspace[], partNumbers: string[], filter?: string, globalVariableOptions: QueryBuilderOption[] = []) { + reactNode = React.createElement(ProductsQueryBuilder, { filter, workspaces, partNumbers, globalVariableOptions, onChange: jest.fn(), }); + const renderResult = render(reactNode); + return { + renderResult, + conditionsContainer: renderResult.container.getElementsByClassName(`${containerClass}`) + }; + } + + it('should render empty query builder', () => { + const { renderResult, conditionsContainer } = renderElement([], [], ''); + expect(conditionsContainer.length).toBe(1); + expect(renderResult.findByLabelText('Empty condition row')).toBeTruthy(); + }) + it('should select workspace in query builder', () => { + const { conditionsContainer } = renderElement([workspace], partNumber, 'Workspace = "1" && PartNumber = "partNumber1"'); + expect(conditionsContainer?.length).toBe(2); + expect(conditionsContainer.item(0)?.textContent).toContain(workspace.name); + expect(conditionsContainer.item(1)?.textContent).toContain("partNumber1"); + }) + it('should select part number in query builder', () => { + const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = "partNumber1"'); + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.textContent).toContain("partNumber1"); + }); - expect(conditionsContainer?.length).toBe(1); - expect(conditionsContainer.item(0)?.innerHTML).not.toContain('alert(\'Family\')'); - }) + it('should select global variable option', () => { + const globalVariableOption = { label: 'Global variable', value: 'global_variable' }; + const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = \"global_variable\"', [globalVariableOption]); + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.textContent).toContain(globalVariableOption.label); + }); + [['${__from:date}', 'From'], ['${__to:date}', 'To'], ['${__now:date}', 'Now']].forEach(([value, label]) => { + it(`should select user friendly value for updated date`, () => { + const { conditionsContainer } = renderElement([workspace], partNumber, `UpdatedAt > \"${value}\"`); + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.textContent).toContain(label); + }); + }); + it('should sanitize fields in query builder', () => { + const { conditionsContainer } = renderElement([workspace], partNumber, 'Family = ""'); + expect(conditionsContainer?.length).toBe(1); + expect(conditionsContainer.item(0)?.innerHTML).not.toContain('alert(\'Family\')'); + }) }); }); From 6141fd032f822e6ae2597433a21b22f02481fb36 Mon Sep 17 00:00:00 2001 From: richie Date: Wed, 29 Jan 2025 13:49:06 +0530 Subject: [PATCH 35/37] enhance alignment - ProductsQueryBuilder tests --- .../query-builder/ProductsQueryBuilder.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx index 92a8db60..19869c35 100644 --- a/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx +++ b/src/datasources/products/components/query-builder/ProductsQueryBuilder.test.tsx @@ -6,9 +6,11 @@ import { render } from "@testing-library/react"; describe('ProductsQueryBuilder', () => { describe('useEffects', () => { let reactNode: ReactNode + const containerClass = 'smart-filter-group-condition-container'; const workspace = { id: '1', name: 'Selected workspace' } as Workspace; const partNumber = ['partNumber1', 'partNumber2']; + function renderElement(workspaces: Workspace[], partNumbers: string[], filter?: string, globalVariableOptions: QueryBuilderOption[] = []) { reactNode = React.createElement(ProductsQueryBuilder, { filter, workspaces, partNumbers, globalVariableOptions, onChange: jest.fn(), }); const renderResult = render(reactNode); @@ -20,17 +22,22 @@ describe('ProductsQueryBuilder', () => { it('should render empty query builder', () => { const { renderResult, conditionsContainer } = renderElement([], [], ''); + expect(conditionsContainer.length).toBe(1); expect(renderResult.findByLabelText('Empty condition row')).toBeTruthy(); }) + it('should select workspace in query builder', () => { const { conditionsContainer } = renderElement([workspace], partNumber, 'Workspace = "1" && PartNumber = "partNumber1"'); + expect(conditionsContainer?.length).toBe(2); expect(conditionsContainer.item(0)?.textContent).toContain(workspace.name); expect(conditionsContainer.item(1)?.textContent).toContain("partNumber1"); }) + it('should select part number in query builder', () => { const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = "partNumber1"'); + expect(conditionsContainer?.length).toBe(1); expect(conditionsContainer.item(0)?.textContent).toContain("partNumber1"); }); @@ -38,18 +45,23 @@ describe('ProductsQueryBuilder', () => { it('should select global variable option', () => { const globalVariableOption = { label: 'Global variable', value: 'global_variable' }; const { conditionsContainer } = renderElement([workspace], partNumber, 'PartNumber = \"global_variable\"', [globalVariableOption]); + expect(conditionsContainer?.length).toBe(1); expect(conditionsContainer.item(0)?.textContent).toContain(globalVariableOption.label); }); + [['${__from:date}', 'From'], ['${__to:date}', 'To'], ['${__now:date}', 'Now']].forEach(([value, label]) => { it(`should select user friendly value for updated date`, () => { const { conditionsContainer } = renderElement([workspace], partNumber, `UpdatedAt > \"${value}\"`); + expect(conditionsContainer?.length).toBe(1); expect(conditionsContainer.item(0)?.textContent).toContain(label); }); }); + it('should sanitize fields in query builder', () => { const { conditionsContainer } = renderElement([workspace], partNumber, 'Family = ""'); + expect(conditionsContainer?.length).toBe(1); expect(conditionsContainer.item(0)?.innerHTML).not.toContain('alert(\'Family\')'); }) From 34829764a0447e54bfd03f25e8885cfd662ed970 Mon Sep 17 00:00:00 2001 From: richie Date: Thu, 30 Jan 2025 12:18:34 +0530 Subject: [PATCH 36/37] resolved comments --- src/core/query-builder.constants.ts | 10 --- .../products/ProductsDataSource.test.ts | 2 +- .../products/ProductsDataSource.ts | 42 +++++------ .../components/ProductsQueryEditor.tsx | 17 +++-- .../query-builder/ProductsQueryBuilder.tsx | 68 +++++++---------- .../utils/keyValueOperationUtils.ts | 74 +++++++------------ 6 files changed, 88 insertions(+), 125 deletions(-) diff --git a/src/core/query-builder.constants.ts b/src/core/query-builder.constants.ts index 75432c1a..777393c7 100644 --- a/src/core/query-builder.constants.ts +++ b/src/core/query-builder.constants.ts @@ -321,14 +321,4 @@ export const customOperations: QueryBuilderCustomOperation[] = [ QueryBuilderOperations.PROPERTY_DOES_NOT_CONTAIN, QueryBuilderOperations.PROPERTY_IS_BLANK, QueryBuilderOperations.PROPERTY_IS_NOT_BLANK, - QueryBuilderOperations.KEY_VALUE_MATCH, - QueryBuilderOperations.KEY_VALUE_DOES_NOT_MATCH, - QueryBuilderOperations.KEY_VALUE_CONTAINS, - QueryBuilderOperations.KEY_VALUE_DOES_NOT_CONTAINS, - QueryBuilderOperations.KEY_VALUE_IS_GREATER_THAN, - QueryBuilderOperations.KEY_VALUE_IS_GREATER_THAN_OR_EQUAL, - QueryBuilderOperations.KEY_VALUE_IS_LESS_THAN, - QueryBuilderOperations.KEY_VALUE_IS_LESS_THAN_OR_EQUAL, - QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_EQUAL, - QueryBuilderOperations.KEY_VALUE_IS_NUMERICAL_NOT_EQUAL ]; diff --git a/src/datasources/products/ProductsDataSource.test.ts b/src/datasources/products/ProductsDataSource.test.ts index 1cab3d62..c34804b0 100644 --- a/src/datasources/products/ProductsDataSource.test.ts +++ b/src/datasources/products/ProductsDataSource.test.ts @@ -287,7 +287,7 @@ describe('query', () => { expect.objectContaining({ data: { descending: false, - filter: "(PartNumber = \"partNumber1\" || PartNumber = \"partNumber2\")", + filter: "PartNumber = \"partNumber1\" || PartNumber = \"partNumber2\"", orderBy: undefined, projection: ["partNumber"], returnCount: false, take: 1000 diff --git a/src/datasources/products/ProductsDataSource.ts b/src/datasources/products/ProductsDataSource.ts index 2bd85cef..59c6c051 100644 --- a/src/datasources/products/ProductsDataSource.ts +++ b/src/datasources/products/ProductsDataSource.ts @@ -138,7 +138,6 @@ export class ProductsDataSource extends DataSourceBase { refId: query.refId, fields: [] } - } shouldRunQuery(query: ProductQuery): boolean { @@ -150,31 +149,32 @@ export class ProductsDataSource extends DataSourceBase { return { status: 'success', message: 'Data source connected and authentication successful!' }; } - readonly productsComputedDataFields = new Map([ - ...Object.values(ProductsQueryBuilderFieldNames).map(field => [field, this.multipleValuesQuery(field)] as [string, ExpressionTransformFunction]), - [ - ProductsQueryBuilderFieldNames.UPDATED_AT, - (value: string, operation: string, options?: Map) => { - if (value === '${__now:date}') { - return `${ProductsQueryBuilderFieldNames.UPDATED_AT} ${operation} "${new Date().toISOString()}"`; - } - - return `${ProductsQueryBuilderFieldNames.UPDATED_AT} ${operation} "${value}"`; - }]]); + readonly productsComputedDataFields = new Map( + Object.values(ProductsQueryBuilderFieldNames).map(field => [ + field, + field === ProductsQueryBuilderFieldNames.UPDATED_AT + ? this.updatedAtQuery + : this.multipleValuesQuery(field) + ]) + ); protected multipleValuesQuery(field: string): ExpressionTransformFunction { return (value: string, operation: string, _options?: any) => { - if (this.isMultiSelectValue(value)) { - const query = this.getMultipleValuesArray(value) - .map(val => `${field} ${operation} "${val}"`) - .join(` ${this.getLocicalOperator(operation)} `); - return `(${query})`; - } - - return `${field} ${operation} "${value}"` + const isMultiSelect = this.isMultiSelectValue(value); + const valuesArray = this.getMultipleValuesArray(value); + const logicalOperator = this.getLogicalOperator(operation); + + return isMultiSelect ? valuesArray + .map(val => `${field} ${operation} "${val}"`) + .join(` ${logicalOperator} `) : `${field} ${operation} "${value}"`; } } + private updatedAtQuery(value: string, operation: string): string { + const formattedValue = value === '${__now:date}' ? new Date().toISOString() : value; + return `${ProductsQueryBuilderFieldNames.UPDATED_AT} ${operation} "${formattedValue}"`; + } + private isMultiSelectValue(value: string): boolean { return value.startsWith('{') && value.endsWith('}'); } @@ -183,7 +183,7 @@ export class ProductsDataSource extends DataSourceBase { return value.replace(/({|})/g, '').split(','); } - private getLocicalOperator(operation: string): string { + private getLogicalOperator(operation: string): string { return operation === QueryBuilderOperations.EQUALS.name ? '||' : '&&'; } diff --git a/src/datasources/products/components/ProductsQueryEditor.tsx b/src/datasources/products/components/ProductsQueryEditor.tsx index 8237281f..405e1ee7 100644 --- a/src/datasources/products/components/ProductsQueryEditor.tsx +++ b/src/datasources/products/components/ProductsQueryEditor.tsx @@ -19,10 +19,17 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: const [partNumbers, setPartNumbers] = useState([]); useEffect(() => { - Promise.all([datasource.areWorkspacesLoaded$, datasource.arePartNumberLoaded$]).then(() => { + const loadWorkspaces = async () => { + await datasource.areWorkspacesLoaded$; setWorkspaces(Array.from(datasource.workspacesCache.values())); + }; + const loadPartNumbers = async () => { + await datasource.arePartNumberLoaded$; setPartNumbers(Array.from(datasource.partNumbersCache.values())); - }); + }; + + loadWorkspaces(); + loadPartNumbers(); }, [datasource]); const handleQueryChange = useCallback((query: ProductQuery, runQuery = true): void => { @@ -59,7 +66,7 @@ export function ProductsQueryEditor({ query, onChange, onRunQuery, datasource }: return ( <> - + -
      -
      +
      +