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..a6dec272414e --- /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 to clear. 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 search panel text. 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..aab669cc8531 --- /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', + 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)', + 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..dcbd8b336c95 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', 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 4ce840e5fa62..1063942dd10f 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,9 +21,20 @@ 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>; }