1import { toast } from "frappe-ui";
2import { h, isVNode, type Component } from "vue";
3import StoredIcon from "./Icon.vue";
4import { __ } from "./translation";
5
6type ToastType = "success" | "error" | "warning" | "info";
7
8// vue-sonner renders icons via `<component :is>`.
9function resolveIcon(icon: unknown): Component | undefined {
10 if (icon == null) return undefined;
11 if (typeof icon === "string") {
12 return () => h(StoredIcon, { icon, class: "size-4" });
13 }
14 if (isVNode(icon)) return () => icon;
15 return icon as Component;
16}
17
18// Shim for pre-v1 `toast.create({ message })` in form scripts.
19function createToast({
20 message,
21 type,
22 icon,
23 ...options
24}: {
25 message: string;
26 type?: ToastType;
27 icon?: unknown;
28 [key: string]: unknown;
29}) {
30 const data: Record<string, unknown> = { ...options };
31 if (icon) data.icon = resolveIcon(icon);
32 if (typeof options.duration === "number") {
33 data.duration = toMilliseconds(options.duration);
34 }
35 return type ? toast[type](message, data) : toast(message, data);
36}
37
38// The old API took seconds, with 0 meaning stay open.
39function toMilliseconds(seconds: number) {
40 return seconds === 0 ? Infinity : seconds * 1000;
41}
42
43export async function setupCustomizations(doc, obj) {
44 // Supporting old format, will have to refactor later
45 let data = doc.data ?? doc;
46 if (!data) return;
47 if (!data._form_script) return [];
48 const scripts = Array.isArray(data._form_script)
49 ? data._form_script
50 : [data._form_script];
51 let actions = [];
52 let links = [];
53 let onChangeFieldMap = {};
54 for (const script of scripts) {
55 const parsed = await parseScript(script, { toast, createToast, ...obj });
56 actions = actions.concat(parsed.actions);
57 links = links.concat(parsed.links);
58 if (parsed.onChange) {
59 parseOnChangeFn(onChangeFieldMap, parsed.onChange);
60 }
61 }
62 data._customActions = withLegacyGroupOptions(actions);
63 data._customLinks = links;
64 if (Object.keys(onChangeFieldMap).length) {
65 data._customOnChange = onChangeFieldMap;
66 }
67}
68
69// Pre-v1 scripts name a group's children `items`.
70function withLegacyGroupOptions(actions: any[]) {
71 return actions.map((action) =>
72 action.items && !action.options
73 ? { ...action, options: action.items }
74 : action
75 );
76}
77
78function parseOnChangeFn(fieldMap: object, currentField: object) {
79 for (const [key, value] of Object.entries(currentField)) {
80 if (!fieldMap[key]) {
81 fieldMap[key] = new Set();
82 }
83 fieldMap[key].add(value);
84 }
85}
86
87async function parseScript(script, obj) {
88 try {
89 const scriptFn = new Function(script + "\nreturn setupForm")();
90 const formScript = await scriptFn(obj);
91 return {
92 actions: formScript?.actions || [],
93 links: formScript?.links || [],
94 onChange: formScript?.onChange || null,
95 };
96 } catch (error) {
97 console.error(error);
98 toast.error(__("A form script on this page failed to run"));
99 return { actions: [], links: [], onChange: null };
100 }
101}
102
103function handleSelectFieldUpdate(
104 f: any,
105 fieldname: string,
106 filters: any,
107 doc: any,
108 oldDoc: any
109) {
110 if (!filters || !filters.length) {
111 f.options = oldDoc.find((f) => f.fieldname === fieldname).options;
112 f.disabled = true;
113 } else {
114 f.options = filters.join("\n");
115 f.disabled = false;
116 }
117 // reset dependent field
118 doc[fieldname] = "";
119}
120
121function handleLinkFieldUpdate(
122 f: any,
123 fieldname: string,
124 filters: any,
125 doc: any,
126 oldDoc: any
127) {
128 if (!filters || !filters.length) {
129 f.link_filters = oldDoc.find((f) => f.fieldname === fieldname).link_filters;
130 f.disabled = true;
131 return;
132 }
133 f.link_filters = JSON.stringify([[f.options, "name", "in", filters]]);
134 f.disabled = false;
135
136 // reset dependent field
137 doc[fieldname] = "";
138}
139
140// Backs form scripts' `applyFilters(fieldname, values)`.
141export function applyFieldFilters(
142 fields: any[],
143 fieldname: string,
144 filters: any,
145 doc: any,
146 oldFields: any[]
147) {
148 const f = fields.find((f) => f.fieldname === fieldname);
149 if (f?.fieldtype === "Select") {
150 handleSelectFieldUpdate(f, fieldname, filters, doc, oldFields);
151 } else if (f?.fieldtype === "Link") {
152 handleLinkFieldUpdate(f, fieldname, filters, doc, oldFields);
153 }
154}
155
156// Callable context so form scripts written for `onClick(close)` keep working
157// alongside frappe-ui v1's `onClick({ close })`.
158export function withLegacyCloseContext(actions) {
159 return actions?.map((action) =>
160 action.onClick
161 ? {
162 ...action,
163 onClick: ({ close }) => action.onClick(Object.assign(() => close(), { close })),
164 }
165 : action
166 );
167}
168