1<template>
2 <Popover
3 side="bottom"
4 align="start"
5 :matchTriggerWidth="true"
6 v-model:open="popoverIsOpen"
7 >
8 <template #trigger>
9 <div class="flex flex-col gap-1.5 w-full">
10 <span v-if="!hideLabel" class="block text-base text-ink-gray-5">
11 {{ __("Assignee") }}
12 </span>
13 <Button
14 ref="triggerRef"
15 :variant="ghost ? 'ghost' : 'outline'"
16 class="group !flex !justify-start w-full active:!bg-inherit [&>span]:w-full"
17 :class="
18 ghost
19 ? [
20 '!h-7 !rounded-4 !border !border-transparent !bg-surface-base !px-2 hover:!bg-surface-base focus:focus-ring',
21 // Hold the ring while the dropdown is open, mirroring the Link
22 // field's data-[state=open]:focus-ring (focus lives in the popover).
23 popoverIsOpen && 'focus-ring',
24 ]
25 : 'hover:shadow-sm'
26 "
27 >
28 <div class="flex items-center min-h-5 gap-2 w-full min-w-0">
29 <template v-if="localAssignees.length > 0">
30 <MultipleAvatar
31 :avatars="localAssignees.map((a) => a.name)"
32 size="sm"
33 :max="2"
34 />
35 <span
36 v-if="localAssignees.length > 1"
37 class="text-ink-gray-7 truncate"
38 >
39 {{ localAssignees.length }} {{ __("assignees") }}
40 </span>
41 </template>
42 <template v-else>
43 <span v-if="ghost" class="text-ink-gray-4">
44 {{ __("Set Assignee") }}...
45 </span>
46 <span v-else class="text-ink-gray-5 leading-5">
47 {{ emptyLabel }}
48 </span>
49 </template>
50 </div>
51 <template #suffix>
52 <LucideChevronDown
53 class="ms-auto size-4 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
54 :class="[
55 ghost ? 'text-ink-gray-6' : 'text-ink-gray-5',
56 popoverIsOpen && 'rotate-180',
57 // Filled + closed: keep the chevron hidden until hover/focus,
58 // like a filled Link field's action icons. Empty always shows it.
59 ghost && localAssignees.length > 0 && !popoverIsOpen
60 ? 'hidden group-hover:block group-focus-within:block'
61 : '',
62 ]"
63 />
64 </template>
65 </Button>
66 </div>
67 </template>
68 <!-- body-main (not body) so the shared PopoverPanel supplies the shell
69 chrome and the combobox's scale-from-trigger open animation. -->
70 <template #default="{ open: isOpen }">
71 <!-- Pin to the trigger width. matchTriggerWidth only sets min-width, so the
72 panel is otherwise shrink-to-fit and grows to the widest agent name
73 (then collapses as you filter) -> width jitter. Fixing the width lets
74 the rows' min-w-0 truncate instead. -->
75 <div
76 v-if="isOpen"
77 class="w-[var(--reka-popover-trigger-width)] focus:outline-none"
78 >
79 <!-- Search Header: mirror the tag picker's flush search row - a
80 borderless input with a bottom divider and the key-cap suffix. -->
81 <div class="flex items-center border-b border-outline-gray-2 px-3">
82 <TextInput
83 ref="inputRef"
84 v-model="searchText"
85 :placeholder="__('Search agents...')"
86 variant="ghost"
87 class="flex-1 search-agents-input"
88 @click.stop
89 @keydown="handleInputKeydown"
90 >
91 <template #suffix>
92 <ShortcutKey v-if="!searchText" keys="A" />
93 </template>
94 </TextInput>
95 </div>
96
97 <!-- Agent List -->
98 <div class="px-1.5 pb-1.5 max-h-64 overflow-y-auto">
99 <div class="pt-1.5">
100 <!-- Loading state -->
101 <div
102 v-if="agentResource.loading"
103 class="px-2 py-4 text-center text-sm text-ink-gray-5"
104 >
105 {{ __("Loading...") }}
106 </div>
107
108 <template v-else-if="sortedAgentOptions.length > 0">
109 <button
110 v-for="(agent, index) in sortedAgentOptions"
111 :key="agent.value"
112 :ref="(el) => setOptionRef(index, el as Element)"
113 class="group flex h-7 w-full items-center rounded-4 px-2 text-base text-ink-gray-6 gap-2"
114 :class="
115 index === highlightedIndex
116 ? 'bg-surface-gray-3'
117 : 'hover:bg-surface-gray-3'
118 "
119 @click="toggleAgent(agent)"
120 >
121 <Checkbox
122 :modelValue="isSelected(agent.value)"
123 class="flex-shrink-0"
124 />
125 <div class="relative flex-shrink-0">
126 <Tooltip
127 side="top"
128 :text="
129 availabilitySubtitle(
130 agent.availability,
131 agent.availability_changed_on
132 )
133 "
134 >
135 <UserAvatar :name="agent.value" size="sm" />
136 </Tooltip>
137 <span
138 class="absolute block translate-x-1/2 translate-y-1/2 transform rounded-full bottom-0.5 right-0.5"
139 >
140 <span
141 class="block h-2 w-2 rounded-full border border-slate-2"
142 :class="
143 agentStatusStore.statusColor(agent.availability || '')
144 "
145 />
146 </span>
147 </div>
148 <span
149 class="text-ink-gray-7 min-w-0 flex-1 text-start truncate"
150 >
151 {{ agent.label }}
152 </span>
153 </button>
154 </template>
155
156 <!-- No results -->
157 <div v-else class="px-2 py-4 text-center text-sm text-ink-gray-5">
158 {{ __("No agents found") }}
159 </div>
160 </div>
161 </div>
162 </div>
163 </template>
164 </Popover>
165</template>
166
167<script setup lang="ts">
168import ShortcutKey from "@opendesk/shared/ShortcutKey.vue";
169import { useShortcut } from "@opendesk/shared/shortcuts";
170import { useAgentStatusStore } from "@/stores/agentStatus.ts";
171import { useConfigStore } from "@/stores/config";
172import { useUserStore } from "@/stores/user";
173import { capture } from "@/telemetry";
174import { __ } from "@/translation";
175import {
176 AgentOption,
177 AssigneeSymbol,
178 LocalAssignee,
179 TicketSymbol,
180} from "@/types";
181import { prettyDate } from "@/utils.ts";
182import { useDebounceFn } from "@vueuse/core";
183import {
184 Button,
185 Checkbox,
186 Popover,
187 TextInput,
188 Tooltip,
189 createListResource,
190 createResource,
191 dayjsLocal,
192 toast,
193} from "frappe-ui";
194import { computed, inject, nextTick, ref, useTemplateRef, watch } from "vue";
195import MultipleAvatar from "../MultipleAvatar.vue";
196import UserAvatar from "../UserAvatar.vue";
197interface Props {
198 hideLabel?: boolean;
199 ghost?: boolean;
200}
201
202const props = withDefaults(defineProps<Props>(), {
203 hideLabel: false,
204 ghost: false,
205});
206
207const { hideLabel, ghost } = props;
208
209// No ticket in context (bulk assign) means nothing to save to: the picker then
210// just reports its selection through v-model and the parent decides what to do.
211const ticket = inject(TicketSymbol, null);
212const assignees = inject(AssigneeSymbol, null);
213const selection = defineModel<string[]>({ default: () => [] });
214
215// On a ticket the trigger reports state ("No one" is assigned); standalone it is
216// an input still waiting on a choice, so it reads as a placeholder.
217const emptyLabel = computed(() =>
218 ticket ? __("No one") : __("Select agents")
219);
220
221const { getUser } = useUserStore();
222const currentUser = computed(() => getUser("")); // empty string returns current user
223const agentStatusStore = useAgentStatusStore();
224const currentAgentName = window.agent;
225
226const searchText = ref("");
227const highlightedIndex = ref(0);
228const inputRef = useTemplateRef<InstanceType<typeof TextInput>>("inputRef");
229const triggerRef = useTemplateRef("triggerRef");
230
231const popoverIsOpen = ref(false);
232const hasBeenOpened = ref(false);
233
234// Local copy of assignees
235const localAssignees = ref<LocalAssignee[]>([]);
236const snapshotAssignees = ref<LocalAssignee[]>([]);
237
238// Sync from injected assignees when popover is not open
239watch(
240 () => assignees?.value?.data,
241 (data) => {
242 if (!popoverIsOpen.value && data) {
243 localAssignees.value = [...data];
244 }
245 },
246 { immediate: true, deep: true }
247);
248
249// Standalone mode: the selection itself is the output, so publish it live.
250watch(
251 localAssignees,
252 (list) => {
253 if (!ticket) selection.value = list.map((a) => a.name);
254 },
255 { deep: true }
256);
257
258// Standalone mode: let the parent clear the picker (e.g. on dialog open).
259watch(selection, (names) => {
260 if (ticket || names.length || !localAssignees.value.length) return;
261 localAssignees.value = [];
262});
263
264// Watch popover open/close — same pattern as old AssignToBody
265watch(popoverIsOpen, (isOpen) => {
266 if (isOpen) {
267 // Opening: take snapshot
268 hasBeenOpened.value = true;
269 snapshotAssignees.value = localAssignees.value.map((a) => ({ ...a }));
270 pinnedSelectedNames.value = new Set(
271 localAssignees.value.map((a) => a.name)
272 );
273 searchText.value = "";
274 highlightedIndex.value = 0;
275 nextTick(() => {
276 inputRef.value?.focus();
277 });
278 } else if (hasBeenOpened.value) {
279 // Closing after a real open: compute diff and save
280 hasBeenOpened.value = false;
281 searchText.value = "";
282 if (!ticket) return;
283 const currentNames = localAssignees.value.map((a) => a.name);
284 const oldNames = snapshotAssignees.value.map((a) => a.name);
285 const added = currentNames.filter((n) => !oldNames.includes(n));
286 const removed = oldNames.filter((n) => !currentNames.includes(n));
287 saveAssignees(added, removed);
288 }
289});
290
291const configStore = useConfigStore();
292const ticketTeam = computed(() => ticket?.value?.doc?.agent_group);
293const restrictToTeam = computed(
294 () =>
295 configStore.teamRestrictionApplied &&
296 configStore.assignWithinTeam &&
297 Boolean(ticketTeam.value)
298);
299const teamMembers = createResource({
300 url: "opendesk.opendesk.doctype.od_team.od_team.get_team_members",
301 makeParams: () => ({ team: ticketTeam.value }),
302 onSuccess: () => reloadAgents(),
303});
304
305const agentResource = createListResource({
306 doctype: "OD Agent",
307 fields: [
308 "name",
309 "agent_name",
310 "user_image",
311 "availability",
312 "availability_changed_on",
313 ],
314 filters: { is_active: true },
315 pageLength: 20,
316});
317
318function reloadAgents(text = "") {
319 const filters: Record<string, any> = { is_active: true };
320 if (text) {
321 filters.agent_name = ["like", `%${text}%`];
322 }
323 if (restrictToTeam.value) {
324 filters.name = ["in", teamMembers.data || []];
325 }
326 agentResource.filters = filters;
327 agentResource.reload();
328}
329
330const debouncedSearch = useDebounceFn(reloadAgents, 300);
331
332function isOfferable(agentName: string) {
333 return !restrictToTeam.value || teamMembers.data?.includes(agentName);
334}
335
336watch(searchText, (text) => {
337 debouncedSearch(text);
338});
339
340watch(
341 [restrictToTeam, ticketTeam],
342 ([restricted]) => {
343 if (!restricted) return reloadAgents();
344 // Drop the previous team's members so none stay offerable while fetching.
345 teamMembers.reset();
346 teamMembers.fetch();
347 },
348 { immediate: true }
349);
350
351// Prefer the live status pushed over the socket (agentStatusStore.liveStatuses)
352// so the dot/tooltip reflect any agent's change made elsewhere this session,
353// falling back to the value fetched when this dropdown first loaded.
354function liveAvailability(agent: {
355 name: string;
356 availability?: string;
357 availability_changed_on?: string;
358}) {
359 const live = agentStatusStore.liveStatuses[agent.name];
360 return {
361 availability: live?.availability ?? agent.availability,
362 availability_changed_on: live?.changedOn ?? agent.availability_changed_on,
363 };
364}
365
366const agentOptions = computed<AgentOption[]>(() => {
367 const agents: AgentOption[] = [];
368 const seen = new Set<string>();
369
370 // Include current agent only when not searching. Built from the session user
371 // and the store's live status (seeded from auth.get_user) — no extra fetch.
372 if (!searchText.value && currentAgentName && isOfferable(currentAgentName)) {
373 agents.push({
374 value: currentAgentName,
375 label: currentUser.value.full_name || currentAgentName,
376 image: currentUser.value.user_image || "",
377 ...liveAvailability({ name: currentAgentName }),
378 });
379 seen.add(currentAgentName);
380 }
381
382 if (agentResource.data) {
383 for (const agent of agentResource.data) {
384 if (!seen.has(agent.name) && isOfferable(agent.name)) {
385 agents.push({
386 value: agent.name,
387 label: agent.agent_name || getUser(agent.name).full_name,
388 image: agent.user_image || getUser(agent.name).user_image,
389 ...liveAvailability(agent),
390 });
391 seen.add(agent.name);
392 }
393 }
394 }
395
396 return agents;
397});
398
399// Snapshot of selected names at open time — used to pin assigned agents at top
400// without them jumping around as the user toggles selections.
401const pinnedSelectedNames = ref<Set<string>>(new Set());
402
403const sortedAgentOptions = computed<AgentOption[]>(() => {
404 const options = [...agentOptions.value];
405 const isSearching = searchText.value.length > 0;
406
407 // Merge in any locally-selected agents not present in the fetched list
408 // (e.g. agents found via search who aren't in the default top 20)
409 const seen = new Set(options.map((o) => o.value));
410 if (!isSearching) {
411 for (const a of localAssignees.value) {
412 if (!seen.has(a.name)) {
413 const user = getUser(a.name);
414 options.push({
415 value: a.name,
416 label: a.label || a.agent_name || user.full_name || a.name,
417 image: a.image || a.user_image || user.user_image,
418 ...liveAvailability(a),
419 });
420 seen.add(a.name);
421 }
422 }
423 }
424
425 const pinned = pinnedSelectedNames.value;
426
427 const selfOption: AgentOption[] = [];
428 const assigned: AgentOption[] = [];
429 const rest: AgentOption[] = [];
430
431 for (const opt of options) {
432 if (!isSearching && currentAgentName && opt.value === currentAgentName) {
433 selfOption.push(opt);
434 } else if (pinned.has(opt.value)) {
435 assigned.push(opt);
436 } else {
437 rest.push(opt);
438 }
439 }
440
441 // If there are pinned assignees, show them first then current user, otherwise current user first
442 if (assigned.length > 0) {
443 return [...assigned, ...selfOption, ...rest];
444 }
445
446 return [...selfOption, ...rest];
447});
448
449function availabilitySubtitle(
450 availability?: string,
451 changedOn?: string
452): string {
453 if (!availability) return "";
454 const status = agentStatusStore.getStatus(availability);
455 if (!status) return "";
456 if (status.category === "Active") return __("Active now");
457
458 const label = __(availability);
459 if (!changedOn) return label;
460 // Suffix with how long ago they were last active, e.g. "Away · Last seen 2
461 // minutes ago". prettyDate says "Just now" under a minute, which we lowercase
462 // so it reads cleanly after "Last seen".
463 const secondsSinceChange = dayjsLocal().diff(
464 dayjsLocal(changedOn),
465 "seconds"
466 );
467 const lastSeen =
468 secondsSinceChange < 60 ? __("just now") : prettyDate(changedOn);
469 return lastSeen ? __("{0} · Last active {1}", label, lastSeen) : label;
470}
471
472function isSelected(agentName: string): boolean {
473 return localAssignees.value.some((a) => a.name === agentName);
474}
475
476function toggleAgent(agent: AgentOption) {
477 const isSearching = searchText.value.length > 0;
478 if (isSelected(agent.value)) {
479 localAssignees.value = localAssignees.value.filter(
480 (a) => a.name !== agent.value
481 );
482 // Only unpin if it was pinned during search, keep original pins stable
483 if (isSearching) {
484 pinnedSelectedNames.value.delete(agent.value);
485 }
486 } else {
487 const added: LocalAssignee = {
488 name: agent.value,
489 image: agent.image || "",
490 label: agent.label,
491 };
492 // Carry the option's status so it survives the localAssignees fallback in
493 // sortedAgentOptions (e.g. a search-added agent outside the default page resource call).
494 if (agent.availability) added.availability = agent.availability;
495 if (agent.availability_changed_on)
496 added.availability_changed_on = agent.availability_changed_on;
497 localAssignees.value.push(added);
498 // Pin only when selecting during search so they stay visible when search clears
499 if (isSearching) {
500 pinnedSelectedNames.value.add(agent.value);
501 }
502 }
503}
504
505// Keyboard navigation, ref forwarding to scroll highlighted option into view
506const optionRefs = ref<Map<number, Element>>(new Map());
507
508function setOptionRef(index: number, el: Element | null) {
509 if (el) {
510 optionRefs.value.set(index, el);
511 } else {
512 optionRefs.value.delete(index);
513 }
514}
515
516// Only scroll when highlightedIndex changes (keyboard nav), not on every re-render
517watch(highlightedIndex, (index) => {
518 nextTick(() => {
519 optionRefs.value.get(index)?.scrollIntoView({ block: "nearest" });
520 });
521});
522
523function handleInputKeydown(event: KeyboardEvent) {
524 if (
525 event.key === "Enter" &&
526 sortedAgentOptions.value[highlightedIndex.value]
527 ) {
528 event.preventDefault();
529 event.stopPropagation();
530 toggleAgent(sortedAgentOptions.value[highlightedIndex.value]);
531 } else if (event.key === "ArrowDown") {
532 event.preventDefault();
533 highlightedIndex.value = Math.min(
534 highlightedIndex.value + 1,
535 sortedAgentOptions.value.length - 1
536 );
537 } else if (event.key === "ArrowUp") {
538 event.preventDefault();
539 highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
540 }
541}
542
543// Reset highlight when search changes
544watch(searchText, () => {
545 highlightedIndex.value = 0;
546});
547
548// triggered when the popover is closed
549const addAssigneesResource = createResource({
550 url: "frappe.desk.form.assign_to.add",
551 makeParams: (addedAssignees: string[]) => ({
552 doctype: "OD Ticket",
553 name: ticket?.value?.name,
554 assign_to: addedAssignees,
555 }),
556 onSuccess: () => {
557 capture("ticket_assigned", {
558 data: { doctype: "OD Ticket", source: "popover" },
559 });
560 },
561});
562
563const removeAssigneesResource = createResource({
564 url: "opendesk.api.doc.remove_assignments",
565 makeParams: (removedAssignees: string[]) => ({
566 doctype: "OD Ticket",
567 name: ticket?.value?.name,
568 assignees: removedAssignees,
569 }),
570});
571
572// Toast a warning for each newly-added agent who isn't currently active.
573// Returns true if any warning was shown so the caller can defer the success toast.
574function warnUnavailableAgents(addedNames: string[]): boolean {
575 let hasUnavailable = false;
576 // Iterate the rendered options (not just the fetched page) so agents picked
577 // via search — who aren't in the default list — are still checked.
578 for (const agent of sortedAgentOptions.value) {
579 if (!addedNames.includes(agent.value)) continue;
580 // No point warning agents about their own status when assigning themselves.
581 if (agent.value === currentAgentName) continue;
582 const category = agentStatusStore.getStatus(
583 agent.availability || ""
584 )?.category;
585 if (category !== "Away" && category !== "Unavailable") continue;
586
587 toast.warning(
588 category === "Unavailable"
589 ? __("{0} is currently unavailable.", agent.label)
590 : __("{0} is currently away.", agent.label)
591 );
592 hasUnavailable = true;
593 }
594 return hasUnavailable;
595}
596
597async function saveAssignees(added: string[], removed: string[]) {
598 if (!added.length && !removed.length) return;
599 let hasUnavailable = false;
600
601 try {
602 if (removed.length) {
603 const removeResult = await removeAssigneesResource.submit(removed);
604 if (removeResult?.exc) throw new Error(removeResult.exc);
605 }
606 if (added.length) {
607 hasUnavailable = warnUnavailableAgents(added);
608 const addResult = await addAssigneesResource.submit(added);
609 if (addResult?.exc) throw new Error(addResult.exc);
610 }
611
612 // core assign_to writes the timeline entry itself
613
614 // Delay the success toast when warnings were shown so they land first.
615 const successDelay = hasUnavailable ? 1000 : 0;
616 setTimeout(() => {
617 toast.success(__("Assignees updated successfully."));
618 }, successDelay);
619
620 assignees?.value.reload();
621 } catch {
622 toast.error(__("Failed to update Assignees."));
623 localAssignees.value = [...snapshotAssignees.value];
624 }
625}
626
627useShortcut("a", () => {
628 (triggerRef.value?.$el as HTMLElement)?.click();
629});
630</script>
631
632<style scoped>
633/* The class lands on TextInput's component root, which lacks this component's
634 scope id, so a parent-scoped `.search-agents-input :deep(input)` never
635 matches. Deep the whole selector so it applies by class and strips the
636 @tailwindcss/forms white reset that shows through in dark mode. */
637:deep(.search-agents-input input) {
638 @apply bg-transparent px-0;
639}
640
641/* frappe-ui's suffix slot wrapper adds a pe-2; drop it so the shortcut cap
642 sits flush to the row padding, matching the tag picker's search. */
643:deep(.search-agents-input .end-0) {
644 @apply pe-0;
645}
646</style>
647