Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export class ExecuteGridAssistantCommand extends BaseCommand<
};
}

// TODO: check response more carefully
protected parseResult(
response: ExecuteGridAssistantCommandResponse,
): ExecuteGridAssistantCommandResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,54 @@ describe('filterValueCommand', () => {
expect(result.status).toBe('success');
});

it('converts ISO date string to Date object for date columns', async () => {
const instance = await createGrid({
dataSource: [
{ id: 1, SaleDate: new Date(2024, 4, 10) },
],
columns: [
{ dataField: 'id', dataType: 'number' },
{ dataField: 'SaleDate', dataType: 'date' },
],
});
const spy = jest.spyOn(instance, 'option');
const callbacks = createCallbacks();

const result = await filterValueCommand.execute(instance, callbacks)({
expression: singleBasic('SaleDate', '=', '2024-05-10T00:00:00'),
});

expect(spy).toHaveBeenCalledWith(
'filterValue',
['SaleDate', '=', new Date('2024-05-10T00:00:00')],
);
expect(result.status).toBe('success');
Comment thread
Raushen marked this conversation as resolved.
});

it('does not convert invalid date string for date columns', async () => {
const instance = await createGrid({
dataSource: [
{ id: 1, SaleDate: new Date(2024, 4, 10) },
],
columns: [
{ dataField: 'id', dataType: 'number' },
{ dataField: 'SaleDate', dataType: 'date' },
],
});
const spy = jest.spyOn(instance, 'option');
const callbacks = createCallbacks();

const result = await filterValueCommand.execute(instance, callbacks)({
expression: singleBasic('SaleDate', '=', 'not-a-date'),
});

expect(spy).toHaveBeenCalledWith(
'filterValue',
['SaleDate', '=', 'not-a-date'],
);
expect(result.status).toBe('success');
});

