diff --git a/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant.ts similarity index 100% rename from packages/devextreme/js/__internal/grids/data_grid/module_not_extended/ai_assistant.ts rename to packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/grouping.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/grouping.test.ts new file mode 100644 index 000000000000..7fa4518ac584 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/grouping.test.ts @@ -0,0 +1,340 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { Properties } from '@js/ui/data_grid'; +import { + afterTest, + beforeTest, + createDataGrid, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid } from '@ts/grids/grid_core/m_types'; + +import { clearGroupingCommand, groupingCommand } from '../grouping'; + +const createCallbacks = (): { + success: jest.Mock<(message?: string) => CommandResult>; + failure: jest.Mock<(message?: string) => CommandResult>; +} => ({ + success: jest.fn((message?: string) => ({ status: 'success' as const, message: message ?? '' })), + failure: jest.fn((message?: string) => ({ status: 'failure' as const, message: message ?? '' })), +}); + +const createGrid = async ( + options: Record = {}, +): Promise => { + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Alpha', age: 10 }, + { id: 2, name: 'Beta', age: 20 }, + ], + columns: [ + { dataField: 'id', dataType: 'number' }, + { dataField: 'name', caption: 'Full Name', dataType: 'string' }, + { dataField: 'age', dataType: 'number' }, + ], + ...options, + } as unknown as Properties); + return instance as unknown as InternalGrid; +}; + +describe('groupingCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it('accepts valid args with non-negative integer groupIndex', () => { + expect(groupingCommand.schema.safeParse({ + dataField: 'name', + groupIndex: 0, + }).success).toBe(true); + }); + + it('accepts null groupIndex (ungroup)', () => { + expect(groupingCommand.schema.safeParse({ + dataField: 'name', + groupIndex: null, + }).success).toBe(true); + }); + + it('rejects when dataField is missing', () => { + expect(groupingCommand.schema.safeParse({ + groupIndex: 0, + }).success).toBe(false); + }); + + it('rejects when groupIndex is missing', () => { + expect(groupingCommand.schema.safeParse({ + dataField: 'name', + }).success).toBe(false); + }); + + it('rejects when dataField is not a string', () => { + expect(groupingCommand.schema.safeParse({ + dataField: 123, + groupIndex: 0, + }).success).toBe(false); + }); + + it('rejects when groupIndex is negative', () => { + expect(groupingCommand.schema.safeParse({ + dataField: 'name', + groupIndex: -1, + }).success).toBe(false); + }); + + it('rejects when groupIndex is not an integer', () => { + expect(groupingCommand.schema.safeParse({ + dataField: 'name', + groupIndex: 1.5, + }).success).toBe(false); + }); + + it('rejects unknown properties', () => { + expect(groupingCommand.schema.safeParse({ + dataField: 'name', + groupIndex: 0, + extra: 1, + }).success).toBe(false); + }); + }); + + describe('execute', () => { + it('returns failure and skips columnOption write when column.allowGrouping is false', async () => { + const instance = await createGrid({ + columns: [ + { dataField: 'id', dataType: 'number' }, + { + dataField: 'name', caption: 'Full Name', dataType: 'string', allowGrouping: false, + }, + ], + }); + const columnsController = instance.getController('columns'); + const spy = jest.spyOn(columnsController, 'columnOption'); + const callbacks = createCallbacks(); + + const result = await groupingCommand.execute(instance, callbacks)({ + dataField: 'name', + groupIndex: 0, + }); + + expect(result.status).toBe('failure'); + expect(callbacks.success).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalledWith( + expect.anything(), + 'groupIndex', + expect.anything(), + ); + }); + + it('returns failure and skips columnOption write when dataField does not match any column', async () => { + const instance = await createGrid(); + const columnsController = instance.getController('columns'); + const spy = jest.spyOn(columnsController, 'columnOption'); + const callbacks = createCallbacks(); + + const result = await groupingCommand.execute(instance, callbacks)({ + dataField: 'unknown', + groupIndex: 0, + }); + + expect(result.status).toBe('failure'); + expect(callbacks.success).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalledWith( + expect.anything(), + 'groupIndex', + expect.anything(), + ); + }); + + it('calls columnsController.columnOption(column.index, "groupIndex", groupIndex) for non-null groupIndex', async () => { + const instance = await createGrid(); + const columnsController = instance.getController('columns'); + const spy = jest.spyOn(columnsController, 'columnOption'); + const callbacks = createCallbacks(); + + const result = await groupingCommand.execute(instance, callbacks)({ + dataField: 'name', + groupIndex: 0, + }); + + const nameColumn = columnsController.columnOption('name') as { index: number }; + expect(spy).toHaveBeenCalledWith(nameColumn.index, 'groupIndex', 0); + expect(result.status).toBe('success'); + }); + + it('passes undefined when groupIndex is null (ungroups the column)', async () => { + const instance = await createGrid(); + const columnsController = instance.getController('columns'); + const spy = jest.spyOn(columnsController, 'columnOption'); + const callbacks = createCallbacks(); + + const result = await groupingCommand.execute(instance, callbacks)({ + dataField: 'name', + groupIndex: null, + }); + + const nameColumn = columnsController.columnOption('name') as { index: number }; + expect(spy).toHaveBeenCalledWith(nameColumn.index, 'groupIndex', undefined); + expect(result.status).toBe('success'); + }); + + it('returns failure when columnOption throws', async () => { + const instance = await createGrid(); + const columnsController = instance.getController('columns'); + const originalColumnOption = columnsController.columnOption.bind(columnsController); + jest.spyOn(columnsController, 'columnOption').mockImplementation((...args: unknown[]) => { + if (args.length >= 3) { + throw new Error('Error setting groupIndex'); + } + return (originalColumnOption as (...a: unknown[]) => unknown)(...args); + }); + const callbacks = createCallbacks(); + + const result = await groupingCommand.execute(instance, callbacks)({ + dataField: 'name', + groupIndex: 0, + }); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses `Group data against "[caption]".` for non-null groupIndex', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await groupingCommand.execute(instance, callbacks)({ + dataField: 'name', + groupIndex: 0, + }); + + expect(callbacks.success).toHaveBeenCalledWith( + 'Group data against "Full Name".', + ); + }); + + it('uses `Ungroup data against "[caption]".` for null groupIndex', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await groupingCommand.execute(instance, callbacks)({ + dataField: 'name', + groupIndex: null, + }); + + expect(callbacks.success).toHaveBeenCalledWith( + 'Ungroup data against "Full Name".', + ); + }); + + it('passes the same default message to failure when executability fails', async () => { + const instance = await createGrid({ + columns: [ + { dataField: 'id', dataType: 'number' }, + { + dataField: 'name', caption: 'Full Name', dataType: 'string', allowGrouping: false, + }, + ], + }); + const callbacks = createCallbacks(); + + await groupingCommand.execute(instance, callbacks)({ + dataField: 'name', + groupIndex: 0, + }); + + expect(callbacks.failure).toHaveBeenCalledWith( + 'Group data against "Full Name".', + ); + }); + + it('falls back to the raw dataField when no column matches', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await groupingCommand.execute(instance, callbacks)({ + dataField: 'unknown', + groupIndex: 0, + }); + + expect(callbacks.failure).toHaveBeenCalledWith( + 'Group data against "unknown".', + ); + }); + }); +}); + +describe('clearGroupingCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it('accepts an empty object', () => { + expect(clearGroupingCommand.schema.safeParse({}).success).toBe(true); + }); + + it('rejects unknown properties', () => { + expect(clearGroupingCommand.schema.safeParse({ + extra: 1, + }).success).toBe(false); + }); + }); + + describe('execute', () => { + it('calls columnsController.clearGrouping() exactly once', async () => { + const instance = await createGrid(); + const columnsController = instance.getController('columns'); + const spy = jest.spyOn(columnsController, 'clearGrouping'); + const callbacks = createCallbacks(); + + const result = await clearGroupingCommand.execute(instance, callbacks)(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result.status).toBe('success'); + }); + + it('returns failure when clearGrouping throws', async () => { + const instance = await createGrid(); + const columnsController = instance.getController('columns'); + jest.spyOn(columnsController, 'clearGrouping').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + const result = await clearGroupingCommand.execute(instance, callbacks)(); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses the literal `Clear grouping.`', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await clearGroupingCommand.execute(instance, callbacks)(); + + expect(callbacks.success).toHaveBeenCalledWith('Clear grouping.'); + }); + + it('passes the same default message to failure when executability fails', async () => { + const instance = await createGrid(); + const columnsController = instance.getController('columns'); + jest.spyOn(columnsController, 'clearGrouping').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + await clearGroupingCommand.execute(instance, callbacks)(); + + expect(callbacks.failure).toHaveBeenCalledWith('Clear grouping.'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts new file mode 100644 index 000000000000..f63bd8dfac9d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/grouping.ts @@ -0,0 +1,68 @@ +import type { Column as DataGridColumn } from '@js/ui/data_grid'; +import { defineGridCommand } from '@ts/grids/grid_core/ai_assistant/commands/defineGridCommand'; +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import type { Column as GridCoreColumn } from '@ts/grids/grid_core/columns_controller/types'; +import { z } from 'zod'; + +type Column = GridCoreColumn & DataGridColumn; + +const groupingCommandSchema = z.object({ + dataField: z.string(), + // eslint-disable-next-line spellcheck/spell-checker + groupIndex: z.number().int().nonnegative().nullable(), +}).strict(); + +const getGroupingDefaultMessage = ( + args: z.infer, + column: Column | undefined, +): string => { + const columnName = column?.caption ?? args.dataField; + + if (args.groupIndex === null) { + return `Ungroup data against "${columnName}".`; + } + + return `Group data against "${columnName}".`; +}; + +export const groupingCommand = defineGridCommand({ + name: 'grouping', + description: 'Group rows by a column at the given level (0 = outermost). Setting groupIndex to an in-use value shifts the existing column down; gaps auto-collapse. Pass null to ungroup. To replace existing grouping, ungroup each currently grouped column, then group new ones at consecutive indices 0, 1, 2, ...', + schema: groupingCommandSchema, + execute: (component, { success, failure }) => (args): Promise => { + const columnsController = component.getController('columns'); + const column: Column | undefined = columnsController.columnOption(args.dataField); + const defaultMessage = getGroupingDefaultMessage(args, column); + + if (!column || column.allowGrouping === false) { + return Promise.resolve(failure(defaultMessage)); + } + + try { + // Handles remote operations via data controller listening for the `grouping` change + columnsController.columnOption(column.index, 'groupIndex', args.groupIndex ?? undefined); + + return Promise.resolve(success(defaultMessage)); + } catch { + return Promise.resolve(failure(defaultMessage)); + } + }, +}); + +export const clearGroupingCommand = defineGridCommand({ + name: 'clearGrouping', + description: 'Remove grouping from all columns', + schema: z.object({}).strict(), + execute: (component, { success, failure }) => (): Promise => { + const defaultMessage = 'Clear grouping.'; + + try { + // Handles remote operations via data controller listening for the `grouping` change + component.getController('columns').clearGrouping(); + + return Promise.resolve(success(defaultMessage)); + } catch { + return Promise.resolve(failure(defaultMessage)); + } + }, +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/m_widget.ts b/packages/devextreme/js/__internal/grids/data_grid/m_widget.ts index c4aae3a92e8a..0c0d20b9a217 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/m_widget.ts @@ -34,6 +34,6 @@ import './export/m_export'; import './focus/m_focus'; import './module_not_extended/row_dragging'; import './module_not_extended/toast'; -import './module_not_extended/ai_assistant'; +import './ai_assistant/ai_assistant'; export default DataGrid; 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 new file mode 100644 index 000000000000..0034c50499ff --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts @@ -0,0 +1,369 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { Properties } from '@js/ui/data_grid'; +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid } from '@ts/grids/grid_core/m_types'; + +import { + afterTest, + beforeTest, + createDataGrid, +} from '../../../__tests__/__mock__/helpers/utils'; +import { + clearFilterCommand, + filterValueCommand, + searchingCommand, +} from '../filtering'; + +const createCallbacks = (): { + success: jest.Mock<(message?: string) => CommandResult>; + failure: jest.Mock<(message?: string) => CommandResult>; +} => ({ + success: jest.fn((message?: string) => ({ status: 'success' as const, message: message ?? '' })), + failure: jest.fn((message?: string) => ({ status: 'failure' as const, message: message ?? '' })), +}); + +const createGrid = async ( + options: Record = {}, +): Promise => { + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Alpha', age: 10 }, + { id: 2, name: 'Beta', age: 20 }, + { id: 3, name: 'Gamma', age: 30 }, + ], + columns: [ + { dataField: 'id', dataType: 'number' }, + { dataField: 'name', dataType: 'string' }, + { dataField: 'age', dataType: 'number' }, + ], + ...options, + } as unknown as Properties); + return instance as unknown as InternalGrid; +}; + +describe('filterValueCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it('accepts a basic [field, op, value] expression', () => { + expect(filterValueCommand.schema.safeParse({ + expression: ['name', '=', 'Alpha'], + }).success).toBe(true); + }); + + it.each([ + ['='], ['<>'], ['<'], ['<='], ['>'], ['>='], + ['contains'], ['notcontains'], ['startswith'], ['endswith'], + ])('accepts op "%s"', (op) => { + expect(filterValueCommand.schema.safeParse({ + expression: ['name', op, 'Alpha'], + }).success).toBe(true); + }); + + it.each([ + [['name', '=', 'Alpha']], + [['name', '=', 1]], + [['name', '=', true]], + [['name', '=', null]], + ])('accepts scalar value %p', (expression) => { + expect(filterValueCommand.schema.safeParse({ expression }).success).toBe(true); + }); + + it('accepts a combined [expr, "and", expr] expression', () => { + expect(filterValueCommand.schema.safeParse({ + expression: [['name', '=', 'Alpha'], 'and', ['age', '>', 10]], + }).success).toBe(true); + }); + + it('accepts a combined [expr, "or", expr] expression', () => { + expect(filterValueCommand.schema.safeParse({ + expression: [['name', '=', 'Alpha'], 'or', ['name', '=', 'Beta']], + }).success).toBe(true); + }); + + it('accepts a negated ["!", expr] expression', () => { + expect(filterValueCommand.schema.safeParse({ + expression: ['!', ['name', '=', 'Alpha']], + }).success).toBe(true); + }); + + it('accepts deeply nested expressions', () => { + expect(filterValueCommand.schema.safeParse({ + expression: [ + ['!', ['name', '=', 'Alpha']], + 'and', + [['age', '>', 10], 'or', ['age', '<', 30]], + ], + }).success).toBe(true); + }); + + it('accepts null expression', () => { + expect(filterValueCommand.schema.safeParse({ expression: null }).success).toBe(true); + }); + + it('rejects when expression is missing', () => { + expect(filterValueCommand.schema.safeParse({}).success).toBe(false); + }); + + it('rejects an unknown op', () => { + expect(filterValueCommand.schema.safeParse({ + expression: ['name', 'like', 'Alpha'], + }).success).toBe(false); + }); + + it('rejects an unknown combiner', () => { + expect(filterValueCommand.schema.safeParse({ + expression: [['name', '=', 'Alpha'], 'xor', ['age', '>', 10]], + }).success).toBe(false); + }); + + it('rejects an object value (non-scalar)', () => { + expect(filterValueCommand.schema.safeParse({ + expression: ['name', '=', { foo: 1 }], + }).success).toBe(false); + }); + + it('rejects unknown properties', () => { + expect(filterValueCommand.schema.safeParse({ + expression: ['name', '=', 'Alpha'], + extra: 1, + }).success).toBe(false); + }); + }); + + describe('execute', () => { + it('calls component.option("filterValue", expression) exactly once', async () => { + const instance = await createGrid(); + const spy = jest.spyOn(instance, 'option'); + const callbacks = createCallbacks(); + + const result = await filterValueCommand.execute(instance, callbacks)({ + expression: ['name', '=', 'Alpha'], + }); + + expect(spy).toHaveBeenCalledWith('filterValue', ['name', '=', 'Alpha']); + expect(result.status).toBe('success'); + }); + + it('passes undefined when expression is null (clears the filter)', async () => { + const instance = await createGrid(); + const spy = jest.spyOn(instance, 'option'); + const callbacks = createCallbacks(); + + const result = await filterValueCommand.execute(instance, callbacks)({ + expression: null, + }); + + expect(spy).toHaveBeenCalledWith('filterValue', undefined); + expect(result.status).toBe('success'); + }); + + it('returns failure when option throws', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'option').mockImplementationOnce(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + const result = await filterValueCommand.execute(instance, callbacks)({ + expression: ['name', '=', 'Alpha'], + }); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses `Apply a filter.` when expression is set', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await filterValueCommand.execute(instance, callbacks)({ + expression: ['name', '=', 'Alpha'], + }); + + expect(callbacks.success).toHaveBeenCalledWith('Apply a filter.'); + }); + + it('uses `Clear filter.` when expression is null', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await filterValueCommand.execute(instance, callbacks)({ + expression: null, + }); + + expect(callbacks.success).toHaveBeenCalledWith('Clear filter.'); + }); + + it('passes the same default message to failure when executability fails', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'option').mockImplementationOnce(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + await filterValueCommand.execute(instance, callbacks)({ + expression: ['name', '=', 'Alpha'], + }); + + expect(callbacks.failure).toHaveBeenCalledWith('Apply a filter.'); + }); + }); +}); + +describe('clearFilterCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it('accepts an empty object', () => { + expect(clearFilterCommand.schema.safeParse({}).success).toBe(true); + }); + + it('rejects unknown properties', () => { + expect(clearFilterCommand.schema.safeParse({ extra: 1 }).success).toBe(false); + }); + }); + + describe('execute', () => { + it('calls component.clearFilter() exactly once', async () => { + const instance = await createGrid(); + const spy = jest.spyOn(instance, 'clearFilter'); + const callbacks = createCallbacks(); + + const result = await clearFilterCommand.execute(instance, callbacks)(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result.status).toBe('success'); + }); + + it('returns failure when clearFilter throws', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'clearFilter').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + const result = await clearFilterCommand.execute(instance, callbacks)(); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses the literal `Clear filter.`', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await clearFilterCommand.execute(instance, callbacks)(); + + expect(callbacks.success).toHaveBeenCalledWith('Clear filter.'); + }); + + it('passes the same default message to failure when executability fails', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'clearFilter').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + await clearFilterCommand.execute(instance, callbacks)(); + + expect(callbacks.failure).toHaveBeenCalledWith('Clear filter.'); + }); + }); +}); + +describe('searchingCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it('accepts a non-empty string', () => { + expect(searchingCommand.schema.safeParse({ text: 'Alpha' }).success).toBe(true); + }); + + it('accepts an empty string', () => { + expect(searchingCommand.schema.safeParse({ text: '' }).success).toBe(true); + }); + + it('rejects when text is missing', () => { + expect(searchingCommand.schema.safeParse({}).success).toBe(false); + }); + + it('rejects when text is not a string', () => { + expect(searchingCommand.schema.safeParse({ text: 123 }).success).toBe(false); + }); + + it('rejects unknown properties', () => { + expect(searchingCommand.schema.safeParse({ text: 'Alpha', extra: 1 }).success).toBe(false); + }); + }); + + describe('execute', () => { + it('calls component.searchByText(text) exactly once', async () => { + const instance = await createGrid(); + const spy = jest.spyOn(instance, 'searchByText'); + const callbacks = createCallbacks(); + + const result = await searchingCommand.execute(instance, callbacks)({ text: 'Alpha' }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('Alpha'); + expect(result.status).toBe('success'); + }); + + it('returns failure when searchByText throws', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'searchByText').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + const result = await searchingCommand.execute(instance, callbacks)({ text: 'Alpha' }); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses `Search for "[text]".` for non-empty text', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await searchingCommand.execute(instance, callbacks)({ text: 'Alpha' }); + + expect(callbacks.success).toHaveBeenCalledWith('Search for "Alpha".'); + }); + + it('uses `Clear search.` for empty text', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await searchingCommand.execute(instance, callbacks)({ text: '' }); + + expect(callbacks.success).toHaveBeenCalledWith('Clear search.'); + }); + + it('passes the same default message to failure when executability fails', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'searchByText').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + await searchingCommand.execute(instance, callbacks)({ text: 'Alpha' }); + + expect(callbacks.failure).toHaveBeenCalledWith('Search for "Alpha".'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/paging.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/paging.test.ts new file mode 100644 index 000000000000..8fe113372ac6 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/paging.test.ts @@ -0,0 +1,333 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { Properties } from '@js/ui/data_grid'; +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid } from '@ts/grids/grid_core/m_types'; + +import { + afterTest, + beforeTest, + createDataGrid, +} from '../../../__tests__/__mock__/helpers/utils'; +import { + pageIndexCommand, + pageSizeCommand, + pagingCommand, +} from '../paging'; + +const createCallbacks = (): { + success: jest.Mock<(message?: string) => CommandResult>; + failure: jest.Mock<(message?: string) => CommandResult>; +} => ({ + success: jest.fn((message?: string) => ({ status: 'success' as const, message: message ?? '' })), + failure: jest.fn((message?: string) => ({ status: 'failure' as const, message: message ?? '' })), +}); + +const createGrid = async ( + options: Record = {}, +): Promise => { + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, + { id: 3, name: 'Gamma' }, + { id: 4, name: 'Delta' }, + { id: 5, name: 'Epsilon' }, + ], + columns: [ + { dataField: 'id', dataType: 'number' }, + { dataField: 'name', dataType: 'string' }, + ], + ...options, + } as unknown as Properties); + return instance as unknown as InternalGrid; +}; + +describe('pagingCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it.each([ + [true], + [false], + ])('accepts valid args with enabled "%s"', (enabled) => { + expect(pagingCommand.schema.safeParse({ enabled }).success).toBe(true); + }); + + it('rejects when enabled is missing', () => { + expect(pagingCommand.schema.safeParse({}).success).toBe(false); + }); + + it('rejects when enabled is not a boolean', () => { + expect(pagingCommand.schema.safeParse({ enabled: 'true' }).success).toBe(false); + }); + + it('rejects unknown properties', () => { + expect(pagingCommand.schema.safeParse({ enabled: true, extra: 1 }).success).toBe(false); + }); + }); + + describe('execute', () => { + it.each([ + [true], + [false], + ])('calls component.option("paging.enabled", %s) and returns success', async (enabled) => { + const instance = await createGrid({ paging: { enabled: !enabled } }); + const spy = jest.spyOn(instance, 'option'); + const callbacks = createCallbacks(); + + const result = await pagingCommand.execute(instance, callbacks)({ enabled }); + + expect(spy).toHaveBeenCalledWith('paging.enabled', enabled); + expect(result.status).toBe('success'); + }); + + it('returns failure when option throws', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'option').mockImplementationOnce(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + const result = await pagingCommand.execute(instance, callbacks)({ enabled: true }); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses `Turn on pagination.` for enabled true', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await pagingCommand.execute(instance, callbacks)({ enabled: true }); + + expect(callbacks.success).toHaveBeenCalledWith('Turn on pagination.'); + }); + + it('uses `Turn off pagination.` for enabled false', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await pagingCommand.execute(instance, callbacks)({ enabled: false }); + + expect(callbacks.success).toHaveBeenCalledWith('Turn off pagination.'); + }); + }); +}); + +describe('pageSizeCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it.each([ + [0], + [1], + [50], + ])('accepts valid args with pageSize "%s"', (pageSize) => { + expect(pageSizeCommand.schema.safeParse({ pageSize }).success).toBe(true); + }); + + it('rejects when pageSize is missing', () => { + expect(pageSizeCommand.schema.safeParse({}).success).toBe(false); + }); + + it('rejects when pageSize is negative', () => { + expect(pageSizeCommand.schema.safeParse({ pageSize: -1 }).success).toBe(false); + }); + + it('rejects when pageSize is not an integer', () => { + expect(pageSizeCommand.schema.safeParse({ pageSize: 1.5 }).success).toBe(false); + }); + + it('rejects when pageSize is not a number', () => { + expect(pageSizeCommand.schema.safeParse({ pageSize: '5' }).success).toBe(false); + }); + + it('rejects unknown properties', () => { + expect(pageSizeCommand.schema.safeParse({ pageSize: 5, extra: 1 }).success).toBe(false); + }); + }); + + describe('execute', () => { + it('returns failure and skips pageSize when paging.enabled is false', async () => { + const instance = await createGrid({ paging: { enabled: false } }); + const spy = jest.spyOn(instance, 'pageSize'); + const callbacks = createCallbacks(); + + const result = await pageSizeCommand.execute(instance, callbacks)({ pageSize: 5 }); + + expect(result.status).toBe('failure'); + expect(callbacks.success).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('calls component.pageSize(value) exactly once', async () => { + const instance = await createGrid(); + const spy = jest.spyOn(instance, 'pageSize'); + const callbacks = createCallbacks(); + + const result = await pageSizeCommand.execute(instance, callbacks)({ pageSize: 2 }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(2); + expect(result.status).toBe('success'); + }); + + it('returns failure when pageSize throws', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'pageSize').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + const result = await pageSizeCommand.execute(instance, callbacks)({ pageSize: 2 }); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses `Show all rows.` for pageSize 0', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await pageSizeCommand.execute(instance, callbacks)({ pageSize: 0 }); + + expect(callbacks.success).toHaveBeenCalledWith('Show all rows.'); + }); + + it('uses `Change page size to [size].` for pageSize > 0', async () => { + const instance = await createGrid(); + const callbacks = createCallbacks(); + + await pageSizeCommand.execute(instance, callbacks)({ pageSize: 10 }); + + expect(callbacks.success).toHaveBeenCalledWith('Change page size to 10.'); + }); + + it('passes the same default message to failure when executability fails', async () => { + const instance = await createGrid({ paging: { enabled: false } }); + const callbacks = createCallbacks(); + + await pageSizeCommand.execute(instance, callbacks)({ pageSize: 10 }); + + expect(callbacks.failure).toHaveBeenCalledWith('Change page size to 10.'); + }); + }); +}); + +describe('pageIndexCommand', () => { + beforeEach(() => beforeTest()); + afterEach(() => afterTest()); + + describe('schema', () => { + it.each([ + [0], + [1], + [42], + ])('accepts valid args with pageIndex "%s"', (pageIndex) => { + expect(pageIndexCommand.schema.safeParse({ pageIndex }).success).toBe(true); + }); + + it('rejects when pageIndex is missing', () => { + expect(pageIndexCommand.schema.safeParse({}).success).toBe(false); + }); + + it('rejects when pageIndex is negative', () => { + expect(pageIndexCommand.schema.safeParse({ pageIndex: -1 }).success).toBe(false); + }); + + it('rejects when pageIndex is not an integer', () => { + expect(pageIndexCommand.schema.safeParse({ pageIndex: 1.5 }).success).toBe(false); + }); + + it('rejects when pageIndex is not a number', () => { + expect(pageIndexCommand.schema.safeParse({ pageIndex: '0' }).success).toBe(false); + }); + + it('rejects unknown properties', () => { + expect(pageIndexCommand.schema.safeParse({ pageIndex: 0, extra: 1 }).success).toBe(false); + }); + }); + + describe('execute', () => { + it('returns failure and skips pageIndex when paging.enabled is false', async () => { + const instance = await createGrid({ paging: { enabled: false } }); + const spy = jest.spyOn(instance, 'pageIndex'); + const callbacks = createCallbacks(); + + const result = await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 0 }); + + expect(result.status).toBe('failure'); + expect(callbacks.success).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('returns failure and skips pageIndex when pageIndex is out of bounds', async () => { + const instance = await createGrid({ paging: { enabled: true, pageSize: 2 } }); + const spy = jest.spyOn(instance, 'pageIndex'); + const callbacks = createCallbacks(); + + const result = await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 99 }); + + expect(result.status).toBe('failure'); + expect(callbacks.success).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('calls component.pageIndex(value) exactly once', async () => { + const instance = await createGrid({ paging: { enabled: true, pageSize: 2 } }); + const spy = jest.spyOn(instance, 'pageIndex') + .mockReturnValue(Promise.resolve()); + const callbacks = createCallbacks(); + + const result = await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 1 }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(1); + expect(result.status).toBe('success'); + }); + + it('returns failure when pageIndex throws', async () => { + const instance = await createGrid(); + jest.spyOn(instance, 'pageIndex').mockImplementation(() => { + throw new Error('Error'); + }); + const callbacks = createCallbacks(); + + const result = await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 0 }); + + expect(result.status).toBe('failure'); + }); + }); + + describe('default message', () => { + it('uses `Switch the view to page number [number].`', async () => { + const instance = await createGrid({ paging: { enabled: true, pageSize: 2 } }); + jest.spyOn(instance, 'pageIndex').mockReturnValue(Promise.resolve()); + const callbacks = createCallbacks(); + + await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 1 }); + + expect(callbacks.success).toHaveBeenCalledWith('Switch the view to page number 1.'); + }); + + it('passes the same default message to failure when executability fails', async () => { + const instance = await createGrid({ paging: { enabled: false } }); + const callbacks = createCallbacks(); + + await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 2 }); + + expect(callbacks.failure).toHaveBeenCalledWith('Switch the view to page number 2.'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/sorting.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/sorting.test.ts index 8237b4dde0f9..146230653f30 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/sorting.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/sorting.test.ts @@ -287,7 +287,7 @@ describe('clearSortingCommand', () => { it('returns failure when clearSorting throws', async () => { const instance = await createGrid(); jest.spyOn(instance, 'clearSorting').mockImplementation(() => { - throw new Error('boom'); + throw new Error('Error'); }); const callbacks = createCallbacks(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/defineGridCommand.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/defineGridCommand.ts new file mode 100644 index 000000000000..914b6ed5e5d0 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/defineGridCommand.ts @@ -0,0 +1,8 @@ +import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types'; +import type { ZodObject, ZodRawShape } from 'zod'; + +export function defineGridCommand>( + command: GridCommand, +): GridCommand { + return command; +} 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 new file mode 100644 index 000000000000..3408c2dc5c6d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts @@ -0,0 +1,93 @@ +import type { SearchOperation } from '@js/common/data.types'; +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from 'zod'; + +import { defineGridCommand } from './defineGridCommand'; + +// Runtime source of truth for the filter operators; `satisfies` ensures every +// entry is a valid `SearchOperation` (compile error if a typo or stale value). +const FILTER_OPS = [ + '=', '<>', '<', '<=', '>', '>=', + 'contains', 'notcontains', 'startswith', 'endswith', +] as const satisfies readonly SearchOperation[]; + +// Recursive filter expression shape mirroring the public `filterValue` API: +// basic: [field, op, value]; combine: [expr, 'and'|'or', expr]; negate: ['!', expr]. +type FilterExpr = | [string, SearchOperation, string | number | boolean | null] + | [FilterExpr, 'and' | 'or', FilterExpr] + | ['!', FilterExpr]; + +const filterOpSchema = z.enum(FILTER_OPS); + +const filterValueScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); + +const filterExprSchema: z.ZodType = z.lazy(() => z.union([ + z.tuple([z.string(), filterOpSchema, filterValueScalarSchema]), + z.tuple([filterExprSchema, z.enum(['and', 'or']), filterExprSchema]), + z.tuple([z.literal('!'), filterExprSchema]), +])); + +const filterValueCommandSchema = z.object({ + expression: filterExprSchema.nullable(), +}).strict(); + +export const filterValueCommand = defineGridCommand({ + name: 'filterValue', + description: 'Apply a filter expression to the grid. Replaces any existing filter; pass null for expression to clear. Expression forms: basic [dataField, op, value], combined [expr, "and"|"or", expr], negated ["!", expr]. The first element of a basic expression is the column dataField (not the caption). Supported ops: "=", "<>", "<", "<=", ">", ">=", "contains", "notcontains", "startswith", "endswith". To express "not and" / "not or", wrap the group in negation: ["!", [a, "and", b]].', + schema: filterValueCommandSchema, + execute: (component, { success, failure }) => (args): Promise => { + const defaultMessage = args.expression === null + ? 'Clear filter.' + : 'Apply a filter.'; + + try { + // Handles remote operations via data controller listening for the `filtering` change + component.option('filterValue', args.expression ?? undefined); + + return Promise.resolve(success(defaultMessage)); + } catch { + return Promise.resolve(failure(defaultMessage)); + } + }, +}); + +export const clearFilterCommand = defineGridCommand({ + name: 'clearFilter', + description: 'Clear all filters', + schema: z.object({}).strict(), + execute: (component, { success, failure }) => (): Promise => { + const defaultMessage = 'Clear filter.'; + + try { + // Handles remote operations via data controller listening for the `filtering` change + component.clearFilter(); + + return Promise.resolve(success(defaultMessage)); + } catch { + return Promise.resolve(failure(defaultMessage)); + } + }, +}); + +const searchingCommandSchema = z.object({ + text: z.string(), +}).strict(); + +export const searchingCommand = defineGridCommand({ + name: 'searching', + description: 'Set the global search text that filters rows across all searchable columns. Pass empty string to clear search.', + schema: searchingCommandSchema, + execute: (component, { success, failure }) => (args): Promise => { + const defaultMessage = args.text === '' + ? 'Clear search.' + : `Search for "${args.text}".`; + + try { + component.searchByText(args.text); + + return Promise.resolve(success(defaultMessage)); + } catch { + return Promise.resolve(failure(defaultMessage)); + } + }, +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts new file mode 100644 index 000000000000..98ba72ae3581 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/paging.ts @@ -0,0 +1,84 @@ +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { z } from 'zod'; + +import { defineGridCommand } from './defineGridCommand'; + +const pagingCommandSchema = z.object({ + enabled: z.boolean(), +}).strict(); + +export const pagingCommand = defineGridCommand({ + name: 'paging', + description: 'Enable or disable pagination', + schema: pagingCommandSchema, + execute: (component, { success, failure }) => (args): Promise => { + const defaultMessage = `Turn ${args.enabled ? 'on' : 'off'} pagination.`; + + try { + component.option('paging.enabled', args.enabled); + + return Promise.resolve(success(defaultMessage)); + } catch { + return Promise.resolve(failure(defaultMessage)); + } + }, +}); + +const pageSizeCommandSchema = z.object({ + // eslint-disable-next-line spellcheck/spell-checker + pageSize: z.number().int().nonnegative(), +}).strict(); + +export const pageSizeCommand = defineGridCommand({ + name: 'pageSize', + description: 'Change the number of rows per page. Pass 0 to show all rows on a single page.', + schema: pageSizeCommandSchema, + execute: (component, { success, failure }) => (args): Promise => { + const paging = component.option('paging'); + const defaultMessage = args.pageSize === 0 + ? 'Show all rows.' + : `Change page size to ${args.pageSize}.`; + + if (paging?.enabled === false) { + return Promise.resolve(failure(defaultMessage)); + } + + try { + component.pageSize(args.pageSize); + + return Promise.resolve(success(defaultMessage)); + } catch { + return Promise.resolve(failure(defaultMessage)); + } + }, +}); + +const pageIndexCommandSchema = z.object({ + // eslint-disable-next-line spellcheck/spell-checker + pageIndex: z.number().int().nonnegative(), +}).strict(); + +export const pageIndexCommand = defineGridCommand({ + name: 'pageIndex', + description: 'Navigate to a specific page (0-based: page 0 is first; pageIndex must be less than the total page count).', + schema: pageIndexCommandSchema, + execute: (component, { success, failure }) => async (args): Promise => { + const paging = component.option('paging'); + const dataController = component.getController('data'); + const defaultMessage = `Switch the view to page number ${args.pageIndex}.`; + + const isIndexValid = args.pageIndex < dataController.pageCount(); + + if (paging?.enabled === false || !isIndexValid) { + return failure(defaultMessage); + } + + try { + await component.pageIndex(args.pageIndex); + + return success(defaultMessage); + } catch { + return failure(defaultMessage); + } + }, +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts index 36264af47591..ed94d359bac9 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/sorting.ts @@ -1,16 +1,16 @@ -import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types'; +import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import { z } from 'zod'; +import { defineGridCommand } from './defineGridCommand'; + const sortingCommandSchema = z.object({ dataField: z.string(), sortOrder: z.enum(['asc', 'desc', 'none']), }).strict(); -type SortingCommandArgs = z.infer; - const getSortingDefaultMessage = ( - args: SortingCommandArgs, + args: z.infer, column: Column | undefined, ): string => { const columnName = column?.caption ?? args.dataField; @@ -24,11 +24,11 @@ const getSortingDefaultMessage = ( return `Sort data against "${columnName}" in ${sortOrder} order.`; }; -export const sortingCommand: GridCommand = { +export const sortingCommand = defineGridCommand({ name: 'sorting', - description: 'Sort a column ascending, descending, or remove its sort', + description: 'Sort a column ascending or descending. Pass sortOrder "none" to remove sorting from this column only (use clearSorting to remove sorting from all columns).', schema: sortingCommandSchema, - execute: (component, { success, failure }) => (args) => { + execute: (component, { success, failure }) => (args): Promise => { const columnsController = component.getController('columns'); const column: Column | undefined = columnsController.columnOption(args.dataField); const defaultMessage = getSortingDefaultMessage(args, column); @@ -46,13 +46,13 @@ export const sortingCommand: GridCommand = { return Promise.resolve(failure(defaultMessage)); } }, -}; +}); -export const clearSortingCommand: GridCommand = { +export const clearSortingCommand = defineGridCommand({ name: 'clearSorting', description: 'Remove sorting from all columns', schema: z.object({}).strict(), - execute: (component, { success, failure }) => () => { + execute: (component, { success, failure }) => (): Promise => { const defaultMessage = 'Clear sorting.'; try { @@ -64,4 +64,4 @@ export const clearSortingCommand: GridCommand = { return Promise.resolve(failure(defaultMessage)); } }, -}; +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts index 2428ddeed0aa..2cb721926f4c 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/types.ts @@ -1,5 +1,5 @@ import type { InternalGrid } from '@ts/grids/grid_core/m_types'; -import type { ZodObject, ZodRawShape } from 'zod'; +import type { z, ZodObject, ZodRawShape } from 'zod'; type CommandStatus = 'success' | 'failure' | 'aborted'; @@ -21,11 +21,22 @@ export type CommandExecutor = ( ...args: ArgsTuple ) => Promise; -export interface GridCommand { +// Empty schemas (no keys) collapse args to `undefined` so the executor +// signature becomes `() => Promise` for no-arg commands. +type CommandArgs> = keyof z.infer extends never + ? undefined + : z.infer; + +export interface GridCommand< + TSchema extends ZodObject = ZodObject, +> { name: string; description: string; - schema: ZodObject; - execute: (component: InternalGrid, callbacks: CommandCallbacks) => CommandExecutor; + schema: TSchema; + execute: ( + component: InternalGrid, + callbacks: CommandCallbacks, + ) => CommandExecutor>; } export interface Command {