1<template>
2 <div class="flex flex-col">
3 <LayoutHeader>
4 <template #left-header>
5 <div class="text-lg-medium text-ink-gray-9">
6 {{ __("Knowledge Base") }}
7 </div>
8 </template>
9 <template #right-header>
10 <Dropdown :options="headerOptions">
11 <Button
12 :label="__('Create')"
13 variant="solid"
14 class="rtl:flex-row-reverse"
15 >
16 <template #prefix>
17 <LucidePlus class="h-4 w-4" />
18 </template>
19 </Button>
20 </Dropdown>
21 </template>
22 </LayoutHeader>
23 <ListViewBuilder
24 ref="listViewRef"
25 :options="options"
26 @row-click="(row) => $router.push(`kb/articles/${row}`)"
27 />
28 <CategoryModal
29 :edit="editTitle"
30 v-model="showCategoryModal"
31 v-model:title="category.title"
32 v-model:icon="category.icon"
33 @update="handleCategoryUpdate"
34 @create="handleCategoryCreate"
35 />
36 <MoveToCategoryModal
37 v-model="moveToModal"
38 :exclude-category="selectionCategory"
39 @move="handleMoveToCategory"
40 />
41 <MergeCategoryModal
42 :categoryTitle="category.title"
43 :category-id="category.id"
44 v-model="mergeModal"
45 @merge="handleMergeCategory"
46 />
47 <ArticleAccessModal
48 v-model="accessDialog.open"
49 :title="accessDialog.title"
50 :visibility="accessDialog.visibility"
51 :action-label="__('Update access')"
52 require-change
53 @publish="
54 accessDialog.forCategory
55 ? updateCategoryAccess($event)
56 : updateArticlesAccess($event)
57 "
58 >
59 <template #default="{ access }">
60 <p class="text-p-sm text-ink-gray-7">
61 {{
62 accessDialog.forCategory
63 ? categoryAccessNote(access)
64 : articlesAccessNote(access)
65 }}
66 </p>
67 </template>
68 </ArticleAccessModal>
69 </div>
70</template>
71
72<script setup lang="ts">
73import Icon from "@opendesk/shared/Icon.vue";
74import { ArticleIcon } from "@/components/icons";
75import LayoutHeader from "@/components/LayoutHeader.vue";
76import ListViewBuilder from "@/components/ListViewBuilder.vue";
77import ArticleAccessModal from "@/components/knowledge-base/ArticleAccessModal.vue";
78import CategoryModal from "@/components/knowledge-base/CategoryModal.vue";
79import MergeCategoryModal from "@/components/knowledge-base/MergeCategoryModal.vue";
80import MoveToCategoryModal from "@/components/knowledge-base/MoveToCategoryModal.vue";
81import { globalStore } from "@/stores/globalStore";
82import {
83 deleteArticles,
84 deleteRes as deleteCategory,
85 mergeCategory,
86 moveToCategory,
87 newCategory,
88 updateCategory,
89} from "@/stores/knowledgeBase";
90import { capture } from "@/telemetry";
91import { Error } from "@/types";
92import { CUSTOMER_PORTAL_ROOT } from "@/utils";
93import { copyToClipboard } from "@opendesk/shared/utils";
94import { ROUTES } from "@opendesk/shared/portalRoutes";
95import { categoryAccess } from "@opendesk/shared/settings/categoryAccess.ts";
96import {
97 Badge,
98 Button,
99 Dropdown,
100 call,
101 createResource,
102 toast,
103 usePageMeta,
104} from "frappe-ui";
105import { computed, h, markRaw, onMounted, reactive, ref } from "vue";
106import { __ } from "@/translation";
107import { useRouter } from "vue-router";
108import LucideMerge from "~icons/lucide/merge";
109
110const router = useRouter();
111const { $dialog } = globalStore();
112
113const category = reactive({
114 title: "",
115 id: "",
116 icon: "lucide-folder",
117});
118
119const listViewRef = ref(null);
120const editTitle = ref(false);
121
122// modals state
123const showCategoryModal = ref(false);
124const moveToModal = ref(false);
125const mergeModal = ref(false);
126const accessDialog = reactive({
127 open: false,
128 forCategory: true,
129 title: "",
130 visibility: null as string | null,
131});
132const hasActiveFilters = computed(
133 () => Object.keys(listViewRef.value?.list?.params?.filters || {}).length > 0
134);
135const generalCategory = createResource({
136 url: "opendesk.api.knowledge_base.get_general_category",
137 auto: true,
138 cache: ["GeneralCategory"],
139});
140
141const headerOptions = [
142 {
143 label: __("Category"),
144 icon: "lucide-folder",
145 onClick: () => {
146 resetState();
147 editTitle.value = false;
148 showCategoryModal.value = true;
149 },
150 },
151 {
152 label: __("Article"),
153 icon: markRaw(ArticleIcon),
154 onClick: () => {
155 router.push({
156 name: "NewArticle",
157 params: {
158 id: generalCategory.data,
159 },
160 query: {
161 title: "General",
162 },
163 });
164 },
165 },
166];
167
168const groupByActions = [
169 {
170 label: __("Add New Article"),
171 icon: "lucide-plus",
172 onClick: (groupedRow) => {
173 router.push({
174 name: "NewArticle",
175 params: {
176 id: groupedRow.group.value,
177 },
178 query: {
179 title: groupedRow.group.label,
180 },
181 });
182 },
183 },
184 {
185 label: __("Edit"),
186 icon: "lucide-edit",
187 onClick: (groupedRow) => {
188 editTitle.value = true;
189 showCategoryModal.value = true;
190 category.title = groupedRow.group.label;
191 category.id = groupedRow.group.value;
192 category.icon = groupedRow.group.icon || "lucide-folder";
193 },
194 },
195 {
196 label: __("Merge"),
197 icon: LucideMerge,
198 onClick: (groupedRow) => {
199 mergeModal.value = true;
200 category.title = groupedRow.group.label;
201 category.id = groupedRow.group.value;
202 },
203 },
204 {
205 label: __("Change access"),
206 icon: "lucide-lock",
207 onClick: async ({ group }) => {
208 category.title = group.label;
209 category.id = group.value;
210 const visibility = await categoryVisibility.submit({
211 category: group.value,
212 });
213 Object.assign(accessDialog, {
214 open: true,
215 forCategory: true,
216 title: __("Change category access"),
217 visibility,
218 });
219 },
220 },
221 {
222 label: __("Copy link"),
223 icon: "lucide-link",
224 onClick: ({ group }) =>
225 copyToClipboard(
226 window.location.origin +
227 CUSTOMER_PORTAL_ROOT +
228 ROUTES.category(group.value),
229 __("Category link copied to clipboard.")
230 ),
231 },
232 {
233 label: __("Delete"),
234 icon: "lucide-trash-2",
235 onClick: (groupedRow) => {
236 handleCategoryDelete(groupedRow);
237 },
238 },
239];
240
241const categoryVisibility = createResource({
242 url: "opendesk.api.knowledge_base.get_category_visibility",
243});
244const setCategoryVisibility = createResource({
245 url: "opendesk.api.knowledge_base.set_category_visibility",
246});
247
248function categoryAccessNote(access: string | null) {
249 if (!access) {
250 return __(
251 "Articles in this category have different access. Choosing one applies it to every article."
252 );
253 }
254 if (access === accessDialog.visibility) {
255 return {
256 Public: __(
257 "Anyone, including visitors who aren't logged in, can read the articles in this category."
258 ),
259 Customers: __(
260 "Only logged-in customers and agents can read the articles in this category."
261 ),
262 Private: __(
263 "Only agents can read the articles in this category. It doesn't appear on the customer portal."
264 ),
265 }[access];
266 }
267 return {
268 Public: __(
269 "Every article in this category will be readable by anyone, including visitors who aren't logged in."
270 ),
271 Customers: __(
272 "Every article in this category will be readable only by logged-in customers and agents."
273 ),
274 Private: __(
275 "Every article in this category will be readable only by agents, and the category will no longer appear on the customer portal."
276 ),
277 }[access];
278}
279
280function updateCategoryAccess(visibility: string) {
281 setCategoryVisibility.submit(
282 { category: category.id, visibility },
283 {
284 onSuccess: () => {
285 toast.success(
286 __("Access updated for every article in {0}.", [category.title])
287 );
288 listViewRef.value?.reload();
289 },
290 onError: (error: Error) =>
291 toast.error(error?.messages?.[0] || error.message),
292 }
293 );
294}
295
296function articlesAccessNote(access: string | null) {
297 if (!access) {
298 return __(
299 "The selected articles have different access. Choosing one applies it to all of them."
300 );
301 }
302 if (access === accessDialog.visibility) {
303 return {
304 Public: __(
305 "Anyone, including visitors who aren't logged in, can read the selected articles."
306 ),
307 Customers: __(
308 "Only logged-in customers and agents can read the selected articles."
309 ),
310 Private: __("Only agents can read the selected articles."),
311 }[access];
312 }
313 return {
314 Public: __(
315 "The selected articles will be readable by anyone, including visitors who aren't logged in."
316 ),
317 Customers: __(
318 "The selected articles will be readable only by logged-in customers and agents."
319 ),
320 Private: __("The selected articles will be readable only by agents."),
321 }[access];
322}
323
324async function changeArticlesAccess(selections: Set<string>) {
325 listSelections.value = new Set(selections);
326 const rows = await call("frappe.client.get_list", {
327 doctype: "OD Article",
328 filters: { name: ["in", Array.from(selections)] },
329 fields: ["visibility"],
330 limit_page_length: 0,
331 });
332 const current = new Set(rows.map((row) => row.visibility));
333 Object.assign(accessDialog, {
334 open: true,
335 forCategory: false,
336 title:
337 selections.size === 1
338 ? __("Change access for 1 article")
339 : __("Change access for {0} articles", [selections.size]),
340 visibility: current.size === 1 ? [...current][0] : null,
341 });
342}
343
344async function updateArticlesAccess(visibility: string) {
345 let failed_docs;
346 try {
347 ({ failed_docs } = await call("frappe.client.bulk_update", {
348 docs: Array.from(listSelections.value).map((docname) => ({
349 doctype: "OD Article",
350 docname,
351 visibility,
352 })),
353 }));
354 } catch {
355 // per-article failures come back in `failed_docs`
356 toast.error(__("Could not update access"));
357 return;
358 }
359 if (failed_docs.length) {
360 toast.error(__("Could not update access for some articles."));
361 } else {
362 toast.success(__("Access updated for the selected articles."));
363 }
364 listViewRef.value?.reload();
365 listViewRef.value?.unselectAll();
366 listSelections.value.clear();
367}
368
369const listSelections = ref(new Set());
370// The category every selected article is in, if they share one.
371const selectionCategory = computed(() => {
372 const categories = new Set(
373 (listViewRef.value?.list?.data?.data || [])
374 .filter((row) => listSelections.value.has(row.name))
375 .map((row) => row.category)
376 );
377 return categories.size === 1 ? [...categories][0] : undefined;
378});
379const selectBannerActions = [
380 {
381 label: __("Change access"),
382 icon: "lucide-lock",
383 onClick: changeArticlesAccess,
384 },
385 {
386 label: __("Move To"),
387 icon: "lucide-corner-up-right",
388 onClick: (selections: Set<string>) => {
389 listSelections.value = new Set(selections);
390 moveToModal.value = true;
391 },
392 },
393 {
394 label: __("Delete"),
395 icon: "lucide-trash-2",
396 onClick: (selections: Set<string>) => {
397 listSelections.value = selections;
398 $dialog({
399 title: __("Delete articles"),
400 message: __("Are you sure you want to delete these articles?"),
401 actions: [
402 {
403 label: __("Delete"),
404 theme: "red",
405 iconLeft: "lucide-trash-2",
406 variant: "solid",
407 onClick({ close }) {
408 handleDeleteArticles();
409 close();
410 },
411 },
412 ],
413 });
414 },
415 },
416];
417
418function handleMoveToCategory(category: string) {
419 moveToCategory.submit(
420 {
421 category,
422 articles: Array.from(listSelections.value),
423 },
424 {
425 onSuccess: () => {
426 moveToModal.value = false;
427 listViewRef.value?.reload();
428 listViewRef.value?.unselectAll();
429 listSelections.value.clear();
430 toast.success(__("Articles moved successfully."));
431 },
432 onError: (error: Error) => {
433 const title = error?.messages?.[0] || error.message;
434 toast.error(title);
435 moveToModal.value = false;
436 },
437 }
438 );
439}
440
441function handleCategoryCreate() {
442 newCategory.submit(
443 {
444 title: category.title,
445 icon: category.icon,
446 },
447 {
448 onSuccess: (data: any) => {
449 listViewRef.value.reload();
450 showCategoryModal.value = false;
451 router.push({
452 name: "Article",
453 params: {
454 articleId: data.article,
455 },
456 query: {
457 category: data.category,
458 title: category.title,
459 isEdit: 1,
460 },
461 });
462 toast.success(__("Category created successfully."));
463 capture("category_created", {
464 data: {
465 category: category.title,
466 },
467 });
468 resetState();
469 },
470 onError: (error: any) => {
471 const message =
472 error?.messages?.[0] ||
473 error?.message ||
474 __("Failed to create category");
475 toast.error(message);
476 },
477 }
478 );
479}
480
481function handleCategoryUpdate() {
482 updateCategory.submit(
483 {
484 doctype: "OD Article Category",
485 name: category.id,
486 fieldname: { category_name: category.title, icon: category.icon },
487 },
488 {
489 onSuccess: () => {
490 listViewRef.value.reload();
491 showCategoryModal.value = false;
492 editTitle.value = false;
493
494 toast.success(__("Category updated successfully."));
495 resetState();
496 },
497 onError: (error: any) => {
498 const title =
499 error?.messages?.[0] ||
500 error?.message ||
501 __("Failed to update category.");
502 toast.error(title);
503 },
504 }
505 );
506}
507
508function handleCategoryDelete(groupedRow) {
509 $dialog({
510 title: __("Delete category?"),
511 message: __(
512 "All articles from this category will move to General category."
513 ),
514 actions: [
515 {
516 label: __("Confirm"),
517 variant: "solid",
518 onClick({ close }: { close: () => void }) {
519 deleteCategory.submit(
520 {
521 doctype: "OD Article Category",
522 name: groupedRow.group.value,
523 },
524 {
525 onSuccess: () => {
526 toast.success(__("Category deleted successfully."));
527 listViewRef.value.reload();
528 },
529 }
530 );
531 close();
532 },
533 },
534 ],
535 });
536}
537
538function handleDeleteArticles() {
539 deleteArticles.submit(
540 {
541 articles: Array.from(listSelections.value),
542 },
543 {
544 onSuccess: () => {
545 listViewRef.value?.reload();
546 listViewRef.value?.unselectAll();
547 listSelections.value?.clear();
548 toast.success(__("Articles deleted successfully."));
549 },
550 }
551 );
552}
553
554function handleMergeCategory(source: string, target: string) {
555 mergeCategory.submit(
556 {
557 source,
558 target,
559 },
560 {
561 onSuccess: () => {
562 listViewRef.value.reload();
563 toast.success(__("Category merged successfully."));
564 mergeModal.value = false;
565 resetState();
566 },
567 onError: (error: Error) => {
568 const title = error?.messages?.[0] || error.message;
569 toast.error(title);
570 },
571 }
572 );
573}
574
575function resetState() {
576 category.title = "";
577 category.id = "";
578 category.icon = "lucide-folder";
579}
580
581const options = computed(() => {
582 return {
583 doctype: "OD Article",
584 selectable: true,
585 view: {
586 view_type: "group_by",
587 group_by_field: "category",
588 label_doc: "OD Article Category",
589 label_field: "category_name",
590 icon_field: "icon",
591 },
592 columnConfig: {
593 title: {
594 prefix: () => {
595 return h(ArticleIcon, {
596 class: "h-4 w-4 flex-shrink-0 text-ink-gray-6",
597 });
598 },
599 },
600 status: {
601 custom: ({ item }) => {
602 return h(Badge, {
603 ...statusMap[item],
604 });
605 },
606 },
607 visibility: {
608 custom: ({ item }) =>
609 h("div", { class: "flex items-center gap-1.5 text-ink-gray-7" }, [
610 h(Icon, {
611 icon: categoryAccess()[item]?.icon,
612 class: "h-4 w-4 flex-shrink-0 text-ink-gray-6",
613 }),
614 categoryAccess()[item]?.label,
615 ]),
616 },
617 },
618 emptyState: {
619 title: "No articles found",
620 icon: h(ArticleIcon, {
621 class: "h-10 w-10",
622 }),
623 description: hasActiveFilters.value
624 ? __(
625 "No articles found for the applied filters. Try adjusting or clearing your filters."
626 )
627 : __("No articles found in the following category."),
628 },
629 rowRoute: {
630 name: "Article",
631 prop: "articleId",
632 },
633 groupByActions,
634 showSelectBanner: true,
635 selectBannerActions,
636 default_page_length: 100,
637 };
638});
639
640const statusMap = {
641 Published: {
642 label: __("Published"),
643 theme: "green",
644 },
645 Draft: {
646 label: __("Draft"),
647 theme: "amber",
648 },
649 Archived: {
650 label: __("Archived"),
651 theme: "gray",
652 },
653};
654
655onMounted(() => {
656 capture("kb_agent_page_viewed");
657});
658
659usePageMeta(() => {
660 return {
661 title: __("Knowledge Base"),
662 };
663});
664</script>
665