it('converts a combined node into the legacy array form', async () => {
const instance = await createGrid();
const spy = jest.spyOn(instance, 'option');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
} from '@jest/globals';
import { z } from 'zod';

// eslint-disable-next-line spellcheck/spell-checker
import { isKeyShapeValid, normalizeKey, optionalNullish } from '../utils';
import {
// eslint-disable-next-line spellcheck/spell-checker
isKeyShapeValid, normalizeKey, optionalNullish, resolveFilterValue,
} from '../utils';

describe('normalizeKey', () => {
it('returns a string key as-is', () => {
Expand Down Expand Up @@ -130,3 +132,39 @@ describe('isKeyShapeValid', () => {
});
});
});

describe('resolveFilterValue', () => {
it('converts a valid ISO date string to Date for "date" dataType', () => {
const result = resolveFilterValue('date', '2024-05-10T00:00:00');
expect(result).toEqual(new Date('2024-05-10T00:00:00'));
});

it('converts a valid ISO date string to Date for "datetime" dataType', () => {
const result = resolveFilterValue('datetime', '2024-05-10T14:30:00');
expect(result).toEqual(new Date('2024-05-10T14:30:00'));
});

it('returns the original string for an invalid date with "date" dataType', () => {
expect(resolveFilterValue('date', 'not-a-date')).toBe('not-a-date');
});

it('returns the original string when dataType is "string"', () => {
expect(resolveFilterValue('string', '2024-05-10T00:00:00')).toBe('2024-05-10T00:00:00');
});

it('returns the original string when dataType is undefined', () => {
expect(resolveFilterValue(undefined, '2024-05-10T00:00:00')).toBe('2024-05-10T00:00:00');
});

it('returns number values as-is regardless of dataType', () => {
expect(resolveFilterValue('date', 42)).toBe(42);
});

it('returns null as-is regardless of dataType', () => {
expect(resolveFilterValue('date', null)).toBeNull();
});

it('returns boolean values as-is regardless of dataType', () => {
expect(resolveFilterValue('date', true)).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import type { SearchOperation } from '@js/common/data.types';
import type { FilterExprNode, FilterExprTree } from '@js/common/grids';
import type { BasicFilterExpr, FilterExprNode, FilterExprTree } from '@js/common/grids';
import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types';
import type { InternalGrid } from '@ts/grids/grid_core/m_types';
import { z } from 'zod';

import { defineGridCommand } from './defineGridCommand';
import { resolveFilterValue } from './utils';

const FILTER_OPS = [
'=', '<>', '<', '<=', '>', '>=',
'contains', 'notcontains', 'startswith', 'endswith',
] as const satisfies readonly SearchOperation[];

type FilterExprArray = | [string, SearchOperation, string | number | boolean | null]
type FilterExprArray = | [string, SearchOperation, BasicFilterExpr['value']]
| [FilterExprArray, 'and' | 'or', FilterExprArray]
| ['!', FilterExprArray];

const filterOpSchema = z.enum(FILTER_OPS);

const filterValueScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
const filterValueScalarSchema = z.union([
z.string().describe(
'A plain string value. Date values should be in "YYYY-MM-DDTHH:mm:ss" format (e.g. "2024-05-10T00:00:00", "2024-05-10T14:30:00"). The time part is always required. The "Z" suffix or timezone offset should not be appended unless the user explicitly requests it.',
),
z.number().describe('A numeric filter value.'),
z.boolean().describe('A boolean filter value.'),
z.null().describe('A null filter value.'),
]);
Comment thread
Raushen marked this conversation as resolved.

const basicFilterExprSchema = z.object({
type: z.enum(['basic']),
Expand Down Expand Up @@ -57,7 +66,10 @@ const filterValueCommandSchema = z.object({
expression: filterExprTreeSchema.nullable(),
}).strict();

function convertFilterExprToArray(tree: FilterExprTree): FilterExprArray {
function convertFilterExprToArray(
component: InternalGrid,
tree: FilterExprTree,
): FilterExprArray {
const byId = new Map<string, FilterExprNode>();
for (const node of tree.nodes) {
if (byId.has(node.id)) {
Expand All @@ -79,8 +91,11 @@ function convertFilterExprToArray(tree: FilterExprTree): FilterExprArray {
try {
const { expr } = node;
switch (expr.type) {
case 'basic':
return [expr.field, expr.operator, expr.value];
case 'basic': {
const dataType = component.columnOption(expr.field, 'dataType');
const resolved = resolveFilterValue(dataType, expr.value);
return [expr.field, expr.operator, resolved];
}
case 'combined':
return [walk(expr.leftId), expr.combiner, walk(expr.rightId)];
case 'negated':
Expand All @@ -98,7 +113,7 @@ function convertFilterExprToArray(tree: FilterExprTree): FilterExprArray {

export const filterValueCommand = defineGridCommand({
name: 'filterValue',
description: 'Apply a filter expression to the grid. Replaces any existing filter; pass null for expression to clear. The expression is a flat node list: {"rootId":id,"nodes":[...]}. Each node is {"id":<unique string like "n1">,"expr":<expression>}, where "expr" is one of: basic {"type":"basic","field":dataField,"operator":op,"value":val}, combined {"type":"combined","combiner":"and"|"or","leftId":nodeId,"rightId":nodeId}, negated {"type":"negated","expressionId":nodeId}. "rootId" MUST be the "id" of the outermost node (the top of the expression tree) and must match one of the node ids exactly — never invent a value like "root". Every "leftId"/"rightId"/"expressionId" must also match a node "id". Ids must be unique and must not form cycles. The "field" is the column dataField (not the caption). Supported operators: "=", "<>", "<", "<=", ">", ">=", "contains", "notcontains", "startswith", "endswith". To express "not and" / "not or", add a negated node whose expressionId points at a combined node. Example for name = "Alpha" AND age > 10 (rootId is "n3", the combined node): {"rootId":"n3","nodes":[{"id":"n1","expr":{"type":"basic","field":"name","operator":"=","value":"Alpha"}},{"id":"n2","expr":{"type":"basic","field":"age","operator":">","value":10}},{"id":"n3","expr":{"type":"combined","combiner":"and","leftId":"n1","rightId":"n2"}}]}.',
description: 'Apply a filter expression to the grid. Replaces any existing filter; pass null for expression to clear. The expression is a flat node list: {"rootId":id,"nodes":[...]}. Each node is {"id":<unique string like "n1">,"expr":<expression>}, where "expr" is one of: basic {"type":"basic","field":dataField,"operator":op,"value":val}, combined {"type":"combined","combiner":"and"|"or","leftId":nodeId,"rightId":nodeId}, negated {"type":"negated","expressionId":nodeId}. "rootId" MUST be the "id" of the outermost node (the top of the expression tree) and must match one of the node ids exactly — never invent a value like "root". Every "leftId"/"rightId"/"expressionId" must also match a node "id". Ids must be unique and must not form cycles. The "field" is the column dataField (not the caption). Supported operators: "=", "<>", "<", "<=", ">", ">=", "contains", "notcontains", "startswith", "endswith". DATE VALUES: When a value is a date or datetime, always use "YYYY-MM-DDTHH:mm:ss" format without timezone suffix, e.g. "2024-05-10T00:00:00" for midnight or "2024-05-10T14:30:00" for a specific time. Always include the "T" and time part. Do NOT use date-only format like "2024-05-10" without time. Do NOT append "Z" or any timezone offset unless the user explicitly requests it. Do NOT use natural language for dates. To express "not and" / "not or", add a negated node whose expressionId points at a combined node. Example for name = "Alpha" AND age > 10 (rootId is "n3", the combined node): {"rootId":"n3","nodes":[{"id":"n1","expr":{"type":"basic","field":"name","operator":"=","value":"Alpha"}},{"id":"n2","expr":{"type":"basic","field":"age","operator":">","value":10}},{"id":"n3","expr":{"type":"combined","combiner":"and","leftId":"n1","rightId":"n2"}}]}.',
schema: filterValueCommandSchema,
execute: (component, { success, failure }) => (args): Promise<CommandResult> => {
const defaultMessage = args.expression === null
Expand All @@ -108,7 +123,7 @@ export const filterValueCommand = defineGridCommand({
try {
const filterValue = args.expression === null
? undefined
: convertFilterExprToArray(args.expression);
: convertFilterExprToArray(component, args.expression);

// Handles remote operations via data controller listening for the `filtering` change
component.option('filterValue', filterValue);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { CompositeKeyPair } from '@js/common/grids';
import type { BasicFilterExpr, CompositeKeyPair } from '@js/common/grids';
import { isString } from '@js/core/utils/type';
import { dateUtilsTs } from '@ts/core/utils/date';
import { isDateType } from '@ts/grids/grid_core/m_utils';
import { z } from 'zod';

type RowKey = string | number | Record<string, string | number>;
Expand Down Expand Up @@ -64,3 +66,18 @@ export const isKeyShapeValid = (

return keyExpr.every((field) => field in key);
};

type FilterExprValue = BasicFilterExpr['value'];

export function resolveFilterValue(
Comment thread
anna-shakhova marked this conversation as resolved.
dataType: string | undefined,
value: FilterExprValue,
): FilterExprValue {
if (typeof value === 'string' && isDateType(dataType)) {
if (!dateUtilsTs.isValidDate(value)) {
return value;
Comment thread
anna-shakhova marked this conversation as resolved.
}
return new Date(value);
}
return value;
}
Comment thread
Raushen marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ const DATE_INTERVAL_SELECTORS = {

const DEFAULT_COLUMN_WIDTH = 50;

export function isDateType(dataType: string | undefined): boolean {
return dataType === 'date' || dataType === 'datetime';
}

const getIntervalSelector = function () {
const data = arguments[1];
const value = this.calculateCellValue(data);
Expand All @@ -81,10 +85,6 @@ const getIntervalSelector = function () {
}
};

function isDateType(dataType) {
return dataType === 'date' || dataType === 'datetime';
}

const getGlobalFormat = (dataType) => {
const globalFormat = getGlobalFormatByDataType(dataType);

Expand Down
2 changes: 1 addition & 1 deletion packages/devextreme/js/common/grids.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export type BasicFilterExpr = {
type: 'basic';
field: string;
operator: SearchOperation;
value: string | number | boolean | null;
value: string | number | boolean | null | Date;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/devextreme/testing/helpers/stubs/zodStub.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
string: function() { return z; },
boolean: function() { return z; },
number: function() { return z; },
date: function() { return z; },
null: function() { return z; },
enum: function() { return z; },
union: function() { return z; },
Comment thread
Raushen marked this conversation as resolved.
Expand All @@ -33,6 +34,7 @@
min: function() { return z; },
max: function() { return z; },
transform: function() { return z; },
describe: function() { return z; },
// validation
safeParse: function() { return { success: true, data: {} }; },
};
Expand Down
2 changes: 1 addition & 1 deletion packages/devextreme/ts/dx.all.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4706,7 +4706,7 @@ declare module DevExpress.common.grids {
type: 'basic';
field: string;
operator: DevExpress.common.data.SearchOperation;
value: string | number | boolean | null;
value: string | number | boolean | null | Date;
};
/**
* [descr:ColumnAIOptions]
Expand Down
Loading