From cce94aa936ad4a16cd6fc9fa8b27c87d3fd20a5f Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:51:30 +0530 Subject: [PATCH] chore: rework the canned response picker with search and preview (#15354) # Pull Request Template ## Description This PR reworks the canned response picker to make it easier to search, browse, and preview canned responses before inserting them. Typing `/` now opens a larger picker with its own search field and a preview pane. Previously, only a few responses were visible at a time, there was no way to preview the full content, and searching relied on typing into the composer, which stopped working for multi-word queries. Search is now handled entirely inside the picker, so the composer stays untouched while searching. Results match both the canned response shortcut and its content, and each result shows a snippet centered around the matched text instead of always displaying the beginning of the response. The preview renders the response exactly as it will be inserted, with variables resolved against the current conversation and formatting unsupported by the channel already stripped. The picker is positioned relative to the current typing line and teleported to `body`, so it is no longer clipped by the composer. It behaves consistently across the reply editor, the New Conversation composer, and narrower editors such as Contact Notes, where the preview moves below the list instead of disappearing. This also fixes a pre-existing bug where variables without a value were removed from the inserted text instead of being left for the backend to resolve. In the New Conversation composer, where no variables are available, all `{{ }}` placeholders were previously being silently removed. Fixes https://linear.app/chatwoot/issue/CW-7854/inconvenient-canned-response-picker-and-lack-of-personal-canned ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screenshots image image ### Steps 1. Open a conversation and type `/` in the reply editor. 2. Search using a multi-word phrase that appears within a canned response. Verify the matching response appears with a snippet centered around the matched text. 3. Navigate the results with the arrow keys or Tab and verify the preview updates. 4. Press Enter or click a response to insert it, and press Escape to close the picker. 5. Repeat in a narrow editor such as Contact Notes and verify the preview pane moves below the list. 6. In the New Conversation composer, insert a canned response containing variables and verify the `{{ }}` placeholders are kept rather than removed. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../dashboard/api/cannedResponse.js | 8 +- .../components-next/popover/Popover.vue | 16 +- .../preview-picker/CaretAnchoredPicker.vue | 195 ++++++++++++++++++ .../preview-picker/PreviewPicker.vue | 175 ++++++++++++++++ .../components/widgets/WootWriter/Editor.vue | 51 ++++- .../widgets/conversation/CannedResponse.vue | 176 ++++++++++++---- .../composables/useKeyboardNavigableList.js | 2 + .../dashboard/helper/editorHelper.js | 13 +- .../helper/specs/editorContentHelper.spec.js | 43 +++- .../dashboard/store/modules/cannedResponse.js | 4 +- 10 files changed, 612 insertions(+), 71 deletions(-) create mode 100644 app/javascript/dashboard/components-next/preview-picker/CaretAnchoredPicker.vue create mode 100644 app/javascript/dashboard/components-next/preview-picker/PreviewPicker.vue 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) {