From 4c61dc602072c205f2926e0999ac3024ef79a68e Mon Sep 17 00:00:00 2001 From: Raushen Date: Thu, 28 May 2026 13:12:09 +0300 Subject: [PATCH 1/5] Fix filter command --- .../commands/executeGridAssistant.test.ts | 24 ++++++++++++++++++ .../commands/executeGridAssistant.ts | 25 ++++++++++++++++--- .../commands/__tests__/filtering.test.ts | 15 +++++++++++ .../ai_assistant/commands/filtering.ts | 8 +++--- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.test.ts b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.test.ts index 137155d3868f..d90e8a6e9bfd 100644 --- a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.test.ts +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.test.ts @@ -118,6 +118,30 @@ describe('ExecuteGridAssistantCommand', () => { actions: [{ name: 'sort', args: { columnName: 'name', sortOrder: 'asc' } }], }); }); + + it('should convert AIDate strings to Date objects in string response', () => { + const response = '{"actions":[{"name":"filterValue","args":{"expression":{"field":"SaleDate","operator":"=","value":"AIDate(2024, 5, 10)"}}}]}'; + // @ts-expect-error Access to protected property for a test + const result = command.parseResult(response); + + const { args } = result.actions[0]; + const expression = args.expression as Record; + expect(expression.value).toBeInstanceOf(Date); + expect(expression.value).toStrictEqual(new Date(2024, 4, 10)); + }); + + it('should convert AIDate strings to Date objects in stringified actions', () => { + const response = { + actions: '[{"name":"filterValue","args":{"expression":{"field":"SaleDate","operator":"=","value":"AIDate(2024, 12, 1)"}}}]', + }; + // @ts-expect-error Access to protected property for a test + const result = command.parseResult(response); + + const { args } = result.actions[0]; + const expression = args.expression as Record; + expect(expression.value).toBeInstanceOf(Date); + expect(expression.value).toStrictEqual(new Date(2024, 11, 1)); + }); }); describe('execute', () => { diff --git a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts index 4dea8c208a54..d15e5d044ff9 100644 --- a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts @@ -6,6 +6,26 @@ import type { import { BaseCommand } from '@ts/core/ai_integration/commands/base'; import type { PromptData, PromptTemplateName } from '@ts/core/ai_integration/core/prompt_manager'; +/** + * Matches "AIDate(year, month, day)" format used by the filtering command. + * All components are 1-based. + */ +const AI_DATE_REGEX = /^AIDate\((\d+),\s*(\d+),\s*(\d+)\)$/; + +function parseDates(_key: string, value: unknown): unknown { + if (typeof value === 'string') { + const match = AI_DATE_REGEX.exec(value); + if (match) { + return new Date( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + ); + } + } + return value; +} + export class ExecuteGridAssistantCommand extends BaseCommand< ExecuteGridAssistantCommandParams, ExecuteGridAssistantCommandResult @@ -23,7 +43,6 @@ export class ExecuteGridAssistantCommand extends BaseCommand< }; } - // TODO: check response more carefully protected parseResult( response: ExecuteGridAssistantCommandResponse, ): ExecuteGridAssistantCommandResult { @@ -31,11 +50,11 @@ export class ExecuteGridAssistantCommand extends BaseCommand< if (response === '') { return { actions: [] }; } - return JSON.parse(response) as ExecuteGridAssistantCommandResult; + return JSON.parse(response, parseDates) as ExecuteGridAssistantCommandResult; } const actions = typeof response.actions === 'string' - ? JSON.parse(response.actions) + ? JSON.parse(response.actions, parseDates) : response.actions; return { actions }; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts index 26980fc12d33..a56a9759ddd1 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts @@ -113,6 +113,7 @@ describe('filterValueCommand', () => { [singleBasic('name', '=', 1)], [singleBasic('name', '=', true)], [singleBasic('name', '=', null)], + [singleBasic('name', '=', new Date(2024, 4, 10))], ])('accepts scalar value %p', (expression) => { expect(filterValueCommand.schema.safeParse({ expression }).success).toBe(true); }); @@ -240,6 +241,20 @@ describe('filterValueCommand', () => { expect(result.status).toBe('success'); }); + it('passes Date values through to the filter array', async () => { + const instance = await createGrid(); + const spy = jest.spyOn(instance, 'option'); + const callbacks = createCallbacks(); + const date = new Date(2024, 4, 10); + + const result = await filterValueCommand.execute(instance, callbacks)({ + expression: singleBasic('SaleDate', '=', date), + }); + + expect(spy).toHaveBeenCalledWith('filterValue', ['SaleDate', '=', 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'); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts index ad1437533a17..d176f7075998 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts @@ -13,7 +13,7 @@ interface BasicFilterExpr { type: 'basic'; field: string; operator: SearchOperation; - value: string | number | boolean | null; + value: string | number | boolean | Date | null; } interface CombinedFilterExpr { @@ -40,13 +40,13 @@ interface FilterExprTree { nodes: FilterExprNode[]; } -type FilterExprArray = | [string, SearchOperation, string | number | boolean | null] +type FilterExprArray = | [string, SearchOperation, string | number | boolean | Date | null] | [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(), z.number(), z.boolean(), z.null(), z.date()]); const basicFilterExprSchema = z.object({ type: z.enum(['basic']), @@ -128,7 +128,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":,"expr":}, 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":,"expr":}, 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, encode it as "AIDate(year, month, day)" where year is the full year, month is 1-based (1=January, 12=December), day is the day of the month. Example: May 10, 2024 → "AIDate(2024, 5, 10)". Do NOT use ISO strings or any other date format. 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 => { const defaultMessage = args.expression === null From 2ff245ffc369bad5db4215ede8793c309c4e90dc Mon Sep 17 00:00:00 2001 From: Raushen Date: Thu, 28 May 2026 16:01:07 +0300 Subject: [PATCH 2/5] Fix test/comments --- .../commands/executeGridAssistant.ts | 21 +++++++++++++------ .../commands/__tests__/filtering.test.ts | 12 +++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts index d15e5d044ff9..241aadf237dc 100644 --- a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts @@ -8,7 +8,7 @@ import type { PromptData, PromptTemplateName } from '@ts/core/ai_integration/cor /** * Matches "AIDate(year, month, day)" format used by the filtering command. - * All components are 1-based. + * The year is the full year; month and day are 1-based. */ const AI_DATE_REGEX = /^AIDate\((\d+),\s*(\d+),\s*(\d+)\)$/; @@ -16,11 +16,20 @@ function parseDates(_key: string, value: unknown): unknown { if (typeof value === 'string') { const match = AI_DATE_REGEX.exec(value); if (match) { - return new Date( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - ); + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const date = new Date(year, month, day); + + const isValid = date.getFullYear() === year + && date.getMonth() === month + && date.getDate() === day; + + if (!isValid) { + return value; + } + + return date; } } return value; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts index a56a9759ddd1..e225ecd2a50f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts @@ -242,10 +242,18 @@ describe('filterValueCommand', () => { }); it('passes Date values through to the filter array', async () => { - const instance = await createGrid(); + const date = new Date(2024, 4, 10); + const instance = await createGrid({ + dataSource: [ + { id: 1, SaleDate: date }, + ], + columns: [ + { dataField: 'id', dataType: 'number' }, + { dataField: 'SaleDate', dataType: 'date' }, + ], + }); const spy = jest.spyOn(instance, 'option'); const callbacks = createCallbacks(); - const date = new Date(2024, 4, 10); const result = await filterValueCommand.execute(instance, callbacks)({ expression: singleBasic('SaleDate', '=', date), From c016dddac121b2fa4f65e4a97a3b49f172ed7008 Mon Sep 17 00:00:00 2001 From: Raushen Date: Thu, 28 May 2026 16:46:41 +0300 Subject: [PATCH 3/5] Fix qUnit --- packages/devextreme/testing/helpers/stubs/zodStub.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/devextreme/testing/helpers/stubs/zodStub.js b/packages/devextreme/testing/helpers/stubs/zodStub.js index 41a13cd622da..8bfbb1045240 100644 --- a/packages/devextreme/testing/helpers/stubs/zodStub.js +++ b/packages/devextreme/testing/helpers/stubs/zodStub.js @@ -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; }, From b4b30086c1370eb79d9e066e07410953e5df3ab5 Mon Sep 17 00:00:00 2001 From: Raushen Date: Thu, 28 May 2026 17:12:57 +0300 Subject: [PATCH 4/5] Extract utils --- .../commands/executeGridAssistant.ts | 29 +-------- .../ai_integration/commands/utils.test.ts | 65 +++++++++++++++++++ .../core/ai_integration/commands/utils.ts | 28 ++++++++ 3 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 packages/devextreme/js/__internal/core/ai_integration/commands/utils.test.ts create mode 100644 packages/devextreme/js/__internal/core/ai_integration/commands/utils.ts diff --git a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts index 241aadf237dc..a356d9ffc8c8 100644 --- a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts @@ -6,34 +6,7 @@ import type { import { BaseCommand } from '@ts/core/ai_integration/commands/base'; import type { PromptData, PromptTemplateName } from '@ts/core/ai_integration/core/prompt_manager'; -/** - * Matches "AIDate(year, month, day)" format used by the filtering command. - * The year is the full year; month and day are 1-based. - */ -const AI_DATE_REGEX = /^AIDate\((\d+),\s*(\d+),\s*(\d+)\)$/; - -function parseDates(_key: string, value: unknown): unknown { - if (typeof value === 'string') { - const match = AI_DATE_REGEX.exec(value); - if (match) { - const year = Number(match[1]); - const month = Number(match[2]) - 1; - const day = Number(match[3]); - const date = new Date(year, month, day); - - const isValid = date.getFullYear() === year - && date.getMonth() === month - && date.getDate() === day; - - if (!isValid) { - return value; - } - - return date; - } - } - return value; -} +import { parseDates } from './utils'; export class ExecuteGridAssistantCommand extends BaseCommand< ExecuteGridAssistantCommandParams, diff --git a/packages/devextreme/js/__internal/core/ai_integration/commands/utils.test.ts b/packages/devextreme/js/__internal/core/ai_integration/commands/utils.test.ts new file mode 100644 index 000000000000..7734722095d9 --- /dev/null +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/utils.test.ts @@ -0,0 +1,65 @@ +import { + describe, + expect, + it, +} from '@jest/globals'; + +import { parseDates } from './utils'; + +describe('parseDates', () => { + it('converts valid AIDate string to Date object', () => { + const result = parseDates('key', 'AIDate(2024, 5, 10)'); + expect(result).toEqual(new Date(2024, 4, 10)); + }); + + it('handles single-digit month and day', () => { + const result = parseDates('key', 'AIDate(2024, 1, 1)'); + expect(result).toEqual(new Date(2024, 0, 1)); + }); + + it('handles December 31', () => { + const result = parseDates('key', 'AIDate(2024, 12, 31)'); + expect(result).toEqual(new Date(2024, 11, 31)); + }); + + it('returns original string for invalid date (month 13)', () => { + const result = parseDates('key', 'AIDate(2024, 13, 1)'); + expect(result).toBe('AIDate(2024, 13, 1)'); + }); + + it('returns original string for invalid date (day 32)', () => { + const result = parseDates('key', 'AIDate(2024, 1, 32)'); + expect(result).toBe('AIDate(2024, 1, 32)'); + }); + + it('returns original string for February 30', () => { + const result = parseDates('key', 'AIDate(2024, 2, 30)'); + expect(result).toBe('AIDate(2024, 2, 30)'); + }); + + it('passes through non-AIDate strings unchanged', () => { + expect(parseDates('key', 'hello')).toBe('hello'); + expect(parseDates('key', '2024-05-10')).toBe('2024-05-10'); + }); + + it('passes through non-string values unchanged', () => { + expect(parseDates('key', 42)).toBe(42); + expect(parseDates('key', null)).toBe(null); + expect(parseDates('key', true)).toBe(true); + }); + + it('works as JSON.parse reviver', () => { + const json = '{"date":"AIDate(2024, 5, 10)","name":"test","count":5}'; + const result = JSON.parse(json, parseDates); + expect(result).toEqual({ + date: new Date(2024, 4, 10), + name: 'test', + count: 5, + }); + }); + + it('handles AIDate without spaces after commas', () => { + const result = parseDates('key', 'AIDate(2024,5,10)'); + expect(result).toEqual(new Date(2024, 4, 10)); + }); +}); diff --git a/packages/devextreme/js/__internal/core/ai_integration/commands/utils.ts b/packages/devextreme/js/__internal/core/ai_integration/commands/utils.ts new file mode 100644 index 000000000000..21b96a47f0ba --- /dev/null +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/utils.ts @@ -0,0 +1,28 @@ +/** + * Matches "AIDate(year, month, day)" format used by the filtering command. + * The year is the full year; month and day are 1-based. + */ +const AI_DATE_REGEX = /^AIDate\((\d+),\s*(\d+),\s*(\d+)\)$/; + +export function parseDates(_key: string, value: unknown): unknown { + if (typeof value === 'string') { + const match = AI_DATE_REGEX.exec(value); + if (match) { + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const date = new Date(year, month, day); + + const isValid = date.getFullYear() === year + && date.getMonth() === month + && date.getDate() === day; + + if (!isValid) { + return value; + } + + return date; + } + } + return value; +} From be3a2a448bb78b6809c8e2cd4bbee83b1d0d8627 Mon Sep 17 00:00:00 2001 From: Raushen Date: Fri, 29 May 2026 18:02:08 +0300 Subject: [PATCH 5/5] Fix .d.ts --- packages/devextreme/ts/dx.all.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 0cee04115773..0a793a6a963f 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -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]