Skip to content

Commit d8d082e

Browse files
marker-daomarker dao ®
andauthored
HtmlEditor: Add aiIntegration option
Co-authored-by: marker dao ® <[email protected]>
1 parent 552e388 commit d8d082e

File tree

7 files changed

+248
-134
lines changed

7 files changed

+248
-134
lines changed

packages/devextreme/js/__internal/ui/html_editor/html_editor.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ class HtmlEditor extends Editor<Properties> {
114114

115115
return {
116116
...super._getDefaultOptions(),
117+
// @ts-expect-error undefined is not allowed
118+
aiIntegration: null,
117119
allowSoftLineBreak: false,
118120
// @ts-expect-error undefined is not allowed
119121
converter: null,
@@ -575,14 +577,25 @@ class HtmlEditor extends Editor<Properties> {
575577
this._formDialog = new FormDialog(this.$element(), options);
576578
}
577579

578-
_renderAIDialog(): void {
579-
const { aiIntegration } = this.option();
580+
_shouldRenderAIDialog(): boolean {
581+
const { aiIntegration, toolbar } = this.option();
580582

581-
if (!aiIntegration) {
582-
return;
583+
if (!(aiIntegration && toolbar?.items)) {
584+
return false;
583585
}
584586

585-
this._aiDialog = new AIDialog(this.$element(), aiIntegration);
587+
return toolbar.items.some((item) => (typeof item === 'string' ? item === 'ai' : item.name === 'ai'));
588+
}
589+
590+
_renderAIDialog(): void {
591+
const shouldRenderAIDialog = this._shouldRenderAIDialog();
592+
593+
if (shouldRenderAIDialog) {
594+
const { aiIntegration } = this.option();
595+
596+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
597+
this._aiDialog = new AIDialog(this.$element(), aiIntegration!);
598+
}
586599
}
587600

588601
_getStylingModePrefix(): string {
@@ -634,10 +647,26 @@ class HtmlEditor extends Editor<Properties> {
634647
}
635648
}
636649

650+
_processAIIntegrationUpdate(): void {
651+
if (isDefined(this._aiDialog)) {
652+
const { aiIntegration } = this.option();
653+
654+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
655+
this._aiDialog.updateAIIntegration(aiIntegration!);
656+
657+
return;
658+
}
659+
this._renderAIDialog();
660+
}
661+
637662
_optionChanged(args: OptionChanged<Properties>): void {
638663
const { name, value, previousValue } = args;
639664

640665
switch (name) {
666+
case 'aiIntegration': {
667+
this._processAIIntegrationUpdate();
668+
break;
669+
}
641670
case 'converter': {
642671
this._htmlConverter = value as Properties[typeof name];
643672

packages/devextreme/js/__internal/ui/html_editor/ui/aiDialog.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import BaseDialog from './m_baseDialog';
1616

1717
const AI_DIALOG_COMMANDS_WITH_OPTIONS = ['translate', 'changeStyle', 'changeTone', 'custom'];
1818

19-
const AI_DIALOG_CLASS = 'dx-aidialog';
20-
const AI_DIALOG_CONTROLS_CLASS = 'dx-aidialog-controls';
21-
const AI_DIALOG_CONTENT_CLASS = 'dx-aidialog-content';
19+
export const AI_DIALOG_CLASS = 'dx-aidialog';
20+
export const AI_DIALOG_CONTROLS_CLASS = 'dx-aidialog-controls';
21+
export const AI_DIALOG_CONTENT_CLASS = 'dx-aidialog-content';
2222
const AI_DIALOG_TITLE_CLASS = 'dx-aidialog-title';
2323
const AI_DIALOG_TITLE_TEXT_CLASS = 'dx-aidialog-title-text';
2424
const ICON_CLASS = 'dx-icon';
@@ -62,7 +62,7 @@ export default class AIDialog extends BaseDialog<AIDialogResult> {
6262

6363
private _isAskAICommandSelected = false;
6464

65-
private readonly _aiIntegration?: AIIntegration;
65+
private _aiIntegration: AIIntegration;
6666

6767
private _commandsMap: CommandsMap = {};
6868

@@ -90,7 +90,7 @@ export default class AIDialog extends BaseDialog<AIDialogResult> {
9090

9191
constructor(
9292
$container: dxElementWrapper,
93-
aiIntegration?: AIIntegration,
93+
aiIntegration: AIIntegration,
9494
popupConfig?: PopupProperties,
9595
) {
9696
super($container, popupConfig);
@@ -431,6 +431,10 @@ export default class AIDialog extends BaseDialog<AIDialogResult> {
431431
return this._isAskAICommandSelected ? DialogState.Asking : DialogState.Initial;
432432
}
433433

434+
updateAIIntegration(aiIntegration: AIIntegration): void {
435+
this._aiIntegration = aiIntegration;
436+
}
437+
434438
replaceButtonAction(event: ItemClickEvent): void {
435439
this.hide(this._resultText, event);
436440
}

packages/devextreme/testing/helpers/aiDialog.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import $ from 'jquery';
22

3+
const AI_DIALOG_CLASS = 'dx-aidialog';
4+
const AI_DIALOG_CONTROLS_CLASS = 'dx-aidialog-controls';
5+
const AI_DIALOG_CONTENT_CLASS = 'dx-aidialog-content';
36
const DROP_DOWN_BUTTON_CLASS = 'dx-dropdownbutton';
47
const BUTTON_CLASS = 'dx-button';
58
const LIST_ITEM_CLASS = 'dx-list-item';
69
const OVERLAY_CLASS = 'dx-overlay-content';
7-
const DIALOG_CLASS = 'dx-aidialog';
8-
const AI_DIALOG_CONTENT_CLASS = 'dx-aidialog-content';
9-
const AI_DIALOG_CONTROLS_CLASS = 'dx-aidialog-controls';
1010
const SELECT_BOX_CLASS = 'dx-selectbox';
1111
const TEXT_AREA_CLASS = 'dx-textarea';
1212

@@ -44,7 +44,7 @@ const getDropDownButtonOption = (index) => {
4444
};
4545

4646
const getDialogSelectBoxes = ($container) => {
47-
const $wrapper = $container.find(`.${DIALOG_CLASS}`);
47+
const $wrapper = $container.find(`.${AI_DIALOG_CLASS}`);
4848
const $aiContent = $wrapper.find(`.${AI_DIALOG_CONTENT_CLASS}`);
4949
const $controls = $aiContent.find(`.${AI_DIALOG_CONTROLS_CLASS}`);
5050
return $controls.find(`.${SELECT_BOX_CLASS}`);
@@ -70,7 +70,7 @@ export const clickActionButton = (insertionMode) => {
7070
insertBelow: 2,
7171
};
7272

73-
getDropDownButton($(`.${DIALOG_CLASS}`)).trigger(CLICK_EVENT_NAME);
73+
getDropDownButton($(`.${AI_DIALOG_CLASS}`)).trigger(CLICK_EVENT_NAME);
7474
getDropDownButtonOption(insertionModeToIndexMap[insertionMode]).trigger(CLICK_EVENT_NAME);
7575
};
7676

packages/devextreme/testing/tests/DevExpress.ui.widgets.htmlEditor/htmlEditor.tests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import mentionIntegrationTests from './htmlEditorParts/mentionIntegration.tests.
3030
import './htmlEditorParts/scrolling.tests.js';
3131
import multilineIntegrationTests from './htmlEditorParts/multilineIntegration.tests.js';
3232
import './htmlEditorParts/aiDialog.tests.js';
33-
import './htmlEditorParts/toolbarAIDialogIntegration.tests.js';
33+
import './htmlEditorParts/aiDialogIntegration.tests.js';
3434

3535
markupTests();
3636
valueRenderingTests();

packages/devextreme/testing/tests/DevExpress.ui.widgets.htmlEditor/htmlEditorParts/aiDialog.tests.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import $ from 'jquery';
2-
import AIDialog from '__internal/ui/html_editor/ui/aiDialog';
2+
import AIDialog, {
3+
AI_DIALOG_CLASS,
4+
AI_DIALOG_CONTROLS_CLASS,
5+
AI_DIALOG_CONTENT_CLASS,
6+
} from '__internal/ui/html_editor/ui/aiDialog';
37
import { isPromise } from 'core/utils/type';
48
import {
59
showAIDialog,
@@ -14,9 +18,6 @@ import 'ui/popup';
1418
import 'ui/text_area';
1519
import 'ui/select_box';
1620

17-
const AI_DIALOG_CLASS = 'dx-aidialog';
18-
const AI_DIALOG_CONTENT_CLASS = 'dx-aidialog-content';
19-
const AI_DIALOG_CONTROLS_CLASS = 'dx-aidialog-controls';
2021
const TEXT_AREA_CLASS = 'dx-textarea';
2122
const SELECT_BOX_CLASS = 'dx-selectbox';
2223

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import $ from 'jquery';
2+
3+
import 'ui/html_editor';
4+
5+
import { openAIDialog } from '../../../helpers/aiToolbarMenu.js';
6+
import {
7+
clickActionButton,
8+
setResultText,
9+
getResultText,
10+
} from '../../../helpers/aiDialog.js';
11+
import { AI_DIALOG_CLASS } from '__internal/ui/html_editor/ui/aiDialog';
12+
13+
const setupHtmlEditorWithAi = (config) => {
14+
return $('#htmlEditor').dxHtmlEditor({
15+
value: 'Test value',
16+
aiIntegration: {},
17+
toolbar: {
18+
items: [{
19+
name: 'ai',
20+
commands: ['summarize']
21+
}],
22+
},
23+
...config
24+
}).dxHtmlEditor('instance');
25+
};
26+
27+
const getAIDialog = (htmlEditor) => {
28+
return htmlEditor._aiDialog;
29+
};
30+
31+
const getAIDialogElement = ($htmlEditor) => {
32+
return $htmlEditor.find(`.${AI_DIALOG_CLASS}`);
33+
};
34+
35+
QUnit.module('AI dialog integration', {}, () => {
36+
QUnit.module('render', () => {
37+
['ai', { name: 'ai' }].forEach(aiToolbarItem => {
38+
QUnit.test(`should be rendered if aiIntegration and ai toolbar item (as ${typeof aiToolbarItem}) are passed`, function(assert) {
39+
const $element = $('#htmlEditor').dxHtmlEditor({
40+
aiIntegration: {},
41+
toolbar: { items: [ aiToolbarItem ] },
42+
});
43+
const $dialog = getAIDialogElement($element);
44+
45+
assert.strictEqual($dialog.length, 1, 'dialog is rendered');
46+
});
47+
48+
QUnit.test('should not be rendered if aiIntegration is not passed and ai toolbar item is passed', function(assert) {
49+
const $element = $('#htmlEditor').dxHtmlEditor({
50+
toolbar: { items: [ aiToolbarItem ] },
51+
});
52+
const $dialog = getAIDialogElement($element);
53+
54+
assert.strictEqual($dialog.length, 0, 'dialog is not rendered');
55+
});
56+
57+
QUnit.test('should not be rendered if ai toolbar item is not passed and aiIntegration is passed', function(assert) {
58+
const $element = $('#htmlEditor').dxHtmlEditor({
59+
aiIntegration: {},
60+
});
61+
const $dialog = getAIDialogElement($element);
62+
63+
assert.strictEqual($dialog.length, 0, 'dialog is not rendered');
64+
});
65+
});
66+
});
67+
68+
QUnit.module('aiIntegration option', () => {
69+
QUnit.test('process update if ai dailog was rendered before', function(assert) {
70+
const htmlEditor = setupHtmlEditorWithAi({});
71+
const dialog = getAIDialog(htmlEditor);
72+
const $dialog = getAIDialogElement(htmlEditor.$element());
73+
const updateAIIntegrationSpy = sinon.spy(dialog, 'updateAIIntegration');
74+
75+
assert.strictEqual(updateAIIntegrationSpy.callCount, 0);
76+
assert.strictEqual($dialog.length, 1, 'dialog is rendered');
77+
78+
htmlEditor.option({ aiIntegration: {} });
79+
80+
assert.strictEqual(updateAIIntegrationSpy.callCount, 1, 'updateAIIntegration is called once');
81+
});
82+
83+
QUnit.test('process update if ai dailog was not rendered before', function(assert) {
84+
const htmlEditor = setupHtmlEditorWithAi({ aiIntegration: null });
85+
let $dialog = getAIDialogElement(htmlEditor.$element());
86+
87+
assert.strictEqual($dialog.length, 0, 'dialog is not rendered');
88+
89+
htmlEditor.option({ aiIntegration: {} });
90+
91+
$dialog = getAIDialogElement(htmlEditor.$element());
92+
93+
assert.strictEqual($dialog.length, 1, 'dialog is rendered');
94+
});
95+
});
96+
97+
QUnit.module('action buttons', () => {
98+
QUnit.test('replace button click should replace selected text with a text in result textArea', function(assert) {
99+
const done = assert.async();
100+
101+
const instance = setupHtmlEditorWithAi({
102+
onValueChanged: () => {
103+
const value = instance.option('value');
104+
assert.strictEqual(value, '<p>Inserted value</p>', 'value replaced');
105+
done();
106+
}
107+
});
108+
109+
openAIDialog($('#htmlEditor'));
110+
setResultText('Inserted value');
111+
clickActionButton('replace');
112+
});
113+
114+
QUnit.test('insertAbove button click should insert text from result textArea above the selected text', function(assert) {
115+
const done = assert.async();
116+
117+
const instance = setupHtmlEditorWithAi({
118+
onValueChanged: () => {
119+
const value = instance.option('value');
120+
assert.strictEqual(value, '<p>Inserted value</p><p>Test value</p>', 'inserted above');
121+
done();
122+
}
123+
});
124+
125+
openAIDialog($('#htmlEditor'));
126+
setResultText('Inserted value');
127+
clickActionButton('insertAbove');
128+
});
129+
130+
QUnit.test('insertBelow button click should insert text from result textArea below the selected text', function(assert) {
131+
const done = assert.async();
132+
133+
const instance = setupHtmlEditorWithAi({
134+
onValueChanged: () => {
135+
const value = instance.option('value');
136+
assert.strictEqual(value, '<p>Test value</p><p>Inserted value</p>', 'inserted below');
137+
done();
138+
}
139+
});
140+
141+
openAIDialog($('#htmlEditor'));
142+
setResultText('Inserted value');
143+
clickActionButton('insertBelow');
144+
});
145+
});
146+
147+
QUnit.module('input source based on selection', () => {
148+
QUnit.test('Should use selected text as input', function(assert) {
149+
const instance = setupHtmlEditorWithAi();
150+
151+
instance.setSelection(0, 4);
152+
153+
openAIDialog($('#htmlEditor'));
154+
clickActionButton('replace');
155+
156+
const resultText = getResultText();
157+
158+
assert.strictEqual(resultText, 'Test', 'selected text used in resultTextArea');
159+
});
160+
161+
QUnit.test('Should use all text as input if nothing is selected', function(assert) {
162+
const instance = setupHtmlEditorWithAi();
163+
164+
instance.setSelection(0, 0);
165+
166+
openAIDialog($('#htmlEditor'));
167+
clickActionButton('replace');
168+
169+
const resultText = getResultText();
170+
171+
assert.strictEqual(resultText, 'Test value\n', 'all text used in resultTextArea');
172+
});
173+
});
174+
175+
QUnit.module('onValueChanged', () => {
176+
QUnit.test('should receive correct event parameter if value is updated after action button click', function(assert) {
177+
const done = assert.async();
178+
179+
setupHtmlEditorWithAi({
180+
onValueChanged: ({ event }) => {
181+
const clickedText = $(event.target).text();
182+
183+
assert.strictEqual(event.type, 'dxclick', 'called with correct event type');
184+
assert.strictEqual(clickedText, 'Replace', 'called on correct element');
185+
done();
186+
}
187+
});
188+
189+
openAIDialog($('#htmlEditor'));
190+
setResultText('Inserted value');
191+
clickActionButton('replace');
192+
});
193+
});
194+
});

0 commit comments

Comments
 (0)