diff --git a/app/javascript/dashboard/api/cannedResponse.js b/app/javascript/dashboard/api/cannedResponse.js index f558dcaca..1299a395c 100644 --- a/app/javascript/dashboard/api/cannedResponse.js +++ b/app/javascript/dashboard/api/cannedResponse.js @@ -7,9 +7,11 @@ class CannedResponse extends ApiClient { super('canned_responses', { accountScoped: true }); } - get({ searchKey }) { - const url = searchKey ? `${this.url}?search=${searchKey}` : this.url; - return axios.get(url); + get({ searchKey, signal } = {}) { + return axios.get(this.url, { + params: searchKey ? { search: searchKey } : undefined, + signal, + }); } } diff --git a/app/javascript/dashboard/components-next/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue index 9d369133f..6691e0a32 100644 --- a/app/javascript/dashboard/components-next/popover/Popover.vue +++ b/app/javascript/dashboard/components-next/popover/Popover.vue @@ -107,9 +107,23 @@ const clickOutsideIgnore = [ '[data-popover-content]', ]; +// An overlay opened from inside the popover teleports out of it, so its own Escape handler +// registers after this one and cannot stop it. Leave Escape to whichever overlay the key +// was pressed in; closing the popover out from under it would discard the work in progress. +const isNestedOverlay = event => { + const overlay = event.target?.closest?.(clickOutsideIgnore.join(',')); + return Boolean( + overlay && + overlay !== popoverRef.value && + overlay !== mobileContentRef.value + ); +}; + useKeyboardEvents({ Escape: { - action: () => isActive.value && hide(), + action: event => { + if (isActive.value && !isNestedOverlay(event)) hide(); + }, allowOnFocusedInput: true, }, }); diff --git a/app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue b/app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue new file mode 100644 index 000000000..1a5546595 --- /dev/null +++ b/app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue @@ -0,0 +1,195 @@ + + + diff --git a/app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue b/app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue new file mode 100644 index 000000000..fa3e69a31 --- /dev/null +++ b/app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue @@ -0,0 +1,175 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 634276361..3762a5bf3 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -196,7 +196,7 @@ const showEmojiMenu = ref(false); const showToolsMenu = ref(false); const mentionSearchKey = ref(''); const toolSearchKey = ref(''); -const cannedSearchTerm = ref(''); +const cannedSearchKey = ref(''); const variableSearchTerm = ref(''); const emojiSearchTerm = ref(''); const range = ref(null); @@ -208,6 +208,17 @@ const editorRoot = useTemplateRef('editorRoot'); const imageUpload = useTemplateRef('imageUpload'); const editor = useTemplateRef('editor'); +// Anchors the picker to the trigger character, since editors can be much taller than the +// line being typed on. Offsets are relative to the editor so the picker can sit on that +// line and track it from there. +const caretPosition = computed(() => { + if (!editorView || !range.value || !editorRoot.value) return null; + const from = Math.min(range.value.from, editorView.state.doc.content.size); + const { top, bottom } = editorView.coordsAtPos(from); + const editorTop = editorRoot.value.getBoundingClientRect().top; + return { top: top - editorTop, height: bottom - top }; +}); + const isEditorMenuPopover = computed( () => editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false @@ -242,22 +253,41 @@ const shouldShowCannedResponses = computed(() => { ); }); +// The picker owns the search field, so it takes focus while open. Dismissing it hands +// focus back; selecting one does so through the insert itself. The suggestion stays +// active in the document, so the picker only reopens once the trigger is typed afresh. +const dismissCannedResponses = () => { + showCannedMenu.value = false; + editorView?.focus(); +}; + +// Deleting the trigger drops the suggestion, so the plugin closes the picker through +// `onExit` on its own. +const removeSuggestionTrigger = () => { + if (!editorView || !range.value) return; + const { from, to } = range.value; + const end = Math.min(to, editorView.state.doc.content.size); + editorView.dispatch(editorView.state.tr.delete(from, end)); + editorView.focus(); +}; + function createSuggestionPlugin({ trigger, minChars = 0, showMenu, searchTerm, isAllowed = () => true, + interceptEnter = true, }) { return suggestionsPlugin({ matcher: triggerCharacters(trigger, minChars), suggestionClass: '', onEnter: args => { if (!isAllowed()) return false; - showMenu.value = true; range.value = args.range; editorView = args.view; if (searchTerm) searchTerm.value = args.text || ''; + showMenu.value = true; return false; }, onChange: args => { @@ -272,7 +302,7 @@ function createSuggestionPlugin({ return false; }, onKeyDown: ({ event }) => { - return event.keyCode === 13 && showMenu.value; + return event.keyCode === 13 && showMenu.value && interceptEnter; }, }); } @@ -298,8 +328,9 @@ const plugins = computed(() => { createSuggestionPlugin({ trigger: '/', showMenu: showCannedMenu, - searchTerm: cannedSearchTerm, + searchTerm: cannedSearchKey, isAllowed: () => !props.isPrivate, + interceptEnter: false, }), createSuggestionPlugin({ trigger: '{{', @@ -338,8 +369,8 @@ const sendWithSignature = computed(() => { watch(showUserMentions, updatedValue => { emit('toggleUserMention', props.isPrivate && updatedValue); }); -watch(showCannedMenu, updatedValue => { - emit('toggleCannedMenu', !props.isPrivate && updatedValue); +watch(shouldShowCannedResponses, updatedValue => { + emit('toggleCannedMenu', updatedValue); }); watch(showVariables, updatedValue => { emit('toggleVariablesMenu', !props.isPrivate && updatedValue); @@ -799,7 +830,6 @@ watch( showCannedMenu.value = false; showEmojiMenu.value = false; showVariables.value = false; - cannedSearchTerm.value = ''; reloadState(props.modelValue); } ); @@ -884,7 +914,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); /> -import { mapGetters } from 'vuex'; -import MentionBox from '../mentions/MentionBox.vue'; + - diff --git a/app/javascript/dashboard/composables/useKeyboardNavigableList.js b/app/javascript/dashboard/composables/useKeyboardNavigableList.js index aa9fb9e1d..82a8c22aa 100644 --- a/app/javascript/dashboard/composables/useKeyboardNavigableList.js +++ b/app/javascript/dashboard/composables/useKeyboardNavigableList.js @@ -92,6 +92,8 @@ export function useKeyboardNavigableList({ selectedIndex, }) { const moveSelection = direction => { + if (!items.value?.length) return; + selectedIndex.value = updateSelectionIndex( selectedIndex.value, items.value.length, diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 3d342f06e..e01a2f622 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -6,7 +6,6 @@ import { messageSchema, Selection, } from '@chatwoot/prosemirror-schema'; -import { replaceVariablesInMessage } from '@chatwoot/utils'; import * as Sentry from '@sentry/vue'; import camelcaseKeys from 'camelcase-keys'; import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor'; @@ -433,12 +432,19 @@ export function stripUnsupportedFormatting(content, schema) { // Liquid delimiters ({{ }} / {% %}) the backend evaluates on send. const LIQUID_SYNTAX = /\{\{|\{%/; +const VARIABLE_PLACEHOLDER = /{{(.*?)}}/g; + // Value when set (and not itself Liquid), else the {{placeholder}} for the backend. export const resolveVariableText = (key, variables) => { const value = String(variables?.[key] ?? ''); return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`; }; +export const resolveVariablesInMessage = (message, variables) => + message?.replace(VARIABLE_PLACEHOLDER, (_, key) => + resolveVariableText(key.trim(), variables) + ); + // Name variables normalized like the backend drops (UserDrop/ContactDrop): // name split on whitespace, each word Ruby-capitalized (rest downcased). const getNameVariables = (prefix, name) => { @@ -537,10 +543,7 @@ const nodeCreators = { to, }), cannedResponse: (editorView, content, from, to, variables) => { - const updatedMessage = replaceVariablesInMessage({ - message: content, - variables, - }); + const updatedMessage = resolveVariablesInMessage(content, variables); const node = createNode(editorView, 'cannedResponse', updatedMessage); return { node, diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js index 57d8bd533..66b8bfac3 100644 --- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js @@ -2,16 +2,11 @@ // the mock of chatwoot/prosemirror-schema is getting conflicted with other specs import { getContentNode } from '../editorHelper'; import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema'; -import { replaceVariablesInMessage } from '@chatwoot/utils'; vi.mock('@chatwoot/prosemirror-schema', () => ({ MessageMarkdownTransformer: vi.fn(), })); -vi.mock('@chatwoot/utils', () => ({ - replaceVariablesInMessage: vi.fn(), -})); - describe('getContentNode', () => { let editorView; @@ -61,8 +56,6 @@ describe('getContentNode', () => { // Mock the node that will be returned by parse const mockNode = { textContent: updatedMessage }; - replaceVariablesInMessage.mockReturnValue(updatedMessage); - // Mock MessageMarkdownTransformer instance with parse method const mockTransformer = { parse: vi.fn().mockReturnValue(mockNode), @@ -77,10 +70,6 @@ describe('getContentNode', () => { variables ); - expect(replaceVariablesInMessage).toHaveBeenCalledWith({ - message: content, - variables, - }); expect(MessageMarkdownTransformer).toHaveBeenCalledWith( editorView.state.schema ); @@ -91,6 +80,38 @@ describe('getContentNode', () => { expect(result.from).toBe(from); expect(result.to).toBe(to); }); + + it('should keep the placeholder for variables that have no value', () => { + const mockTransformer = { parse: vi.fn().mockReturnValue({}) }; + MessageMarkdownTransformer.mockImplementation(() => mockTransformer); + + getContentNode( + editorView, + 'cannedResponse', + 'Hi {{contact.name}}, your plan is {{contact.custom_attribute.plan}}', + { from: 0, to: 10 }, + { 'contact.name': 'John' } + ); + + expect(mockTransformer.parse).toHaveBeenCalledWith( + 'Hi John, your plan is {{contact.custom_attribute.plan}}' + ); + }); + + it('should keep every placeholder when no variables are available', () => { + const mockTransformer = { parse: vi.fn().mockReturnValue({}) }; + MessageMarkdownTransformer.mockImplementation(() => mockTransformer); + + getContentNode( + editorView, + 'cannedResponse', + 'Hi {{contact.name}}', + { from: 0, to: 10 }, + {} + ); + + expect(mockTransformer.parse).toHaveBeenCalledWith('Hi {{contact.name}}'); + }); }); describe('getVariableNode', () => { diff --git a/app/javascript/dashboard/store/modules/cannedResponse.js b/app/javascript/dashboard/store/modules/cannedResponse.js index 568150392..4845f06f7 100644 --- a/app/javascript/dashboard/store/modules/cannedResponse.js +++ b/app/javascript/dashboard/store/modules/cannedResponse.js @@ -35,11 +35,11 @@ const getters = { const actions = { getCannedResponse: async function getCannedResponse( { commit }, - { searchKey } = {} + { searchKey, signal } = {} ) { commit(types.default.SET_CANNED_UI_FLAG, { fetchingList: true }); try { - const response = await CannedResponseAPI.get({ searchKey }); + const response = await CannedResponseAPI.get({ searchKey, signal }); commit(types.default.SET_CANNED, response.data); commit(types.default.SET_CANNED_UI_FLAG, { fetchingList: false }); } catch (error) {