1<template>
2 <Dialog v-model:open="open" :title="__('Edit')">
3 <template #default>
4 <div class="flex flex-col gap-4">
5 <Combobox
6 v-model="fieldname"
7 :label="__('Field')"
8 :options="fieldOptions"
9 :placeholder="__('Select a field')"
10 />
11 <component
12 v-if="field"
13 :is="getFieldComponent(field.fieldtype)"
14 :key="fieldname"
15 :field="field"
16 :model-value="value"
17 @update:model-value="(newValue: any) => (value = newValue)"
18 />
19 </div>
20 </template>
21 <template #actions>
22 <div class="flex items-center justify-end gap-2">
23 <Button :label="__('Cancel')" @click="open = false" />
24 <Button
25 variant="solid"
26 :loading="loading"
27 :label="__('Update {0} tickets', selections.size)"
28 @click="submit"
29 />
30 </div>
31 </template>
32 </Dialog>
33</template>
34
35<script setup lang="ts">
36import { globalStore } from "@/stores/globalStore";
37import { getMeta } from "@/stores/meta";
38import { __ } from "@/translation";
39import { getFieldComponent } from "@framework/ui/fields";
40import { Button, call, Combobox, Dialog, toast } from "frappe-ui";
41import { computed, onMounted, onUnmounted, ref, watch } from "vue";
42
43const BULK_UPDATE_METHOD =
44 "frappe.desk.doctype.bulk_update.bulk_update.submit_cancel_or_update_docs";
45
46// frappe runs the update inline below this count, enqueues above.
47const BACKGROUND_THRESHOLD = 20;
48
49// Fieldtypes that hold no value, so there is nothing to bulk set.
50const NO_VALUE_TYPES = new Set([
51 "Section Break",
52 "Column Break",
53 "Tab Break",
54 "Fold",
55 "Heading",
56 "HTML",
57 "Image",
58 "Button",
59 "Table",
60 "Table MultiSelect",
61 "Read Only",
62]);
63
64const open = defineModel<boolean>();
65
66const props = defineProps<{
67 selections: Set<string>;
68}>();
69const emit = defineEmits<{
70 (e: "success"): void;
71}>();
72
73const { $socket } = globalStore();
74const { getFields } = getMeta("OD Ticket");
75
76const fieldname = ref<string | null>(null);
77const value = ref<any>(null);
78const loading = ref(false);
79
80let pending: {
81 taskId: string;
82 toastId: string | number;
83 total: number;
84} | null = null;
85
86const editableFields = computed(() =>
87 getFields().filter(
88 (field: any) =>
89 field.fieldname &&
90 !NO_VALUE_TYPES.has(field.fieldtype) &&
91 !field.hidden &&
92 !field.read_only &&
93 !field.is_virtual
94 )
95);
96
97const fieldOptions = computed(() =>
98 editableFields.value
99 .map((field: any) => ({ label: __(field.label), value: field.fieldname }))
100 .sort((a, b) => a.label.localeCompare(b.label))
101);
102
103const selectedField = computed(
104 () =>
105 editableFields.value.find((f: any) => f.fieldname === fieldname.value) ??
106 null
107);
108
109// Its own label would repeat the Combobox; the default placeholder is the raw doctype.
110const field = computed(
111 () =>
112 selectedField.value && {
113 ...selectedField.value,
114 label: __("Value"),
115 placeholder: __("Select {0}", selectedField.value.label),
116 }
117);
118
119async function submit() {
120 if (!fieldname.value) {
121 toast.error(__("Select a field"));
122 return;
123 }
124 // Blanking a mandatory field fails `doc.save()` on every ticket.
125 if (selectedField.value.reqd && (value.value ?? "") === "") {
126 toast.error(__("{0} is required", selectedField.value.label));
127 return;
128 }
129
130 const docnames = Array.from(props.selections);
131 const total = docnames.length;
132 const inBackground = total >= BACKGROUND_THRESHOLD;
133 const taskId = `bulk-edit-${Math.random().toString(36).slice(2, 10)}`;
134 loading.value = true;
135 try {
136 if (inBackground) $socket.emit("task_subscribe", taskId);
137 const failed = await call(BULK_UPDATE_METHOD, {
138 doctype: "OD Ticket",
139 docnames,
140 action: "update",
141 data: { [fieldname.value]: value.value ?? null },
142 task_id: taskId,
143 });
144 open.value = false;
145
146 if (inBackground) {
147 if (pending) toast.dismiss(pending.toastId);
148 const toastId = toast.loading(__("Updating {0} tickets…", total));
149 pending = { taskId, toastId, total };
150 return;
151 }
152 // The inline path returns the docnames it could not update.
153 if (failed?.length) {
154 toast.warning(
155 __("Updated {0} of {1} tickets", total - failed.length, total)
156 );
157 } else {
158 toast.success(__("Updated {0} tickets", total));
159 }
160 emit("success");
161 } finally {
162 loading.value = false;
163 }
164}
165
166// Unrelated jobs publish `progress` on the user room too, so match the id.
167function onProgress(data: { percent: number; task_id?: string }) {
168 if (!pending || data.task_id !== pending.taskId || data.percent < 100) return;
169 // Same id replaces the pending toast.
170 toast.success(__("Updated {0} tickets", pending.total), {
171 id: pending.toastId,
172 });
173 pending = null;
174 emit("success");
175}
176
177watch(open, (isOpen) => {
178 if (isOpen) fieldname.value = null;
179});
180
181watch(fieldname, () => {
182 value.value = null;
183});
184
185onMounted(() => $socket.on("progress", onProgress));
186onUnmounted(() => $socket.off("progress", onProgress));
187</script>
188