Skip to content

Commit

Permalink
feat(asset-calibration): added systems as dropdown values for location
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert Aradei committed Oct 7, 2024
1 parent ca6e609 commit 849d57d
Show file tree
Hide file tree
Showing 9 changed files with 203 additions and 81 deletions.
42 changes: 35 additions & 7 deletions src/core/query-builder.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { expressionBuilderCallback, expressionReaderCallback, transformComputedFieldsQuery } from "./query-builder.utils"
import { expressionBuilderCallback, expressionReaderCallback, ExpressionTransformFunction, transformComputedFieldsQuery } from "./query-builder.utils"
import TTLCache from "@isaacs/ttlcache";

describe('QueryBuilderUtils', () => {
describe('transformComputedFieldsQuery', () => {
const computedDataFields = {
Object1: '(object1.prop1 = {value} || object1.prop2 = {value})',
Object2: '(object2.prop1 = {value} || object2.extra.prop2 = {value} || object2.prop3 = {value} || object2.prop4 = {value})',
Object3: '(object3.prop1 = {value} || object3.prop2 = {value} || object3.prop3 = {value})'
const mockTransformation: ExpressionTransformFunction = (value, operation, _options) => {
return `obj.prop1 ${operation} ${value}`;
};

const computedDataFields = new Map<string, ExpressionTransformFunction>([
['Object1', mockTransformation],
['Object2', mockTransformation],
['Object3', mockTransformation],
]);

it('should transform a query with computed fields', () => {
const query = 'Object1 = "value1" AND Object2 = "value2"';
const result = transformComputedFieldsQuery(query, computedDataFields);
expect(result).toBe('(object1.prop1 = value1 || object1.prop2 = value1) AND (object2.prop1 = value2 || object2.extra.prop2 = value2 || object2.prop3 = value2 || object2.prop4 = value2)');
expect(result).toBe('obj.prop1 = value1 AND obj.prop1 = value2');
});

it('should return the original query if no computed fields are present', () => {
Expand All @@ -23,14 +28,37 @@ describe('QueryBuilderUtils', () => {
it('should handle multiple computed fields correctly', () => {
const query = 'Object1 = "value1" AND Object3 = "value3"';
const result = transformComputedFieldsQuery(query, computedDataFields);
expect(result).toBe('(object1.prop1 = value1 || object1.prop2 = value1) AND (object3.prop1 = value3 || object3.prop2 = value3 || object3.prop3 = value3)');
expect(result).toBe('obj.prop1 = value1 AND obj.prop1 = value3');
});

it('should handle an empty query', () => {
const query = '';
const result = transformComputedFieldsQuery(query, computedDataFields);
expect(result).toBe(query);
});

it('should handle unsupported operations correctly', () => {
const query = 'Object1 > "value1" AND Object2 < "value2"';
const result = transformComputedFieldsQuery(query, computedDataFields);
expect(result).toBe(query);
});

it('should handle supported operations correctly', () => {
const query = 'Object1 != "value1" AND Object2 = "value2"';
const result = transformComputedFieldsQuery(query, computedDataFields);
expect(result).toBe('obj.prop1 != value1 AND obj.prop1 = value2');
});

it('should handle options correctly', () => {
const options = new Map<string, TTLCache<string, unknown>>();
const cache = new TTLCache<string, unknown>();
cache.set('key', 'value', { ttl: 1 });
options.set('Object1', cache);

const query = 'Object1 = "value1"';
const result = transformComputedFieldsQuery(query, computedDataFields, options);
expect(result).toBe('obj.prop1 = value1');
});
});

describe('expressionBuilderCallback', () => {
Expand Down
31 changes: 27 additions & 4 deletions src/core/query-builder.utils.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
import { QueryBuilderCustomOperation } from "smart-webcomponents-react";
import { QueryBuilderOption } from "./types";
import TTLCache from "@isaacs/ttlcache";

/**
* Should be used when looking to build a custom expression for a field
* @param value The value to be transformed
* @param operation The operation to be performed
* @param options The options to be used in the transformation
* @returns The transformed value
*/
export type ExpressionTransformFunction = (value: string, operation: string, options?: TTLCache<string, unknown>) => string;

/**
* Supported operations for computed fields
*/
export const computedFieldsupportedOperations = ['=', '!='];

/**
* The function will replace the computed fields with their transformation
* Example: object = "value" => object1.prop1 = "value" || object1.prop2 = "value"
* @param query Query builder provided string
* @param computedDataFields Object with computed fields and their transformations
* @param options Object with options for the computed fields
* @returns Updated query with computed fields transformed
*/
export function transformComputedFieldsQuery(query: string, computedDataFields: Record<string, string>) {
for (const [field, transformation] of Object.entries(computedDataFields)) {
const regex = new RegExp(`\\b${field}\\s*=\\s*"([^"]*)"`, 'g');
query = query.replace(regex, (_match, value) => transformation.replace(/{value}/g, value));
export function transformComputedFieldsQuery(
query: string,
computedDataFields: Map<string, ExpressionTransformFunction>,
options?: Map<string, TTLCache<string, unknown>>
) {
for (const [field, transformation] of computedDataFields.entries()) {
const regex = new RegExp(`\\b${field}\\s*(${computedFieldsupportedOperations.join('|')})\\s*"([^"]*)"`, 'g');

query = query.replace(regex, (_match, operation, value) => {
return transformation(value, operation, options?.get(field));
});
}

return query;
Expand Down
35 changes: 30 additions & 5 deletions src/datasources/asset-calibration/AssetCalibrationDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,27 @@ import {
ColumnDescriptorType,
FieldDTOWithDescriptor,
} from './types';
import { transformComputedFieldsQuery } from 'core/query-builder.utils';
import { AssetComputedDataFields } from './constants';
import { ExpressionTransformFunction, transformComputedFieldsQuery } from 'core/query-builder.utils';
import { AssetCalibrationFieldNames } from './constants';
import { AssetModel, AssetsResponse } from 'datasources/asset-common/types';
import TTLCache from '@isaacs/ttlcache';
import { metadataCacheTTL } from 'datasources/data-frame/constants';
import { SystemMetadata } from 'datasources/system/types';
import { defaultOrderBy, defaultProjection } from 'datasources/system/constants';
import { QueryBuilderOperations } from 'core/query-builder.constants';

export class AssetCalibrationDataSource extends DataSourceBase<AssetCalibrationQuery> {
public defaultQuery = {
groupBy: [],
filter: ''
};

public areSystemsLoaded = false;

public readonly systemAliasCache: TTLCache<string, SystemMetadata> = new TTLCache<string, SystemMetadata>({ ttl: metadataCacheTTL });

private readonly baseUrl = this.instanceSettings.url + '/niapm/v1';

constructor(
readonly instanceSettings: DataSourceInstanceSettings,
readonly backendSrv: BackendSrv = getBackendSrv(),
Expand All @@ -37,15 +44,32 @@ export class AssetCalibrationDataSource extends DataSourceBase<AssetCalibrationQ
super(instanceSettings, backendSrv, templateSrv);
}

baseUrl = this.instanceSettings.url + '/niapm/v1';

systemAliasCache: TTLCache<string, SystemMetadata> = new TTLCache<string, SystemMetadata>({ ttl: metadataCacheTTL });

private readonly assetComputedDataFields = new Map<AssetCalibrationFieldNames, ExpressionTransformFunction>([
[
AssetCalibrationFieldNames.LOCATION,
(value: string, operation: string, options?: TTLCache<string, unknown>) => {
if (options?.has(value)) {
return `Location.MinionId ${operation} "${value}"`
}

const logicalOperator = operation === QueryBuilderOperations.EQUALS.name ? '||' : '&&';
return `(Location.MinionId ${operation} "${value}" ${logicalOperator} Location.PhysicalLocation ${operation} "${value}")`;
}
],
]);

private readonly queryTransformationOptions = new Map<AssetCalibrationFieldNames, TTLCache<string, unknown>>([
[AssetCalibrationFieldNames.LOCATION, this.systemAliasCache]
]);

async runQuery(query: AssetCalibrationQuery, options: DataQueryRequest): Promise<DataFrameDTO> {
await this.loadSystems();

if (query.filter) {
query.filter = this.templateSrv.replace(transformComputedFieldsQuery(query.filter, AssetComputedDataFields), options.scopedVars);
const transformedQuery = transformComputedFieldsQuery(query.filter, this.assetComputedDataFields, this.queryTransformationOptions);
query.filter = this.templateSrv.replace(transformedQuery, options.scopedVars);
}

return await this.processCalibrationForecastQuery(query as AssetCalibrationQuery, options);
Expand Down Expand Up @@ -178,6 +202,7 @@ export class AssetCalibrationDataSource extends DataSourceBase<AssetCalibrationQ
}

const systems = await this.querySystems('', ['id', 'alias','connected.data.state', 'workspace']);
this.areSystemsLoaded = true;
systems.forEach(system => this.systemAliasCache.set(system.id, system));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import React, { ReactNode } from "react";
import { AssetCalibrationQueryBuilder } from "./AssetCalibrationQueryBuilder";
import { render } from "@testing-library/react";
import { Workspace } from "core/types";
import { SystemMetadata } from "datasources/system/types";

describe('AssetCalibrationQueryBuilder', () => {
describe('useEffects', () => {
let reactNode: ReactNode;

const containerClass = 'smart-filter-group-condition-container'

function renderElement(workspaces: Workspace[], filter?: string) {
reactNode = React.createElement(AssetCalibrationQueryBuilder, { workspaces, filter, onChange: jest.fn() });
function renderElement(workspaces: Workspace[], systems: SystemMetadata[], filter?: string) {
reactNode = React.createElement(AssetCalibrationQueryBuilder, { workspaces, systems, filter, onChange: jest.fn(), areDependenciesLoaded: true });
const renderResult = render(reactNode);
return {
renderResult,
Expand All @@ -19,18 +20,29 @@ describe('AssetCalibrationQueryBuilder', () => {
}

it('should render empty query builder', () => {
const { renderResult, conditionsContainer } = renderElement([], '');
const { renderResult, conditionsContainer } = renderElement([], [], '');
expect(conditionsContainer.length).toBe(1);
expect(renderResult.findByLabelText('Empty condition row')).toBeTruthy();
})

it('should populate query builder', () => {
const workspace = { id: '1', name: 'Selected workspace' } as Workspace
const { conditionsContainer } = renderElement([workspace], 'Workspace = "1" && ModelName = "SomeRandomModelName"');
it('should select workspace in query builder', () => {
const workspace = { id: '1', name: 'Selected workspace' } as Workspace;
const system = { id: '1', alias: 'Selected system' } as SystemMetadata;
const { conditionsContainer } = renderElement([workspace], [system], 'Workspace = "1" && ModelName = "SomeRandomModelName"');

expect(conditionsContainer?.length).toBe(2);
expect(conditionsContainer.item(0)?.textContent).toContain(workspace.name);
expect(conditionsContainer.item(1)?.textContent).toContain("SomeRandomModelName");
})
})
})

it('should select system in query builder', () => {
const workspace = { id: '1', name: 'Selected workspace' } as Workspace;
const system = { id: '1', alias: 'Selected system' } as SystemMetadata;

const { conditionsContainer } = renderElement([workspace], [system], 'Location = "1"');

expect(conditionsContainer?.length).toBe(1);
expect(conditionsContainer.item(0)?.textContent).toContain(system.alias);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { QueryBuilder, QueryBuilderCustomOperation, QueryBuilderProps } from 'smart-webcomponents-react/querybuilder';
import { useTheme2 } from '@grafana/ui';

Expand All @@ -13,27 +13,59 @@ import { Workspace, QueryBuilderOption } from 'core/types';
import { QBField } from '../types';
import { queryBuilderMessages, QueryBuilderOperations } from 'core/query-builder.constants';
import { expressionBuilderCallback, expressionReaderCallback } from 'core/query-builder.utils';
import { SystemMetadata } from 'datasources/system/types';

type AssetCalibrationQueryBuilderProps = QueryBuilderProps &
React.HTMLAttributes<Element> & {
filter?: string;
workspaces: Workspace[]
workspaces: Workspace[],
systems: SystemMetadata[],
areDependenciesLoaded: boolean;
};

export const AssetCalibrationQueryBuilder: React.FC<AssetCalibrationQueryBuilderProps> = ({ filter, onChange, workspaces }) => {
export const AssetCalibrationQueryBuilder: React.FC<AssetCalibrationQueryBuilderProps> = ({ filter, onChange, workspaces, systems, areDependenciesLoaded }) => {
const theme = useTheme2();
document.body.setAttribute('theme', theme.isDark ? 'dark-orange' : 'orange');

const [fields, setFields] = useState<QBField[]>([]);
const [operations, setOperations] = useState<QueryBuilderCustomOperation[]>([]);

useEffect(() => {
if (workspaces.length) {
const workspaceField = getWorkspaceField(workspaces);
const workspaceField = useMemo(() => {
const workspaceField = AssetCalibrationFields.WORKSPACE;

return {
...workspaceField,
lookup: {
...workspaceField.lookup,
dataSource: [
...workspaceField.lookup?.dataSource || [],
...workspaces.map(({ id, name }) => ({ label: name, value: id }))
]
}
};
}, [workspaces]);

const locationField = useMemo(() => {
const locationField = AssetCalibrationFields.LOCATION;

return {
...locationField,
lookup: {
...locationField.lookup,
dataSource: [
...locationField.lookup?.dataSource || [],
...systems.map(({ id, alias }) => ({ label: alias || id, value: id }))
]
}
};
}, [systems]);

useEffect(() => {
if (areDependenciesLoaded) {
const fields = [
...AssetCalibrationStaticFields,
workspaceField,
locationField,
...AssetCalibrationStaticFields,
];

setFields(fields);
Expand Down Expand Up @@ -65,7 +97,7 @@ export const AssetCalibrationQueryBuilder: React.FC<AssetCalibrationQueryBuilder
QueryBuilderOperations.DOES_NOT_CONTAIN
]);
}
}, [workspaces]);
}, [workspaceField, locationField, areDependenciesLoaded]);

return (
<QueryBuilder
Expand All @@ -77,20 +109,3 @@ export const AssetCalibrationQueryBuilder: React.FC<AssetCalibrationQueryBuilder
/>
);
};

function getWorkspaceField(workspaces: Workspace[]) {
const workspaceField = AssetCalibrationFields.WORKSPACE;

return {
...workspaceField,
lookup: {
...workspaceField.lookup,
dataSource: [
...workspaceField.lookup?.dataSource || [],
...workspaces.map(({ id, name }) => ({ label: name, value: id }))
]
}
};
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.asset-calibration-forecast {
margin-top: 10px;
display: flex;
column-gap: 100px;
}
Loading

0 comments on commit 849d57d

Please sign in to comment.