Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/ChatComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const {
suggestions,
isActive: mentionActive,
loading: mentionLoading,
pending: mentionPending,
onTextChange,
insertMention,
close: closeMentions,
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/components/MentionDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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]);
Expand Down
6 changes: 3 additions & 3 deletions src/components/QuickFilters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
>
Expand All @@ -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)]',
]"
></span>
</button>
Expand Down
3 changes: 3 additions & 0 deletions src/components/ReplyComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const {
suggestions,
isActive: mentionActive,
loading: mentionLoading,
pending: mentionPending,
onTextChange,
insertMention,
close: closeMentions,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 7 additions & 5 deletions src/components/TicketList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</th>
Expand Down Expand Up @@ -490,7 +490,7 @@ const colCount = computed(() => {
<input
type="checkbox"
:checked="isColumnActive(col.key)"
class="h-3.5 w-3.5 rounded border-gray-300 text-[var(--esc-panel-accent)] focus:ring-[var(--esc-panel-accent)]"
class="h-3.5 w-3.5 rounded border-gray-300 text-[var(--esc-panel-accent,#3b82f6)] focus:ring-[var(--esc-panel-accent,#3b82f6)]"
@change="toggleColumn(col.key)"
/>
{{ col.label }}
Expand All @@ -506,15 +506,17 @@ 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',
]"
>
<td v-if="selectable" class="w-10 px-3 py-3">
<input
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)"
/>
</td>
Expand All @@ -530,7 +532,7 @@ const colCount = computed(() => {
></span>
<Link
:href="route(`${routePrefix}.show`, ticket.reference)"
class="text-[var(--esc-panel-accent)] hover:text-[var(--esc-panel-accent-hover)]"
class="text-[var(--esc-panel-accent,#3b82f6)] hover:text-[var(--esc-panel-accent-hover,#2563eb)]"
>
{{ ticket.reference }}
</Link>
Expand Down
21 changes: 17 additions & 4 deletions src/composables/useMentions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ref, onUnmounted } from 'vue';
import { ref, computed, onUnmounted } from 'vue';

/**
* Composable that detects `@` typed in a text input/textarea,
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -132,6 +144,7 @@ export function useMentions(options = {}) {
isActive,
query,
loading,
pending,
onTextChange,
insertMention,
close,
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Guest/Create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ async function findTickets() {
</form>

<form
v-if="lookup_url"
v-if="lookup_url && verification_url"
class="mt-8 space-y-4 rounded-lg border border-gray-200 bg-white p-6"
@submit.prevent="findTickets"
>
Expand Down
19 changes: 16 additions & 3 deletions src/widget/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <head>, 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);

Expand All @@ -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() {
Expand Down
45 changes: 44 additions & 1 deletion tests/components/ChatComposer.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
13 changes: 13 additions & 0 deletions tests/components/MentionDropdown.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions tests/composables/useMentions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions tests/pages/GuestCreate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading
Loading