diff --git a/packages/devextreme/js/__internal/core/ai_integration/templates/index.ts b/packages/devextreme/js/__internal/core/ai_integration/templates/index.ts index 14e854802efb..e03c75744f18 100644 --- a/packages/devextreme/js/__internal/core/ai_integration/templates/index.ts +++ b/packages/devextreme/js/__internal/core/ai_integration/templates/index.ts @@ -42,8 +42,7 @@ export const templates: PromptTemplates = { user: 'User prompt text: {{text}}. Dataset: {{data}}.', }, executeGridAssistant: { - // TODO: Implement system prompt when grid operations are ready - system: 'You are a helpful AI assistant for a data grid component. The user sends a natural language request describing an operation on the grid (e.g., sorting, filtering, grouping). You receive the user\'s message, a context object describing the current grid state, and a JSON schema describing the available commands and their arguments. Your task is to interpret the user\'s request and return a JSON object with one field: "actions" — an array of command objects, each with "name" (the command name) and "args" (an object of arguments matching the schema). Output must be a valid JSON string, directly parsable by JSON.parse. Do not include any markdown, formatting, or extra text — only the raw JSON object.', + system: 'You are a helpful AI assistant for a data grid component. The user sends a natural language request describing an operation on the grid (e.g., sorting, filtering, grouping). You receive the user\'s message, a context object describing the current grid state, and a JSON schema describing the available commands and their arguments. Your task is to interpret the user\'s request and return a JSON object with one field: "actions" — an array of command objects, each with "name" (the command name) and "args" (an object of arguments matching the schema). Output must be a valid JSON string, directly parsable by JSON.parse. Do not include any markdown, formatting, or extra text — only the raw JSON object.\n\nCRITICAL RULE FOR OPTIONAL ARGUMENTS: If an optional argument is not used, the field MUST be ENTIRELY ABSENT from the JSON object — the key must not appear at all. NEVER emit an optional field with value null, empty string "", empty array [], or any placeholder. This rule overrides any instinct to "complete" the object — omitted IS the value.', user: 'User request: {{text}}. Grid context: {{context}}.', }, }; diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/summary.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/summary.test.ts index a6739f359852..d05827e0895a 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/summary.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/summary.test.ts @@ -57,6 +57,7 @@ describe('summaryCommand', () => { ])('accepts summaryType "%s"', (summaryType) => { expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount', summaryType }], + groupItems: [], }).success).toBe(true); }); @@ -67,34 +68,51 @@ describe('summaryCommand', () => { }).success).toBe(true); }); - it('accepts empty object (executability layer rejects it)', () => { - expect(summaryCommand.schema.safeParse({}).success).toBe(true); + it('rejects empty object (totalItems and groupItems are required)', () => { + expect(summaryCommand.schema.safeParse({}).success).toBe(false); + }); + + it('rejects when totalItems is omitted', () => { + expect(summaryCommand.schema.safeParse({ + groupItems: [{ column: 'amount', summaryType: 'avg' }], + }).success).toBe(false); + }); + + it('rejects when groupItems is omitted', () => { + expect(summaryCommand.schema.safeParse({ + totalItems: [{ column: 'amount', summaryType: 'sum' }], + }).success).toBe(false); }); it('rejects an unknown summaryType (including "custom")', () => { expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount', summaryType: 'custom' }], + groupItems: [], }).success).toBe(false); expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount', summaryType: 'median' }], + groupItems: [], }).success).toBe(false); }); it('rejects when an item is missing column', () => { expect(summaryCommand.schema.safeParse({ totalItems: [{ summaryType: 'sum' }], + groupItems: [], }).success).toBe(false); }); it('rejects when an item is missing summaryType', () => { expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount' }], + groupItems: [], }).success).toBe(false); }); it('rejects unknown properties on the root', () => { expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount', summaryType: 'sum' }], + groupItems: [], extra: 1, }).success).toBe(false); }); @@ -102,6 +120,7 @@ describe('summaryCommand', () => { it('rejects unknown properties on the item', () => { expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount', summaryType: 'sum', extra: 1 }], + groupItems: [], }).success).toBe(false); }); @@ -113,11 +132,13 @@ describe('summaryCommand', () => { showInColumn: 'name', displayFormat: 'Sum: {0}', }], + groupItems: [], }).success).toBe(true); }); it('accepts showInColumn, displayFormat, showInGroupFooter, alignByColumn on groupItems', () => { expect(summaryCommand.schema.safeParse({ + totalItems: [], groupItems: [{ column: 'amount', summaryType: 'avg', @@ -136,6 +157,7 @@ describe('summaryCommand', () => { summaryType: 'sum', showInGroupFooter: true, }], + groupItems: [], }).success).toBe(false); }); @@ -146,47 +168,41 @@ describe('summaryCommand', () => { summaryType: 'sum', alignByColumn: true, }], + groupItems: [], }).success).toBe(false); }); it('rejects when showInColumn is not a string', () => { expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount', summaryType: 'sum', showInColumn: 1 }], + groupItems: [], }).success).toBe(false); }); it('rejects when displayFormat is not a string', () => { expect(summaryCommand.schema.safeParse({ totalItems: [{ column: 'amount', summaryType: 'sum', displayFormat: 5 }], + groupItems: [], }).success).toBe(false); }); it('rejects when showInGroupFooter is not a boolean', () => { expect(summaryCommand.schema.safeParse({ + totalItems: [], groupItems: [{ column: 'amount', summaryType: 'sum', showInGroupFooter: 'yes' }], }).success).toBe(false); }); it('rejects when alignByColumn is not a boolean', () => { expect(summaryCommand.schema.safeParse({ + totalItems: [], groupItems: [{ column: 'amount', summaryType: 'sum', alignByColumn: 1 }], }).success).toBe(false); }); }); describe('execute', () => { - it('returns failure when both totalItems and groupItems are empty/omitted', async () => { - const instance = await createGrid(); - const optionSpy = jest.spyOn(instance, 'option'); - const callbacks = createCallbacks(); - - const result = await summaryCommand.execute(instance, callbacks)({}); - - expect(result.status).toBe('failure'); - expect(optionSpy).not.toHaveBeenCalledWith('summary', expect.anything()); - }); - - it('returns failure when both arrays are explicitly empty', async () => { + it('returns failure when both arrays are empty', async () => { const instance = await createGrid(); const optionSpy = jest.spyOn(instance, 'option'); const callbacks = createCallbacks(); @@ -210,6 +226,7 @@ describe('summaryCommand', () => { { column: 'amount', summaryType: 'sum' }, { column: 'unknown', summaryType: 'avg' }, ], + groupItems: [], }); expect(result.status).toBe('failure'); @@ -222,6 +239,7 @@ describe('summaryCommand', () => { const callbacks = createCallbacks(); const result = await summaryCommand.execute(instance, callbacks)({ + totalItems: [], groupItems: [{ column: 'unknown', summaryType: 'sum' }], }); @@ -240,6 +258,7 @@ describe('summaryCommand', () => { summaryType: 'sum', showInColumn: 'unknown', }], + groupItems: [], }); expect(result.status).toBe('failure'); @@ -263,21 +282,6 @@ describe('summaryCommand', () => { expect(result.status).toBe('success'); }); - it('passes empty arrays when one of the inputs is omitted', async () => { - const instance = await createGrid(); - const optionSpy = jest.spyOn(instance, 'option'); - const callbacks = createCallbacks(); - - await summaryCommand.execute(instance, callbacks)({ - totalItems: [{ column: 'amount', summaryType: 'sum' }], - }); - - expect(optionSpy).toHaveBeenCalledWith('summary', { - totalItems: [{ column: 'amount', summaryType: 'sum' }], - groupItems: [], - }); - }); - it('returns failure when option throws', async () => { const instance = await createGrid(); const realOption = instance.option.bind(instance); @@ -291,6 +295,7 @@ describe('summaryCommand', () => { const result = await summaryCommand.execute(instance, callbacks)({ totalItems: [{ column: 'amount', summaryType: 'sum' }], + groupItems: [], }); expect(result.status).toBe('failure'); @@ -304,6 +309,7 @@ describe('summaryCommand', () => { await summaryCommand.execute(instance, callbacks)({ totalItems: [{ column: 'amount', summaryType: 'sum' }], + groupItems: [], }); expect(callbacks.success).toHaveBeenCalledWith( @@ -316,6 +322,7 @@ describe('summaryCommand', () => { const callbacks = createCallbacks(); await summaryCommand.execute(instance, callbacks)({ + totalItems: [], groupItems: [{ column: 'amount', summaryType: 'avg' }], }); @@ -336,6 +343,7 @@ describe('summaryCommand', () => { await summaryCommand.execute(instance, callbacks)({ totalItems: [{ column: 'amount', summaryType: summaryType as 'sum' | 'min' | 'max' | 'avg' | 'count' }], + groupItems: [], }); expect(callbacks.success).toHaveBeenCalledWith( @@ -349,6 +357,7 @@ describe('summaryCommand', () => { await summaryCommand.execute(instance, callbacks)({ totalItems: [{ column: 'name', summaryType: 'count' }], + groupItems: [], }); // 'name' has no explicit caption — DevExtreme auto-derives "Name" @@ -366,6 +375,7 @@ describe('summaryCommand', () => { { column: 'amount', summaryType: 'sum' }, { column: 'amount', summaryType: 'avg' }, ], + groupItems: [], }); expect(callbacks.success).toHaveBeenCalledWith( @@ -391,7 +401,10 @@ describe('summaryCommand', () => { const instance = await createGrid(); const callbacks = createCallbacks(); - await summaryCommand.execute(instance, callbacks)({}); + await summaryCommand.execute(instance, callbacks)({ + totalItems: [], + groupItems: [], + }); expect(callbacks.failure).toHaveBeenCalledWith('Display data summaries.'); }); @@ -403,6 +416,7 @@ describe('summaryCommand', () => { // Single item with unresolved column → failure path, item-list message await summaryCommand.execute(instance, callbacks)({ totalItems: [{ column: 'unknown', summaryType: 'sum' }], + groupItems: [], }); expect(callbacks.failure).toHaveBeenCalledWith( @@ -427,7 +441,7 @@ describe('clearSummaryCommand', () => { }); describe('execute', () => { - it('calls component.option("summary", { groupItems: undefined, totalItems: undefined }) on success', async () => { + it('calls component.option("summary", { groupItems: [], totalItems: [] }) on success', async () => { const instance = await createGrid(); const optionSpy = jest.spyOn(instance, 'option'); const callbacks = createCallbacks(); @@ -435,8 +449,8 @@ describe('clearSummaryCommand', () => { const result = await clearSummaryCommand.execute(instance, callbacks)(); expect(optionSpy).toHaveBeenCalledWith('summary', { - groupItems: undefined, - totalItems: undefined, + groupItems: [], + totalItems: [], }); expect(result.status).toBe('success'); }); diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts index ad69d28c0f3c..2dbef50759bb 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts @@ -23,8 +23,8 @@ const groupItemSchema = z.object({ }).strict(); const summaryCommandSchema = z.object({ - totalItems: z.array(totalItemSchema).optional(), - groupItems: z.array(groupItemSchema).optional(), + totalItems: z.array(totalItemSchema), + groupItems: z.array(groupItemSchema), }).strict(); type TotalItem = z.infer; @@ -69,20 +69,19 @@ const buildDefaultMessage = ( export const summaryCommand = defineGridCommand({ name: 'summary', - description: 'Configure column summaries. totalItems aggregate the entire data set; groupItems aggregate within each group. Provide at least one item across the two arrays — use clearSummary to remove all summaries. Replaces existing summaries entirely; pre-merge with the grid\'s current summary items if you intend to add rather than replace.\n' + description: 'Configure column summaries. totalItems aggregate the entire data set; groupItems aggregate within each group. Replaces the configuration entirely — both kinds are written every call. ALWAYS provide BOTH totalItems and groupItems; pass an explicit empty array [] for a kind to clear it. To keep one kind unchanged, copy its current items from the grid context into your args. To ADD items, pre-merge with the kind\'s current items. To REMOVE a specific item, pass the kind\'s remaining items (and copy the other kind unchanged). At least one kind must be non-empty. Use clearSummary only when EVERY summary should be removed.\n' + 'Each item supports:\n' - + '- column (required): dataField of the column to aggregate.\n' + + '- column (required): dataField of the column whose VALUES are aggregated. Phrases like "sum of X", "average X", "total X", "summary for X" → column="X" (X is what gets aggregated).\n' + '- summaryType (required): one of "sum", "min", "max", "avg", "count".\n' - + '- showInColumn (optional): dataField of the column under which the summary value is rendered. For totalItems, controls which column\'s footer cell shows the value. For groupItems, used when showInGroupFooter=true or alignByColumn=true to pick the column the value is shown under. Must match an existing column.\n' + + '- showInColumn (optional): dataField of the column where the value is DISPLAYED (not aggregated). OMIT unless the user explicitly names a second column with a phrase like "show in Y", "display under Y", "in the Y column" → showInColumn="Y". Example: "sum of Amount in the SaleDate column" → column="Amount", showInColumn="SaleDate". One column mentioned → OMIT. For totalItems, controls which footer cell shows the value. For groupItems, showInColumn has effect ONLY when paired with showInGroupFooter=true OR alignByColumn=true — so whenever you set showInColumn on a group item, you MUST also set alignByColumn=true (the default pairing), unless the user explicitly asked for footer placement (then set showInGroupFooter=true instead).\n' + '- displayFormat (optional): format template for the rendered value. Placeholders: "{0}" — the formatted summary value; "{1}" — the parent column\'s caption (for group items only resolvable when showInColumn is specified). Example: "Sum: {0}" or "{1}: {0}".\n' + 'Group items additionally support:\n' - + '- showInGroupFooter (optional, default false): render in the group footer instead of the group row.\n' - + '- alignByColumn (optional, default false): when false, group summary items are listed in parentheses after the group row header. When true, items are aligned by their columns within the group row.', + + '- showInGroupFooter (optional): OMIT this field unless the user explicitly requests the group footer area. Default behavior renders the summary in the group row (the header that displays the group value). Set to true ONLY when the user explicitly says "group footer", "below the group", or "in the footer". Requests like "in the header", "in the group row", "next to the group name", or no placement mention at all → OMIT (do not set to false either; just omit the field).\n' + + '- alignByColumn (optional): OMIT this field unless the user explicitly requests column-aligned layout. Default behavior lists items in parentheses after the group row caption (e.g. "Category: Bikes (Sum: 100, Count: 5)"). Set to true ONLY when the user explicitly asks to "align by column", "show under each column", or "align with the column". Otherwise OMIT.', schema: summaryCommandSchema, execute: (component, { success, failure }) => (args): Promise => { const columnsController = component.getController('columns'); - const totalItems = args.totalItems ?? []; - const groupItems = args.groupItems ?? []; + const { totalItems, groupItems } = args; const allItems: SummaryItem[] = [...totalItems, ...groupItems]; const defaultMessage = buildDefaultMessage(totalItems, groupItems, columnsController); @@ -108,10 +107,7 @@ export const summaryCommand = defineGridCommand({ } try { - component.option('summary', { - totalItems: args.totalItems ?? [], - groupItems: args.groupItems ?? [], - }); + component.option('summary', { totalItems, groupItems }); return Promise.resolve(success(defaultMessage)); } catch { @@ -122,15 +118,15 @@ export const summaryCommand = defineGridCommand({ export const clearSummaryCommand = defineGridCommand({ name: 'clearSummary', - description: 'Remove all summary items.', + description: 'Remove ALL summary items. Do NOT call this for partial removals. Use only when every summary should be cleared (both totalItems and groupItems). To remove a subset — e.g., clear only totalItems while keeping groupItems (or vice versa), or drop a specific item — call the summary command with the items that should remain, since summary replaces existing summaries entirely.', schema: z.object({}).strict(), execute: (component, { success, failure }) => (): Promise => { const defaultMessage = 'Clear column summaries.'; try { component.option('summary', { - groupItems: undefined, - totalItems: undefined, + groupItems: [], + totalItems: [], }); return Promise.resolve(success(defaultMessage)); 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 index 5921fc034098..777f93ff5b20 100644 --- 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 @@ -318,7 +318,7 @@ describe('pageIndexCommand', () => { await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 1 }); - expect(callbacks.success).toHaveBeenCalledWith('Switch the view to page number 1.'); + expect(callbacks.success).toHaveBeenCalledWith('Switch the view to page number 2.'); }); it('passes the same default message to failure when executability fails', async () => { @@ -327,7 +327,7 @@ describe('pageIndexCommand', () => { await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 2 }); - expect(callbacks.failure).toHaveBeenCalledWith('Switch the view to page number 2.'); + expect(callbacks.failure).toHaveBeenCalledWith('Switch the view to page number 3.'); }); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts index 7b8e5e8fd29b..6b382a06f4d7 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/selection.test.ts @@ -271,8 +271,8 @@ describe('selectByIndexesCommand', () => { afterEach(() => afterTest()); describe('schema', () => { - it('accepts an array of non-negative integers', () => { - expect(selectByIndexesCommand.schema.safeParse({ indexes: [0, 1, 2] }).success).toBe(true); + it('accepts an array of positive integers', () => { + expect(selectByIndexesCommand.schema.safeParse({ indexes: [1, 2, 3] }).success).toBe(true); }); it('rejects when indexes is missing', () => { @@ -283,6 +283,10 @@ describe('selectByIndexesCommand', () => { expect(selectByIndexesCommand.schema.safeParse({ indexes: [] }).success).toBe(false); }); + it('rejects zero (indexes are 1-based)', () => { + expect(selectByIndexesCommand.schema.safeParse({ indexes: [0] }).success).toBe(false); + }); + it('rejects negative indexes', () => { expect(selectByIndexesCommand.schema.safeParse({ indexes: [-1] }).success).toBe(false); }); @@ -293,7 +297,7 @@ describe('selectByIndexesCommand', () => { it('rejects unknown properties', () => { expect(selectByIndexesCommand.schema.safeParse({ - indexes: [0], + indexes: [1], extra: 1, }).success).toBe(false); }); @@ -316,9 +320,9 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); const callbacks = createCallbacks(); - // Three rows in createGrid; index 99 has no row on the current page. + // Three rows in createGrid; 1-based index 100 has no row on the current page. const result = await selectByIndexesCommand.execute(instance, callbacks)({ - indexes: [0, 99], + indexes: [1, 100], }); expect(result.status).toBe('failure'); @@ -327,7 +331,7 @@ describe('selectByIndexesCommand', () => { it('returns failure when any index points at a non-data row (e.g. group row)', async () => { // Grouping by `name` produces group rows interleaved with data rows. - // Index 0 is a group row → command must reject the entire set. + // 1-based index 1 (→ 0 after normalization) is a group row → command rejects the entire set const instance = await createGrid({ columns: [ { dataField: 'id', dataType: 'number' }, @@ -337,18 +341,18 @@ describe('selectByIndexesCommand', () => { const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes'); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [0] }); + const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1] }); expect(result.status).toBe('failure'); expect(selectSpy).not.toHaveBeenCalled(); }); - it('calls selectRowsByIndexes with indexes on success', async () => { + it('normalizes 1-based input to 0-based when calling selectRowsByIndexes', async () => { const instance = await createGrid(); const selectSpy = jest.spyOn(instance, 'selectRowsByIndexes').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [0, 2] }); + const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3] }); expect(selectSpy).toHaveBeenCalledWith([0, 2]); expect(result.status).toBe('success'); @@ -361,7 +365,7 @@ describe('selectByIndexesCommand', () => { }); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [0] }); + const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1] }); expect(result.status).toBe('failure'); }); @@ -372,7 +376,7 @@ describe('selectByIndexesCommand', () => { .mockReturnValue(Promise.reject(new Error('Error')) as never); const callbacks = createCallbacks(); - const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [0] }); + const result = await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1] }); expect(result.status).toBe('failure'); }); @@ -384,7 +388,7 @@ describe('selectByIndexesCommand', () => { jest.spyOn(instance, 'selectRowsByIndexes').mockReturnValue(Promise.resolve([]) as never); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [0, 2] }); + await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1, 3] }); expect(callbacks.success).toHaveBeenCalledWith('Select row(s) number 1, 3 on the current page.'); }); @@ -393,7 +397,7 @@ describe('selectByIndexesCommand', () => { const instance = await createGrid({ selection: { mode: 'none' } }); const callbacks = createCallbacks(); - await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [0] }); + await selectByIndexesCommand.execute(instance, callbacks)({ indexes: [1] }); expect(callbacks.failure).toHaveBeenCalledWith('Select row(s) number 1 on the current page.'); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts index 6ee03c216f9c..a9a10e85a972 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/columns.ts @@ -11,7 +11,7 @@ const columnsVisibilityCommandSchema = z.object({ export const columnsVisibilityCommand = defineGridCommand({ name: 'columnsVisibility', - description: 'Show or hide a column. Requires columnChooser.enabled to be true on the grid.', + description: 'Show or hide a column.', schema: columnsVisibilityCommandSchema, execute: (component, { success, failure }) => (args): Promise => { const columnsController = component.getController('columns'); @@ -48,7 +48,7 @@ const columnsReorderCommandSchema = z.object({ export const columnsReorderCommand = defineGridCommand({ name: 'columnsReorder', - description: 'Move a column to a new visible position. visibleIndex is the 0-based target slot among visible columns. Requires allowColumnReordering to be true on the grid.', + description: 'Move a column to a new visible position. visibleIndex is the 0-based target slot among visible columns. When two columns share the same visibleIndex, the moved column is placed before the existing one in that slot. Therefore, to place a column AFTER a column at index N, set visibleIndex to N+1. To place it BEFORE a column at index N, set visibleIndex to N. Example: columns are Product(0), Amount(1), Region(2), Sector(3), SaleDate(4), Customer(5). To move Product between SaleDate and Customer, set visibleIndex=5.', schema: columnsReorderCommandSchema, execute: (component, { success, failure }) => (args): Promise => { const columnsController = component.getController('columns'); @@ -82,7 +82,7 @@ const columnsPinningCommandSchema = z.object({ export const columnsPinningCommand = defineGridCommand({ name: 'columnsPinning', - description: 'Pin a column to the left or right edge, or unpin it. fixedPosition is optional: when omitted with fixed=true, the grid resolves it to "left" for LTR layouts and "right" for RTL. Ignored when fixed=false. Requires columnFixing.enabled to be true on the grid.', + description: 'Pin a column to the left or right edge, or unpin it. fixedPosition is optional: when omitted with fixed=true, the grid resolves it to "left" for LTR layouts and "right" for RTL. Ignored when fixed=false.', schema: columnsPinningCommandSchema, execute: (component, { success, failure }) => (args): Promise => { const columnsController = component.getController('columns'); @@ -119,7 +119,7 @@ const columnsResizeCommandSchema = z.object({ export const columnsResizeCommand = defineGridCommand({ name: 'columnsResize', - description: 'Resize a column. Pass a number for pixel width, or a string for CSS dimensions ("auto", "50%", "120px"). Requires allowColumnResizing to be true on the grid.', + description: 'Resize a column. Pass a number for pixel width, or a string for CSS dimensions ("auto", "50%", "120px").', schema: columnsResizeCommandSchema, execute: (component, { success, failure }) => (args): Promise => { const columnsController = component.getController('columns'); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts index 7629966d552b..c0638fb94ee5 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/focus.ts @@ -48,7 +48,7 @@ const focusRowByKeyCommandSchema = z.object({ export const focusRowByKeyCommand = defineGridCommand({ name: 'focusRowByKey', - description: 'Focus a specific row by its key value. The key matches the grid\'s keyExpr or the underlying store\'s key — pass a string or number for a single-field key, or an array of {field, value} pairs for a composite key. Requires focusedRowEnabled to be true on the grid.', + description: 'Focus a specific row by its key value. The key matches the grid\'s keyExpr or the underlying store\'s key — pass a string or number for a single-field key, or an array of {field, value} pairs for a composite key.', schema: focusRowByKeyCommandSchema, execute: (component, { success, failure }) => async (args): Promise => { const defaultMessage = 'Focus row.'; 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 index 21457eee2739..d5a31a1df4d5 100644 --- 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 @@ -60,12 +60,12 @@ const pageIndexCommandSchema = z.object({ 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).', + description: 'Navigate to a specific page. If the user asks to set pageIndex, it is 0-based: page 0 is the first; pageIndex must be less than the total page count. But if the user asks to change the page number, it is 1-based. So the first page has pageIndex=0, the fifth page has pageIndex=4.', 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 defaultMessage = `Switch the view to page number ${args.pageIndex + 1}.`; const isIndexValid = args.pageIndex < dataController.pageCount(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts index 24df92995740..1d8a5402823d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/selection.ts @@ -44,16 +44,15 @@ export const selectByKeysCommand = defineGridCommand({ }); const selectByIndexesCommandSchema = z.object({ - // eslint-disable-next-line spellcheck/spell-checker - indexes: z.array(z.number().int().nonnegative()).min(1), + indexes: z.array(z.number().int().positive()).min(1), }).strict(); export const selectByIndexesCommand = defineGridCommand({ name: 'selectByIndexes', - description: 'Select rows by their 0-based indexes within the current page. Index 0 is the first row on the visible page; group/header rows are not selectable. To select rows that are not on the current page, use selectByKeys, or call pageIndex first to switch the page.', + description: 'Select rows by their 1-based indexes within the current page. Index 1 is the first row on the visible page; group/header rows are not selectable. To select rows that are not on the current page, use selectByKeys, or call pageIndex first to switch the page.', schema: selectByIndexesCommandSchema, execute: (component, { success, failure }) => async (args): Promise => { - const rowIndexes = args.indexes.map((index) => index + 1).join(', '); + const rowIndexes = args.indexes.join(', '); const defaultMessage = `Select row(s) number ${rowIndexes} on the current page.`; if (component.option('selection.mode') === 'none') { @@ -61,7 +60,8 @@ export const selectByIndexesCommand = defineGridCommand({ } const items = component.getController('data').items(); - const allIndexesValid = args.indexes.every( + const normalizedRowIndexes = args.indexes.map((index) => index - 1); + const allIndexesValid = normalizedRowIndexes.every( (index) => items[index]?.rowType === 'data', ); @@ -70,7 +70,7 @@ export const selectByIndexesCommand = defineGridCommand({ } try { - await component.selectRowsByIndexes(args.indexes); + await component.selectRowsByIndexes(normalizedRowIndexes); return success(defaultMessage); } catch { @@ -123,7 +123,7 @@ export const deselectAllCommand = defineGridCommand({ export const clearSelectionCommand = defineGridCommand({ name: 'clearSelection', - description: 'Clear selection of all rows on all pages.', + description: 'Clear selection of all rows across all pages, regardless of selection.selectAllMode. To clear selection only on the current page, use deselectAll instead (it respects selection.selectAllMode = "page").', schema: z.object({}).strict(), execute: (component, { success, failure }) => async (): Promise => { const defaultMessage = 'Clear selection.'; diff --git a/packages/devextreme/testing/helpers/stubs/zodStub.js b/packages/devextreme/testing/helpers/stubs/zodStub.js index cb64b2851956..db6dbc38bba5 100644 --- a/packages/devextreme/testing/helpers/stubs/zodStub.js +++ b/packages/devextreme/testing/helpers/stubs/zodStub.js @@ -27,6 +27,7 @@ int: function() { return z; }, // eslint-disable-next-line spellcheck/spell-checker nonnegative: function() { return z; }, + positive: function() { return z; }, min: function() { return z; }, max: function() { return z; }, // validation