DEV: Move composer AI helper to toolbar (#796)
Previously we had moved the AI helper from the options menu to a selection menu that appears when selecting text in the composer. This had the benefit of making the AI helper a more discoverable feature. Now that some time has passed and the AI helper is more recognized, we will be moving it back to the composer toolbar. This is better because: - It consistent with other behavior and ways of accessing tools in the composer - It has an improved mobile experience - It reduces unnecessary code and keeps things easier to migrate when we have composer V2. - It allows for easily triggering AI helper for all content by clicking the button instead of having to select everything.
This commit is contained in:
parent
5b9add0ac8
commit
9cd14b0003
|
@ -2,15 +2,7 @@ import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import { modifier } from "ember-modifier";
|
|
||||||
import { eq } from "truth-helpers";
|
|
||||||
import DButton from "discourse/components/d-button";
|
|
||||||
import { ajax } from "discourse/lib/ajax";
|
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
||||||
import { bind } from "discourse-common/utils/decorators";
|
|
||||||
import I18n from "discourse-i18n";
|
import I18n from "discourse-i18n";
|
||||||
import AiHelperButtonGroup from "../components/ai-helper-button-group";
|
|
||||||
import AiHelperLoading from "../components/ai-helper-loading";
|
|
||||||
import AiHelperOptionsList from "../components/ai-helper-options-list";
|
import AiHelperOptionsList from "../components/ai-helper-options-list";
|
||||||
import ModalDiffModal from "../components/modal/diff-modal";
|
import ModalDiffModal from "../components/modal/diff-modal";
|
||||||
import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions";
|
import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions";
|
||||||
|
@ -18,30 +10,14 @@ import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions";
|
||||||
export default class AiComposerHelperMenu extends Component {
|
export default class AiComposerHelperMenu extends Component {
|
||||||
@service modal;
|
@service modal;
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
@service aiComposerHelper;
|
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
@service capabilities;
|
@service site;
|
||||||
@tracked newSelectedText;
|
@tracked newSelectedText;
|
||||||
@tracked diff;
|
@tracked diff;
|
||||||
@tracked initialValue = "";
|
|
||||||
@tracked customPromptValue = "";
|
@tracked customPromptValue = "";
|
||||||
@tracked loading = false;
|
|
||||||
@tracked lastUsedOption = null;
|
|
||||||
@tracked thumbnailSuggestions = null;
|
|
||||||
@tracked showThumbnailModal = false;
|
|
||||||
@tracked lastSelectionRange = null;
|
|
||||||
MENU_STATES = this.aiComposerHelper.MENU_STATES;
|
|
||||||
prompts = [];
|
prompts = [];
|
||||||
promptTypes = {};
|
promptTypes = {};
|
||||||
|
|
||||||
documentListeners = modifier(() => {
|
|
||||||
document.addEventListener("keydown", this.onKeyDown, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", this.onKeyDown);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
get helperOptions() {
|
get helperOptions() {
|
||||||
let prompts = this.currentUser?.ai_helper_prompts;
|
let prompts = this.currentUser?.ai_helper_prompts;
|
||||||
|
|
||||||
|
@ -94,260 +70,51 @@ export default class AiComposerHelperMenu extends Component {
|
||||||
return prompts;
|
return prompts;
|
||||||
}
|
}
|
||||||
|
|
||||||
get reviewButtons() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
icon: "exchange-alt",
|
|
||||||
label: "discourse_ai.ai_helper.context_menu.view_changes",
|
|
||||||
action: () =>
|
|
||||||
this.modal.show(ModalDiffModal, {
|
|
||||||
model: {
|
|
||||||
diff: this.diff,
|
|
||||||
oldValue: this.initialValue,
|
|
||||||
newValue: this.newSelectedText,
|
|
||||||
revert: this.undoAiAction,
|
|
||||||
confirm: () => this.updateMenuState(this.MENU_STATES.resets),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
classes: "view-changes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "undo",
|
|
||||||
label: "discourse_ai.ai_helper.context_menu.revert",
|
|
||||||
action: this.undoAiAction,
|
|
||||||
classes: "revert",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "check",
|
|
||||||
label: "discourse_ai.ai_helper.context_menu.confirm",
|
|
||||||
action: () => this.updateMenuState(this.MENU_STATES.resets),
|
|
||||||
classes: "confirm",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
get resetButtons() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
icon: "undo",
|
|
||||||
label: "discourse_ai.ai_helper.context_menu.undo",
|
|
||||||
action: this.undoAiAction,
|
|
||||||
classes: "undo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "discourse-sparkles",
|
|
||||||
label: "discourse_ai.ai_helper.context_menu.regen",
|
|
||||||
action: () => this.updateSelected(this.lastUsedOption),
|
|
||||||
classes: "regenerate",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
get canCloseMenu() {
|
|
||||||
if (
|
|
||||||
document.activeElement ===
|
|
||||||
document.querySelector(".ai-custom-prompt__input")
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.loading && this._activeAiRequest !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.aiComposerHelper.menuState === this.MENU_STATES.review) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isExpanded() {
|
|
||||||
if (this.aiComposerHelper.menuState === this.MENU_STATES.triggers) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "is-expanded";
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
onKeyDown(event) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
return this.closeMenu();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
event.key === "Backspace" &&
|
|
||||||
this.args.data.selectedText &&
|
|
||||||
this.aiComposerHelper.menuState === this.MENU_STATES.triggers
|
|
||||||
) {
|
|
||||||
return this.closeMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleAiHelperOptions() {
|
suggestChanges(option) {
|
||||||
this.updateMenuState(this.MENU_STATES.options);
|
if (option.name === "illustrate_post") {
|
||||||
}
|
this.modal.show(ThumbnailSuggestion, {
|
||||||
|
model: {
|
||||||
@action
|
|
||||||
async updateSelected(option) {
|
|
||||||
this._toggleLoadingState(true);
|
|
||||||
this.lastUsedOption = option;
|
|
||||||
this.updateMenuState(this.MENU_STATES.loading);
|
|
||||||
this.initialValue = this.args.data.selectedText;
|
|
||||||
this.lastSelectionRange = this.args.data.selectionRange;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._activeAiRequest = await ajax("/discourse-ai/ai-helper/suggest", {
|
|
||||||
method: "POST",
|
|
||||||
data: {
|
|
||||||
mode: option.id,
|
mode: option.id,
|
||||||
text: this.args.data.selectedText,
|
selectedText: this.args.data.selectedText,
|
||||||
custom_prompt: this.customPromptValue,
|
thumbnails: this.thumbnailSuggestions,
|
||||||
force_default_locale: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return this.args.close();
|
||||||
const data = await this._activeAiRequest;
|
|
||||||
|
|
||||||
// resets the values if new suggestion is started:
|
|
||||||
this.diff = null;
|
|
||||||
this.newSelectedText = null;
|
|
||||||
this.thumbnailSuggestions = null;
|
|
||||||
|
|
||||||
if (option.name === "illustrate_post") {
|
|
||||||
this._toggleLoadingState(false);
|
|
||||||
this.closeMenu();
|
|
||||||
this.thumbnailSuggestions = data.thumbnails;
|
|
||||||
this.modal.show(ThumbnailSuggestion, {
|
|
||||||
model: {
|
|
||||||
thumbnails: this.thumbnailSuggestions,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this._updateSuggestedByAi(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
popupAjaxError(error);
|
|
||||||
} finally {
|
|
||||||
this._toggleLoadingState(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this._activeAiRequest;
|
this.modal.show(ModalDiffModal, {
|
||||||
}
|
model: {
|
||||||
|
mode: option.id,
|
||||||
@action
|
selectedText: this.args.data.selectedText,
|
||||||
cancelAiAction() {
|
revert: this.undoAiAction,
|
||||||
if (this._activeAiRequest) {
|
toolbarEvent: this.args.data.toolbarEvent,
|
||||||
this._activeAiRequest.abort();
|
customPromptValue: this.customPromptValue,
|
||||||
this._activeAiRequest = null;
|
},
|
||||||
this._toggleLoadingState(false);
|
});
|
||||||
this.closeMenu();
|
return this.args.close();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateMenuState(newState) {
|
|
||||||
this.aiComposerHelper.menuState = newState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
if (!this.canCloseMenu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.customPromptValue = "";
|
this.customPromptValue = "";
|
||||||
this.updateMenuState(this.MENU_STATES.triggers);
|
|
||||||
this.args.close();
|
this.args.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
undoAiAction() {
|
|
||||||
if (this.capabilities.isFirefox) {
|
|
||||||
// execCommand("undo") is no not supported in Firefox so we insert old text at range
|
|
||||||
// we also need to calculate the length diffrence between the old and new text
|
|
||||||
const lengthDifference =
|
|
||||||
this.args.data.selectedText.length - this.initialValue.length;
|
|
||||||
const end = this.lastSelectionRange.y - lengthDifference;
|
|
||||||
this._insertAt(this.lastSelectionRange.x, end, this.initialValue);
|
|
||||||
} else {
|
|
||||||
document.execCommand("undo", false, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// context menu is prevented from closing when in review state
|
|
||||||
// so we change to reset state quickly before closing
|
|
||||||
this.updateMenuState(this.MENU_STATES.resets);
|
|
||||||
this.closeMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
_toggleLoadingState(loading) {
|
|
||||||
if (loading) {
|
|
||||||
this.args.data.dEditorInput.classList.add("loading");
|
|
||||||
return (this.loading = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.args.data.dEditorInput.classList.remove("loading");
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateSuggestedByAi(data) {
|
|
||||||
this.newSelectedText = data.suggestions[0];
|
|
||||||
|
|
||||||
if (data.diff) {
|
|
||||||
this.diff = data.diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._insertAt(
|
|
||||||
this.args.data.selectionRange.x,
|
|
||||||
this.args.data.selectionRange.y,
|
|
||||||
this.newSelectedText
|
|
||||||
);
|
|
||||||
|
|
||||||
this.updateMenuState(this.MENU_STATES.review);
|
|
||||||
}
|
|
||||||
|
|
||||||
_insertAt(start, end, text) {
|
|
||||||
this.args.data.dEditorInput.setSelectionRange(start, end);
|
|
||||||
this.args.data.dEditorInput.focus();
|
|
||||||
document.execCommand("insertText", false, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="ai-composer-helper-menu">
|
||||||
class="ai-composer-helper-menu {{this.isExpanded}}"
|
{{#if this.site.mobileView}}
|
||||||
{{this.documentListeners}}
|
<div class="ai-composer-helper-menu__selected-text">
|
||||||
>
|
{{@data.selectedText}}
|
||||||
{{#if (eq this.aiComposerHelper.menuState this.MENU_STATES.triggers)}}
|
</div>
|
||||||
<ul class="ai-composer-helper-menu__triggers">
|
|
||||||
<li>
|
|
||||||
<DButton
|
|
||||||
@icon="discourse-sparkles"
|
|
||||||
@label="discourse_ai.ai_helper.context_menu.trigger"
|
|
||||||
@action={{this.toggleAiHelperOptions}}
|
|
||||||
class="btn-flat"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.options)}}
|
|
||||||
<AiHelperOptionsList
|
|
||||||
@options={{this.helperOptions}}
|
|
||||||
@customPromptValue={{this.customPromptValue}}
|
|
||||||
@performAction={{this.updateSelected}}
|
|
||||||
/>
|
|
||||||
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.loading)}}
|
|
||||||
<AiHelperLoading @cancel={{this.cancelAiAction}} />
|
|
||||||
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.review)}}
|
|
||||||
<AiHelperButtonGroup
|
|
||||||
@buttons={{this.reviewButtons}}
|
|
||||||
class="ai-composer-helper-menu__review"
|
|
||||||
/>
|
|
||||||
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.resets)}}
|
|
||||||
<AiHelperButtonGroup
|
|
||||||
@buttons={{this.resetButtons}}
|
|
||||||
class="ai-composer-helper-menu__resets"
|
|
||||||
/>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
<AiHelperOptionsList
|
||||||
|
@options={{this.helperOptions}}
|
||||||
|
@customPromptValue={{this.customPromptValue}}
|
||||||
|
@performAction={{this.suggestChanges}}
|
||||||
|
@shortcutVisible={{true}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import DButton from "discourse/components/d-button";
|
|
||||||
import concatClass from "discourse/helpers/concat-class";
|
|
||||||
|
|
||||||
const AiHelperButtonGroup = <template>
|
|
||||||
<ul class="ai-helper-button-group" ...attributes>
|
|
||||||
{{#each @buttons as |button|}}
|
|
||||||
<li>
|
|
||||||
<DButton
|
|
||||||
@icon={{button.icon}}
|
|
||||||
@label={{button.label}}
|
|
||||||
@action={{button.action}}
|
|
||||||
class={{concatClass "btn-flat" button.classes}}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
</template>;
|
|
||||||
|
|
||||||
export default AiHelperButtonGroup;
|
|
|
@ -4,7 +4,6 @@ import { on } from "@ember/modifier";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import withEventValue from "discourse/helpers/with-event-value";
|
import withEventValue from "discourse/helpers/with-event-value";
|
||||||
import autoFocus from "discourse/modifiers/auto-focus";
|
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
import not from "truth-helpers/helpers/not";
|
import not from "truth-helpers/helpers/not";
|
||||||
|
|
||||||
|
@ -29,12 +28,7 @@ export default class AiHelperCustomPrompt extends Component {
|
||||||
}}
|
}}
|
||||||
class="ai-custom-prompt__input"
|
class="ai-custom-prompt__input"
|
||||||
type="text"
|
type="text"
|
||||||
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus"
|
autofocus="autofocus"
|
||||||
because built in autofocus doesn't work consistently when component is
|
|
||||||
invoked twice separetly without a page refresh.
|
|
||||||
(i.e. trigger in post AI helper followed by trigger in composer AI helper)
|
|
||||||
--}}
|
|
||||||
{{autoFocus}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DButton
|
<DButton
|
||||||
|
|
|
@ -1,31 +1,44 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
import { fn } from "@ember/helper";
|
import { fn } from "@ember/helper";
|
||||||
|
import { service } from "@ember/service";
|
||||||
|
import { and } from "truth-helpers";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import eq from "truth-helpers/helpers/eq";
|
import eq from "truth-helpers/helpers/eq";
|
||||||
import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt";
|
import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt";
|
||||||
|
|
||||||
const AiHelperOptionsList = <template>
|
export default class AiHelperOptionsList extends Component {
|
||||||
<ul class="ai-helper-options">
|
@service site;
|
||||||
{{#each @options as |option|}}
|
|
||||||
{{#if (eq option.name "custom_prompt")}}
|
|
||||||
<AiHelperCustomPrompt
|
|
||||||
@value={{@customPromptValue}}
|
|
||||||
@promptArgs={{option}}
|
|
||||||
@submit={{@performAction}}
|
|
||||||
/>
|
|
||||||
{{else}}
|
|
||||||
<li data-name={{option.translated_name}} data-value={{option.id}}>
|
|
||||||
<DButton
|
|
||||||
@icon={{option.icon}}
|
|
||||||
@translatedLabel={{option.translated_name}}
|
|
||||||
@action={{fn @performAction option}}
|
|
||||||
data-name={{option.name}}
|
|
||||||
data-value={{option.id}}
|
|
||||||
class="ai-helper-options__button"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
{{/if}}
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
</template>;
|
|
||||||
|
|
||||||
export default AiHelperOptionsList;
|
get showShortcut() {
|
||||||
|
return this.site.desktopView && this.args.shortcutVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="ai-helper-options">
|
||||||
|
{{#each @options as |option|}}
|
||||||
|
{{#if (eq option.name "custom_prompt")}}
|
||||||
|
<AiHelperCustomPrompt
|
||||||
|
@value={{@customPromptValue}}
|
||||||
|
@promptArgs={{option}}
|
||||||
|
@submit={{@performAction}}
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<li data-name={{option.translated_name}} data-value={{option.id}}>
|
||||||
|
<DButton
|
||||||
|
@icon={{option.icon}}
|
||||||
|
@translatedLabel={{option.translated_name}}
|
||||||
|
@action={{fn @performAction option}}
|
||||||
|
data-name={{option.name}}
|
||||||
|
data-value={{option.id}}
|
||||||
|
class="ai-helper-options__button"
|
||||||
|
>
|
||||||
|
{{#if (and (eq option.name "proofread") this.showShortcut)}}
|
||||||
|
<kbd class="shortcut">⌘⌥p</kbd>
|
||||||
|
{{/if}}
|
||||||
|
</DButton>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
|
|
|
@ -295,6 +295,7 @@ export default class AiPostHelperMenu extends Component {
|
||||||
@options={{this.helperOptions}}
|
@options={{this.helperOptions}}
|
||||||
@customPromptValue={{this.customPromptValue}}
|
@customPromptValue={{this.customPromptValue}}
|
||||||
@performAction={{this.performAiSuggestion}}
|
@performAction={{this.performAiSuggestion}}
|
||||||
|
@shortcutVisible={{false}}
|
||||||
/>
|
/>
|
||||||
{{else if (eq this.menuState this.MENU_STATES.loading)}}
|
{{else if (eq this.menuState this.MENU_STATES.loading)}}
|
||||||
<AiHelperLoading @cancel={{this.cancelAiAction}} />
|
<AiHelperLoading @cancel={{this.cancelAiAction}} />
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { next } from "@ember/runloop";
|
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import { htmlSafe } from "@ember/template";
|
import { htmlSafe } from "@ember/template";
|
||||||
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||||
import CookText from "discourse/components/cook-text";
|
import CookText from "discourse/components/cook-text";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import DModal from "discourse/components/d-modal";
|
import DModal from "discourse/components/d-modal";
|
||||||
|
@ -15,30 +15,24 @@ export default class ModalDiffModal extends Component {
|
||||||
@service currentUser;
|
@service currentUser;
|
||||||
@tracked loading = false;
|
@tracked loading = false;
|
||||||
@tracked diff;
|
@tracked diff;
|
||||||
suggestion = "";
|
@tracked suggestion = "";
|
||||||
|
|
||||||
PROOFREAD_ID = -303;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
this.diff = this.args.model.diff;
|
this.suggestChanges();
|
||||||
|
|
||||||
next(() => {
|
|
||||||
if (this.args.model.toolbarEvent) {
|
|
||||||
this.loadDiff();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadDiff() {
|
@action
|
||||||
|
async suggestChanges() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const suggestion = await ajax("/discourse-ai/ai-helper/suggest", {
|
const suggestion = await ajax("/discourse-ai/ai-helper/suggest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: {
|
data: {
|
||||||
mode: this.PROOFREAD_ID,
|
mode: this.args.model.mode,
|
||||||
text: this.selectedText,
|
text: this.args.model.selectedText,
|
||||||
|
custom_prompt: this.args.model.customPromptValue,
|
||||||
force_default_locale: true,
|
force_default_locale: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -52,37 +46,18 @@ export default class ModalDiffModal extends Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedText() {
|
|
||||||
const selected = this.args.model.toolbarEvent.selected;
|
|
||||||
|
|
||||||
if (selected.value === "") {
|
|
||||||
return selected.pre + selected.post;
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
triggerConfirmChanges() {
|
triggerConfirmChanges() {
|
||||||
this.args.closeModal();
|
this.args.closeModal();
|
||||||
if (this.args.model.confirm) {
|
|
||||||
this.args.model.confirm();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.args.model.toolbarEvent && this.suggestion) {
|
if (this.suggestion) {
|
||||||
this.args.model.toolbarEvent.replaceText(
|
this.args.model.toolbarEvent.replaceText(
|
||||||
this.selectedText,
|
this.args.model.selectedText,
|
||||||
this.suggestion
|
this.suggestion
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
triggerRevertChanges() {
|
|
||||||
this.args.model.revert();
|
|
||||||
this.args.closeModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DModal
|
<DModal
|
||||||
class="composer-ai-helper-modal"
|
class="composer-ai-helper-modal"
|
||||||
|
@ -90,23 +65,26 @@ export default class ModalDiffModal extends Component {
|
||||||
@closeModal={{@closeModal}}
|
@closeModal={{@closeModal}}
|
||||||
>
|
>
|
||||||
<:body>
|
<:body>
|
||||||
{{#if this.loading}}
|
<ConditionalLoadingSpinner @condition={{this.loading}}>
|
||||||
<div class="composer-ai-helper-modal__loading">
|
{{#if this.loading}}
|
||||||
<CookText @rawText={{this.selectedText}} />
|
<div class="composer-ai-helper-modal__loading">
|
||||||
</div>
|
<CookText @rawText={{this.selectedText}} />
|
||||||
{{else}}
|
</div>
|
||||||
{{#if this.diff}}
|
|
||||||
{{htmlSafe this.diff}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="composer-ai-helper-modal__old-value">
|
{{#if this.diff}}
|
||||||
{{@model.oldValue}}
|
{{htmlSafe this.diff}}
|
||||||
</div>
|
{{else}}
|
||||||
|
<div class="composer-ai-helper-modal__old-value">
|
||||||
|
{{@model.selectedText}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="composer-ai-helper-modal__new-value">
|
<div class="composer-ai-helper-modal__new-value">
|
||||||
{{@model.newValue}}
|
{{this.suggestion}}
|
||||||
</div>
|
</div>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
</ConditionalLoadingSpinner>
|
||||||
|
|
||||||
</:body>
|
</:body>
|
||||||
|
|
||||||
<:footer>
|
<:footer>
|
||||||
|
@ -122,13 +100,17 @@ export default class ModalDiffModal extends Component {
|
||||||
@action={{this.triggerConfirmChanges}}
|
@action={{this.triggerConfirmChanges}}
|
||||||
@label="discourse_ai.ai_helper.context_menu.confirm"
|
@label="discourse_ai.ai_helper.context_menu.confirm"
|
||||||
/>
|
/>
|
||||||
{{#if @model.revert}}
|
<DButton
|
||||||
<DButton
|
class="btn-flat discard"
|
||||||
class="btn-flat revert"
|
@action={{@closeModal}}
|
||||||
@action={{this.triggerRevertChanges}}
|
@label="discourse_ai.ai_helper.context_menu.discard"
|
||||||
@label="discourse_ai.ai_helper.context_menu.revert"
|
/>
|
||||||
/>
|
<DButton
|
||||||
{{/if}}
|
class="regenerate"
|
||||||
|
@icon="arrows-rotate"
|
||||||
|
@action={{this.suggestChanges}}
|
||||||
|
@label="discourse_ai.ai_helper.context_menu.regen"
|
||||||
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</:footer>
|
</:footer>
|
||||||
</DModal>
|
</DModal>
|
||||||
|
|
|
@ -1,19 +1,50 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
|
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import DModal from "discourse/components/d-modal";
|
import DModal from "discourse/components/d-modal";
|
||||||
import DModalCancel from "discourse/components/d-modal-cancel";
|
import DModalCancel from "discourse/components/d-modal-cancel";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
import ThumbnailSuggestionItem from "../thumbnail-suggestion-item";
|
import ThumbnailSuggestionItem from "../thumbnail-suggestion-item";
|
||||||
|
|
||||||
export default class ThumbnailSuggestions extends Component {
|
export default class ThumbnailSuggestions extends Component {
|
||||||
|
@tracked loading = false;
|
||||||
@tracked selectedImages = [];
|
@tracked selectedImages = [];
|
||||||
|
@tracked thumbnails = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
|
||||||
|
this.findThumbnails();
|
||||||
|
}
|
||||||
|
|
||||||
get isDisabled() {
|
get isDisabled() {
|
||||||
return this.selectedImages.length === 0;
|
return this.selectedImages.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findThumbnails() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const thumbnails = await ajax("/discourse-ai/ai-helper/suggest", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
mode: this.args.model.mode,
|
||||||
|
text: this.args.model.selectedText,
|
||||||
|
force_default_locale: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.thumbnails = thumbnails.thumbnails;
|
||||||
|
} catch (error) {
|
||||||
|
popupAjaxError(error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
addSelection(selection) {
|
addSelection(selection) {
|
||||||
const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`;
|
const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`;
|
||||||
|
@ -52,15 +83,17 @@ export default class ThumbnailSuggestions extends Component {
|
||||||
@closeModal={{@closeModal}}
|
@closeModal={{@closeModal}}
|
||||||
>
|
>
|
||||||
<:body>
|
<:body>
|
||||||
<div class="ai-thumbnail-suggestions">
|
<ConditionalLoadingSpinner @condition={{this.loading}}>
|
||||||
{{#each @model.thumbnails as |thumbnail|}}
|
<div class="ai-thumbnail-suggestions">
|
||||||
<ThumbnailSuggestionItem
|
{{#each this.thumbnails as |thumbnail|}}
|
||||||
@thumbnail={{thumbnail}}
|
<ThumbnailSuggestionItem
|
||||||
@addSelection={{this.addSelection}}
|
@thumbnail={{thumbnail}}
|
||||||
@removeSelection={{this.removeSelection}}
|
@addSelection={{this.addSelection}}
|
||||||
/>
|
@removeSelection={{this.removeSelection}}
|
||||||
{{/each}}
|
/>
|
||||||
</div>
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</ConditionalLoadingSpinner>
|
||||||
</:body>
|
</:body>
|
||||||
|
|
||||||
<:footer>
|
<:footer>
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
import { showComposerAiHelper } from "../../lib/show-ai-helper";
|
||||||
|
|
||||||
export default class AiCategorySuggestion extends Component {
|
export default class AiCategorySuggestion extends Component {
|
||||||
static shouldRender(outletArgs, helper) {
|
static shouldRender(outletArgs, helper) {
|
||||||
return showComposerAIHelper(outletArgs, helper, "suggestions");
|
return showComposerAiHelper(
|
||||||
|
outletArgs?.composer,
|
||||||
|
helper.siteSettings,
|
||||||
|
helper.currentUser,
|
||||||
|
"suggestions"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
import { showComposerAiHelper } from "../../lib/show-ai-helper";
|
||||||
|
|
||||||
export default class AiTagSuggestion extends Component {
|
export default class AiTagSuggestion extends Component {
|
||||||
static shouldRender(outletArgs, helper) {
|
static shouldRender(outletArgs, helper) {
|
||||||
return showComposerAIHelper(outletArgs, helper, "suggestions");
|
return showComposerAiHelper(
|
||||||
|
outletArgs?.composer,
|
||||||
|
helper.siteSettings,
|
||||||
|
helper.currentUser,
|
||||||
|
"suggestions"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
import { showComposerAiHelper } from "../../lib/show-ai-helper";
|
||||||
|
|
||||||
export default class AiTitleSuggestion extends Component {
|
export default class AiTitleSuggestion extends Component {
|
||||||
static shouldRender(outletArgs, helper) {
|
static shouldRender(outletArgs, helper) {
|
||||||
return showComposerAIHelper(outletArgs, helper, "suggestions");
|
return showComposerAiHelper(
|
||||||
|
outletArgs?.composer,
|
||||||
|
helper.siteSettings,
|
||||||
|
helper.currentUser,
|
||||||
|
"suggestions"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,221 +0,0 @@
|
||||||
import Component from "@glimmer/component";
|
|
||||||
import { tracked } from "@glimmer/tracking";
|
|
||||||
import { inject as service } from "@ember/service";
|
|
||||||
import { modifier } from "ember-modifier";
|
|
||||||
import { caretPosition, getCaretPosition } from "discourse/lib/utilities";
|
|
||||||
import { INPUT_DELAY } from "discourse-common/config/environment";
|
|
||||||
import { afterRender, bind, debounce } from "discourse-common/utils/decorators";
|
|
||||||
import AiComposerHelperMenu from "../../components/ai-composer-helper-menu";
|
|
||||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
|
||||||
import virtualElementFromCaretCoords from "../../lib/virtual-element-from-caret-coords";
|
|
||||||
|
|
||||||
export default class AiComposerHelper extends Component {
|
|
||||||
static shouldRender(outletArgs, helper) {
|
|
||||||
return showComposerAIHelper(outletArgs, helper, "context_menu");
|
|
||||||
}
|
|
||||||
|
|
||||||
@service site;
|
|
||||||
@service menu;
|
|
||||||
@service aiComposerHelper;
|
|
||||||
@tracked caretCoords;
|
|
||||||
@tracked menuPlacement = "bottom-start";
|
|
||||||
@tracked menuOffset = [9, 21];
|
|
||||||
@tracked selectedText = "";
|
|
||||||
@tracked isSelecting = false;
|
|
||||||
@tracked menuElement = null;
|
|
||||||
@tracked menuInstance = null;
|
|
||||||
@tracked dEditorInput;
|
|
||||||
@tracked selectionRange = { x: 0, y: 0 };
|
|
||||||
minSelectionChars = 3;
|
|
||||||
|
|
||||||
documentListeners = modifier(() => {
|
|
||||||
document.addEventListener("mousedown", this.onMouseDown, { passive: true });
|
|
||||||
document.addEventListener("mouseup", this.onMouseUp, { passive: true });
|
|
||||||
document.addEventListener("selectionchange", this.onSelectionChanged);
|
|
||||||
window.visualViewport.addEventListener("resize", this.onResize, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dEditorInput = document.querySelector(".d-editor-input");
|
|
||||||
|
|
||||||
if (this.dEditorInput) {
|
|
||||||
this.dEditorInput.addEventListener("scroll", this.updatePosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", this.onMouseDown);
|
|
||||||
document.removeEventListener("mouseup", this.onMouseUp);
|
|
||||||
document.removeEventListener("selectionchange", this.onSelectionChanged);
|
|
||||||
window.visualViewport.removeEventListener("resize", this.onResize);
|
|
||||||
|
|
||||||
if (this.dEditorInput) {
|
|
||||||
this.dEditorInput.removeEventListener("scroll", this.updatePosition);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
willDestroy() {
|
|
||||||
super.willDestroy(...arguments);
|
|
||||||
this.menuInstance?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
onResize() {
|
|
||||||
if (!this.site.mobileView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
"--mobile-virtual-screen-height",
|
|
||||||
`${window.visualViewport.height}px`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
onSelectionChanged() {
|
|
||||||
if (
|
|
||||||
this.aiComposerHelper.menuState !==
|
|
||||||
this.aiComposerHelper.MENU_STATES.triggers
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isSelecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.activeElement !== this.dEditorInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSelect = Boolean(
|
|
||||||
window.getSelection() &&
|
|
||||||
document.activeElement &&
|
|
||||||
document.activeElement.value
|
|
||||||
);
|
|
||||||
|
|
||||||
this.selectedText = canSelect
|
|
||||||
? document.activeElement.value.substring(
|
|
||||||
document.activeElement.selectionStart,
|
|
||||||
document.activeElement.selectionEnd
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
this.selectionRange = canSelect
|
|
||||||
? {
|
|
||||||
x: document.activeElement.selectionStart,
|
|
||||||
y: document.activeElement.selectionEnd,
|
|
||||||
}
|
|
||||||
: { x: 0, y: 0 };
|
|
||||||
|
|
||||||
if (this.selectedText?.length === 0) {
|
|
||||||
this.menuInstance?.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.selectedText?.length < this.minSelectionChars) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
@debounce(INPUT_DELAY)
|
|
||||||
selectionChanged() {
|
|
||||||
this.positionMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
onMouseDown() {
|
|
||||||
this.isSelecting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
onMouseUp() {
|
|
||||||
this.isSelecting = false;
|
|
||||||
this.onSelectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
@bind
|
|
||||||
updatePosition() {
|
|
||||||
if (!this.menuInstance) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.positionMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
@afterRender
|
|
||||||
async positionMenu() {
|
|
||||||
this.caretCoords = getCaretPosition(this.dEditorInput, {
|
|
||||||
pos: caretPosition(this.dEditorInput),
|
|
||||||
});
|
|
||||||
const virtualElement = virtualElementFromCaretCoords(
|
|
||||||
this.caretCoords,
|
|
||||||
this.menuOffset
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.handleBoundaries(virtualElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position context menu at based on if interfering with button bar
|
|
||||||
this.menuInstance = await this.menu.show(virtualElement, {
|
|
||||||
identifier: "ai-composer-helper-menu",
|
|
||||||
placement: this.menuPlacement,
|
|
||||||
component: AiComposerHelperMenu,
|
|
||||||
inline: true,
|
|
||||||
modalForMobile: false,
|
|
||||||
data: {
|
|
||||||
selectedText: this.selectedText,
|
|
||||||
dEditorInput: this.dEditorInput,
|
|
||||||
selectionRange: this.selectionRange,
|
|
||||||
},
|
|
||||||
interactive: true,
|
|
||||||
onClose: () => {
|
|
||||||
this.aiComposerHelper.menuState =
|
|
||||||
this.aiComposerHelper.MENU_STATES.triggers;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.menuElement = this.menuInstance.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBoundaries(virtualElement) {
|
|
||||||
const textAreaWrapper = document
|
|
||||||
.querySelector(".d-editor-textarea-wrapper")
|
|
||||||
.getBoundingClientRect();
|
|
||||||
const buttonBar = document
|
|
||||||
.querySelector(".d-editor-button-bar")
|
|
||||||
.getBoundingClientRect();
|
|
||||||
const boundaryElement = {
|
|
||||||
top: buttonBar.bottom,
|
|
||||||
bottom: textAreaWrapper.bottom,
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuHeightBuffer = 35; // rough estimate of menu height since we can't get actual in this context.
|
|
||||||
if (this.caretCoords.y - menuHeightBuffer < boundaryElement.top) {
|
|
||||||
this.menuPlacement = "bottom-start";
|
|
||||||
} else {
|
|
||||||
this.menuPlacement = "top-start";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isScrolledOutOfBounds(boundaryElement, virtualElement)) {
|
|
||||||
this.menuInstance?.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isScrolledOutOfBounds(boundaryElement, virtualElement) {
|
|
||||||
// Hide context menu if it's scrolled out of bounds:
|
|
||||||
if (virtualElement.rect.top < boundaryElement.top) {
|
|
||||||
return true;
|
|
||||||
} else if (virtualElement.rect.bottom > boundaryElement.bottom) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div {{this.documentListeners}}></div>
|
|
||||||
</template>
|
|
||||||
}
|
|
|
@ -1,11 +1,16 @@
|
||||||
export function showComposerAIHelper(outletArgs, helper, featureType) {
|
export function showComposerAiHelper(
|
||||||
const enableHelper = _helperEnabled(helper.siteSettings);
|
composerModel,
|
||||||
const enableAssistant = helper.currentUser.can_use_assistant;
|
siteSettings,
|
||||||
const canShowInPM = helper.siteSettings.ai_helper_allowed_in_pm;
|
currentUser,
|
||||||
|
featureType
|
||||||
|
) {
|
||||||
|
const enableHelper = _helperEnabled(siteSettings);
|
||||||
|
const enableAssistant = currentUser.can_use_assistant;
|
||||||
|
const canShowInPM = siteSettings.ai_helper_allowed_in_pm;
|
||||||
const enableFeature =
|
const enableFeature =
|
||||||
helper.siteSettings.ai_helper_enabled_features.includes(featureType);
|
siteSettings.ai_helper_enabled_features.includes(featureType);
|
||||||
|
|
||||||
if (outletArgs?.composer?.privateMessage) {
|
if (composerModel?.privateMessage) {
|
||||||
return enableHelper && enableAssistant && canShowInPM && enableFeature;
|
return enableHelper && enableAssistant && canShowInPM && enableFeature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { tracked } from "@glimmer/tracking";
|
|
||||||
import Service from "@ember/service";
|
|
||||||
|
|
||||||
export default class AiComposerHelper extends Service {
|
|
||||||
@tracked menuState = this.MENU_STATES.triggers;
|
|
||||||
|
|
||||||
MENU_STATES = {
|
|
||||||
triggers: "TRIGGERS",
|
|
||||||
options: "OPTIONS",
|
|
||||||
resets: "RESETS",
|
|
||||||
loading: "LOADING",
|
|
||||||
review: "REVIEW",
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,41 +1,86 @@
|
||||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
import i18n from "discourse-common/helpers/i18n";
|
import i18n from "discourse-common/helpers/i18n";
|
||||||
|
import AiComposerHelperMenu from "../discourse/components/ai-composer-helper-menu";
|
||||||
import ModalDiffModal from "../discourse/components/modal/diff-modal";
|
import ModalDiffModal from "../discourse/components/modal/diff-modal";
|
||||||
|
import { showComposerAiHelper } from "../discourse/lib/show-ai-helper";
|
||||||
|
|
||||||
function initializeProofread(api) {
|
function initializeAiHelperTrigger(api) {
|
||||||
api.addComposerToolbarPopupMenuOption({
|
const showErrorToast = () => {
|
||||||
action: (toolbarEvent) => {
|
const toasts = api.container.lookup("service:toasts");
|
||||||
const modal = api.container.lookup("service:modal");
|
|
||||||
const composer = api.container.lookup("service:composer");
|
|
||||||
const toasts = api.container.lookup("service:toasts");
|
|
||||||
|
|
||||||
if (composer.model.reply?.length === 0) {
|
return toasts.error({
|
||||||
toasts.error({
|
duration: 3000,
|
||||||
duration: 3000,
|
data: {
|
||||||
data: {
|
message: i18n("discourse_ai.ai_helper.no_content_error"),
|
||||||
message: i18n("discourse_ai.ai_helper.proofread.no_content_error"),
|
},
|
||||||
},
|
});
|
||||||
});
|
};
|
||||||
return;
|
|
||||||
|
api.onToolbarCreate((toolbar) => {
|
||||||
|
const currentUser = api.getCurrentUser();
|
||||||
|
const modal = api.container.lookup("service:modal");
|
||||||
|
|
||||||
|
const selectedText = (toolbarEvent) => {
|
||||||
|
const composerContent = toolbarEvent.getText();
|
||||||
|
const selected = toolbarEvent.selected.value;
|
||||||
|
|
||||||
|
if (selected && selected.length > 0) {
|
||||||
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.show(ModalDiffModal, {
|
if (composerContent && composerContent.length > 0) {
|
||||||
model: {
|
return composerContent;
|
||||||
toolbarEvent,
|
}
|
||||||
},
|
};
|
||||||
});
|
toolbar.addButton({
|
||||||
},
|
id: "ai-helper-trigger",
|
||||||
icon: "spell-check",
|
group: "extras",
|
||||||
label: "discourse_ai.ai_helper.context_menu.proofread_prompt",
|
icon: "discourse-sparkles",
|
||||||
shortcut: "ALT+P",
|
title: "discourse_ai.ai_helper.context_menu.trigger",
|
||||||
condition: () => {
|
preventFocus: true,
|
||||||
const siteSettings = api.container.lookup("service:site-settings");
|
shortcut: "ALT+P",
|
||||||
const currentUser = api.getCurrentUser();
|
shortcutAction: (toolbarEvent) => {
|
||||||
|
if (toolbarEvent.getText().length === 0) {
|
||||||
|
return showErrorToast();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const mode = currentUser?.ai_helper_prompts.find(
|
||||||
siteSettings.ai_helper_enabled && currentUser?.can_use_assistant_in_post
|
(p) => p.name === "proofread"
|
||||||
);
|
).id;
|
||||||
},
|
|
||||||
|
modal.show(ModalDiffModal, {
|
||||||
|
model: {
|
||||||
|
mode,
|
||||||
|
selectedText: selectedText(toolbarEvent),
|
||||||
|
toolbarEvent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
condition: () =>
|
||||||
|
showComposerAiHelper(
|
||||||
|
api.container.lookup("service:composer").model,
|
||||||
|
api.container.lookup("service:site-settings"),
|
||||||
|
currentUser,
|
||||||
|
"context_menu"
|
||||||
|
),
|
||||||
|
sendAction: (event) => {
|
||||||
|
if (toolbar.context.value.length === 0) {
|
||||||
|
return showErrorToast();
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = api.container.lookup("service:menu");
|
||||||
|
menu.show(document.querySelector(".ai-helper-trigger"), {
|
||||||
|
identifier: "ai-composer-helper-menu",
|
||||||
|
component: AiComposerHelperMenu,
|
||||||
|
modalForMobile: true,
|
||||||
|
interactive: true,
|
||||||
|
data: {
|
||||||
|
toolbarEvent: event,
|
||||||
|
selectedText: selectedText(event),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +89,7 @@ export default {
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
withPluginApi("1.1.0", (api) => {
|
withPluginApi("1.1.0", (api) => {
|
||||||
initializeProofread(api);
|
initializeAiHelperTrigger(api);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -37,6 +37,12 @@
|
||||||
background-color: var(--success-low);
|
background-color: var(--success-low);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.d-modal__footer {
|
||||||
|
.regenerate {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-above-suggested-outlet.related-topics {
|
.topic-above-suggested-outlet.related-topics {
|
||||||
|
@ -44,11 +50,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-composer-helper-menu {
|
.ai-composer-helper-menu {
|
||||||
background: var(--secondary);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
max-width: 25rem;
|
max-width: 25rem;
|
||||||
border: 1px solid var(--primary-low);
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
|
@ -58,23 +61,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-helper-button-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
&__resets {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__review {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-custom-prompt {
|
.ai-custom-prompt {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
@ -569,6 +555,19 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.shortcut {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: var(--font-down-1);
|
||||||
|
color: var(--primary-low-mid);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__button {
|
&__button {
|
||||||
justify-content: left;
|
justify-content: left;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
|
@ -15,18 +15,6 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-post-helper-menu {
|
|
||||||
&__selected-text {
|
|
||||||
margin: 0.75em 1rem;
|
|
||||||
padding: 0.5em;
|
|
||||||
border-radius: var(--d-border-radius);
|
|
||||||
border: 1px solid var(--primary-low);
|
|
||||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.125);
|
|
||||||
overflow: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-helper-options {
|
.ai-helper-options {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
@ -40,4 +28,17 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-post-helper-menu,
|
||||||
|
.ai-composer-helper-menu {
|
||||||
|
&__selected-text {
|
||||||
|
margin: 0.75em 1rem;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-radius: var(--d-border-radius);
|
||||||
|
border: 1px solid var(--primary-low);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.125);
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,87 +0,0 @@
|
||||||
:root {
|
|
||||||
// Overridden in ai-composer-helper.gjs to adjust for virtual keyboard
|
|
||||||
--mobile-virtual-screen-height: 100svh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-composer-helper-menu:not(.is-expanded) {
|
|
||||||
--composer-helper-menu-padding: 1rem;
|
|
||||||
--composer-helper-menu-trigger-size: 3em;
|
|
||||||
--composer-helper-menu-height: calc(1rem + 3em);
|
|
||||||
|
|
||||||
position: fixed;
|
|
||||||
list-style: none;
|
|
||||||
inset: auto;
|
|
||||||
transform: none;
|
|
||||||
max-width: unset;
|
|
||||||
z-index: z("fullscreen");
|
|
||||||
top: calc(
|
|
||||||
var(--mobile-virtual-screen-height) - var(--composer-helper-menu-height) -
|
|
||||||
env(keyboard-inset-height)
|
|
||||||
);
|
|
||||||
right: 0;
|
|
||||||
padding-right: 1rem;
|
|
||||||
padding-bottom: var(--composer-helper-menu-padding);
|
|
||||||
margin: 0px;
|
|
||||||
width: 100vw;
|
|
||||||
text-align: right;
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0,
|
|
||||||
rgba(0, 0, 0, 0.3) 100%
|
|
||||||
);
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
&.out-of-bounds {
|
|
||||||
visibility: visible;
|
|
||||||
pointer-events: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: var(--secondary);
|
|
||||||
border: 1px solid var(--primary-low);
|
|
||||||
border-radius: 100%;
|
|
||||||
box-shadow: 0 0 10px 2px
|
|
||||||
dark-light-choose(
|
|
||||||
rgba(var(--primary-rgb), 0.1),
|
|
||||||
rgba(var(--secondary-rgb), 0.1)
|
|
||||||
);
|
|
||||||
width: var(--composer-helper-menu-trigger-size);
|
|
||||||
height: var(--composer-helper-menu-trigger-size);
|
|
||||||
animation-duration: 0.15s;
|
|
||||||
animation-timing-function: cubic-bezier(0, 0.7, 0.9, 1.2);
|
|
||||||
animation-fill-mode: backwards;
|
|
||||||
animation-name: slide-in;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.discourse-no-touch & {
|
|
||||||
background: var(--secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.d-button-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.d-icon {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--tertiary-high);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ios-safari-composer-hacks .ai-composer-helper-menu:not(.is-expanded) {
|
|
||||||
top: calc(
|
|
||||||
var(--mobile-virtual-screen-height) - var(--composer-helper-menu-height)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-in {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(calc(100% + 1rem));
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -293,22 +293,18 @@ en:
|
||||||
suggest: "Suggest with AI"
|
suggest: "Suggest with AI"
|
||||||
missing_content: "Please enter some content to generate suggestions."
|
missing_content: "Please enter some content to generate suggestions."
|
||||||
context_menu:
|
context_menu:
|
||||||
back: "Back"
|
trigger: "Ask AI"
|
||||||
trigger: "AI"
|
loading: "AI is generating..."
|
||||||
undo: "Undo"
|
|
||||||
loading: "AI is generating"
|
|
||||||
cancel: "Cancel"
|
cancel: "Cancel"
|
||||||
regen: "Try Again"
|
regen: "Try Again"
|
||||||
view_changes: "View Changes"
|
|
||||||
confirm: "Confirm"
|
confirm: "Confirm"
|
||||||
revert: "Revert"
|
discard: "Discard"
|
||||||
changes: "Suggested Edits"
|
changes: "Suggested Edits"
|
||||||
custom_prompt:
|
custom_prompt:
|
||||||
title: "Custom Prompt"
|
title: "Custom Prompt"
|
||||||
placeholder: "Enter a custom prompt..."
|
placeholder: "Enter a custom prompt..."
|
||||||
submit: "Send Prompt"
|
submit: "Send Prompt"
|
||||||
translate_prompt: "Translate to %{language}"
|
translate_prompt: "Translate to %{language}"
|
||||||
proofread_prompt: "Proofread Text"
|
|
||||||
post_options_menu:
|
post_options_menu:
|
||||||
trigger: "Ask AI"
|
trigger: "Ask AI"
|
||||||
title: "Ask AI"
|
title: "Ask AI"
|
||||||
|
@ -336,8 +332,7 @@ en:
|
||||||
prompt: "This post contains non-captioned images. Would you like to enable automatic AI captions on image uploads? (This can be changed in your preferences later)"
|
prompt: "This post contains non-captioned images. Would you like to enable automatic AI captions on image uploads? (This can be changed in your preferences later)"
|
||||||
confirm: "Enable"
|
confirm: "Enable"
|
||||||
cancel: "Don't ask again"
|
cancel: "Don't ask again"
|
||||||
proofread:
|
no_content_error: "Please add some content to perform AI actions on."
|
||||||
no_content_error: "Unable to proofread text, please add some content to proofread."
|
|
||||||
|
|
||||||
reviewables:
|
reviewables:
|
||||||
model_used: "Model used:"
|
model_used: "Model used:"
|
||||||
|
|
|
@ -17,7 +17,6 @@ register_asset "stylesheets/common/streaming.scss"
|
||||||
|
|
||||||
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
|
||||||
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
|
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
|
||||||
register_asset "stylesheets/modules/ai-helper/mobile/ai-helper.scss", :mobile
|
|
||||||
register_asset "stylesheets/modules/ai-helper/mobile/ai-helper-fk-modals.scss", :mobile
|
register_asset "stylesheets/modules/ai-helper/mobile/ai-helper-fk-modals.scss", :mobile
|
||||||
|
|
||||||
register_asset "stylesheets/modules/summarization/mobile/ai-summary.scss", :mobile
|
register_asset "stylesheets/modules/summarization/mobile/ai-summary.scss", :mobile
|
||||||
|
|
|
@ -12,11 +12,12 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:input) { "The rain in spain stays mainly in the Plane." }
|
let(:input) { "The rain in spain stays mainly in the Plane." }
|
||||||
|
|
||||||
let(:composer) { PageObjects::Components::Composer.new }
|
let(:composer) { PageObjects::Components::Composer.new }
|
||||||
let(:ai_helper_context_menu) { PageObjects::Components::AiComposerHelperMenu.new }
|
let(:ai_helper_menu) { PageObjects::Components::AiComposerHelperMenu.new }
|
||||||
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
||||||
let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
|
let(:ai_suggestion_dropdown) { PageObjects::Components::AISuggestionDropdown.new }
|
||||||
|
let(:toasts) { PageObjects::Components::Toasts.new }
|
||||||
|
|
||||||
fab!(:category)
|
fab!(:category)
|
||||||
fab!(:category_2) { Fabricate(:category) }
|
fab!(:category_2) { Fabricate(:category) }
|
||||||
fab!(:video) { Fabricate(:tag) }
|
fab!(:video) { Fabricate(:tag) }
|
||||||
|
@ -25,51 +26,28 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
fab!(:feedback) { Fabricate(:tag) }
|
fab!(:feedback) { Fabricate(:tag) }
|
||||||
fab!(:review) { Fabricate(:tag) }
|
fab!(:review) { Fabricate(:tag) }
|
||||||
|
|
||||||
def trigger_context_menu(content)
|
def trigger_composer_helper(content)
|
||||||
visit("/latest")
|
visit("/latest")
|
||||||
page.find("#create-topic").click
|
page.find("#create-topic").click
|
||||||
composer.fill_content(content)
|
composer.fill_content(content)
|
||||||
page.execute_script("document.querySelector('.d-editor-input')?.select();")
|
composer.click_toolbar_button("ai-helper-trigger")
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when triggering AI with context menu in composer" do
|
context "when triggering composer AI helper" do
|
||||||
it "shows the context menu when selecting a passage of text in the composer" do
|
it "shows the context menu when clicking the AI button in the composer toolbar" do
|
||||||
trigger_context_menu(input)
|
trigger_composer_helper(input)
|
||||||
expect(ai_helper_context_menu).to have_context_menu
|
expect(ai_helper_menu).to have_context_menu
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not show the context menu when selecting insufficient text" do
|
it "shows a toast error when clicking the AI button without content" do
|
||||||
visit("/latest")
|
trigger_composer_helper("")
|
||||||
page.find("#create-topic").click
|
expect(ai_helper_menu).to have_no_context_menu
|
||||||
composer.fill_content(input)
|
expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.no_content_error"))
|
||||||
page.execute_script(
|
|
||||||
"const input = document.querySelector('.d-editor-input'); input.setSelectionRange(0, 2);",
|
|
||||||
)
|
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "shows context menu in 'trigger' state when first showing" do
|
it "shows prompt options in menu when AI button is clicked" do
|
||||||
trigger_context_menu(input)
|
trigger_composer_helper(input)
|
||||||
expect(ai_helper_context_menu).to be_showing_triggers
|
expect(ai_helper_menu).to be_showing_options
|
||||||
end
|
|
||||||
|
|
||||||
it "shows prompt options in context menu when AI button is clicked" do
|
|
||||||
trigger_context_menu(input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
expect(ai_helper_context_menu).to be_showing_options
|
|
||||||
end
|
|
||||||
|
|
||||||
it "closes the context menu when clicking outside" do
|
|
||||||
trigger_context_menu(input)
|
|
||||||
find(".d-editor-preview").click
|
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
|
||||||
end
|
|
||||||
|
|
||||||
it "closes the context menu when selected text is deleted" do
|
|
||||||
trigger_context_menu(input)
|
|
||||||
expect(ai_helper_context_menu).to have_context_menu
|
|
||||||
page.send_keys(:backspace)
|
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when using custom prompt" do
|
context "when using custom prompt" do
|
||||||
|
@ -79,41 +57,28 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
let(:custom_prompt_response) { "La pluie en Espagne reste principalement dans l'avion." }
|
let(:custom_prompt_response) { "La pluie en Espagne reste principalement dans l'avion." }
|
||||||
|
|
||||||
it "shows custom prompt option" do
|
it "shows custom prompt option" do
|
||||||
trigger_context_menu(input)
|
trigger_composer_helper(input)
|
||||||
ai_helper_context_menu.click_ai_button
|
expect(ai_helper_menu).to have_custom_prompt
|
||||||
expect(ai_helper_context_menu).to have_custom_prompt
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "enables the custom prompt button when input is filled" do
|
it "enables the custom prompt button when input is filled" do
|
||||||
trigger_context_menu(input)
|
trigger_composer_helper(input)
|
||||||
ai_helper_context_menu.click_ai_button
|
expect(ai_helper_menu).to have_custom_prompt_button_disabled
|
||||||
expect(ai_helper_context_menu).to have_custom_prompt_button_disabled
|
ai_helper_menu.fill_custom_prompt(custom_prompt_input)
|
||||||
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input)
|
expect(ai_helper_menu).to have_custom_prompt_button_enabled
|
||||||
expect(ai_helper_context_menu).to have_custom_prompt_button_enabled
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "replaces the composed message with AI generated content" do
|
it "replaces the composed message with AI generated content" do
|
||||||
trigger_context_menu(input)
|
trigger_composer_helper(input)
|
||||||
ai_helper_context_menu.click_ai_button
|
ai_helper_menu.fill_custom_prompt(custom_prompt_input)
|
||||||
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input)
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([custom_prompt_response]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses([custom_prompt_response]) do
|
||||||
ai_helper_context_menu.click_custom_prompt_button
|
ai_helper_menu.click_custom_prompt_button
|
||||||
|
diff_modal.confirm_changes
|
||||||
wait_for { composer.composer_input.value == custom_prompt_response }
|
wait_for { composer.composer_input.value == custom_prompt_response }
|
||||||
|
|
||||||
expect(composer.composer_input.value).to eq(custom_prompt_response)
|
expect(composer.composer_input.value).to eq(custom_prompt_response)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should not close the context menu if backspace is pressed" do
|
|
||||||
trigger_context_menu(input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
expect(ai_helper_context_menu).to have_context_menu
|
|
||||||
ai_helper_context_menu.fill_custom_prompt(custom_prompt_input)
|
|
||||||
page.find(".ai-custom-prompt__input").send_keys(:backspace)
|
|
||||||
expect(ai_helper_context_menu).to have_context_menu
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when not a member of custom prompt group" do
|
context "when not a member of custom prompt group" do
|
||||||
|
@ -121,9 +86,8 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s }
|
before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s }
|
||||||
|
|
||||||
it "does not show custom prompt option" do
|
it "does not show custom prompt option" do
|
||||||
trigger_context_menu(input)
|
trigger_composer_helper(input)
|
||||||
ai_helper_context_menu.click_ai_button
|
expect(ai_helper_menu).to have_no_custom_prompt
|
||||||
expect(ai_helper_context_menu).to have_no_custom_prompt
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -133,129 +97,51 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
let(:spanish_input) { "La lluvia en España se queda principalmente en el avión." }
|
let(:spanish_input) { "La lluvia en España se queda principalmente en el avión." }
|
||||||
|
|
||||||
it "replaces the composed message with AI generated content" do
|
it "replaces the composed message with AI generated content" do
|
||||||
trigger_context_menu(spanish_input)
|
trigger_composer_helper(spanish_input)
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
ai_helper_menu.select_helper_model(mode)
|
||||||
|
diff_modal.confirm_changes
|
||||||
wait_for { composer.composer_input.value == input }
|
wait_for { composer.composer_input.value == input }
|
||||||
|
|
||||||
expect(composer.composer_input.value).to eq(input)
|
expect(composer.composer_input.value).to eq(input)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "shows reset options after results are complete" do
|
|
||||||
trigger_context_menu(spanish_input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
|
||||||
|
|
||||||
wait_for { composer.composer_input.value == input }
|
|
||||||
|
|
||||||
ai_helper_context_menu.click_confirm_button
|
|
||||||
expect(ai_helper_context_menu).to be_showing_resets
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "reverts results when Undo button is clicked" do
|
|
||||||
trigger_context_menu(spanish_input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
|
||||||
|
|
||||||
wait_for { composer.composer_input.value == input }
|
|
||||||
|
|
||||||
ai_helper_context_menu.click_confirm_button
|
|
||||||
ai_helper_context_menu.click_undo_button
|
|
||||||
expect(composer.composer_input.value).to eq(spanish_input)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "reverts results when revert button is clicked" do
|
|
||||||
trigger_context_menu(spanish_input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
|
||||||
|
|
||||||
wait_for { composer.composer_input.value == input }
|
|
||||||
|
|
||||||
ai_helper_context_menu.click_revert_button
|
|
||||||
expect(composer.composer_input.value).to eq(spanish_input)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "reverts results when Ctrl/Cmd + Z is pressed on the keyboard" do
|
it "reverts results when Ctrl/Cmd + Z is pressed on the keyboard" do
|
||||||
trigger_context_menu(spanish_input)
|
trigger_composer_helper(spanish_input)
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
ai_helper_menu.select_helper_model(mode)
|
||||||
|
diff_modal.confirm_changes
|
||||||
wait_for { composer.composer_input.value == input }
|
wait_for { composer.composer_input.value == input }
|
||||||
|
ai_helper_menu.press_undo_keys
|
||||||
ai_helper_context_menu.press_undo_keys
|
|
||||||
expect(composer.composer_input.value).to eq(spanish_input)
|
expect(composer.composer_input.value).to eq(spanish_input)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "confirms the results when confirm button is pressed" do
|
it "shows the changes in a modal" do
|
||||||
trigger_context_menu(spanish_input)
|
trigger_composer_helper(spanish_input)
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
ai_helper_menu.select_helper_model(mode)
|
||||||
|
|
||||||
wait_for { composer.composer_input.value == input }
|
|
||||||
|
|
||||||
ai_helper_context_menu.click_confirm_button
|
|
||||||
expect(composer.composer_input.value).to eq(input)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "hides the context menu when pressing Escape on the keyboard" do
|
|
||||||
trigger_context_menu(spanish_input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
ai_helper_context_menu.press_escape_key
|
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
|
||||||
end
|
|
||||||
|
|
||||||
it "shows the changes in a modal when view changes button is pressed" do
|
|
||||||
trigger_context_menu(spanish_input)
|
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
|
||||||
|
|
||||||
wait_for { composer.composer_input.value == input }
|
|
||||||
|
|
||||||
ai_helper_context_menu.click_view_changes_button
|
|
||||||
expect(diff_modal).to be_visible
|
expect(diff_modal).to be_visible
|
||||||
expect(diff_modal.old_value).to eq(spanish_input.gsub(/[[:space:]]+/, " ").strip)
|
expect(diff_modal.old_value).to eq(spanish_input.gsub(/[[:space:]]+/, " ").strip)
|
||||||
expect(diff_modal.new_value).to eq(
|
expect(diff_modal.new_value).to eq(
|
||||||
input.gsub(/[[:space:]]+/, " ").gsub(/[‘’]/, "'").gsub(/[“”]/, '"').strip,
|
input.gsub(/[[:space:]]+/, " ").gsub(/[‘’]/, "'").gsub(/[“”]/, '"').strip,
|
||||||
)
|
)
|
||||||
diff_modal.confirm_changes
|
diff_modal.confirm_changes
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
expect(ai_helper_menu).to have_no_context_menu
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "reverts the changes when revert button is pressed in the modal" do
|
it "does not apply the changes when discard button is pressed in the modal" do
|
||||||
trigger_context_menu(spanish_input)
|
trigger_composer_helper(spanish_input)
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses([input]) do
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
ai_helper_menu.select_helper_model(mode)
|
||||||
|
|
||||||
wait_for { composer.composer_input.value == input }
|
|
||||||
|
|
||||||
ai_helper_context_menu.click_view_changes_button
|
|
||||||
expect(diff_modal).to be_visible
|
expect(diff_modal).to be_visible
|
||||||
diff_modal.revert_changes
|
diff_modal.discard_changes
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
expect(ai_helper_menu).to have_no_context_menu
|
||||||
expect(composer.composer_input.value).to eq(spanish_input)
|
expect(composer.composer_input.value).to eq(spanish_input)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -267,14 +153,12 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." }
|
let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." }
|
||||||
|
|
||||||
it "replaces the composed message with AI generated content" do
|
it "replaces the composed message with AI generated content" do
|
||||||
trigger_context_menu(input)
|
trigger_composer_helper(input)
|
||||||
ai_helper_context_menu.click_ai_button
|
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do
|
||||||
ai_helper_context_menu.select_helper_model(mode)
|
ai_helper_menu.select_helper_model(mode)
|
||||||
|
diff_modal.confirm_changes
|
||||||
wait_for { composer.composer_input.value == proofread_text }
|
wait_for { composer.composer_input.value == proofread_text }
|
||||||
|
|
||||||
expect(composer.composer_input.value).to eq(proofread_text)
|
expect(composer.composer_input.value).to eq(proofread_text)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -402,9 +286,11 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
let(:mode) { CompletionPrompt::GENERATE_TITLES }
|
let(:mode) { CompletionPrompt::GENERATE_TITLES }
|
||||||
before { SiteSetting.ai_helper_enabled = false }
|
before { SiteSetting.ai_helper_enabled = false }
|
||||||
|
|
||||||
it "does not trigger AI context menu" do
|
it "does not show the AI helper button in the composer toolbar" do
|
||||||
trigger_context_menu(input)
|
visit("/latest")
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
page.find("#create-topic").click
|
||||||
|
composer.fill_content(input)
|
||||||
|
expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not trigger AI suggestion buttons" do
|
it "does not trigger AI suggestion buttons" do
|
||||||
|
@ -419,9 +305,11 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
let(:mode) { CompletionPrompt::GENERATE_TITLES }
|
let(:mode) { CompletionPrompt::GENERATE_TITLES }
|
||||||
before { SiteSetting.composer_ai_helper_allowed_groups = non_member_group.id.to_s }
|
before { SiteSetting.composer_ai_helper_allowed_groups = non_member_group.id.to_s }
|
||||||
|
|
||||||
it "does not trigger AI context menu" do
|
it "does not show the AI helper button in the composer toolbar" do
|
||||||
trigger_context_menu(input)
|
visit("/latest")
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
page.find("#create-topic").click
|
||||||
|
composer.fill_content(input)
|
||||||
|
expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "does not trigger AI suggestion buttons" do
|
it "does not trigger AI suggestion buttons" do
|
||||||
|
@ -444,12 +332,14 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when context menu feature is disabled" do
|
context "when composer helper feature is disabled" do
|
||||||
before { SiteSetting.ai_helper_enabled_features = "suggestions" }
|
before { SiteSetting.ai_helper_enabled_features = "suggestions" }
|
||||||
|
|
||||||
it "does not show context menu in the composer" do
|
it "does not show button in the composer toolbar" do
|
||||||
trigger_context_menu(input)
|
visit("/latest")
|
||||||
expect(ai_helper_context_menu).to have_no_context_menu
|
page.find("#create-topic").click
|
||||||
|
composer.fill_content(input)
|
||||||
|
expect(page).to have_no_css(".d-editor-button-bar button.ai-helper-trigger")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
include SystemHelpers
|
||||||
|
|
||||||
RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
|
RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
|
||||||
fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
|
fab!(:admin) { Fabricate(:admin, refresh_auto_groups: true) }
|
||||||
|
|
||||||
|
@ -11,47 +13,42 @@ RSpec.describe "AI Composer Proofreading Features", type: :system, js: true do
|
||||||
|
|
||||||
let(:composer) { PageObjects::Components::Composer.new }
|
let(:composer) { PageObjects::Components::Composer.new }
|
||||||
let(:toasts) { PageObjects::Components::Toasts.new }
|
let(:toasts) { PageObjects::Components::Toasts.new }
|
||||||
|
let(:diff_modal) { PageObjects::Modals::DiffModal.new }
|
||||||
|
|
||||||
it "proofreads selected text using the composer toolbar" do
|
context "when triggering via keyboard shortcut" do
|
||||||
visit "/new-topic"
|
it "proofreads selected text using" do
|
||||||
composer.fill_content("hello worldd !")
|
visit "/new-topic"
|
||||||
|
composer.fill_content("hello worldd !")
|
||||||
|
|
||||||
composer.select_range(6, 12)
|
composer.select_range(6, 12)
|
||||||
|
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses(["world"]) do
|
||||||
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options")
|
composer.composer_input.send_keys([PLATFORM_KEY_MODIFIER, :alt, "p"])
|
||||||
ai_toolbar.expand
|
diff_modal.confirm_changes
|
||||||
ai_toolbar.select_row_by_name("Proofread Text")
|
expect(composer.composer_input.value).to eq("hello world !")
|
||||||
|
end
|
||||||
find(".composer-ai-helper-modal .btn-primary.confirm").click
|
|
||||||
expect(composer.composer_input.value).to eq("hello world !")
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
it "proofreads all text when nothing is selected" do
|
it "proofreads all text when nothing is selected" do
|
||||||
visit "/new-topic"
|
visit "/new-topic"
|
||||||
composer.fill_content("hello worrld")
|
composer.fill_content("hello worrld")
|
||||||
|
|
||||||
# Simulate AI response
|
# Simulate AI response
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do
|
||||||
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options")
|
composer.composer_input.send_keys([PLATFORM_KEY_MODIFIER, :alt, "p"])
|
||||||
ai_toolbar.expand
|
diff_modal.confirm_changes
|
||||||
ai_toolbar.select_row_by_name("Proofread Text")
|
expect(composer.composer_input.value).to eq("hello world")
|
||||||
|
end
|
||||||
find(".composer-ai-helper-modal .btn-primary.confirm").click
|
|
||||||
expect(composer.composer_input.value).to eq("hello world")
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
it "does not trigger proofread modal if composer is empty" do
|
it "does not trigger proofread modal if composer is empty" do
|
||||||
visit "/new-topic"
|
visit "/new-topic"
|
||||||
|
|
||||||
# Simulate AI response
|
# Simulate AI response
|
||||||
DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do
|
DiscourseAi::Completions::Llm.with_prepared_responses(["hello world"]) do
|
||||||
ai_toolbar = PageObjects::Components::SelectKit.new(".toolbar-popup-menu-options")
|
composer.composer_input.send_keys([PLATFORM_KEY_MODIFIER, :alt, "p"])
|
||||||
ai_toolbar.expand
|
expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.no_content_error"))
|
||||||
ai_toolbar.select_row_by_name("Proofread Text")
|
end
|
||||||
expect(toasts).to have_error(I18n.t("js.discourse_ai.ai_helper.proofread.no_content_error"))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,41 +5,18 @@ module PageObjects
|
||||||
class AiComposerHelperMenu < PageObjects::Components::Base
|
class AiComposerHelperMenu < PageObjects::Components::Base
|
||||||
COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
|
COMPOSER_EDITOR_SELECTOR = ".d-editor-input"
|
||||||
CONTEXT_MENU_SELECTOR = ".ai-composer-helper-menu"
|
CONTEXT_MENU_SELECTOR = ".ai-composer-helper-menu"
|
||||||
TRIGGER_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__triggers"
|
|
||||||
OPTIONS_STATE_SELECTOR = ".ai-helper-options"
|
OPTIONS_STATE_SELECTOR = ".ai-helper-options"
|
||||||
LOADING_STATE_SELECTOR = ".ai-helper-loading"
|
LOADING_STATE_SELECTOR = ".ai-helper-loading"
|
||||||
RESETS_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__resets"
|
|
||||||
REVIEW_STATE_SELECTOR = "#{CONTEXT_MENU_SELECTOR}__review"
|
|
||||||
CUSTOM_PROMPT_SELECTOR = "#{CONTEXT_MENU_SELECTOR} .ai-custom-prompt"
|
CUSTOM_PROMPT_SELECTOR = "#{CONTEXT_MENU_SELECTOR} .ai-custom-prompt"
|
||||||
CUSTOM_PROMPT_INPUT_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__input"
|
CUSTOM_PROMPT_INPUT_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__input"
|
||||||
CUSTOM_PROMPT_BUTTON_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__submit"
|
CUSTOM_PROMPT_BUTTON_SELECTOR = "#{CUSTOM_PROMPT_SELECTOR}__submit"
|
||||||
|
|
||||||
def click_ai_button
|
|
||||||
find("#{TRIGGER_STATE_SELECTOR} .btn").click
|
|
||||||
end
|
|
||||||
|
|
||||||
def select_helper_model(mode)
|
def select_helper_model(mode)
|
||||||
find(
|
find(
|
||||||
"#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .ai-helper-options__button",
|
"#{OPTIONS_STATE_SELECTOR} li[data-value=\"#{mode}\"] .ai-helper-options__button",
|
||||||
).click
|
).click
|
||||||
end
|
end
|
||||||
|
|
||||||
def click_undo_button
|
|
||||||
find("#{RESETS_STATE_SELECTOR} .undo").click
|
|
||||||
end
|
|
||||||
|
|
||||||
def click_revert_button
|
|
||||||
find("#{REVIEW_STATE_SELECTOR} .revert").click
|
|
||||||
end
|
|
||||||
|
|
||||||
def click_view_changes_button
|
|
||||||
find("#{REVIEW_STATE_SELECTOR} .view-changes").click
|
|
||||||
end
|
|
||||||
|
|
||||||
def click_confirm_button
|
|
||||||
find("#{REVIEW_STATE_SELECTOR} .confirm").click
|
|
||||||
end
|
|
||||||
|
|
||||||
def press_undo_keys
|
def press_undo_keys
|
||||||
find(COMPOSER_EDITOR_SELECTOR).send_keys([PLATFORM_KEY_MODIFIER, "z"])
|
find(COMPOSER_EDITOR_SELECTOR).send_keys([PLATFORM_KEY_MODIFIER, "z"])
|
||||||
end
|
end
|
||||||
|
@ -65,10 +42,6 @@ module PageObjects
|
||||||
page.has_no_css?(CONTEXT_MENU_SELECTOR)
|
page.has_no_css?(CONTEXT_MENU_SELECTOR)
|
||||||
end
|
end
|
||||||
|
|
||||||
def showing_triggers?
|
|
||||||
page.has_css?(TRIGGER_STATE_SELECTOR)
|
|
||||||
end
|
|
||||||
|
|
||||||
def showing_options?
|
def showing_options?
|
||||||
page.has_css?(OPTIONS_STATE_SELECTOR)
|
page.has_css?(OPTIONS_STATE_SELECTOR)
|
||||||
end
|
end
|
||||||
|
@ -77,14 +50,6 @@ module PageObjects
|
||||||
page.has_css?(LOADING_STATE_SELECTOR)
|
page.has_css?(LOADING_STATE_SELECTOR)
|
||||||
end
|
end
|
||||||
|
|
||||||
def showing_resets?
|
|
||||||
page.has_css?(RESETS_STATE_SELECTOR)
|
|
||||||
end
|
|
||||||
|
|
||||||
def not_showing_resets?
|
|
||||||
page.has_no_css?(RESETS_STATE_SELECTOR)
|
|
||||||
end
|
|
||||||
|
|
||||||
def has_custom_prompt?
|
def has_custom_prompt?
|
||||||
page.has_css?(CUSTOM_PROMPT_SELECTOR)
|
page.has_css?(CUSTOM_PROMPT_SELECTOR)
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,8 +11,8 @@ module PageObjects
|
||||||
find(".d-modal__footer button.confirm", wait: 5).click
|
find(".d-modal__footer button.confirm", wait: 5).click
|
||||||
end
|
end
|
||||||
|
|
||||||
def revert_changes
|
def discard_changes
|
||||||
find(".d-modal__footer button.revert", wait: 5).click
|
find(".d-modal__footer button.discard", wait: 5).click
|
||||||
end
|
end
|
||||||
|
|
||||||
def old_value
|
def old_value
|
||||||
|
|
Loading…
Reference in New Issue