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..6365e4c075ec 100644 --- a/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts +++ b/packages/devextreme/js/__internal/core/ai_integration/commands/executeGridAssistant.ts @@ -23,7 +23,6 @@ export class ExecuteGridAssistantCommand extends BaseCommand< }; } - // TODO: check response more carefully protected parseResult( response: ExecuteGridAssistantCommandResponse, ): ExecuteGridAssistantCommandResult { 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..1544d70bec5a 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 @@ -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'); + }); + + 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'); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts index d18d87b3caaf..ea3ff6065cf7 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/utils.test.ts @@ -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', () => { @@ -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); + }); +}); 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..4392b2d521e3 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 @@ -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.'), +]); const basicFilterExprSchema = z.object({ type: z.enum(['basic']), @@ -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(); for (const node of tree.nodes) { if (byId.has(node.id)) { @@ -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': @@ -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":,"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 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 => { const defaultMessage = args.expression === null @@ -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); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts index 8a4bd45ea407..4a2e7c521faf 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/utils.ts @@ -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; @@ -64,3 +66,18 @@ export const isKeyShapeValid = ( return keyExpr.every((field) => field in key); }; + +type FilterExprValue = BasicFilterExpr['value']; + +export function resolveFilterValue( + dataType: string | undefined, + value: FilterExprValue, +): FilterExprValue { + if (typeof value === 'string' && isDateType(dataType)) { + if (!dateUtilsTs.isValidDate(value)) { + return value; + } + return new Date(value); + } + return value; +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts index 925e1544cc69..f03a205a92c4 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts @@ -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); @@ -81,10 +85,6 @@ const getIntervalSelector = function () { } }; -function isDateType(dataType) { - return dataType === 'date' || dataType === 'datetime'; -} - const getGlobalFormat = (dataType) => { const globalFormat = getGlobalFormatByDataType(dataType); 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..668e5fd8ecd2 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; }, @@ -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: {} }; }, }; 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]