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
<img width="1135" height="576" alt="image"
src="https://github.com/user-attachments/assets/b5f27b94-eeb5-4ac6-b6d9-da7dfd8c2306"
/>
<img width="393" height="490" alt="image"
src="https://github.com/user-attachments/assets/779f88b1-4958-41f5-9f51-c2eb8db7e53c"
/>
### 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
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, useTemplateRef } from 'vue';
|
||||
import {
|
||||
useElementBounding,
|
||||
useResizeObserver,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import PreviewPicker from './PreviewPicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
caretPosition: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'close', 'removeTrigger']);
|
||||
|
||||
const search = defineModel('search', { type: String, default: '' });
|
||||
|
||||
const MIN_HEIGHT = 200;
|
||||
const MAX_HEIGHT = 300;
|
||||
const MAX_WIDTH = 768;
|
||||
const VIEWPORT_MARGIN = 16;
|
||||
const GAP = 8;
|
||||
const SIDE_PREVIEW_MIN_WIDTH = 480;
|
||||
const STACKED_PREVIEW_MIN_HEIGHT = 260;
|
||||
|
||||
const caretAnchorRef = useTemplateRef('caretAnchorRef');
|
||||
const pickerRef = useTemplateRef('pickerRef');
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const caretAnchor = useElementBounding(caretAnchorRef);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const items = computed(() => props.items);
|
||||
|
||||
const caretAnchorStyle = computed(() => ({
|
||||
top: `${props.caretPosition?.top ?? 0}px`,
|
||||
height: `${props.caretPosition?.height ?? 0}px`,
|
||||
}));
|
||||
|
||||
useResizeObserver(
|
||||
() => caretAnchorRef.value?.parentElement,
|
||||
caretAnchor.update
|
||||
);
|
||||
|
||||
const placement = computed(() => {
|
||||
const above = caretAnchor.top.value - VIEWPORT_MARGIN - GAP;
|
||||
const below =
|
||||
windowHeight.value - caretAnchor.bottom.value - VIEWPORT_MARGIN - GAP;
|
||||
const placeAbove = above > below;
|
||||
|
||||
return {
|
||||
placeAbove,
|
||||
height: Math.min(MAX_HEIGHT, Math.max(placeAbove ? above : below, 0)),
|
||||
};
|
||||
});
|
||||
|
||||
const width = computed(() =>
|
||||
Math.min(
|
||||
caretAnchor.width.value,
|
||||
MAX_WIDTH,
|
||||
windowWidth.value - VIEWPORT_MARGIN * 2
|
||||
)
|
||||
);
|
||||
|
||||
const previewLayout = computed(() => {
|
||||
if (width.value >= SIDE_PREVIEW_MIN_WIDTH) return 'side';
|
||||
if (placement.value.height >= STACKED_PREVIEW_MIN_HEIGHT) return 'stacked';
|
||||
return 'none';
|
||||
});
|
||||
|
||||
// Measured from the editor's inline start, which is its right edge in RTL. The teleported
|
||||
// card carries `dir`, so `inset-inline-start` resolves to the matching physical side.
|
||||
const inlineStart = computed(() =>
|
||||
isRTL.value
|
||||
? windowWidth.value - caretAnchor.right.value
|
||||
: caretAnchor.left.value
|
||||
);
|
||||
|
||||
const pickerStyle = computed(() => {
|
||||
const { placeAbove, height } = placement.value;
|
||||
const showsPreview = previewLayout.value !== 'none';
|
||||
const start = Math.min(
|
||||
inlineStart.value,
|
||||
windowWidth.value - width.value - VIEWPORT_MARGIN
|
||||
);
|
||||
|
||||
return {
|
||||
insetInlineStart: `${Math.max(VIEWPORT_MARGIN, start)}px`,
|
||||
width: `${width.value}px`,
|
||||
maxHeight: `${height}px`,
|
||||
minHeight: showsPreview ? `${Math.min(MIN_HEIGHT, height)}px` : null,
|
||||
...(placeAbove
|
||||
? { bottom: `${windowHeight.value - caretAnchor.top.value + GAP}px` }
|
||||
: { top: `${caretAnchor.bottom.value + GAP}px` }),
|
||||
};
|
||||
});
|
||||
|
||||
const adjustScroll = () => pickerRef.value?.scrollSelectedIntoView();
|
||||
|
||||
const onSelect = () => {
|
||||
const item = items.value[selectedIndex.value];
|
||||
if (item) emit('select', item);
|
||||
};
|
||||
|
||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const withPicker = action => ({
|
||||
action: event => {
|
||||
event.preventDefault();
|
||||
action();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
});
|
||||
|
||||
useKeyboardEvents({
|
||||
Tab: withPicker(moveSelectionDown),
|
||||
'Shift+Tab': withPicker(moveSelectionUp),
|
||||
Escape: withPicker(() => emit('close')),
|
||||
Backspace: {
|
||||
action: event => {
|
||||
if (search.value) return;
|
||||
event.preventDefault();
|
||||
emit('removeTrigger');
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onListItemSelection = index => {
|
||||
selectedIndex.value = index;
|
||||
onSelect();
|
||||
};
|
||||
|
||||
watch(items, () => {
|
||||
selectedIndex.value = 0;
|
||||
adjustScroll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="caretAnchorRef"
|
||||
class="absolute inset-x-0 pointer-events-none"
|
||||
:style="caretAnchorStyle"
|
||||
/>
|
||||
<TeleportWithDirection to="body">
|
||||
<PreviewPicker
|
||||
ref="pickerRef"
|
||||
v-model:selected-index="selectedIndex"
|
||||
v-model:search="search"
|
||||
v-on-click-outside="() => emit('close')"
|
||||
:items="items"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:empty-label="emptyLabel"
|
||||
:preview-layout="previewLayout"
|
||||
data-popover-content
|
||||
class="fixed z-[9999]"
|
||||
:style="pickerStyle"
|
||||
@select="onListItemSelection"
|
||||
>
|
||||
<template v-if="$slots.leading" #leading="slotProps">
|
||||
<slot name="leading" v-bind="slotProps" />
|
||||
</template>
|
||||
<template #preview="slotProps">
|
||||
<slot name="preview" v-bind="slotProps" />
|
||||
</template>
|
||||
</PreviewPicker>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, useId, useTemplateRef } from 'vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
previewLayout: {
|
||||
type: String,
|
||||
default: 'side',
|
||||
validator: value => ['side', 'stacked', 'none'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const selectedIndex = defineModel('selectedIndex', {
|
||||
type: Number,
|
||||
default: 0,
|
||||
});
|
||||
const search = defineModel('search', { type: String, default: '' });
|
||||
|
||||
const listRef = useTemplateRef('listRef');
|
||||
const searchRef = useTemplateRef('searchRef');
|
||||
|
||||
const listboxId = useId();
|
||||
const optionId = index => `${listboxId}-${index}`;
|
||||
|
||||
const selectedItem = computed(() => props.items[selectedIndex.value]);
|
||||
|
||||
const isStacked = computed(() => props.previewLayout === 'stacked');
|
||||
|
||||
const hasPreview = computed(
|
||||
() => props.previewLayout !== 'none' && props.items.length > 0
|
||||
);
|
||||
|
||||
const listClass = computed(() => {
|
||||
if (!hasPreview.value) return 'w-full';
|
||||
return isStacked.value
|
||||
? 'w-full flex-1'
|
||||
: 'w-2/5 flex-shrink-0 border-r rtl:border-r-0 rtl:border-l border-n-strong';
|
||||
});
|
||||
|
||||
const previewClass = computed(() =>
|
||||
isStacked.value ? 'h-24 flex-shrink-0 border-t border-n-strong' : 'flex-1'
|
||||
);
|
||||
|
||||
const groupFor = index => {
|
||||
const { group } = props.items[index];
|
||||
return group && group !== props.items[index - 1]?.group ? group : null;
|
||||
};
|
||||
|
||||
// Scrolls the list itself. `scrollIntoView` would also scroll clipped ancestors, which
|
||||
// shifts the card under a stationary cursor and leaves `mouseover` flickering between rows.
|
||||
const scrollSelectedIntoView = () => {
|
||||
nextTick(() => {
|
||||
const list = listRef.value;
|
||||
const item = list?.querySelector(`[data-index="${selectedIndex.value}"]`);
|
||||
if (!item) return;
|
||||
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
|
||||
if (itemRect.top < listRect.top) {
|
||||
list.scrollTop -= listRect.top - itemRect.top;
|
||||
} else if (itemRect.bottom > listRect.bottom) {
|
||||
list.scrollTop += itemRect.bottom - listRect.bottom;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => searchRef.value?.focus());
|
||||
|
||||
defineExpose({ scrollSelectedIntoView });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex overflow-hidden border shadow-lg rounded-xl border-n-strong bg-n-alpha-3 backdrop-blur-[100px]"
|
||||
:class="{ 'flex-col': isStacked }"
|
||||
>
|
||||
<div class="flex flex-col min-h-0" :class="listClass">
|
||||
<div
|
||||
class="relative flex items-center flex-shrink-0 h-11 px-3 border-b border-n-strong"
|
||||
>
|
||||
<Icon icon="i-lucide-search" class="size-4 text-n-slate-10" />
|
||||
<input
|
||||
ref="searchRef"
|
||||
v-model="search"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="items.length ? optionId(selectedIndex) : null"
|
||||
:aria-label="searchPlaceholder"
|
||||
class="w-full h-full px-2 text-sm bg-transparent outline-none text-n-slate-12 placeholder:text-n-slate-10 reset-base"
|
||||
:placeholder="searchPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
<div ref="listRef" class="flex-1 p-1 overflow-y-auto">
|
||||
<ul :id="listboxId" role="listbox" class="m-0 list-none">
|
||||
<template v-for="(item, index) in items" :key="item.id">
|
||||
<li
|
||||
v-if="groupFor(index)"
|
||||
role="presentation"
|
||||
class="px-2 pt-2 pb-1 text-xs font-medium text-n-slate-10"
|
||||
>
|
||||
{{ groupFor(index) }}
|
||||
</li>
|
||||
<li
|
||||
:id="optionId(index)"
|
||||
:data-index="index"
|
||||
role="option"
|
||||
:aria-selected="index === selectedIndex"
|
||||
class="flex items-center w-full gap-2 px-2 py-1 overflow-hidden rounded-lg cursor-pointer text-start"
|
||||
:class="index === selectedIndex ? 'bg-n-alpha-black2' : ''"
|
||||
@mousemove="selectedIndex = index"
|
||||
@click="emit('select', index)"
|
||||
>
|
||||
<slot
|
||||
name="leading"
|
||||
:item="item"
|
||||
:selected="index === selectedIndex"
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 gap-0.5">
|
||||
<span
|
||||
v-dompurify-html="item.title"
|
||||
class="max-w-full min-w-0 text-sm font-medium truncate text-n-slate-12"
|
||||
/>
|
||||
<span
|
||||
v-if="item.subtitle"
|
||||
v-dompurify-html="item.subtitle"
|
||||
class="max-w-full min-w-0 text-xs truncate text-n-slate-11"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div
|
||||
v-if="!items.length"
|
||||
role="status"
|
||||
class="px-2 py-1.5 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ emptyLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasPreview" class="flex flex-col min-w-0" :class="previewClass">
|
||||
<div
|
||||
v-if="!isStacked"
|
||||
class="flex items-center flex-shrink-0 h-11 px-4 border-b border-n-strong"
|
||||
>
|
||||
<span class="min-w-0 text-xs font-medium truncate text-n-slate-11">
|
||||
{{ selectedItem?.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<div class="absolute inset-0 overflow-y-auto">
|
||||
<slot name="preview" :item="selectedItem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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);
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="shouldShowCannedResponses"
|
||||
:search-key="cannedSearchTerm"
|
||||
:caret-position="caretPosition"
|
||||
:search-key="cannedSearchKey"
|
||||
:variables="variables"
|
||||
:schema="editorSchema"
|
||||
@close="dismissCannedResponses"
|
||||
@remove-trigger="removeSuggestionTrigger"
|
||||
@replace="content => insertSpecialContent('cannedResponse', content)"
|
||||
/>
|
||||
<VariableList
|
||||
|
||||
@@ -1,52 +1,146 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import MentionBox from '../mentions/MentionBox.vue';
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import {
|
||||
resolveVariablesInMessage,
|
||||
stripUnsupportedFormatting,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import CaretAnchoredPicker from 'dashboard/components-next/preview-picker/CaretAnchoredPicker.vue';
|
||||
|
||||
export default {
|
||||
components: { MentionBox },
|
||||
props: {
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
const props = defineProps({
|
||||
caretPosition: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
emits: ['replace'],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
cannedMessages: 'getCannedResponses',
|
||||
}),
|
||||
items() {
|
||||
return this.cannedMessages.map(cannedMessage => ({
|
||||
label: cannedMessage.short_code,
|
||||
key: cannedMessage.short_code,
|
||||
description: cannedMessage.content,
|
||||
}));
|
||||
},
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
watch: {
|
||||
searchKey() {
|
||||
this.fetchCannedResponses();
|
||||
},
|
||||
variables: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
mounted() {
|
||||
this.fetchCannedResponses();
|
||||
},
|
||||
methods: {
|
||||
fetchCannedResponses() {
|
||||
this.$store.dispatch('getCannedResponse', { searchKey: this.searchKey });
|
||||
},
|
||||
handleMentionClick(item = {}) {
|
||||
this.$emit('replace', item.description);
|
||||
},
|
||||
schema: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['replace', 'close', 'removeTrigger']);
|
||||
|
||||
// Characters kept before the match when a snippet has to skip ahead
|
||||
const SNIPPET_LEAD = 24;
|
||||
const SEARCH_DEBOUNCE = 200;
|
||||
const HIGHLIGHT_CLASS = 'text-n-blue-text';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { getPlainText, formatMessage, highlightContent } = useMessageFormatter();
|
||||
|
||||
const cannedResponses = useMapGetter('getCannedResponses');
|
||||
// The trigger can already be followed by text, from a draft or a caret moved back onto it
|
||||
const searchQuery = ref(props.searchKey);
|
||||
|
||||
const searchTerm = computed(() => searchQuery.value.trim());
|
||||
|
||||
// An empty term makes `highlightContent`'s regex match at every position, wrapping the
|
||||
// whole string in empty spans
|
||||
const highlightMatches = text =>
|
||||
searchTerm.value
|
||||
? highlightContent(text, searchTerm.value, HIGHLIGHT_CLASS)
|
||||
: text;
|
||||
|
||||
const buildSnippet = text => {
|
||||
const term = searchTerm.value;
|
||||
if (!term) return text;
|
||||
|
||||
const index = text.toLowerCase().indexOf(term.toLowerCase());
|
||||
if (index <= SNIPPET_LEAD) return text;
|
||||
|
||||
return `…${text.slice(index - SNIPPET_LEAD)}`;
|
||||
};
|
||||
|
||||
// Both steps mirror what insertion does: variables are substituted, then formatting the
|
||||
// channel's schema cannot carry is stripped. Previewing the raw content would advertise
|
||||
// styling the message never ends up with.
|
||||
const resolveContent = message =>
|
||||
stripUnsupportedFormatting(
|
||||
resolveVariablesInMessage(message, props.variables),
|
||||
props.schema
|
||||
);
|
||||
|
||||
const records = computed(() =>
|
||||
cannedResponses.value.map(({ id, short_code: shortCode, content }) => {
|
||||
const resolved = resolveContent(content);
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
resolved,
|
||||
shortCode,
|
||||
plainText: getPlainText(resolved).replace(/\s+/g, ' ').trim(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const items = computed(() =>
|
||||
records.value.map(record => ({
|
||||
id: record.id,
|
||||
content: record.content,
|
||||
resolved: record.resolved,
|
||||
label: `/${record.shortCode}`,
|
||||
title: highlightMatches(`/${record.shortCode}`),
|
||||
subtitle: highlightMatches(buildSnippet(record.plainText)),
|
||||
}))
|
||||
);
|
||||
|
||||
const onSelect = item => emit('replace', item.content);
|
||||
|
||||
const { run: runFetch } = useAbortableRequest();
|
||||
|
||||
const fetchCannedResponses = () => {
|
||||
runFetch(signal =>
|
||||
store.dispatch('getCannedResponse', {
|
||||
searchKey: searchTerm.value,
|
||||
signal,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const { start: scheduleFetch } = useTimeoutFn(
|
||||
fetchCannedResponses,
|
||||
SEARCH_DEBOUNCE,
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
watch(searchTerm, scheduleFetch);
|
||||
|
||||
onMounted(fetchCannedResponses);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
<CaretAnchoredPicker
|
||||
v-model:search="searchQuery"
|
||||
:caret-position="caretPosition"
|
||||
:items="items"
|
||||
@mention-select="handleMentionClick"
|
||||
/>
|
||||
:search-placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
:empty-label="
|
||||
searchTerm
|
||||
? t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm })
|
||||
: t('COMBOBOX.EMPTY_STATE')
|
||||
"
|
||||
@select="onSelect"
|
||||
@close="emit('close')"
|
||||
@remove-trigger="emit('removeTrigger')"
|
||||
>
|
||||
<template #preview="{ item }">
|
||||
<div
|
||||
v-dompurify-html="formatMessage(item?.resolved || '')"
|
||||
class="px-4 py-3 text-sm break-words prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
/>
|
||||
</template>
|
||||
</CaretAnchoredPicker>
|
||||
</template>
|
||||
|
||||
@@ -92,6 +92,8 @@ export function useKeyboardNavigableList({
|
||||
selectedIndex,
|
||||
}) {
|
||||
const moveSelection = direction => {
|
||||
if (!items.value?.length) return;
|
||||
|
||||
selectedIndex.value = updateSelectionIndex(
|
||||
selectedIndex.value,
|
||||
items.value.length,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user