1import { EditIcon, PinIcon, UnpinIcon } from "@/components/icons";
2import { useAuthStore } from "@/stores/auth";
3import { globalStore } from "@/stores/globalStore";
4import { __ } from "@/translation";
5import { View } from "@/types";
6import { getIcon } from "@/utils";
7import { useDebounceFn } from "@vueuse/core";
8import {
9 call,
10 createListResource,
11 createResource,
12 toast,
13} from "frappe-ui";
14import { Icon } from "frappe-ui/experimental";
15import { computed, h, ref, unref } from "vue";
16import { useRoute, useRouter } from "vue-router";
17
18const debouncedSetValue = useDebounceFn(
19 (doctype: string, name: string, fieldname: any, cb?: Function) => {
20 call("frappe.client.set_value", { doctype, name, fieldname }).then(() => {
21 cb?.();
22 views.reload();
23 });
24 },
25 300
26);
27
28export const views = createListResource({
29 doctype: "OD View",
30 fields: ["*"],
31 transform: (data: any) => {
32 data.forEach((view: View) => {
33 view.filters = JSON.parse(view.filters) || {};
34 view.columns = JSON.parse(view.columns) || [];
35 view.rows = JSON.parse(view.rows) || [];
36 });
37 },
38 pageLength: 1000,
39});
40
41export const currentView = ref({
42 label: "List",
43 icon: LucideAlignJustify,
44});
45
46export function useView(dt: string = null) {
47 const auth = useAuthStore();
48 const router = useRouter();
49 const route = useRoute();
50 const { $dialog } = globalStore();
51 function callGetViews() {
52 if (
53 (views.filters?.dt === dt && views.data?.length > 0) ||
54 views.list?.promise
55 ) {
56 return;
57 }
58 const filters = {
59 is_customer_portal: false,
60 };
61 if (dt) {
62 filters["dt"] = dt;
63 }
64 views.update({ filters });
65 views.fetch();
66 }
67 callGetViews();
68
69 const getCurrentUserViews = computed(() =>
70 views.data
71 ?.filter(
72 (view: View) =>
73 !view.is_default &&
74 view.user === auth.userId &&
75 !view.public &&
76 !view.pinned &&
77 !view.is_standard
78 )
79 .map(parseView)
80 );
81
82 async function createView(
83 view: View,
84 successCB: Function = () => {},
85 errorCB: Function = () => {}
86 ) {
87 createResource({
88 url: "frappe.client.insert",
89 params: {
90 doc: {
91 doctype: "OD View",
92 ...view,
93 user: auth.userId,
94 },
95 },
96 auto: true,
97 onSuccess: (d) => {
98 views.reload().then(() => {
99 successCB(d);
100 });
101 },
102 onError: (e) => {
103 console.log("error", e);
104 errorCB(e);
105 },
106 });
107 }
108
109 function findView(viewName: string) {
110 return computed(() => views.data?.find((v: View) => v.name === viewName));
111 }
112
113 const pinnedViews = computed(() =>
114 views.data
115 ?.filter((view: View) => view.pinned && view.user === auth.userId)
116 .map(parseView)
117 );
118
119 const publicViews = computed(() =>
120 views.data?.filter((view: View) => view.public).map(parseView)
121 );
122
123 const defaultView = computed(() =>
124 views.data?.find(
125 (v: View) => v.is_default && v.user === auth.userId && v.dt === dt
126 )
127 );
128
129 const standardViews = computed(() =>
130 views.data?.filter((view: View) => view.is_standard).map(parseView)
131 );
132
133 function updateView(view: any, successCB: Function = () => {}) {
134 if (view.name !== "default") {
135 debouncedSetValue("OD View", view.name, view, successCB);
136 } else {
137 // handle default view
138 createOrUpdateDefaultView(view);
139 successCB();
140 }
141 }
142
143 function deleteView(viewName: string) {
144 call("frappe.client.delete", {
145 doctype: "OD View",
146 name: viewName,
147 }).then(() => {
148 views.reload();
149 });
150 }
151
152 function createOrUpdateDefaultView(view: View) {
153 const defaultView = views.data?.find(
154 (v: View) => v.is_default && v.user === auth.userId && v.dt === view.dt
155 );
156 view.is_customer_portal = false;
157 if (defaultView) {
158 delete view["name"];
159
160 debouncedSetValue("OD View", defaultView.name, view);
161 } else {
162 view["doctype"] = "OD View";
163 // create default view
164 createView({
165 ...view,
166 is_default: true,
167 });
168 }
169 }
170
171 function parseView(view: View) {
172 return {
173 label: view.label,
174 name: view.name,
175 icon: getIcon(view.icon),
176 route_name: view.route_name,
177 is_standard: view.is_standard || false,
178 onClick: () => {
179 router.push({
180 name: view.route_name,
181 query: {
182 view: view.name,
183 },
184 });
185 },
186 };
187 }
188
189 // Kebab-menu actions for a single view. Shared between the breadcrumb dropdown
190 // and the sidebar so both expose the same options. `viewDialogConfig` is the caller's
191 // reactive modal state, populated here for the edit/duplicate flows.
192 function viewActions(view: View, viewDialogConfig: any) {
193 const _view = findView(view.name).value;
194
195 const actions: any[] = [
196 {
197 group: __("Default Views"),
198 hideLabel: true,
199 options: [
200 {
201 label: __("Duplicate"),
202 icon: h(Icon, { name: "copy" }),
203 onClick: () => {
204 viewDialogConfig.view.label = _view.label + " (New)";
205 viewDialogConfig.view.icon = _view.icon;
206 viewDialogConfig.view.name = _view.name;
207 viewDialogConfig.mode = "duplicate";
208 viewDialogConfig.show = true;
209 },
210 },
211 ],
212 },
213 ];
214
215 if (!_view.public || auth.isManager) {
216 if (!_view.public && !_view.is_standard) {
217 actions[0].options.push({
218 label: _view?.pinned ? __("Unpin View") : __("Pin View"),
219 icon: h(_view?.pinned ? UnpinIcon : PinIcon, { class: "h-4 w-4" }),
220 onClick: () =>
221 updateView({ name: _view.name, pinned: !_view.pinned }),
222 });
223 }
224 if (_view?.is_standard && auth.isManager) {
225 actions[0].options.push({
226 label: _view?.public
227 ? __("Hide from sidebar")
228 : __("Show in sidebar"),
229 icon: h(Icon, {
230 name: _view?.public ? "eye-off" : "eye",
231 class: "h-4 w-4",
232 }),
233 onClick: () =>
234 toggleViewVisibility(
235 _view,
236 __("Hide view from sidebar"),
237 __(
238 "{0} view is currently visible in the sidebar. Hiding it will remove it from the sidebar.",
239 [_view.label]
240 )
241 ),
242 });
243 }
244 if (!_view.is_standard) {
245 if (auth.isManager) {
246 actions[0].options.push({
247 label: _view?.public ? __("Make Private") : __("Make Public"),
248 icon: h(Icon, {
249 name: _view?.public ? "lock" : "lock-open",
250 class: "h-4 w-4",
251 }),
252 onClick: () =>
253 toggleViewVisibility(
254 _view,
255 __("Make view private"),
256 __(
257 "{0} view is currently public. Changing it to private will hide it for all the users.",
258 [_view.label]
259 )
260 ),
261 });
262 }
263 actions[0].options.push({
264 label: __("Edit"),
265 icon: h(EditIcon, { class: "h-4 w-4" }),
266 onClick: () => {
267 viewDialogConfig.view.label = _view.label;
268 viewDialogConfig.view.icon = _view.icon;
269 viewDialogConfig.view.name = _view.name;
270 viewDialogConfig.mode = "edit";
271 viewDialogConfig.show = true;
272 },
273 });
274 actions.push({
275 group: __("Delete View"),
276 hideLabel: true,
277 options: [
278 {
279 label: __("Delete"),
280 icon: "lucide-trash-2",
281 theme: "red",
282 onClick: () => confirmDeleteView(_view),
283 },
284 ],
285 });
286 }
287 }
288 return actions;
289 }
290
291 function confirmDeleteView(_view: View) {
292 $dialog({
293 title: __("Delete {0}", [_view.label]),
294 message:
295 __("Are you sure you want to delete this view?") +
296 (_view.public
297 ? " " + __("This view is public, and will be removed for all users.")
298 : ""),
299 actions: [
300 {
301 label: __("Confirm"),
302 variant: "solid",
303 iconLeft: "lucide-trash-2",
304 theme: "red",
305 onClick({ close }: any) {
306 if (route.query.view === _view.name) {
307 router.push({
308 name: "TicketsAgent",
309 });
310 }
311 deleteView(_view.name);
312 toast.success(__("View {0}", [__("deleted")]));
313 close();
314 },
315 },
316 ],
317 });
318 }
319
320 function toggleViewVisibility(_view: any, title: string, message: string) {
321 const newView: any = { name: _view.name, public: !_view.public };
322 if (_view.public) {
323 $dialog({
324 title,
325 message,
326 actions: [
327 {
328 label: __("Confirm"),
329 variant: "solid",
330 onClick({ close }: any) {
331 close();
332 updateView(newView);
333 },
334 },
335 ],
336 });
337 } else {
338 updateView(newView);
339 }
340 }
341
342 // Submit handler for the view modal (create/edit/duplicate). `getList` lets the
343 // create flow capture the current list's filters/columns/rows; it is omitted by
344 // callers (e.g. the sidebar) that never create a view.
345 function handleView(
346 viewInfo: any,
347 action: string,
348 viewDialogConfig: any,
349 getList: () => any = () => null
350 ) {
351 let view: View;
352 if (action === "update") {
353 updateView({
354 name: viewInfo.name,
355 label: viewInfo.label,
356 icon: viewInfo.icon,
357 });
358 handleViewSuccess(viewDialogConfig, __("updated"));
359 currentView.value = {
360 label: viewInfo.label,
361 icon: getIcon(viewInfo.icon),
362 };
363 return;
364 } else if (action === "duplicate") {
365 const source = findView(viewDialogConfig.view.name).value;
366 view = {
367 ...source,
368 filters: JSON.stringify(source.filters),
369 columns: JSON.stringify(source.columns),
370 rows: JSON.stringify(source.rows),
371 label: viewInfo.label,
372 icon: viewInfo.icon,
373 public: false,
374 pinned: false,
375 };
376 } else {
377 const list = getList();
378 view = {
379 dt,
380 type: "list",
381 label: viewInfo.label ?? __("List"),
382 icon: viewInfo.icon ?? "",
383 route_name: router.currentRoute.value.name as string,
384 order_by: list?.params.order_by,
385 filters: JSON.stringify(list?.params.filters),
386 // a grouped list never loads its records, so fall back to the view's own columns
387 columns: JSON.stringify(list?.data?.columns ?? list?.params.columns),
388 rows: JSON.stringify(list?.data?.rows ?? list?.params.rows),
389 // a reactive list unwraps the grouping ref, so read it either way
390 list_group_by: unref(list?.groupBy)
391 ? JSON.stringify(unref(list.groupBy))
392 : "",
393 pinned: viewInfo.pinned ?? false,
394 public: viewInfo.public ?? false,
395 is_customer_portal: false,
396 };
397 }
398
399 createView(view, (d) => {
400 currentView.value = {
401 label: d.label || __("List"),
402 icon: getIcon(d.icon),
403 };
404 router.push({
405 name: "TicketsAgent",
406 query: { view: d.name },
407 });
408 handleViewSuccess(viewDialogConfig);
409 });
410 }
411
412 function handleViewSuccess(
413 viewDialogConfig: any,
414 msg: string = __("created")
415 ) {
416 toast.success(__("View {0}", [msg]));
417 resetViewDialog(viewDialogConfig);
418 }
419
420 function resetViewDialog(viewDialogConfig: any) {
421 viewDialogConfig.show = false;
422 viewDialogConfig.view.label = "";
423 viewDialogConfig.view.icon = "";
424 viewDialogConfig.view.name = "";
425 viewDialogConfig.mode = null;
426 }
427
428 return {
429 views,
430 getCurrentUserViews,
431 pinnedViews,
432 publicViews,
433 defaultView,
434 standardViews,
435 findView,
436 createView,
437 updateView,
438 deleteView,
439 createOrUpdateDefaultView,
440 viewActions,
441 handleView,
442 resetViewDialog,
443 };
444}
445