diff --git a/.eslintrc.js b/.eslintrc.js index 8c7bf355a..8c91e1714 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,23 +1,24 @@ module.exports = { - env: { - browser: true, - es2021: true, - node: true, - }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:json/recommended", - "plugin:vue/vue3-recommended", - "plugin:tailwindcss/recommended", - "plugin:prettier/recommended", - ], - parserOptions: { - ecmaVersion: "latest", - sourceType: "module", - parser: "@typescript-eslint/parser", - }, - rules: { - "tailwindcss/no-custom-classname": "off", - }, + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:json/recommended", + "plugin:vue/vue3-recommended", + "plugin:tailwindcss/recommended", + "plugin:prettier/recommended", + ], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + parser: "@typescript-eslint/parser", + }, + rules: { + "tailwindcss/no-custom-classname": "off", + "vue/multi-word-component-names": "off", + }, }; diff --git a/desk/src/components/SidebarLink.vue b/desk/src/components/SidebarLink.vue index f0572e7a3..68ccb441e 100644 --- a/desk/src/components/SidebarLink.vue +++ b/desk/src/components/SidebarLink.vue @@ -28,9 +28,11 @@ }" > {{ label }} - - beta - + + + beta + + diff --git a/desk/src/components/desk/sidebar/SideBar.vue b/desk/src/components/desk/sidebar/SideBar.vue index 21f6439fc..ce2eca8a3 100644 --- a/desk/src/components/desk/sidebar/SideBar.vue +++ b/desk/src/components/desk/sidebar/SideBar.vue @@ -1,10 +1,37 @@ - + + + + + + + + + - - - - - - + + + diff --git a/desk/src/components/index.ts b/desk/src/components/index.ts index 942568e96..e13337332 100644 --- a/desk/src/components/index.ts +++ b/desk/src/components/index.ts @@ -5,6 +5,7 @@ export { default as FilterPopover } from "./FilterPopover.vue"; export { default as HCard } from "./HCard.vue"; export { default as ListView } from "./list-view/LV.vue"; export { default as NestedPopover } from "./NestedPopover.vue"; +export { default as Notifications } from "./notifications/Notifications.vue"; export { default as PageTitle } from "./PageTitle.vue"; export { default as SearchComplete } from "./SearchComplete.vue"; export { default as SidebarLink } from "./SidebarLink.vue"; diff --git a/desk/src/components/notifications/Notifications.vue b/desk/src/components/notifications/Notifications.vue new file mode 100644 index 000000000..12893611c --- /dev/null +++ b/desk/src/components/notifications/Notifications.vue @@ -0,0 +1,114 @@ + + + + + Notifications + + notificationStore.clear.submit()" + > + + + + + notificationStore.toggle()" + > + + + + + + + + notificationStore.toggle()" + > + + + + + + + + {{ dayjs(n.creation).fromNow() }} + + + + + + + + + + + diff --git a/desk/src/components/notifications/NotificationsMention.vue b/desk/src/components/notifications/NotificationsMention.vue new file mode 100644 index 000000000..9a9d7d909 --- /dev/null +++ b/desk/src/components/notifications/NotificationsMention.vue @@ -0,0 +1,20 @@ + + + {{ user.full_name || user }} + mentioned you in ticket + {{ props.reference_ticket }} + + + + diff --git a/desk/src/main.js b/desk/src/main.js index 69eaed82b..7a1957a54 100644 --- a/desk/src/main.js +++ b/desk/src/main.js @@ -3,7 +3,6 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; import { frappeRequest, - onOutsideClickDirective, resourcesPlugin, setConfig, Badge, @@ -35,7 +34,6 @@ setConfig("resourceFetcher", frappeRequest); const pinia = createPinia(); const app = createApp(App); -app.directive("on-outside-click", onOutsideClickDirective); app.use(resourcesPlugin); app.use(pinia); app.use(router); diff --git a/desk/src/pages/desk/AgentRoot.vue b/desk/src/pages/desk/AgentRoot.vue index b350aeedf..15bf6a5cd 100644 --- a/desk/src/pages/desk/AgentRoot.vue +++ b/desk/src/pages/desk/AgentRoot.vue @@ -1,6 +1,7 @@ + @@ -11,6 +12,7 @@ import { useRouter } from "vue-router"; import { useAuthStore } from "@/stores/auth"; import { useConfigStore } from "@/stores/config"; import { CUSTOMER_PORTAL_LANDING, ONBOARDING_PAGE } from "@/router"; +import { Notifications } from "@/components"; import SideBar from "@/components/desk/sidebar/SideBar.vue"; const router = useRouter(); diff --git a/desk/src/socket.ts b/desk/src/socket.ts index 8b70e0cda..c72709fe7 100644 --- a/desk/src/socket.ts +++ b/desk/src/socket.ts @@ -1,15 +1,41 @@ import { io } from "socket.io-client"; +import { getCachedResource, getCachedListResource } from "frappe-ui"; +import { createToast } from "./utils"; import { socketio_port } from "../../../../sites/common_site_config.json"; -function initSocket() { +function init() { const host = window.location.hostname; const port = window.location.port ? `:${socketio_port}` : ""; const protocol = port ? "http" : "https"; const siteName = window["site_name"]; const namespace = !siteName?.startsWith("{{") ? siteName : host; const url = `${protocol}://${host}${port}/${namespace}`; - const socket = io(url, { withCredentials: true }); + const socket = io(url, { + withCredentials: true, + reconnectionAttempts: 5, + }); + + socket.on("connect_error", (err) => { + createToast({ + title: "Socket Connection Error", + text: err.message, + icon: "x", + iconClasses: "text-red-500", + }); + }); + + socket.on("refetch_resource", (data) => { + if (data.cache_key) { + const resource = + getCachedResource(data.cache_key) || + getCachedListResource(data.cache_key); + if (resource) { + resource.reload(); + } + } + }); + return socket; } -export const socket = initSocket(); +export const socket = init(); diff --git a/desk/src/stores/notification.ts b/desk/src/stores/notification.ts new file mode 100644 index 000000000..8442b7491 --- /dev/null +++ b/desk/src/stores/notification.ts @@ -0,0 +1,45 @@ +import { computed, ref } from "vue"; +import { defineStore } from "pinia"; +import { createResource, createListResource } from "frappe-ui"; +import { useError } from "@/composables/error"; +import { Notification, Resource } from "@/types"; + +export const useNotificationStore = defineStore("notification", () => { + const visible = ref(false); + const resource: Resource> = createListResource({ + doctype: "HD Notification", + cache: "Notifications", + fields: [ + "creation", + "name", + "notification_type", + "read", + "reference_comment", + "reference_ticket", + "user_from", + "user_to", + ], + auto: true, + debounce: 500, + }); + const clear = createResource({ + url: "helpdesk.helpdesk.doctype.hd_notification.utils.clear", + auto: false, + onSuccess: () => resource.reload(), + onError: useError(), + }); + const data = computed(() => resource.data || []); + const unread = computed(() => data.value.filter((d) => !d.read).length); + + function toggle() { + visible.value = !visible.value; + } + + return { + clear, + data, + toggle, + unread, + visible, + }; +}); diff --git a/desk/src/stores/sidebar.ts b/desk/src/stores/sidebar.ts index e24c69f48..51f4f61c6 100644 --- a/desk/src/stores/sidebar.ts +++ b/desk/src/stores/sidebar.ts @@ -1,10 +1,13 @@ -import { ref } from "vue"; +import { computed, ref } from "vue"; import { defineStore } from "pinia"; import { useStorage } from "@vueuse/core"; export const useSidebarStore = defineStore("sidebar", () => { const isOpen = ref(true); const isExpanded = useStorage("sidebar_is_expanded", true); + const width = computed(() => { + return isExpanded.value ? "256px" : "50px"; + }); function toggle(state?: boolean) { isOpen.value = state ?? !isOpen.value; @@ -15,9 +18,10 @@ export const useSidebarStore = defineStore("sidebar", () => { } return { - isOpen, isExpanded, + isOpen, toggle, toggleExpanded, + width, }; }); diff --git a/desk/src/types.ts b/desk/src/types.ts index 47ba4accd..c6ea5c214 100644 --- a/desk/src/types.ts +++ b/desk/src/types.ts @@ -141,3 +141,14 @@ export type File = { attached_to_field?: string; attached_to_name?: string; }; + +export type Notification = { + creation: string; + name: string; + notification_type: string; + read: boolean; + reference_comment: string; + reference_ticket: string; + user_from: string; + user_to: string; +}; diff --git a/helpdesk/helpdesk/doctype/hd_notification/hd_notification.json b/helpdesk/helpdesk/doctype/hd_notification/hd_notification.json index 97f0402ed..32641370b 100644 --- a/helpdesk/helpdesk/doctype/hd_notification/hd_notification.json +++ b/helpdesk/helpdesk/doctype/hd_notification/hd_notification.json @@ -7,6 +7,7 @@ "engine": "InnoDB", "field_order": [ "details_section", + "read", "user_from", "user_to", "notification_type", @@ -65,11 +66,17 @@ "fieldname": "details_section", "fieldtype": "Section Break", "label": "Details" + }, + { + "default": "0", + "fieldname": "read", + "fieldtype": "Check", + "label": "Read" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-08-31 00:20:02.891526", + "modified": "2023-09-19 23:49:50.165958", "modified_by": "Administrator", "module": "Helpdesk", "name": "HD Notification", diff --git a/helpdesk/helpdesk/doctype/hd_notification/hd_notification.py b/helpdesk/helpdesk/doctype/hd_notification/hd_notification.py index 3e2b08f70..d7dd341d4 100644 --- a/helpdesk/helpdesk/doctype/hd_notification/hd_notification.py +++ b/helpdesk/helpdesk/doctype/hd_notification/hd_notification.py @@ -1,6 +1,8 @@ import frappe from frappe.model.document import Document +from helpdesk.utils import refetch_resource + class HDNotification(Document): def format_message(self): @@ -45,3 +47,6 @@ def after_insert(self): template="notification", args=self.get_args(), ) + + def on_update(self): + refetch_resource("Notifications") diff --git a/helpdesk/helpdesk/doctype/hd_notification/utils.py b/helpdesk/helpdesk/doctype/hd_notification/utils.py new file mode 100644 index 000000000..a7949559d --- /dev/null +++ b/helpdesk/helpdesk/doctype/hd_notification/utils.py @@ -0,0 +1,22 @@ +import frappe + + +@frappe.whitelist() +def clear(user: str = None, ticket: str | int = None, comment: str = None): + """ + Mark notifications as read. No arguments will clear all notifications for `user`. + + :param user: User to clear notifications for. Defaults to current `user` + :param ticket: Ticket to clear notifications for + :param comment: Comment to clear notifications for + """ + user = user or frappe.session.user + filters = {"user_to": user, "read": False} + if ticket: + filters["reference_ticket"] = ticket + if comment: + filters["reference_comment"] = comment + for n in frappe.get_all("HD Notification", filters=filters): + d = frappe.get_doc("HD Notification", n.name) + d.read = True + d.save() diff --git a/helpdesk/helpdesk/doctype/hd_ticket/hd_ticket.py b/helpdesk/helpdesk/doctype/hd_ticket/hd_ticket.py index 64a984525..2c31bb61b 100644 --- a/helpdesk/helpdesk/doctype/hd_ticket/hd_ticket.py +++ b/helpdesk/helpdesk/doctype/hd_ticket/hd_ticket.py @@ -23,6 +23,7 @@ ) from helpdesk.utils import capture_event, get_customer, is_agent, publish_event +from ..hd_notification.utils import clear as clear_notifications from ..hd_service_level_agreement.utils import get_sla @@ -562,6 +563,7 @@ def create_communication_via_contact(self, message, attachments=[]): def mark_seen(self): self.add_view() self.add_seen() + clear_notifications(ticket=self.name) def add_view(self): d = frappe.new_doc("View Log") diff --git a/helpdesk/mixins/mentions.py b/helpdesk/mixins/mentions.py index 9b3380571..05c1f7625 100644 --- a/helpdesk/mixins/mentions.py +++ b/helpdesk/mixins/mentions.py @@ -1,4 +1,5 @@ import frappe + from helpdesk.utils import extract_mentions diff --git a/helpdesk/utils.py b/helpdesk/utils.py index 7e1bd9415..19a4702ff 100644 --- a/helpdesk/utils.py +++ b/helpdesk/utils.py @@ -1,4 +1,5 @@ import re +from typing import List import frappe from bs4 import BeautifulSoup @@ -32,9 +33,25 @@ def is_agent(user: str = None) -> bool: return bool(frappe.db.exists("HD Agent", {"name": user})) -def publish_event(event: str, data: dict): +def publish_event(event: str, data: dict, user: str = None): + """ + Publish `event` to a room with `data` + + :param event: Event name. Example: "refetch_resource" + :param data: Data to be sent with the event + :param user: User to send the event to, defaults to current user + """ room = get_website_room() - frappe.publish_realtime(event, message=data, room=room, after_commit=True) + user = user or frappe.session.user + frappe.publish_realtime( + event, message=data, room=room, after_commit=True, user=user + ) + + +def refetch_resource(key: str | List[str], user=None): + event = "refetch_resource" + data = {"cache_key": key} + publish_event(event, data, user=user) def capture_event(event: str):