Skip to content

Commit b7f09f3

Browse files
AI Assistant: add filter value to command default message (DevExpress#33817)
1 parent ff8e184 commit b7f09f3

7 files changed

Lines changed: 77 additions & 16 deletions

File tree

packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_index.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@
5959
gap: $ai-chat-message-content-gap;
6060
}
6161

62+
.dx-ai-chat__action-list-item-text {
63+
display: -webkit-box;
64+
-webkit-line-clamp: $ai-chat-action-list-item-text-max-lines;
65+
-webkit-box-orient: vertical;
66+
overflow: hidden;
67+
}
68+
6269
.dx-ai-chat__message-progressbar {
6370
position: absolute;
6471
left: 0;

packages/devextreme-scss/scss/widgets/base/gridBase/layout/aiChat/_variables.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ $ai-chat-message-gap: 8px;
22
$ai-chat-message-content-gap: 4px;
33
$ai-chat-message-width: 280px;
44
$ai-chat-message-regenerate-button-border-radius: 4px;
5+
$ai-chat-action-list-item-text-max-lines: 2;

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/filtering.test.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -338,11 +338,15 @@ describe('filterValueCommand', () => {
338338
expect(result.status).toBe('success');
339339
});
340340

341-
it('returns failure when option throws', async () => {
341+
it('returns failure when applying the filter throws', async () => {
342342
const instance = await createGrid();
343-
jest.spyOn(instance, 'option').mockImplementationOnce(() => {
344-
throw new Error('Error');
345-
});
343+
const original = instance.option.bind(instance) as (...a: unknown[]) => unknown;
344+
jest.spyOn(instance, 'option').mockImplementation(((...args: unknown[]) => {
345+
if (args[0] === 'filterValue' && args.length > 1) {
346+
throw new Error('Error');
347+
}
348+
return original(...args);
349+
}) as never);
346350
const callbacks = createCallbacks();
347351

348352
const result = await filterValueCommand.execute(instance, callbacks)({
@@ -450,15 +454,15 @@ describe('filterValueCommand', () => {
450454
});
451455

452456
describe('default message', () => {
453-
it('uses `Apply a filter.` when expression is set', async () => {
457+
it('appends the human-readable filter text when expression is set', async () => {
454458
const instance = await createGrid();
455459
const callbacks = createCallbacks();
456460

457461
await filterValueCommand.execute(instance, callbacks)({
458462
expression: singleBasic('name', '=', 'Alpha'),
459463
});
460464

461-
expect(callbacks.success).toHaveBeenCalledWith('Apply a filter.');
465+
expect(callbacks.success).toHaveBeenCalledWith("Apply a filter: [Name] Equals 'Alpha'.");
462466
});
463467

464468
it('uses `Clear filter.` when expression is null', async () => {
@@ -472,18 +476,22 @@ describe('filterValueCommand', () => {
472476
expect(callbacks.success).toHaveBeenCalledWith('Clear filter.');
473477
});
474478

475-
it('passes the same default message to failure when executability fails', async () => {
479+
it('passes the same readable default message to failure when applying the filter fails', async () => {
476480
const instance = await createGrid();
477-
jest.spyOn(instance, 'option').mockImplementationOnce(() => {
478-
throw new Error('Error');
479-
});
481+
const original = instance.option.bind(instance) as (...a: unknown[]) => unknown;
482+
jest.spyOn(instance, 'option').mockImplementation(((...args: unknown[]) => {
483+
if (args[0] === 'filterValue' && args.length > 1) {
484+
throw new Error('Error');
485+
}
486+
return original(...args);
487+
}) as never);
480488
const callbacks = createCallbacks();
481489

482490
await filterValueCommand.execute(instance, callbacks)({
483491
expression: singleBasic('name', '=', 'Alpha'),
484492
});
485493

486-
expect(callbacks.failure).toHaveBeenCalledWith('Apply a filter.');
494+
expect(callbacks.failure).toHaveBeenCalledWith("Apply a filter: [Name] Equals 'Alpha'.");
487495
});
488496
});
489497
});

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { SearchOperation } from '@js/common/data.types';
22
import type { BasicFilterExpr, FilterExprNode, FilterExprTree } from '@js/common/grids';
3+
import { when } from '@js/core/utils/deferred';
4+
import { isDefined } from '@js/core/utils/type';
35
import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types';
46
import type { InternalGrid } from '@ts/grids/grid_core/m_types';
57
import { z } from 'zod';
@@ -111,12 +113,28 @@ function convertFilterExprToArray(
111113
return walk(tree.rootId);
112114
}
113115

116+
const getFilterSuccessMessage = async (
117+
component: InternalGrid,
118+
filterValue: FilterExprArray,
119+
): Promise<string> => {
120+
try {
121+
const filterText: string = await when(
122+
// Custom filter operations are omitted as not supported in command
123+
component.getView('filterPanelView').getFilterText(filterValue, []),
124+
);
125+
126+
return `Apply a filter: ${filterText}.`;
127+
} catch {
128+
return 'Apply a filter.';
129+
}
130+
};
131+
114132
export const filterValueCommand = defineGridCommand({
115133
name: 'filterValue',
116134
description: 'Apply a filter expression to the grid. Replaces any existing filter; pass null for expression to clear. The expression is a flat node list: {"rootId":id,"nodes":[...]}. Each node is {"id":<unique string like "n1">,"expr":<expression>}, where "expr" is one of: basic {"type":"basic","field":dataField,"operator":op,"value":val}, combined {"type":"combined","combiner":"and"|"or","leftId":nodeId,"rightId":nodeId}, negated {"type":"negated","expressionId":nodeId}. "rootId" MUST be the "id" of the outermost node (the top of the expression tree) and must match one of the node ids exactly — never invent a value like "root". Every "leftId"/"rightId"/"expressionId" must also match a node "id". Ids must be unique and must not form cycles. The "field" is the column dataField (not the caption). Supported operators: "=", "<>", "<", "<=", ">", ">=", "contains", "notcontains", "startswith", "endswith". DATE VALUES: When a value is a date or datetime, always use "YYYY-MM-DDTHH:mm:ss" format without timezone suffix, e.g. "2024-05-10T00:00:00" for midnight or "2024-05-10T14:30:00" for a specific time. Always include the "T" and time part. Do NOT use date-only format like "2024-05-10" without time. Do NOT append "Z" or any timezone offset unless the user explicitly requests it. Do NOT use natural language for dates. To express "not and" / "not or", add a negated node whose expressionId points at a combined node. Example for name = "Alpha" AND age > 10 (rootId is "n3", the combined node): {"rootId":"n3","nodes":[{"id":"n1","expr":{"type":"basic","field":"name","operator":"=","value":"Alpha"}},{"id":"n2","expr":{"type":"basic","field":"age","operator":">","value":10}},{"id":"n3","expr":{"type":"combined","combiner":"and","leftId":"n1","rightId":"n2"}}]}.',
117135
schema: filterValueCommandSchema,
118-
execute: (component, { success, failure }) => (args): Promise<CommandResult> => {
119-
const defaultMessage = args.expression === null
136+
execute: (component, { success, failure }) => async (args): Promise<CommandResult> => {
137+
let defaultMessage = args.expression === null
120138
? 'Clear filter.'
121139
: 'Apply a filter.';
122140

@@ -125,12 +143,16 @@ export const filterValueCommand = defineGridCommand({
125143
? undefined
126144
: convertFilterExprToArray(component, args.expression);
127145

146+
if (isDefined(filterValue)) {
147+
defaultMessage = await getFilterSuccessMessage(component, filterValue);
148+
}
149+
128150
// Handles remote operations via data controller listening for the `filtering` change
129151
component.option('filterValue', filterValue);
130152

131-
return Promise.resolve(success(defaultMessage));
153+
return success(defaultMessage);
132154
} catch {
133-
return Promise.resolve(failure(defaultMessage));
155+
return failure(defaultMessage);
134156
}
135157
},
136158
});

packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,27 @@ describe('AIChat', () => {
394394
expect(icons[2].textContent).toBe(ABORTED_ITEM_EMOJI);
395395
});
396396

397+
it('should expose the full command message via the title attribute (shown on hover)', () => {
398+
createAIChat();
399+
triggerContentTemplate();
400+
401+
const chatConfig = getChatConfig();
402+
const container = document.createElement('div');
403+
const message = "Apply a filter: [Name] Contains 'Health'";
404+
405+
renderMessageTemplate(chatConfig, {
406+
author: { id: AI_ASSISTANT_AUTHOR_ID, name: 'AI Assistant' },
407+
text: 'Filter',
408+
status: 'success',
409+
commands: [{ status: 'success', message }],
410+
}, container);
411+
412+
const $text = container.querySelector(`.${CLASSES.actionListItemText}`);
413+
414+
expect($text?.textContent).toBe(message);
415+
expect($text?.getAttribute('title')).toBe(message);
416+
});
417+
397418
it('should not render regenerate button when all commands succeed', () => {
398419
const onRegenerate = jest.fn();
399420
createAIChat({ onRegenerate });

packages/devextreme/js/__internal/grids/grid_core/ai_chat/ai_chat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export class AIChat {
223223
$('<span>')
224224
.addClass(CLASSES.actionListItemText)
225225
.text(command.message)
226+
.attr('title', command.message)
226227
.appendTo($item);
227228
}
228229

packages/devextreme/js/__internal/grids/grid_core/filter/m_filter_panel.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import eventsEngine from '@js/common/core/events/core/events_engine';
33
import messageLocalization from '@js/common/core/localization/message';
44
import type { dxElementWrapper } from '@js/core/renderer';
55
import $ from '@js/core/renderer';
6+
import type { DeferredObj } from '@js/core/utils/deferred';
67
import { Deferred, when } from '@js/core/utils/deferred';
78
import { isDefined } from '@js/core/utils/type';
89
import CheckBox from '@js/ui/check_box';
@@ -311,7 +312,7 @@ export class FilterPanelView extends modules.View {
311312
return result;
312313
}
313314

314-
private getFilterText(filterValue, customOperations) {
315+
public getFilterText(filterValue, customOperations): DeferredObj<string> {
315316
const options = {
316317
customOperations,
317318
columns: this._columnsController.getFilteringColumns(),

0 commit comments

Comments
 (0)