Skip to content

Commit 249a9f0

Browse files
GridCore: add sorting grid command (DevExpress#33444)
1 parent 15220bf commit 249a9f0

6 files changed

Lines changed: 432 additions & 4 deletions

File tree

packages/devextreme/eslint.config.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,19 @@ export default [
403403
'spellcheck/spell-checker': 'off',
404404
},
405405
},
406+
// Spellcheck overrides for ai_assistant (Zod dependency)
407+
{
408+
files: ['js/__internal/grids/grid_core/ai_assistant/**/*'],
409+
rules: {
410+
'spellcheck/spell-checker': ['error', {
411+
...spellCheckConfig[0].rules['spellcheck/spell-checker'][1],
412+
skipWords: [
413+
...spellCheckConfig[0].rules['spellcheck/spell-checker'][1].skipWords,
414+
'Zod',
415+
],
416+
}],
417+
},
418+
},
406419
// Rules for js/__internal folder
407420
{
408421
files: ['js/__internal/**/*'],
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
expect,
6+
it,
7+
jest,
8+
} from '@jest/globals';
9+
import type { Properties } from '@js/ui/data_grid';
10+
import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types';
11+
import type { InternalGrid } from '@ts/grids/grid_core/m_types';
12+
13+
import {
14+
afterTest,
15+
beforeTest,
16+
createDataGrid,
17+
} from '../../../__tests__/__mock__/helpers/utils';
18+
import { clearSortingCommand, sortingCommand } from '../sorting';
19+
20+
const createCallbacks = (): {
21+
success: jest.Mock<(message?: string) => CommandResult>;
22+
failure: jest.Mock<(message?: string) => CommandResult>;
23+
} => ({
24+
success: jest.fn((message?: string) => ({ status: 'success' as const, message: message ?? '' })),
25+
failure: jest.fn((message?: string) => ({ status: 'failure' as const, message: message ?? '' })),
26+
});
27+
28+
const createGrid = async (
29+
options: Record<string, unknown> = {},
30+
): Promise<InternalGrid> => {
31+
const { instance } = await createDataGrid({
32+
dataSource: [
33+
{ id: 1, name: 'Alpha' },
34+
{ id: 2, name: 'Beta' },
35+
],
36+
columns: [
37+
{ dataField: 'id', dataType: 'number' },
38+
{ dataField: 'name', caption: 'Full Name', dataType: 'string' },
39+
],
40+
...options,
41+
} as unknown as Properties);
42+
return instance as unknown as InternalGrid;
43+
};
44+
45+
describe('sortingCommand', () => {
46+
beforeEach(() => beforeTest());
47+
afterEach(() => afterTest());
48+
49+
describe('schema', () => {
50+
it.each([
51+
['asc'],
52+
['desc'],
53+
['none'],
54+
])('accepts valid args with sortOrder "%s"', (sortOrder) => {
55+
expect(sortingCommand.schema.safeParse({
56+
dataField: 'name',
57+
sortOrder,
58+
}).success).toBe(true);
59+
});
60+
61+
it('rejects when dataField is missing', () => {
62+
expect(sortingCommand.schema.safeParse({
63+
sortOrder: 'asc',
64+
}).success).toBe(false);
65+
});
66+
67+
it('rejects when sortOrder is missing', () => {
68+
expect(sortingCommand.schema.safeParse({
69+
dataField: 'name',
70+
}).success).toBe(false);
71+
});
72+
73+
it('rejects when dataField is not a string', () => {
74+
expect(sortingCommand.schema.safeParse({
75+
dataField: 123,
76+
sortOrder: 'asc',
77+
}).success).toBe(false);
78+
});
79+
80+
it('rejects when sortOrder is not in the enum', () => {
81+
expect(sortingCommand.schema.safeParse({
82+
dataField: 'name',
83+
sortOrder: 'ascending',
84+
}).success).toBe(false);
85+
});
86+
87+
it('rejects unknown properties', () => {
88+
expect(sortingCommand.schema.safeParse({
89+
dataField: 'name',
90+
sortOrder: 'asc',
91+
extra: 1,
92+
}).success).toBe(false);
93+
});
94+
});
95+
96+
describe('execute', () => {
97+
it('returns failure and skips changeSortOrder when sorting.mode is "none"', async () => {
98+
const instance = await createGrid({ sorting: { mode: 'none' } });
99+
const columnsController = instance.getController('columns');
100+
const spy = jest.spyOn(columnsController, 'changeSortOrder');
101+
const callbacks = createCallbacks();
102+
103+
const result = await sortingCommand.execute(instance, callbacks)({
104+
dataField: 'name',
105+
sortOrder: 'asc',
106+
});
107+
108+
expect(result.status).toBe('failure');
109+
expect(callbacks.success).not.toHaveBeenCalled();
110+
expect(spy).not.toHaveBeenCalled();
111+
});
112+
113+
it('returns failure and skips changeSortOrder when column.allowSorting is false', async () => {
114+
const instance = await createGrid({
115+
columns: [
116+
{ dataField: 'id', dataType: 'number' },
117+
{
118+
dataField: 'name', caption: 'Full Name', dataType: 'string', allowSorting: false,
119+
},
120+
],
121+
});
122+
const columnsController = instance.getController('columns');
123+
const spy = jest.spyOn(columnsController, 'changeSortOrder');
124+
const callbacks = createCallbacks();
125+
126+
const result = await sortingCommand.execute(instance, callbacks)({
127+
dataField: 'name',
128+
sortOrder: 'asc',
129+
});
130+
131+
expect(result.status).toBe('failure');
132+
expect(callbacks.success).not.toHaveBeenCalled();
133+
expect(spy).not.toHaveBeenCalled();
134+
});
135+
136+
it('returns failure and skips changeSortOrder when dataField does not match any column', async () => {
137+
const instance = await createGrid();
138+
const columnsController = instance.getController('columns');
139+
const spy = jest.spyOn(columnsController, 'changeSortOrder');
140+
const callbacks = createCallbacks();
141+
142+
const result = await sortingCommand.execute(instance, callbacks)({
143+
dataField: 'unknown',
144+
sortOrder: 'asc',
145+
});
146+
147+
expect(result.status).toBe('failure');
148+
expect(callbacks.success).not.toHaveBeenCalled();
149+
expect(spy).not.toHaveBeenCalled();
150+
});
151+
152+
it('calls columnsController.changeSortOrder(column.index, sortOrder) exactly once', async () => {
153+
const instance = await createGrid();
154+
const columnsController = instance.getController('columns');
155+
const spy = jest.spyOn(columnsController, 'changeSortOrder');
156+
const callbacks = createCallbacks();
157+
158+
const result = await sortingCommand.execute(instance, callbacks)({
159+
dataField: 'name',
160+
sortOrder: 'asc',
161+
});
162+
163+
const nameColumn = columnsController.columnOption('name') as { index: number };
164+
expect(spy).toHaveBeenCalledTimes(1);
165+
expect(spy).toHaveBeenCalledWith(nameColumn.index, 'asc');
166+
expect(result.status).toBe('success');
167+
});
168+
169+
it('returns failure when changeSortOrder throws', async () => {
170+
const instance = await createGrid();
171+
const columnsController = instance.getController('columns');
172+
jest.spyOn(columnsController, 'changeSortOrder').mockImplementation(() => {
173+
throw new Error('Error changing sort order');
174+
});
175+
const callbacks = createCallbacks();
176+
177+
const result = await sortingCommand.execute(instance, callbacks)({
178+
dataField: 'name',
179+
sortOrder: 'asc',
180+
});
181+
182+
expect(result.status).toBe('failure');
183+
});
184+
});
185+
186+
describe('default message', () => {
187+
it('uses `Sort data against "[caption]" in ascending order.` for sortOrder "asc"', async () => {
188+
const instance = await createGrid();
189+
const callbacks = createCallbacks();
190+
191+
await sortingCommand.execute(instance, callbacks)({
192+
dataField: 'name',
193+
sortOrder: 'asc',
194+
});
195+
196+
expect(callbacks.success).toHaveBeenCalledWith(
197+
'Sort data against "Full Name" in ascending order.',
198+
);
199+
});
200+
201+
it('uses `Sort data against "[caption]" in descending order.` for sortOrder "desc"', async () => {
202+
const instance = await createGrid();
203+
const callbacks = createCallbacks();
204+
205+
await sortingCommand.execute(instance, callbacks)({
206+
dataField: 'name',
207+
sortOrder: 'desc',
208+
});
209+
210+
expect(callbacks.success).toHaveBeenCalledWith(
211+
'Sort data against "Full Name" in descending order.',
212+
);
213+
});
214+
215+
it('uses `Clear sorting against "[caption]".` for sortOrder "none"', async () => {
216+
const instance = await createGrid();
217+
const callbacks = createCallbacks();
218+
219+
await sortingCommand.execute(instance, callbacks)({
220+
dataField: 'name',
221+
sortOrder: 'none',
222+
});
223+
224+
expect(callbacks.success).toHaveBeenCalledWith(
225+
'Clear sorting against "Full Name".',
226+
);
227+
});
228+
229+
it('passes the same default message to failure when executability fails', async () => {
230+
const instance = await createGrid({ sorting: { mode: 'none' } });
231+
const callbacks = createCallbacks();
232+
233+
await sortingCommand.execute(instance, callbacks)({
234+
dataField: 'name',
235+
sortOrder: 'asc',
236+
});
237+
238+
expect(callbacks.failure).toHaveBeenCalledWith(
239+
'Sort data against "Full Name" in ascending order.',
240+
);
241+
});
242+
243+
it('falls back to the raw dataField when no column matches', async () => {
244+
const instance = await createGrid();
245+
const callbacks = createCallbacks();
246+
247+
await sortingCommand.execute(instance, callbacks)({
248+
dataField: 'unknown',
249+
sortOrder: 'asc',
250+
});
251+
252+
expect(callbacks.failure).toHaveBeenCalledWith(
253+
'Sort data against "unknown" in ascending order.',
254+
);
255+
});
256+
});
257+
});
258+
259+
describe('clearSortingCommand', () => {
260+
beforeEach(() => beforeTest());
261+
afterEach(() => afterTest());
262+
263+
describe('schema', () => {
264+
it('accepts an empty object', () => {
265+
expect(clearSortingCommand.schema.safeParse({}).success).toBe(true);
266+
});
267+
268+
it('rejects unknown properties', () => {
269+
expect(clearSortingCommand.schema.safeParse({
270+
extra: 1,
271+
}).success).toBe(false);
272+
});
273+
});
274+
275+
describe('execute', () => {
276+
it('calls grid instance clearSorting() exactly once', async () => {
277+
const instance = await createGrid();
278+
const spy = jest.spyOn(instance, 'clearSorting');
279+
const callbacks = createCallbacks();
280+
281+
const result = await clearSortingCommand.execute(instance, callbacks)();
282+
283+
expect(spy).toHaveBeenCalledTimes(1);
284+
expect(result.status).toBe('success');
285+
});
286+
287+
it('returns failure when clearSorting throws', async () => {
288+
const instance = await createGrid();
289+
jest.spyOn(instance, 'clearSorting').mockImplementation(() => {
290+
throw new Error('boom');
291+
});
292+
const callbacks = createCallbacks();
293+
294+
const result = await clearSortingCommand.execute(instance, callbacks)();
295+
296+
expect(result.status).toBe('failure');
297+
});
298+
});
299+
300+
describe('default message', () => {
301+
it('uses the literal `Clear sorting.`', async () => {
302+
const instance = await createGrid();
303+
const callbacks = createCallbacks();
304+
305+
await clearSortingCommand.execute(instance, callbacks)();
306+
307+
expect(callbacks.success).toHaveBeenCalledWith('Clear sorting.');
308+
});
309+
});
310+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { GridCommand } from '@ts/grids/grid_core/ai_assistant/types';
2+
import type { Column } from '@ts/grids/grid_core/columns_controller/types';
3+
import { z } from 'zod';
4+
5+
const sortingCommandSchema = z.object({
6+
dataField: z.string(),
7+
sortOrder: z.enum(['asc', 'desc', 'none']),
8+
}).strict();
9+
10+
type SortingCommandArgs = z.infer<typeof sortingCommandSchema>;
11+
12+
const getSortingDefaultMessage = (
13+
args: SortingCommandArgs,
14+
column: Column | undefined,
15+
): string => {
16+
const columnName = column?.caption ?? args.dataField;
17+
18+
if (args.sortOrder === 'none') {
19+
return `Clear sorting against "${columnName}".`;
20+
}
21+
22+
const sortOrder = args.sortOrder === 'asc' ? 'ascending' : 'descending';
23+
24+
return `Sort data against "${columnName}" in ${sortOrder} order.`;
25+
};
26+
27+
export const sortingCommand: GridCommand<SortingCommandArgs> = {
28+
name: 'sorting',
29+
description: 'Sort a column ascending, descending, or remove its sort',
30+
schema: sortingCommandSchema,
31+
execute: (component, { success, failure }) => (args) => {
32+
const columnsController = component.getController('columns');
33+
const column: Column | undefined = columnsController.columnOption(args.dataField);
34+
const defaultMessage = getSortingDefaultMessage(args, column);
35+
36+
if (!column || !columnsController.allowColumnSorting(column)) {
37+
return Promise.resolve(failure(defaultMessage));
38+
}
39+
40+
try {
41+
// Handles remote operations via data controller listening for the `sorting` change
42+
columnsController.changeSortOrder(column.index, args.sortOrder);
43+
44+
return Promise.resolve(success(defaultMessage));
45+
} catch {
46+
return Promise.resolve(failure(defaultMessage));
47+
}
48+
},
49+
};
50+
51+
export const clearSortingCommand: GridCommand = {
52+
name: 'clearSorting',
53+
description: 'Remove sorting from all columns',
54+
schema: z.object({}).strict(),
55+
execute: (component, { success, failure }) => () => {
56+
const defaultMessage = 'Clear sorting.';
57+
58+
try {
59+
// Handles remote operations via data controller listening for the `sorting` change
60+
component.clearSorting();
61+
62+
return Promise.resolve(success(defaultMessage));
63+
} catch {
64+
return Promise.resolve(failure(defaultMessage));
65+
}
66+
},
67+
};

0 commit comments

Comments
 (0)