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..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,6 +6,8 @@ import type { import { BaseCommand } from '@ts/core/ai_integration/commands/base'; import type { PromptData, PromptTemplateName } from '@ts/core/ai_integration/core/prompt_manager'; +import { parseDates } from './utils'; + export class ExecuteGridAssistantCommand extends BaseCommand< ExecuteGridAssistantCommandParams, ExecuteGridAssistantCommandResult @@ -23,7 +25,6 @@ export class ExecuteGridAssistantCommand extends BaseCommand< }; } - // TODO: check response more carefully protected parseResult( response: ExecuteGridAssistantCommandResponse, ): ExecuteGridAssistantCommandResult { @@ -31,11 +32,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/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; +} 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..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 @@ -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,28 @@ describe('filterValueCommand', () => { expect(result.status).toBe('success'); }); + it('passes Date values through to the filter array', async () => { + 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 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 c010054f60e4..aea058f6787b 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 @@ -10,13 +10,13 @@ const FILTER_OPS = [ 'contains', 'notcontains', 'startswith', 'endswith', ] as const satisfies readonly SearchOperation[]; -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']), @@ -98,7 +98,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 diff --git a/packages/devextreme/js/common/grids.d.ts b/packages/devextreme/js/common/grids.d.ts index 30b89af484ed..2002bbe7f8ba 100644 --- a/packages/devextreme/js/common/grids.d.ts +++ b/packages/devextreme/js/common/grids.d.ts @@ -109,7 +109,7 @@ export type BasicFilterExpr = { type: 'basic'; field: string; operator: SearchOperation; - value: string | number | boolean | null; + value: string | number | boolean | null | Date; }; /** 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; }, 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]