1<template>
2 <SettingsLayoutBase
3 :back-label="savedReplyData.title || __('New Saved Reply')"
4 :on-back="goBack"
5 :dirty="isDirty"
6 >
7 <template #header-actions>
8 <div class="flex items-center gap-2">
9 <Button
10 v-if="savedReplyData.name"
11 :label="__('Preview')"
12 size="sm"
13 @click="onShowPreview()"
14 icon-left="lucide-eye"
15 :disabled="
16 Boolean(
17 !savedReplyData.message?.replace(/<[^>]*>/g, '')?.trim()?.length
18 ) || isDirty
19 "
20 />
21 <Button
22 :label="__('Save')"
23 variant="solid"
24 theme="gray"
25 @click="onSave"
26 :loading="
27 savedRepliesListResource?.insert.loading ||
28 savedRepliesListResource?.setValue.loading ||
29 renameSavedReplyResource.loading ||
30 getSavedReplyData.loading
31 "
32 :disabled="Boolean(!isDirty)"
33 />
34 </div>
35 </template>
36 <template #content>
37 <div
38 v-if="getSavedReplyData.loading"
39 class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
40 >
41 <LoadingIndicator class="w-4" />
42 </div>
43 <div v-else class="flex flex-col gap-5">
44 <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
45 <div class="space-y-1.5">
46 <FormControl
47 :label="__('Name')"
48 type="text"
49 v-model="savedReplyData.title"
50 required
51 maxLength="50"
52 @change="validateData('title')"
53 />
54 <ErrorMessage class="text-p-sm" :message="errors.title" />
55 </div>
56 <div class="space-y-1.5">
57 <Select
58 :label="__('Visibility')"
59 v-model="savedReplyData.scope"
60 :options="scopeDropdownOptions"
61 required
62 class="w-full"
63 >
64 <template #item-prefix="{ item }">
65 <component :is="item.icon" class="size-4 text-ink-gray-9" />
66 </template>
67 </Select>
68 <FormLabel
69 :label="__('Choose who can view and use this response.')"
70 />
71 </div>
72 </div>
73 <div v-if="savedReplyData.scope === 'Team'" class="space-y-1.5">
74 <FormLabel :label="__('Teams')" required />
75 <MultiSelect
76 :options="teamsList"
77 v-model="savedReplyData.teams"
78 :placeholder="__('Select teams')"
79 @update:modelValue="validateData('teams')"
80 class="w-full"
81 />
82 <div class="text-xs text-ink-gray-5 cursor-default">
83 {{ __("Restrict visibility to these teams") }}
84 </div>
85 <ErrorMessage class="text-p-sm" :message="errors.teams" />
86 </div>
87 <div class="space-y-1.5">
88 <div class="flex items-center justify-between">
89 <FormLabel :label="__('Response')" required />
90 <DocumentationButton
91 url="https://github.com/ikhsanddy/opendesk"
92 />
93 </div>
94 <PreviewDialog v-model="previewDialog" />
95 <CompactEditor
96 ref="content"
97 v-model="savedReplyData.message"
98 :upload-fn="
99 (file: any, options: any) =>
100 uploadFunction(
101 file,
102 'OD Saved Reply',
103 savedReplyData.name,
104 false,
105 options
106 )
107 "
108 :extensions="[FieldAutocomplete]"
109 :placeholder="
110 __(
111 'Hello {{ contact }}, \n\nWe are sorry for the inconvenience, we will get back to you soon. \n\nRegards, \n{{ full_name }}'
112 )
113 "
114 @update:modelValue="validateData('message')"
115 />
116 <ErrorMessage class="text-p-sm" :message="errors.message" />
117 </div>
118 <ActionsEditor
119 v-model="savedReplyData.actions"
120 :error="errors.actions"
121 />
122 </div>
123 </template>
124 </SettingsLayoutBase>
125 <ConfirmDialog
126 v-model="showConfirmDialog.show"
127 :title="showConfirmDialog.title"
128 :message="showConfirmDialog.message"
129 :onConfirm="showConfirmDialog.onConfirm"
130 :onCancel="() => (showConfirmDialog.show = false)"
131 />
132</template>
133
134<script setup lang="ts">
135import CompactEditor from "@/components/CompactEditor.vue";
136import ConfirmDialog from "@/components/ConfirmDialog.vue";
137import DocumentationButton from "@/components/DocumentationButton.vue";
138import { useAuthStore } from "@/stores/auth";
139import { useConfigStore } from "@/stores/config";
140import { __ } from "@/translation";
141import { uploadFunction } from "@/utils";
142import { createFieldAutocomplete } from "@opendesk/shared/tiptap";
143import {
144 Button,
145 createListResource,
146 createResource,
147 ErrorMessage,
148 FormControl,
149 FormLabel,
150 LoadingIndicator,
151 MultiSelect,
152 Select,
153 toast,
154} from "frappe-ui";
155import { storeToRefs } from "pinia";
156import { computed, inject, onUnmounted, ref, watch } from "vue";
157import GlobeIcon from "~icons/lucide/globe";
158import UserIcon from "~icons/lucide/user";
159import UsersIcon from "~icons/lucide/users";
160import { SavedReply, SavedReplyListResourceSymbol, Team } from "../../../types";
161import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
162import { disableSettingModalOutsideClick } from "../settingsModal";
163import ActionsEditor from "./components/ActionsEditor.vue";
164import { actionNeedsValue, isActionValueEmpty } from "./components/actionTypes";
165import PreviewDialog from "./components/PreviewDialog.vue";
166import { getTemplateFields } from "./savedReplies";
167
168const FieldAutocomplete = createFieldAutocomplete(getTemplateFields);
169
170const showConfirmDialog = ref({
171 show: false,
172 title: "",
173 message: "",
174 onConfirm: () => {},
175});
176
177const savedRepliesActiveScreen = inject<any>("savedRepliesActiveScreen");
178const savedRepliesListResource = inject(SavedReplyListResourceSymbol);
179
180const previewDialog = ref({
181 show: false,
182 ticketId: "",
183 savedReply: "",
184 preview: null,
185});
186const content = ref();
187
188const { teamRestrictionApplied, disableGlobalScopeForSavedReplies } =
189 storeToRefs(useConfigStore());
190const { userTeams, isAdmin } = storeToRefs(useAuthStore());
191
192const savedReplyData = ref({
193 name: "",
194 title: "",
195 scope: savedRepliesActiveScreen.value.data?.scope || "Personal",
196 message: "",
197 teams: [],
198 actions: [],
199});
200const initialData = ref("");
201const errors = ref({
202 title: "",
203 message: "",
204 teams: "",
205 actions: "",
206});
207
208const scopeDropdownOptions = computed(() => {
209 const options = [
210 {
211 label: __("Personal"),
212 value: "Personal",
213 icon: UserIcon,
214 },
215 {
216 label: __("Team"),
217 value: "Team",
218 icon: UsersIcon,
219 },
220 {
221 label: __("Global"),
222 value: "Global",
223 icon: GlobeIcon,
224 },
225 ];
226
227 if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
228 options.pop();
229 }
230
231 return options;
232});
233
234const getSavedReplyData = createResource({
235 url: "frappe.client.get",
236 makeParams() {
237 return {
238 doctype: "OD Saved Reply",
239 name: savedRepliesActiveScreen.value.data?.name,
240 };
241 },
242 auto: false,
243 onSuccess: (data: SavedReply) => {
244 savedReplyData.value = {
245 name: data.name,
246 title: data.title,
247 scope: data.scope,
248 message: data.message,
249 teams: data.teams?.map((team) => team.team) || [],
250 actions: JSON.parse(data.actions || "[]"),
251 };
252 initialData.value = JSON.stringify(savedReplyData.value);
253 },
254});
255
256const getTeamsListResource = createListResource({
257 doctype: "OD Team",
258 auto: true,
259 fields: ["name"],
260 filters: {
261 disabled: 0,
262 },
263 start: 0,
264 pageLength: 999,
265 transform: (data: Array<Team>) => {
266 return data.map((item) => ({
267 value: item.name,
268 label: item.name,
269 }));
270 },
271});
272
273const teamsList = computed(() => {
274 if (!isAdmin.value && teamRestrictionApplied.value) {
275 return (
276 userTeams.value?.map((team: string) => ({
277 value: team,
278 label: team,
279 })) || []
280 );
281 }
282 return getTeamsListResource.data || [];
283});
284
285const onShowPreview = () => {
286 previewDialog.value.show = true;
287 previewDialog.value.savedReply = savedReplyData.value.name;
288};
289
290if (savedRepliesActiveScreen.value.data?.name) {
291 getSavedReplyData.submit();
292} else {
293 initialData.value = JSON.stringify(savedReplyData.value);
294}
295
296const isDirty = ref(false);
297
298const goBack = () => {
299 const confirmDialogInfo = {
300 show: true,
301 title: __("Unsaved changes"),
302 message: __(
303 "Are you sure you want to go back? Unsaved changes will be lost."
304 ),
305 onConfirm: goBack,
306 };
307 if (isDirty.value && !showConfirmDialog.value.show) {
308 showConfirmDialog.value = confirmDialogInfo;
309 return;
310 }
311 // Workaround fix for settings modal not closing after going back
312 setTimeout(() => {
313 savedRepliesActiveScreen.value = {
314 screen: "list",
315 data: null,
316 };
317 }, 250);
318 showConfirmDialog.value.show = false;
319};
320
321const onSave = () => {
322 validateData();
323
324 // Each field shows its own inline error; no toast needed
325 if (Object.values(errors.value).some((e) => e)) return;
326
327 if (savedRepliesActiveScreen.value.data?.name) {
328 updateSavedReply();
329 } else {
330 createSavedReply();
331 }
332};
333
334const createSavedReply = () => {
335 savedRepliesListResource?.insert.submit(
336 {
337 title: savedReplyData.value.title,
338 message: savedReplyData.value.message,
339 scope: savedReplyData.value.scope,
340 teams: savedReplyData.value.teams.map((team) => ({
341 team: team,
342 })),
343 actions: savedReplyData.value.actions,
344 },
345 {
346 onSuccess: (data) => {
347 toast.success(__("Saved reply saved successfully."));
348 savedReplyData.value = {
349 ...savedReplyData.value,
350 name: data.name,
351 };
352 initialData.value = JSON.stringify(savedReplyData.value);
353 savedRepliesActiveScreen.value = {
354 screen: "view",
355 data: { name: data.name },
356 };
357 },
358 }
359 );
360};
361
362const renameSavedReplyResource = createResource({
363 url: "frappe.client.rename_doc",
364 makeParams() {
365 return {
366 doctype: "OD Saved Reply",
367 old_name: savedRepliesActiveScreen.value.data.name,
368 new_name: savedReplyData.value.title,
369 };
370 },
371});
372
373const updateSavedReply = async () => {
374 await savedRepliesListResource?.setValue.submit({
375 name: savedReplyData.value.name,
376 title: savedReplyData.value.title,
377 subject: savedReplyData.value.title,
378 message: savedReplyData.value.message,
379 scope: savedReplyData.value.scope,
380 teams: savedReplyData.value.teams.map((team) => ({
381 team: team,
382 })),
383 actions: savedReplyData.value.actions,
384 });
385
386 if (savedReplyData.value.name !== savedReplyData.value.title) {
387 await renameSavedReplyResource.submit().catch(async (err: any) => {
388 const error =
389 err?.messages?.[0] ||
390 __("Some error occurred while renaming saved reply");
391 toast.error(error);
392 // Reset saved reply to previous state
393 await getSavedReplyData.reload();
394 });
395
396 await getSavedReplyData.submit({
397 doctype: "OD Saved Reply",
398 name: savedReplyData.value.title,
399 });
400
401 savedRepliesActiveScreen.value = {
402 screen: "view",
403 data: { name: savedReplyData.value.title },
404 };
405 } else {
406 await getSavedReplyData.reload();
407 }
408
409 savedRepliesListResource?.reload();
410 isDirty.value = false;
411 toast.success(__("Saved reply updated successfully."));
412};
413
414const validateData = (key?: string) => {
415 const validateField = (key: string) => {
416 switch (key) {
417 case "title":
418 if (!savedReplyData.value.title) {
419 errors.value.title = __("Title is required");
420 } else {
421 errors.value.title = "";
422 }
423 break;
424
425 case "message":
426 if (
427 !savedReplyData.value.message?.replace(/<[^>]*>/g, "")?.trim()?.length
428 ) {
429 errors.value.message = __("Response is required");
430 } else {
431 errors.value.message = "";
432 }
433 break;
434
435 case "teams":
436 if (
437 savedReplyData.value.scope === "Team" &&
438 !savedReplyData.value.teams.length
439 ) {
440 errors.value.teams = __("At least one team is required");
441 } else {
442 errors.value.teams = "";
443 }
444 break;
445
446 case "actions": {
447 const hasEmpty = savedReplyData.value.actions.some(
448 (action) =>
449 actionNeedsValue(action.action_type) &&
450 isActionValueEmpty(action.action_type, action.value)
451 );
452 errors.value.actions = hasEmpty ? __("Values can't be empty") : "";
453 break;
454 }
455
456 default:
457 break;
458 }
459 };
460 if (key) {
461 validateField(key);
462 } else {
463 (Object.keys(errors.value) as string[]).forEach(validateField);
464 }
465
466 return errors.value;
467};
468
469watch(
470 savedReplyData,
471 (newVal) => {
472 if (errors.value.actions) validateData("actions");
473 if (!initialData.value) return;
474 isDirty.value = JSON.stringify(newVal) != initialData.value;
475 if (isDirty.value) {
476 disableSettingModalOutsideClick.value = true;
477 } else {
478 disableSettingModalOutsideClick.value = false;
479 }
480 },
481 { deep: true }
482);
483
484onUnmounted(() => {
485 disableSettingModalOutsideClick.value = false;
486});
487</script>
488