1<template>
2 <Editor
3 ref="editorRef"
4 v-model="newComment"
5 :extensions="extensions"
6 :editable="editable"
7 :placeholder="placeholder"
8 :upload-function="
9 (file: any, options: any) =>
10 track(uploadFunction(file, doctype, ticketId, true, options))
11 "
12 >
13 <template #default="{ isEmpty }">
14 <!-- Scroll here so selected nodes aren't clipped. -->
15 <div :class="editable && 'max-h-[44vh] overflow-y-auto'">
16 <EditorContent
17 :class="[
18 'prose-sm max-w-none',
19 editable && 'min-h-[7rem] mx-5 border-t py-3',
20 getFontFamily(newComment),
21 ]"
22 />
23 </div>
24 <!-- Attachments -->
25 <AttachmentList
26 class="my-2 ms-5"
27 :attachments="attachments"
28 @remove="removeAttachment"
29 />
30 <div v-if="editable" class="flex flex-col gap-2 border-t">
31 <div class="px-4">
32 <!-- Fixed Menu -->
33 <div class="flex justify-between gap-2 overflow-hidden py-2.5">
34 <div class="flex min-w-0 flex-1 items-center overflow-x-auto">
35 <div class="inline-flex items-center gap-1.5 p-1">
36 <FileUploader
37 :doctype="doctype"
38 :docname="ticketId"
39 private
40 @success="(f) => attachments.push(f)"
41 >
42 <template #default="{ openFileSelector, uploading }">
43 {{ void (loading = uploading) }}
44 <button
45 class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
46 @click="openFileSelector()"
47 :disabled="uploading"
48 >
49 <LoadingIndicator v-if="uploading" class="h-4 w-4" />
50 <AttachmentIcon
51 v-else
52 class="h-4 w-4"
53 style="stroke-width: 1.5 !important"
54 />
55 </button>
56 </template>
57 </FileUploader>
58 <div class="h-4 w-[2px] border-s ml-1" />
59 </div>
60 <EditorFixedMenu :items="fullToolbar" />
61 <EditorTableMenu />
62 </div>
63 <div class="flex shrink-0 items-center justify-end gap-x-2">
64 <Button
65 label="Discard"
66 @click="
67 () => {
68 newComment = '';
69 attachments = [];
70 dropUnused(null);
71 emit('discard');
72 }
73 "
74 />
75 <!-- A disabled button fires no pointer events, so the span
76 carries the hover for the tooltip -->
77 <Tooltip
78 :text="isUploading ? __('Please wait, media is uploading') : ''"
79 >
80 <span class="inline-flex">
81 <Button
82 variant="solid"
83 :label="label"
84 :disabled="isDisabled"
85 :loading="loading"
86 @click="submitComment()"
87 />
88 </span>
89 </Tooltip>
90 </div>
91 </div>
92 </div>
93 </div>
94 </template>
95 </Editor>
96</template>
97<script setup lang="ts">
98import {
99 FileUploader,
100 LoadingIndicator,
101 Tooltip,
102 createResource,
103 dayjs,
104 toast,
105} from "frappe-ui";
106import {
107 Editor,
108 EditorContent,
109 EditorFixedMenu,
110 EditorTableMenu,
111} from "frappe-ui/editor";
112import { addPendingActivity } from "@framework/ui/ActivityTimeline";
113import { useOnboarding } from "@framework/ui";
114import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
115
116import { AttachmentList } from "@/components/";
117import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
118import { AttachmentIcon } from "@/components/icons/";
119import { useTyping } from "@/composables/realtime";
120import { useUploadTracker } from "@/composables/useUploadTracker";
121import { useAgentStore } from "@/stores/agent";
122import { useAuthStore } from "@/stores/auth";
123import { useUserStore } from "@/stores/user";
124import { __ } from "@/translation";
125import { capture } from "@/telemetry";
126import {
127 getFontFamily,
128 isContentEmpty,
129 removeAttachmentFromServer,
130 uploadFunction,
131} from "@/utils";
132import { useStorage } from "@vueuse/core";
133import { storeToRefs } from "pinia";
134
135const { updateOnboardingStep } = useOnboarding("opendesk") ?? {};
136const { agents: agentsList, dropdown } = storeToRefs(useAgentStore());
137const authStore = useAuthStore();
138const { isManager } = authStore;
139const { getUser } = useUserStore();
140
141const props = defineProps({
142 ticketId: {
143 type: String,
144 default: null,
145 },
146 placeholder: {
147 type: String,
148 default: null,
149 },
150 label: {
151 type: String,
152 default: "Comment",
153 },
154 editable: {
155 type: Boolean,
156 default: true,
157 },
158 doctype: {
159 type: String,
160 default: "OD Ticket",
161 },
162});
163
164const emit = defineEmits(["submit", "discard", "sending", "restore"]);
165
166const newComment = useStorage("commentBoxContent" + props.ticketId, null);
167
168// Mentions as a reactive getter so the `@` list stays in sync as agents load.
169const extensions = buildEditorExtensions({
170 mentions: () => dropdown.value ?? [],
171});
172
173// Initialize typing composable
174const { onUserType, cleanup } = useTyping(props.ticketId);
175
176const attachments = ref([]);
177const { isUploading, track, dropUnused } = useUploadTracker();
178const isDisabled = computed(() => {
179 return isContentEmpty(newComment.value) || loading.value || isUploading.value;
180});
181const loading = ref(false);
182
183function removeAttachment(attachment) {
184 attachments.value = attachments.value.filter((a) => a !== attachment);
185 removeAttachmentFromServer(attachment.name);
186}
187
188async function submitComment() {
189 if (isContentEmpty(newComment.value)) {
190 return false;
191 }
192 // The keyboard shortcut reaches here without passing the disabled button
193 if (isUploading.value || loading.value) return false;
194
195 const content = newComment.value;
196 const sentAttachments = attachments.value;
197 const pendingRow = addPendingActivity(props.doctype, props.ticketId, {
198 type: "comment",
199 timestamp: dayjs().format("YYYY-MM-DD HH:mm:ss"),
200 author: {
201 email: getUser(authStore.userId).email,
202 // the users list may still be a stub with a guessed name and no avatar
203 fullname: authStore.userName,
204 image: authStore.userImage,
205 },
206 data: { name: "", content, attachments: sentAttachments },
207 });
208
209 // drop before clearing, or an unmount mid-send deletes what the comment carries
210 dropUnused(content);
211 newComment.value = null;
212 attachments.value = [];
213 loading.value = true;
214 emit("sending");
215
216 const comment = createResource({
217 url: "run_doc_method",
218 makeParams: () => ({
219 dt: props.doctype,
220 dn: props.ticketId,
221 method: "new_comment",
222 args: { content, attachments: sentAttachments },
223 }),
224 onSuccess: (res: { message?: string } | string) => {
225 // run_doc_method answers with the whole body, since it always carries `docs`
226 const name = typeof res === "string" ? res : res?.message;
227 // the real row replaces the pending one the moment it arrives; unkeyed,
228 // it would outlive it
229 name ? pendingRow.resolve(`comment:${name}`) : pendingRow.drop();
230 capture("comment_added");
231 if (isManager) {
232 updateOnboardingStep?.("comment_on_ticket");
233 }
234 emit("submit");
235 loading.value = false;
236 },
237 onError: () => {
238 toast.error(__("Could not add the comment"));
239 pendingRow.drop();
240 newComment.value = content;
241 attachments.value = sentAttachments;
242 loading.value = false;
243 emit("restore");
244 },
245 });
246
247 comment.submit();
248}
249
250const editorRef = ref(null);
251const editor = computed(() => editorRef.value?.editor);
252
253// Watch for changes in comment content to trigger typing events
254watch(newComment, (newValue, oldValue) => {
255 if (newValue !== oldValue && newValue) {
256 onUserType();
257 }
258});
259
260onMounted(() => {
261 if (
262 agentsList.value.loading ||
263 agentsList.value.data?.length ||
264 agentsList.value.list.promise
265 ) {
266 return;
267 }
268 agentsList.value.fetch();
269});
270
271onBeforeUnmount(() => {
272 cleanup();
273 // the saved draft still references what it shows, so only what it dropped goes
274 dropUnused(newComment.value);
275 if (isContentEmpty(newComment.value)) {
276 localStorage.removeItem("commentBoxContent" + props.ticketId);
277 }
278});
279
280defineExpose({
281 submitComment,
282 editor,
283});
284</script>
285