1<template>
2 <div>
3 <LayoutHeader>
4 <template #left-header>
5 <div class="flex gap-2 items-center crumbs max-w-[50vw]">
6 <Breadcrumbs :items="breadcrumbs" class="-ms-0.5 truncate" />
7 <Badge
8 v-if="article.data"
9 variant="subtle"
10 :theme="article.data?.status === 'Draft' ? 'amber' : 'green'"
11 size="md"
12 >{{ article.data?.status }}</Badge
13 >
14 </div>
15 </template>
16 <template #right-header>
17 <!-- Default Buttons -->
18 <div class="flex gap-2" v-if="!editable && article.data">
19 <Button
20 :label="isPublished ? __('Unpublish') : __('Publish')"
21 :iconLeft="isPublished ? undefined : 'lucide-globe'"
22 @click="togglePublished()"
23 />
24 </div>
25 </template>
26 </LayoutHeader>
27
28 <div
29 class="py-4 mx-auto w-full max-w-3xl px-5 flex flex-col"
30 v-if="article.data"
31 >
32 <!-- article Info -->
33 <div
34 class="flex flex-col gap-3 p-4 w-full"
35 :class="editable && 'border rounded-6 overflow-hidden'"
36 >
37 <!-- Top Element -->
38 <div class="flex flex-col gap-3">
39 <!-- Title -->
40 <div class="flex sm:flex-row flex-col justify-between">
41 <div class="w-full">
42 <textarea
43 ref="titleRef"
44 class="w-full resize-none border-0 text-3xl-bold bg-transparent placeholder-ink-gray-3 p-0 focus:ring-0 overflow-hidden"
45 v-model="title"
46 :placeholder="__('Title')"
47 rows="1"
48 wrap="soft"
49 maxlength="140"
50 autofocus
51 :disabled="!editable"
52 />
53 <div
54 v-if="!editable && !isMobileView"
55 class="flex items-center gap-2 text-p-sm text-ink-gray-5"
56 >
57 <span>{{ viewsLabel }}</span>
58 <template v-if="access && isPublished">
59 <span
60 class="size-1 rounded-full bg-[var(--outline-gray-3)]"
61 />
62 <span class="flex items-center gap-1.5">
63 <Icon :icon="access.icon" class="size-4 shrink-0" />
64 {{ access.label }}
65 </span>
66 </template>
67 </div>
68 </div>
69 <div class="flex gap-4 justify-between sm:items-start">
70 <div class="flex gap-4 text-p-sm items-center">
71 <div class="flex items-center gap-2" v-if="!editable">
72 <Button
73 variant="ghost"
74 size="md"
75 class="flex shrink-0 !w-auto"
76 disabled
77 >
78 <template #suffix>
79 {{ likes }}
80 </template>
81 <template #icon>
82 <ThumbsUpIcon class="size-4" />
83 </template>
84 </Button>
85 <Button
86 variant="ghost"
87 size="md"
88 class="flex shrink-0 !w-auto"
89 disabled
90 >
91 <template #suffix>
92 {{ dislikes }}
93 </template>
94 <template #icon>
95 <ThumbsDownIcon class="size-4" />
96 </template>
97 </Button>
98 </div>
99 </div>
100 <div class="flex gap-1 items-start justify-between">
101 <Dropdown
102 :options="articleActions"
103 v-if="!editable"
104 @update:open="
105 (open) => open && (isConfirmingDeleteArticle = false)
106 "
107 >
108 <Button size="md" variant="ghost">
109 <template #icon>
110 <IconMoreHorizontal class="h-4 w-4" />
111 </template>
112 </Button>
113 </Dropdown>
114 <div class="flex gap-2" v-if="editable">
115 <DiscardButton
116 :disabled="!isDirty"
117 :hide-dialog="!isDirty"
118 :title="__('Discard changes?')"
119 :message="__('Are you sure you want to discard changes?')"
120 @discard="handleDiscard"
121 />
122
123 <Button
124 :label="__('Save')"
125 @click="handleSave"
126 variant="solid"
127 />
128 </div>
129 </div>
130 </div>
131 </div>
132 </div>
133
134 <!-- Article Content -->
135 <Editor
136 ref="editorRef"
137 :model-value="textEditorContentWithIDs"
138 :extensions="extensions"
139 :editable="editable"
140 :upload-function="
141 (file: any, options: any) =>
142 uploadFunction(file, 'OD Article', articleId, false, options)
143 "
144 @change="(event:string) => { content = event; }"
145 :placeholder="__('Write your article here...')"
146 >
147 <template #default>
148 <!-- Scroll here so selected nodes aren't clipped. -->
149 <div :class="editorScrollClass">
150 <EditorContent
151 class="rounded-b-6 max-w-[unset] prose-sm"
152 :class="ARTICLE_IMAGE_CLASSES"
153 />
154 </div>
155 <EditorFixedMenu
156 v-if="editable"
157 class="-ms-1 overflow-x-auto w-full"
158 :items="fullToolbar"
159 />
160 <EditorTableMenu v-if="editable" />
161 </template>
162 </Editor>
163 <div v-if="!editable" class="flex gap-1 items-center pt-1.5 mt-4">
164 <!-- Avatar -->
165 <div class="flex gap-2 items-center justify-center">
166 <Avatar
167 :image="article.data.author.image"
168 :label="article.data.author.name"
169 size="lg"
170 />
171 <div class="flex flex-col justify-start gap-1">
172 <p class="truncate capitalize text-p-base-medium text-ink-gray-9">
173 <span class="text-base text-ink-gray-5"
174 >{{ __("published by") }}
175 </span>
176 {{ article.data.author.name }}
177 </p>
178 <div class="flex items-center gap-1">
179 <span class="text-p-xs text-ink-gray-6">
180 {{
181 dayjsLocal(article.data.modified).format("MMM D, h:mm A")
182 }}
183 </span>
184 <IconDot v-if="isMobileView" class="h-4 w-4 text-ink-gray-5" />
185
186 <span
187 v-if="isMobileView"
188 class="text-p-xs text-ink-gray-4 items-center"
189 >
190 {{ viewsLabel }}
191 </span>
192 </div>
193 </div>
194 </div>
195 </div>
196 </div>
197 </div>
198 <!-- First load only: reloads after a save or move keep the page shown. -->
199 <div
200 v-if="!article.data"
201 class="w-full h-screen flex items-center justify-center"
202 >
203 <LoadingIndicator class="size-10" />
204 </div>
205 <MoveToCategoryModal
206 v-model="moveToModal"
207 @move="handleMoveToCategory"
208 :exclude-category="article.data?.category"
209 />
210 <CategoryModal
211 :edit="editTitle"
212 v-model:title="category.title"
213 v-model:icon="category.icon"
214 v-model="showCategoryModal"
215 @create="handleCategoryCreate"
216 />
217 <ArticleAccessModal
218 v-if="article.data"
219 v-model="showAccessModal"
220 :title="
221 isPublished ? __('Change article access') : __('Publish article as')
222 "
223 :visibility="article.data.visibility"
224 :action-label="isPublished ? __('Update access') : __('Publish')"
225 @publish="publishArticle"
226 >
227 <template #default="{ access }">
228 <p v-if="access" class="text-p-sm text-ink-gray-7">
229 {{ accessNote(access) }}
230 </p>
231 </template>
232 </ArticleAccessModal>
233 </div>
234</template>
235
236<script setup lang="ts">
237import DiscardButton from "@/components/DiscardButton.vue";
238import LayoutHeader from "@/components/LayoutHeader.vue";
239import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
240import { ARTICLE_IMAGE_CLASSES } from "@opendesk/shared/tiptap";
241import { ThumbsDownIcon, ThumbsUpIcon } from "@/components/icons";
242import ArticleAccessModal from "@/components/knowledge-base/ArticleAccessModal.vue";
243import CategoryModal from "@/components/knowledge-base/CategoryModal.vue";
244import MoveToCategoryModal from "@/components/knowledge-base/MoveToCategoryModal.vue";
245import { useScreenSize } from "@/composables/screen";
246import {
247 deleteRes as deleteArticle,
248 moveToCategory,
249 newCategory,
250 updateRes as updateArticle,
251} from "@/stores/knowledgeBase";
252import { capture } from "@/telemetry";
253import { __ } from "@/translation";
254import { Article, Breadcrumb, Error, Resource } from "@/types";
255import { ConfirmDelete, CUSTOMER_PORTAL_ROOT, uploadFunction } from "@/utils";
256import { ROUTES } from "@opendesk/shared/portalRoutes";
257import Icon from "@opendesk/shared/Icon.vue";
258import { categoryAccess } from "@opendesk/shared/settings/categoryAccess.ts";
259import {
260 addHeadingIds,
261 copyToClipboard,
262 countLabel,
263} from "@opendesk/shared/utils";
264import {
265 Avatar,
266 Badge,
267 Breadcrumbs,
268 Button,
269 createResource,
270 dayjsLocal,
271 debounce,
272 Dropdown,
273 LoadingIndicator,
274 toast,
275 usePageMeta,
276} from "frappe-ui";
277import {
278 Editor,
279 EditorContent,
280 EditorFixedMenu,
281 EditorTableMenu,
282} from "frappe-ui/editor";
283import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
284import { useRoute, useRouter } from "vue-router";
285import IconDot from "~icons/lucide/dot";
286import IconMoreHorizontal from "~icons/lucide/more-horizontal";
287
288const extensions = buildEditorExtensions();
289const { isMobileView } = useScreenSize();
290
291const props = defineProps({
292 articleId: {
293 type: String,
294 required: true,
295 },
296});
297
298const showCategoryModal = ref(false);
299const editTitle = ref(false);
300
301function handleCategoryCreate() {
302 newCategory.submit(
303 {
304 title: category.title,
305 icon: category.icon,
306 },
307 {
308 onSuccess: (data: any) => {
309 showCategoryModal.value = false;
310 router.push({
311 name: "Article",
312 params: {
313 articleId: data.article,
314 },
315 query: {
316 category: data.category,
317 title: category.title,
318 isEdit: 1,
319 },
320 });
321 //update category name in breadcrumb
322 article.data.category_name = category.title;
323 toast.success(__("Category created successfully."));
324 },
325 onError: (error: string) => {
326 toast.error(error);
327 },
328 }
329 );
330}
331
332const category = reactive({
333 title: "",
334 id: "",
335 icon: "lucide-folder",
336});
337
338const router = useRouter();
339const route = useRoute();
340
341const editorRef = ref(null);
342const editable = ref(route.query.isEdit ?? false);
343const likes = ref(0);
344const dislikes = ref(0);
345const views = ref(0);
346const access = computed(
347 () => categoryAccess()[article.data?.visibility as string]
348);
349const viewsLabel = computed(() =>
350 countLabel(views.value, __("1 view"), __("{0} views"))
351);
352const content = ref("");
353const title = ref("");
354
355const titleRef = ref(null);
356watch(
357 () => titleRef.value,
358 (newVal) => {
359 if (!newVal) return;
360
361 if (newVal.scrollHeight > newVal.clientHeight) {
362 newVal.style.height = newVal.scrollHeight + "px";
363 }
364 }
365);
366
367const categories = createResource({
368 url: "frappe.client.get_count",
369 makeParams: () => ({
370 doctype: "OD Article Category",
371 }),
372 auto: true,
373});
374
375const article: Resource<Article> = createResource({
376 url: "opendesk.api.knowledge_base.get_article",
377 method: "GET",
378 params: {
379 name: props.articleId,
380 },
381 auto: true,
382 onSuccess: (data: Article) => {
383 content.value = data.content;
384 title.value = data.title;
385 },
386 onError: (err: Error) => {
387 if (!["DoesNotExistError", "PermissionError"].includes(err.exc_type)) {
388 return;
389 }
390 toast.error(__("Article not found"));
391 router.replace({ name: "AgentKnowledgeBase" });
392 },
393});
394
395const articleStats = createResource({
396 url: "opendesk.api.article.get_article_stats",
397 params: { article_name: props.articleId },
398 onSuccess(data) {
399 likes.value = data.likes;
400 dislikes.value = data.dislikes;
401 views.value = data.views;
402 },
403 auto: true,
404});
405
406const isPublished = computed(() => article.data?.status === "Published");
407
408const togglePublished = debounce(
409 () =>
410 isPublished.value
411 ? save({ status: "Draft" }, __("Article unpublished."))
412 : (showAccessModal.value = true),
413 300
414);
415
416const showAccessModal = ref(false);
417
418function accessNote(access: string) {
419 return {
420 Public: __(
421 "Anyone, including visitors who aren't logged in, can read this article."
422 ),
423 Customers: __("Only logged-in customers and agents can read this article."),
424 Private: __("Only agents can read this article."),
425 }[access];
426}
427
428function publishArticle(visibility: string) {
429 save(
430 { status: "Published", visibility },
431 isPublished.value ? __("Access updated.") : __("Article published.")
432 );
433}
434
435// Show the new status at once. A reload would wait a second round trip and
436// swap the page for the loading state.
437function save(values: Record<string, string>, message: string) {
438 Object.assign(article.data, values);
439 updateArticle.submit(
440 { doctype: "OD Article", name: article.data.name, fieldname: values },
441 {
442 onSuccess: (doc: Article) => {
443 article.data.modified = doc.modified;
444 toast.success(message);
445 },
446 onError: (error: Error) => {
447 toast.error(error?.messages?.[0] || error.message);
448 article.reload();
449 },
450 }
451 );
452}
453const isDirty = ref(false);
454
455const moveToModal = ref(false);
456
457function handleMoveToCategory(category: string) {
458 moveToCategory.submit(
459 {
460 category,
461 articles: [props.articleId],
462 },
463 {
464 onSuccess: () => {
465 article.reload();
466 moveToModal.value = false;
467 toast.success(__(`Article has been successfully moved.`));
468 },
469 onError: (error: Error) => {
470 let msg = error?.messages?.[0] || error.message;
471 toast.error(msg);
472 moveToModal.value = false;
473 },
474 }
475 );
476}
477
478function handleEditMode() {
479 editable.value = true;
480 editorRef.value.editor.chain().focus("end").run();
481}
482
483function handleDiscard() {
484 editable.value = false;
485 isDirty.value = false;
486 title.value = article.data.title;
487 content.value = article.data.content;
488 const original = addHeadingIds(article.data.content).html;
489 textEditorContentWithIDs.value = null;
490 nextTick(() => {
491 textEditorContentWithIDs.value = original;
492 });
493}
494
495function hasParagraphContent(html: string) {
496 if (!html) return false;
497 const parser = new DOMParser();
498 const doc = parser.parseFromString(html, "text/html");
499 const paragraphs = doc.querySelectorAll("p");
500 return Array.from(paragraphs).some((p) => {
501 return p.textContent.trim().length > 0;
502 });
503}
504
505function handleSave() {
506 const titleVal = title.value?.trim();
507 const bodyText = hasParagraphContent(content.value);
508
509 if (!titleVal) {
510 toast.error(__("Article title cannot be set as empty"));
511 return;
512 }
513
514 if (!bodyText) {
515 toast.error(__("Article body cannot be set as empty."));
516 return;
517 }
518
519 editable.value = false;
520 handleArticleUpdate();
521}
522
523function handleArticleUpdate() {
524 if (!isDirty.value) return;
525 updateArticle.submit(
526 {
527 doctype: "OD Article",
528 name: article.data.name,
529 fieldname: {
530 content: content.value,
531 title: title.value,
532 },
533 },
534 {
535 onSuccess: () => {
536 capture("article_updated", {
537 data: {
538 category: props.articleId,
539 },
540 });
541 toast.success(__("Article updated successfully."));
542 isDirty.value = false;
543 article.reload();
544 },
545 }
546 );
547}
548
549function handleDelete() {
550 deleteArticle.submit(
551 { doctype: "OD Article", name: article.data.name },
552 {
553 onSuccess: () => {
554 toast.success(__("Article deleted successfully."));
555 router.push({ name: "AgentKnowledgeBase" });
556 },
557 }
558 );
559}
560const textEditorContentWithIDs = ref(null);
561watch(
562 () => article.data?.content,
563 (newContent) => {
564 if (newContent) {
565 textEditorContentWithIDs.value = addHeadingIds(newContent).html;
566 }
567 },
568 { immediate: true }
569);
570
571function scrollToHeading() {
572 const articleHeading = window.location.hash;
573 if (!articleHeading) return;
574 const headingElement = document.querySelector(articleHeading) as HTMLElement;
575 if (!headingElement) return;
576 headingElement.scrollIntoView({ behavior: "smooth" });
577 headingElement.classList.add("transition-all");
578 const fontSize = headingElement.style.fontSize;
579 setTimeout(() => {
580 headingElement.style.fontSize = "1.5rem";
581 setTimeout(() => {
582 headingElement.style.fontSize = fontSize;
583 }, 500);
584 }, 500);
585}
586
587watch(
588 () => articleStats.data,
589 (stats) => {
590 if (stats) {
591 likes.value = stats.likes;
592 dislikes.value = stats.dislikes;
593 }
594 }
595);
596
597watch([() => content.value, () => title.value], ([newContent, newTitle]) => {
598 isDirty.value =
599 newContent !== article.data.content || newTitle !== article.data.title;
600});
601
602const editorScrollClass = computed(
603 () =>
604 editable.value &&
605 "-mx-4 px-4 overflow-auto h-[calc(100vh-340px)] sm:h-[calc(100vh-250px)]"
606);
607
608const isConfirmingDeleteArticle = ref(false);
609
610const articleActions = computed(() => [
611 {
612 label: __("Edit"),
613 icon: "lucide-edit",
614 onClick: () => {
615 handleEditMode();
616 },
617 },
618
619 ...(categories.data && categories.data > 1
620 ? [
621 {
622 label: __("Move To"),
623 icon: "lucide-corner-up-right",
624 onClick: () => (moveToModal.value = true),
625 },
626 ]
627 : [
628 {
629 label: __("Add Category"),
630 icon: "lucide-folder-plus",
631 onClick: () => (showCategoryModal.value = true),
632 },
633 ]),
634 // Drafts pick their access when published; the modal publishes them.
635 ...(isPublished.value
636 ? [
637 {
638 label: __("Change access"),
639 icon: "lucide-lock",
640 onClick: () => (showAccessModal.value = true),
641 },
642 ]
643 : []),
644 {
645 label: __("Copy link"),
646 icon: "lucide-link",
647 onClick: () =>
648 copyToClipboard(
649 window.location.origin +
650 CUSTOMER_PORTAL_ROOT +
651 ROUTES.article({
652 name: props.articleId,
653 title_slug: article.data?.title_slug,
654 }),
655 __("Article link copied to clipboard.")
656 ),
657 },
658 {
659 group: __("Danger"),
660 hideLabel: true,
661 options: [
662 ...ConfirmDelete({
663 onConfirmDelete: handleDelete,
664 isConfirmingDelete: isConfirmingDeleteArticle,
665 }),
666 ],
667 },
668]);
669
670const breadcrumbs = computed(() => {
671 const items: Breadcrumb[] = [
672 {
673 label: isMobileView.value ? __("KB") : __("Knowledge Base"),
674 route: { name: "AgentKnowledgeBase" },
675 },
676 ];
677 if (article.data?.category_name) {
678 items.push({
679 label: article.data?.category_name,
680 route: { name: "AgentKnowledgeBase" },
681 });
682 }
683 if (article.data?.title) {
684 items.push({
685 label: article.data?.title,
686 route: { name: "Article" },
687 });
688 }
689 return items;
690});
691
692onMounted(() => {
693 setTimeout(() => {
694 scrollToHeading();
695 }, 100);
696});
697
698usePageMeta(() => {
699 return {
700 title: article.data?.title + ` - ${article.data?.category_name} `,
701 };
702});
703</script>
704