1<template>
2 <div
3 v-if="pendingActions.length"
4 ref="container"
5 class="overflow-hidden rounded-6 border border-outline-gray-1 bg-surface-gray-1"
6 >
7 <div class="flex h-8 items-center gap-1.5 ps-3.5 pe-1.5">
8 <LucideInfo class="size-3 shrink-0 text-ink-gray-6" />
9 <template v-if="sourceLabel">
10 <Tooltip :text="sourceLabel">
11 <span class="min-w-0 truncate text-sm-medium text-ink-gray-7">
12 {{ sourceLabel }}
13 </span>
14 </Tooltip>
15 <span class="shrink-0 text-ink-gray-3">·</span>
16 </template>
17 <span class="shrink-0 whitespace-nowrap text-sm text-ink-gray-5">
18 {{ countSentence }}
19 </span>
20 <div class="ms-auto flex shrink-0 items-center gap-0.5">
21 <Button
22 variant="ghost"
23 size="xs"
24 :aria-expanded="expanded"
25 :icon="expanded ? 'lucide-chevron-down' : 'lucide-chevron-right'"
26 :label="expanded ? __('Hide actions') : __('Show actions')"
27 @click="toggleExpanded"
28 />
29 <Button
30 variant="ghost"
31 size="xs"
32 icon="lucide-x"
33 :label="__('Clear all actions')"
34 @click="clear"
35 :tooltip="__('Clear all actions')"
36 />
37 </div>
38 </div>
39 <div v-if="expanded">
40 <div
41 v-if="chipActions.length"
42 class="flex flex-wrap items-center gap-1.5 px-1.5 pb-2"
43 >
44 <SavedReplyActionChip
45 v-for="action in visibleChips"
46 :key="actionKey(action)"
47 :action="action"
48 :taken="takenTags(action)"
49 @update="updateAction(action, $event)"
50 @remove="removeAction(action)"
51 />
52 <button v-if="overflowCount" type="button" @click="showAllChips = true">
53 <Badge theme="gray" variant="outline" size="lg">
54 {{ __("+{0} more", overflowCount) }}
55 </Badge>
56 </button>
57 </div>
58 <SavedReplyActionComment
59 v-if="noteAction"
60 :action="noteAction"
61 @update:value="setNoteValue"
62 @remove="removeAction(noteAction)"
63 />
64 </div>
65 </div>
66</template>
67
68<script setup lang="ts">
69import { isTagAction } from "@/components/Settings/SavedReplies/components/actionTypes";
70import { reloadTicket, reloadTicketFeed } from "@/composables/useTicket";
71import { userStorage } from "@/composables/userStorage";
72import { __ } from "@/translation";
73import { RenderedSavedReply, SavedReplyAction } from "@/types";
74import { useElementSize } from "@vueuse/core";
75import { Badge, Button, Tooltip, createResource, toast } from "frappe-ui";
76import { computed, ref } from "vue";
77import LucideInfo from "~icons/lucide/info";
78import SavedReplyActionChip from "./SavedReplyActionChip.vue";
79import SavedReplyActionComment from "./SavedReplyActionComment.vue";
80
81/** Chips beyond this stay hidden until the agent asks for the rest. */
82const MAX_VISIBLE_CHIPS = 4;
83/** Failures hold long enough to read: one asks for a decision, the other for work. */
84const FAILURE_TOAST_DURATION = 15000;
85/** Below this the long sentence squeezes the reply name to nothing. */
86const NARROW_WIDTH = 470;
87
88const props = defineProps<{
89 ticketId: string;
90 doctype: string;
91}>();
92
93// Persisted like the draft body, so a refresh keeps text and actions in sync.
94// Scoped to the agent: a staged comment must not survive a shift handover.
95const pendingActions = userStorage<SavedReplyAction[]>(
96 "pendingSavedReplyActions" + props.ticketId,
97 []
98);
99const expanded = userStorage(
100 "pendingSavedReplyActionsExpanded" + props.ticketId,
101 false
102);
103
104const showAllChips = ref(false);
105// Not reactive: it only gates the next failure's retry
106let isRetry = false;
107const container = ref<HTMLElement>();
108const { width } = useElementSize(container);
109
110const noteAction = computed(() =>
111 pendingActions.value.find((action) => action.action_type === "Add Comment")
112);
113
114const chipActions = computed(() =>
115 pendingActions.value.filter((action) => action.action_type !== "Add Comment")
116);
117
118const visibleChips = computed(() =>
119 showAllChips.value || chipActions.value.length <= MAX_VISIBLE_CHIPS + 1
120 ? chipActions.value
121 : chipActions.value.slice(0, MAX_VISIBLE_CHIPS)
122);
123
124const overflowCount = computed(
125 () => chipActions.value.length - visibleChips.value.length
126);
127
128/** Collapsing puts the chip list back to its capped state. */
129function toggleExpanded() {
130 expanded.value = !expanded.value;
131 if (!expanded.value) showAllChips.value = false;
132}
133
134// Only one reply is ever staged, so its title is the whole label
135const sourceLabel = computed(() => pendingActions.value[0]?.source ?? "");
136
137const countSentence = computed(() => {
138 const count = pendingActions.value.length;
139 if (width.value && width.value < NARROW_WIDTH) {
140 return __("{0} actions after sending", count);
141 }
142 return count === 1
143 ? __("1 action will be applied after sending the email")
144 : __("{0} actions will be applied after sending the email", count);
145});
146
147/** Stage the actions of an applied saved reply, replacing whatever was staged. */
148function add(reply: RenderedSavedReply) {
149 pendingActions.value = (reply.actions ?? []).map((action) => ({
150 ...action,
151 source: reply.title,
152 // Kept so the staged comment can report edits and restore
153 ...(action.action_type === "Add Comment"
154 ? { original_value: action.value }
155 : {}),
156 }));
157}
158
159/** What is staged right now, for the replace confirmation. Null when nothing is. */
160function stagedSummary(): { count: number; source: string } | null {
161 if (!pendingActions.value.length) return null;
162 return { count: pendingActions.value.length, source: sourceLabel.value };
163}
164
165// Tags share an action type, so they key on their value too
166function actionKey(action: SavedReplyAction): string {
167 return isTagAction(action.action_type)
168 ? `${action.action_type}:${action.value}`
169 : action.action_type;
170}
171
172// Dont re-use duplicate tags
173function takenTags(action: SavedReplyAction): string[] {
174 if (!isTagAction(action.action_type)) return [];
175 return pendingActions.value
176 .filter((other) => other !== action && isTagAction(other.action_type))
177 .map((other) => other.value);
178}
179
180/** Repoint an action, e.g. an escalation routed to a different team. */
181function updateAction(action: SavedReplyAction, updated: SavedReplyAction) {
182 const index = pendingActions.value.indexOf(action);
183 if (index !== -1) pendingActions.value[index] = updated;
184}
185
186function removeAction(action: SavedReplyAction) {
187 pendingActions.value = pendingActions.value.filter(
188 (pending) => actionKey(pending) !== actionKey(action)
189 );
190}
191
192// Whatever is in the editor at send time is what gets applied
193function setNoteValue(value: string) {
194 if (noteAction.value) noteAction.value.value = value;
195}
196
197function reloadFeed() {
198 reloadTicket(props.ticketId);
199 reloadTicketFeed(props.ticketId);
200}
201
202const applyActions = createResource({
203 url: "opendesk.api.saved_replies.apply_saved_reply_actions",
204 onSuccess: (result: {
205 applied: SavedReplyAction[];
206 skipped: SavedReplyAction[];
207 }) => {
208 if (result.applied?.length) {
209 toast.success(
210 __("Applied {0} saved reply action(s)", result.applied.length)
211 );
212 }
213 if (result.skipped?.length) {
214 toast.warning(
215 __(
216 "Skipped {0} saved reply action(s) that are no longer valid",
217 result.skipped.length
218 )
219 );
220 }
221 pendingActions.value = [];
222 reloadFeed();
223 },
224 onError: (error: { status?: number }) => {
225 const failed = [...pendingActions.value];
226 // Only a response proves it rolled back, and one retry is enough
227 const canRetry = Boolean(error?.status) && !isRetry;
228 // Unstaged always: a kept batch would ride the next, unrelated send
229 pendingActions.value = [];
230 toast.error(__("Could not apply actions from the saved reply."), {
231 duration: FAILURE_TOAST_DURATION,
232 ...(canRetry
233 ? { action: { label: __("Retry"), onClick: () => retry(failed) } }
234 : { description: __("Please apply the actions manually") }),
235 });
236 reloadFeed();
237 },
238});
239
240/** Re-send a batch the agent kept from the failure toast. */
241function retry(actions: SavedReplyAction[]) {
242 isRetry = true;
243 applyActions.submit({ ticket_id: props.ticketId, actions });
244}
245
246/** Apply the staged actions to the ticket; call after the email is sent. */
247function submit() {
248 if (!pendingActions.value.length || props.doctype !== "OD Ticket") return;
249 // Actions stay staged during the request, so guard the second call
250 if (applyActions.loading) return;
251 isRetry = false;
252 applyActions.submit({
253 ticket_id: props.ticketId,
254 actions: [...pendingActions.value],
255 });
256}
257
258/** Drop the staged actions without applying them. */
259function clear() {
260 pendingActions.value = [];
261}
262
263defineExpose({ add, stagedSummary, submit, clear });
264</script>
265