diff --git a/src/components/ChatComposer.vue b/src/components/ChatComposer.vue index 1549180..c41da61 100644 --- a/src/components/ChatComposer.vue +++ b/src/components/ChatComposer.vue @@ -32,6 +32,7 @@ const { suggestions, isActive: mentionActive, loading: mentionLoading, + pending: mentionPending, onTextChange, insertMention, close: closeMentions, @@ -274,6 +275,7 @@ async function submit() { :suggestions="suggestions" :visible="mentionActive" :loading="mentionLoading" + :pending="mentionPending" class="bottom-0 left-0 translate-y-full" @select="handleMentionSelect" @close="closeMentions" diff --git a/src/components/MentionDropdown.vue b/src/components/MentionDropdown.vue index be650db..1d0ef73 100644 --- a/src/components/MentionDropdown.vue +++ b/src/components/MentionDropdown.vue @@ -5,6 +5,8 @@ const props = defineProps({ suggestions: { type: Array, default: () => [] }, visible: { type: Boolean, default: false }, loading: { type: Boolean, default: false }, + // True while the shown suggestions belong to an older query. + pending: { type: Boolean, default: false }, }); const emit = defineEmits(['select', 'close']); @@ -54,6 +56,12 @@ function onKeydown(e) { } if (e.key === 'Enter' || e.key === 'Tab') { + // The list is stale: don't pick an agent for an older query, and + // don't let the key fall through to sending the message either. + if (props.pending) { + e.preventDefault(); + return true; + } if (props.suggestions.length > 0) { e.preventDefault(); emit('select', props.suggestions[activeIndex.value]); diff --git a/src/components/QuickFilters.vue b/src/components/QuickFilters.vue index 24f044e..ac75fa0 100644 --- a/src/components/QuickFilters.vue +++ b/src/components/QuickFilters.vue @@ -51,10 +51,10 @@ function selectTab(tab) { activeTab === tab.key ? escDark ? 'text-cyan-400 focus-visible:ring-cyan-500/40' - : 'text-[var(--esc-panel-accent)] focus-visible:ring-[var(--esc-panel-accent)]' + : 'text-[var(--esc-panel-accent,#3b82f6)] focus-visible:ring-[var(--esc-panel-accent,#3b82f6)]' : escDark ? 'text-neutral-500 hover:text-neutral-300 focus-visible:ring-cyan-500/40' - : 'text-gray-500 hover:text-gray-700 focus-visible:ring-[var(--esc-panel-accent)]', + : 'text-gray-500 hover:text-gray-700 focus-visible:ring-[var(--esc-panel-accent,#3b82f6)]', ]" @click="selectTab(tab)" > @@ -63,7 +63,7 @@ function selectTab(tab) { v-if="activeTab === tab.key" :class="[ 'absolute bottom-0 left-0 right-0 h-0.5 rounded-t', - escDark ? 'bg-cyan-400' : 'bg-[var(--esc-panel-accent)]', + escDark ? 'bg-cyan-400' : 'bg-[var(--esc-panel-accent,#3b82f6)]', ]" > diff --git a/src/components/ReplyComposer.vue b/src/components/ReplyComposer.vue index ff5b69a..cc029c2 100644 --- a/src/components/ReplyComposer.vue +++ b/src/components/ReplyComposer.vue @@ -31,6 +31,7 @@ const { suggestions, isActive: mentionActive, loading: mentionLoading, + pending: mentionPending, onTextChange, insertMention, close: closeMentions, @@ -194,6 +195,7 @@ const buttonLabel = computed(() => { :suggestions="suggestions" :visible="mentionActive" :loading="mentionLoading" + :pending="mentionPending" class="bottom-0 left-0 translate-y-full" @select="handleMentionSelect" @close="closeMentions" @@ -289,6 +291,7 @@ const buttonLabel = computed(() => { :suggestions="suggestions" :visible="mentionActive" :loading="mentionLoading" + :pending="mentionPending" class="bottom-0 left-0 translate-y-full" @select="handleMentionSelect" @close="closeMentions" diff --git a/src/components/TicketList.vue b/src/components/TicketList.vue index 82e8da9..6b2c7dc 100644 --- a/src/components/TicketList.vue +++ b/src/components/TicketList.vue @@ -439,7 +439,7 @@ const colCount = computed(() => { type="checkbox" :checked="allSelected" aria-label="Select all tickets" - class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]" + class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent,#3b82f6)] focus:ring-[var(--esc-panel-accent,#3b82f6)]" @change="toggleAll" /> @@ -490,7 +490,7 @@ const colCount = computed(() => { {{ col.label }} @@ -506,7 +506,9 @@ const colCount = computed(() => { :key="ticket.id" :class="[ 'group', - navigable && focusedIndex === index ? 'bg-[var(--esc-panel-active)]' : 'hover:bg-gray-50', + navigable && focusedIndex === index + ? 'bg-[var(--esc-panel-active,#eff6ff)]' + : 'hover:bg-gray-50', ]" > @@ -514,7 +516,7 @@ const colCount = computed(() => { type="checkbox" :checked="selectedIds.includes(ticket.id)" aria-label="Select ticket" - class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]" + class="h-4 w-4 rounded border-gray-300 text-[var(--esc-panel-accent,#3b82f6)] focus:ring-[var(--esc-panel-accent,#3b82f6)]" @change="toggleOne(ticket.id)" /> @@ -530,7 +532,7 @@ const colCount = computed(() => { > {{ ticket.reference }} diff --git a/src/composables/useMentions.js b/src/composables/useMentions.js index c25ab62..67c8a10 100644 --- a/src/composables/useMentions.js +++ b/src/composables/useMentions.js @@ -1,4 +1,4 @@ -import { ref, onUnmounted } from 'vue'; +import { ref, computed, onUnmounted } from 'vue'; /** * Composable that detects `@` typed in a text input/textarea, @@ -18,6 +18,10 @@ export function useMentions(options = {}) { const query = ref(''); const mentionStart = ref(null); // cursor position where `@` was typed const loading = ref(false); + // The query the visible suggestions were fetched for. While it differs + // from `query`, the list on screen is stale (a newer search is pending). + const suggestionsQuery = ref(null); + const pending = computed(() => isActive.value && suggestionsQuery.value !== query.value); let timer = null; let requestVersion = 0; @@ -58,7 +62,8 @@ export function useMentions(options = {}) { clearTimer(); const version = ++requestVersion; // Keep the current list on screen while the next search is pending, - // so the dropdown stays open and Enter/Tab still pick a mention. + // so the dropdown stays open. `pending` marks it stale, so Enter/Tab + // wait for the fresh results instead of picking an old match. // requestVersion already discards any older response. loading.value = true; @@ -85,9 +90,15 @@ export function useMentions(options = {}) { }, }); const results = response.ok ? await response.json() : []; - if (version === requestVersion) suggestions.value = results; + if (version === requestVersion) { + suggestions.value = results; + suggestionsQuery.value = searchQuery; + } } catch { - if (version === requestVersion) suggestions.value = []; + if (version === requestVersion) { + suggestions.value = []; + suggestionsQuery.value = searchQuery; + } } finally { if (version === requestVersion) loading.value = false; } @@ -120,6 +131,7 @@ export function useMentions(options = {}) { loading.value = false; isActive.value = false; suggestions.value = []; + suggestionsQuery.value = null; query.value = ''; mentionStart.value = null; clearTimer(); @@ -132,6 +144,7 @@ export function useMentions(options = {}) { isActive, query, loading, + pending, onTextChange, insertMention, close, diff --git a/src/pages/Guest/Create.vue b/src/pages/Guest/Create.vue index f955855..c12dfd1 100644 --- a/src/pages/Guest/Create.vue +++ b/src/pages/Guest/Create.vue @@ -201,7 +201,7 @@ async function findTickets() {
diff --git a/src/widget/index.js b/src/widget/index.js index 4f6c197..a34c4bc 100644 --- a/src/widget/index.js +++ b/src/widget/index.js @@ -70,12 +70,25 @@ export function mountEscalatedWidget(options = {}) { }; } -function boot() { +// `document.currentScript` is only set while this script is first evaluated. +// When embed.js sits in , boot() runs later on DOMContentLoaded, where +// it is null, so capture the tag now and hand it to boot(). +const loaderScript = typeof document !== 'undefined' ? document.currentScript : null; + +function findLoaderScript(captured) { + if (captured) return captured; + return ( + document.querySelector('script[data-escalated-widget]') || + document.querySelector('script[src*="/widget/embed.js"]') + ); +} + +function boot(captured = loaderScript) { const existing = window.EscalatedWidget; // Loading the script twice must not mount a second launcher. if (existing && existing.__escalatedLoaded) return; - const scriptTag = document.currentScript || document.querySelector('script[data-escalated-widget]'); + const scriptTag = findLoaderScript(captured); const config = readEmbedConfig(scriptTag, existing || {}); const instance = mountEscalatedWidget(config); @@ -95,7 +108,7 @@ function boot() { if (typeof window !== 'undefined' && !globalThis.__ESCALATED_WIDGET_NO_AUTOBOOT__) { if (document.body) boot(); - else document.addEventListener('DOMContentLoaded', boot, { once: true }); + else document.addEventListener('DOMContentLoaded', () => boot(), { once: true }); } function getWidgetStyles() { diff --git a/tests/components/ChatComposer.test.js b/tests/components/ChatComposer.test.js index a838b9d..7a4f97c 100644 --- a/tests/components/ChatComposer.test.js +++ b/tests/components/ChatComposer.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { mount } from '@vue/test-utils'; import { computed } from 'vue'; import ChatComposer from '../../src/components/ChatComposer.vue'; @@ -50,6 +50,49 @@ describe('ChatComposer', () => { }); }); + describe('mentions', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function typeInto(textarea, value) { + textarea.element.value = value; + textarea.element.setSelectionRange(value.length, value.length); + await textarea.trigger('input'); + } + + it('neither sends nor picks a stale agent on Enter while a newer search is pending', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn(async (url) => ({ + ok: true, + json: async () => (url.endsWith('q=') ? [{ id: 1, name: 'Aaron' }] : [{ id: 2, name: 'Alice' }]), + })), + ); + const wrapper = mountComposer(); + const textarea = wrapper.find('textarea'); + + await typeInto(textarea, '@'); + await vi.runAllTimersAsync(); + await typeInto(textarea, '@alice please look'); + await textarea.trigger('keydown', { key: 'Enter', shiftKey: false }); + + expect(wrapper.emitted('send')).toBeFalsy(); + expect(textarea.element.value).toBe('@alice please look'); + // The dropdown stays up with the previous list while searching. + expect(wrapper.find('[role="listbox"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Aaron'); + + await vi.runAllTimersAsync(); + await textarea.trigger('keydown', { key: 'Enter', shiftKey: false }); + + expect(wrapper.emitted('send')).toBeFalsy(); + expect(textarea.element.value).toBe('@{Alice} '); + }); + }); + describe('disabled state', () => { it('disables textarea when disabled prop is true', () => { const wrapper = mountComposer({ disabled: true }); diff --git a/tests/components/MentionDropdown.test.js b/tests/components/MentionDropdown.test.js index 964762d..cb5dea2 100644 --- a/tests/components/MentionDropdown.test.js +++ b/tests/components/MentionDropdown.test.js @@ -114,6 +114,19 @@ describe('MentionDropdown', () => { expect(wrapper.emitted('select')[0][0]).toEqual(agents[0]); }); + it.each(['Enter', 'Tab'])('swallows %s without selecting while the suggestions are stale', (key) => { + const wrapper = mountDropdown({ pending: true, loading: true }); + let prevented = false; + + const handled = wrapper.vm.onKeydown({ key, preventDefault: () => (prevented = true) }); + + expect(handled).toBe(true); + expect(prevented).toBe(true); + expect(wrapper.emitted('select')).toBeFalsy(); + // The stale list stays on screen while the new search runs. + expect(wrapper.findAll('button[role="option"]')).toHaveLength(3); + }); + it('emits close on Escape', () => { const wrapper = mountDropdown(); const vm = wrapper.vm; diff --git a/tests/composables/useMentions.test.js b/tests/composables/useMentions.test.js index eced43b..6ced4f9 100644 --- a/tests/composables/useMentions.test.js +++ b/tests/composables/useMentions.test.js @@ -78,6 +78,34 @@ describe('useMentions', () => { vi.unstubAllGlobals(); }); + it('marks the shown suggestions pending until results for the current query arrive', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url) => ({ + ok: true, + json: async () => (url.endsWith('q=') ? [{ id: 1, name: 'Aaron' }] : [{ id: 2, name: 'Alice' }]), + })), + ); + const wrapper = mount(TestComponent); + const vm = wrapper.vm; + vm.onTextChange({ selectionStart: 1 }, '@'); + expect(vm.pending).toBe(true); + await vi.runAllTimersAsync(); + expect(vm.pending).toBe(false); + + vm.onTextChange({ selectionStart: 6 }, '@alice'); + expect(vm.suggestions).toEqual([{ id: 1, name: 'Aaron' }]); + expect(vm.pending).toBe(true); + + await vi.runAllTimersAsync(); + expect(vm.suggestions).toEqual([{ id: 2, name: 'Alice' }]); + expect(vm.pending).toBe(false); + + vm.close(); + expect(vm.pending).toBe(false); + vi.unstubAllGlobals(); + }); + it('does not activate for @ in middle of word', async () => { const wrapper = mount(TestComponent); const vm = wrapper.vm; diff --git a/tests/pages/GuestCreate.test.js b/tests/pages/GuestCreate.test.js index efa2121..c05b8ea 100644 --- a/tests/pages/GuestCreate.test.js +++ b/tests/pages/GuestCreate.test.js @@ -82,6 +82,14 @@ describe('verified browser guest form', () => { expect(form.find('input[autocomplete="one-time-code"]').exists()).toBe(false); }); + it('hides the lookup form when the backend sends no verification_url', () => { + // The lookup needs a verification code, so without the endpoint it + // would post to "null". + setup({ verification_url: null }); + expect(wrapper.findAll('form')).toHaveLength(1); + expect(wrapper.find('input[autocomplete="one-time-code"]').exists()).toBe(false); + }); + it('discards private lookup results when the tenant changes during the request', async () => { setup(); const form = wrapper.findAll('form')[1]; diff --git a/tests/widget/loader.test.js b/tests/widget/loader.test.js index c0fca4d..31dbf97 100644 --- a/tests/widget/loader.test.js +++ b/tests/widget/loader.test.js @@ -13,6 +13,9 @@ beforeEach(() => { }); afterEach(() => { + delete document.body; + delete document.currentScript; + document.head.querySelectorAll('script').forEach((el) => el.remove()); document.body.innerHTML = ''; delete window.EscalatedWidget; delete globalThis.__ESCALATED_WIDGET_NO_AUTOBOOT__; @@ -26,6 +29,22 @@ function addScript(attributes) { document.body.appendChild(el); } +/** + * Imports the loader the way a browser runs a '; + const el = document.head.querySelector('script'); + + await importFromHead(el); + + expect(fetch).toHaveBeenCalledWith('https://help.example.com/support/widget/config', expect.anything()); + expect(shadowOf('#escalated-widget-host').querySelector('.esc-w-header h2').textContent).toBe('Head Help'); + }); + + it('finds the backend-served embed.js by its src when currentScript is unavailable', async () => { + document.head.innerHTML = ''; + + await importFromHead(null); + + expect(fetch).toHaveBeenCalledWith('https://help.example.com/support/widget/config', expect.anything()); + }); + it('mounts extra instances on demand and removes them again', async () => { globalThis.__ESCALATED_WIDGET_NO_AUTOBOOT__ = true; document.body.innerHTML = '
';