1<template>
2 <SettingsLayoutBase
3 :back-label="dependencyLabel"
4 :on-back="handleBackNavigation"
5 :dirty="isDirty"
6 >
7 <template #header-actions>
8 <div class="flex gap-4">
9 <!-- Switch -->
10 <div class="flex gap-2 items-center">
11 <Switch
12 class="!w-fit"
13 :model-value="Boolean(state.enabled)"
14 @update:model-value="(value) => (state.enabled = value)"
15 />
16 <span class="text-p-base text-ink-gray-6">
17 {{ __("Enabled") }}
18 </span>
19 </div>
20 <!-- Actions -->
21 <div class="flex gap-1">
22 <Button
23 :label="__('Save')"
24 variant="solid"
25 size="sm"
26 :disabled="
27 !state.selectedParentField ||
28 !state.selectedChildField ||
29 Object.keys(state.childSelections).length === 0
30 "
31 :loading="createUpdateFieldDependency.loading"
32 @click="handleSubmit"
33 />
34 </div>
35 </div>
36 </template>
37 <template #content>
38 <div>
39 <!-- Body -->
40 <div class="w-full flex-1 flex flex-col gap-8">
41 <!-- Field Selection -->
42 <FieldDependencyFieldsSelection
43 v-model="state"
44 :is-new="isNew"
45 :parent-fields="parentFields"
46 />
47
48 <div class="flex flex-col gap-8">
49 <!-- Value Selection -->
50 <FieldDependencyValueSelection
51 v-model="state"
52 :is-new="isNew"
53 :parent-fields="parentFields"
54 />
55
56 <!-- Criteria selection -->
57 <FieldDependencyCriteria
58 :parent-field-values="state.parentFieldValues"
59 v-model="fieldCriteriaState"
60 v-model:selections="state"
61 v-if="state.selectedParentField && state.selectedChildField"
62 />
63 </div>
64 </div>
65 </div>
66 </template>
67 </SettingsLayoutBase>
68 <ConfirmDialog
69 v-model="showConfirmDialog"
70 :title="__('Unsaved changes')"
71 :message="
72 __('Are you sure you want to go back? Unsaved changes will be lost.')
73 "
74 :onConfirm="() => $emit('update:step', 'fd-list')"
75 :onCancel="() => (showConfirmDialog = false)"
76 />
77</template>
78
79<script setup lang="ts">
80import ConfirmDialog from "@/components/ConfirmDialog.vue";
81import { getMeta } from "@/stores/meta";
82import { getFieldDependencyLabel } from "@/utils";
83import { createResource, Switch, toast } from "frappe-ui";
84import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
85import { disableSettingModalOutsideClick } from "../settingsModal";
86import {
87 clearFieldOptionsCache,
88 getFieldOptions,
89 hiddenChildFields,
90} from "./fieldDependency";
91import FieldDependencyCriteria from "./FieldDependencyCriteria.vue";
92import FieldDependencyFieldsSelection from "./FieldDependencyFieldsSelection.vue";
93import FieldDependencyValueSelection from "./FieldDependencyValueSelection.vue";
94import { __ } from "@/translation";
95import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
96
97const props = defineProps({
98 fieldDependencyName: {
99 type: String,
100 default: "",
101 },
102});
103const emit = defineEmits(["update:step"]);
104
105const isNew = computed(() => !props.fieldDependencyName);
106
107const dependencyLabel = computed(() => {
108 if (isNew.value) return __("New Field Dependency");
109 return getFieldDependencyLabel(props.fieldDependencyName);
110});
111
112const { getFields } = getMeta("OD Ticket");
113
114const parentFields = computed(() => {
115 let _fields = getFields();
116
117 if (!_fields || _fields.length === 0) {
118 return [];
119 }
120 const notAllowedFields = ["status", "agreement_status"];
121 _fields = _fields.filter(
122 (f) =>
123 (f.fieldtype === "Select" || f.fieldtype === "Link") &&
124 !notAllowedFields.includes(f.fieldname)
125 );
126 return _fields.map((f) => ({
127 label: f.label,
128 value: f.fieldname,
129 options: f.options || [],
130 type: f.fieldtype,
131 }));
132});
133
134let state = reactive({
135 selectedParentField: "",
136 selectedChildField: "",
137 childFields: [],
138
139 parentFieldValues: [],
140 childFieldValues: [],
141
142 currentParentSelection: "",
143
144 childSelections: {}, // Initial value is a Set
145 initialChildSelections: {},
146 parentSearch: "",
147 childSearch: "",
148
149 enabled: true,
150});
151
152let fieldCriteriaState = reactive({
153 display: {
154 enabled: true,
155 value: [{ label: "Any", value: "Any" }],
156 },
157 mandatory: {
158 enabled: true,
159 value: [{ label: "Any", value: "Any" }],
160 },
161});
162
163const createUpdateFieldDependency = createResource({
164 url: "opendesk.api.settings.field_dependency.create_update_field_dependency",
165 auto: false,
166 makeParams: () => ({
167 parent_field: state.selectedParentField,
168 child_field: state.selectedChildField,
169 parent_child_mapping: stringifyParentChildMapping(),
170 enabled: state.enabled,
171 fields_criteria: JSON.stringify(fieldCriteriaState),
172 }),
173 onSuccess: () => {
174 if (!isNew.value) {
175 fieldDependency.reload();
176 return;
177 }
178 emit("update:step", "fd-list");
179 },
180});
181
182const fieldDependency = createResource({
183 url: "opendesk.api.settings.field_dependency.get_field_dependency",
184 params: {
185 name: props.fieldDependencyName,
186 },
187 auto: !isNew.value,
188 onSuccess: (data) => {
189 state.selectedParentField = data.parent_field;
190 state.selectedChildField = data.child_field;
191 state.enabled = data.enabled;
192 parseMapping(data.parent_child_mapping);
193 parseFieldCriteria(data.fields_criteria);
194 },
195});
196function parseMapping(data: string) {
197 const mapping = JSON.parse(data || "{}");
198 let selections = {};
199 Object.keys(mapping).forEach((parent) => {
200 selections[parent] = new Set(mapping[parent]);
201 });
202 state.childSelections = selections;
203
204 // for checking dirty state
205 state.initialChildSelections = structuredClone(selections);
206
207 if (!state.currentParentSelection) {
208 state.currentParentSelection = Object.keys(mapping)[0] || "";
209 }
210}
211
212function parseFieldCriteria(data: string) {
213 const criteria = JSON.parse(data || "{}");
214 fieldCriteriaState.display = criteria.display || {
215 enabled: true,
216 value: [{ label: "Any", value: "Any" }],
217 };
218 fieldCriteriaState.mandatory = criteria.mandatory || {
219 enabled: true,
220 value: [{ label: "Any", value: "Any" }],
221 };
222}
223
224const isDirty = computed(() => {
225 if (isNew.value) {
226 return state.enabled !== true || state.selectedParentField !== "";
227 }
228 if (fieldDependency.loading) return false;
229 return (
230 Boolean(fieldDependency.data?.enabled) !== Boolean(state.enabled) ||
231 stringifyParentChildMapping(state.childSelections) !==
232 stringifyParentChildMapping(state.initialChildSelections) ||
233 JSON.stringify(fieldCriteriaState) !== fieldDependency.data?.fields_criteria
234 );
235});
236
237watch(
238 () => isDirty.value,
239 (val) => {
240 disableSettingModalOutsideClick.value = val;
241 }
242);
243
244function stringifyParentChildMapping(selections = null) {
245 const _selections =
246 selections && Object.keys(selections).length > 0
247 ? selections
248 : state.childSelections;
249 const mapping = {};
250 Object.keys(_selections).forEach((parent) => {
251 let selections = Array.from(_selections[parent]);
252 if (selections.length) {
253 mapping[parent] = Array.from(_selections[parent]);
254 }
255 });
256 return JSON.stringify(mapping);
257}
258
259async function handleFieldValues(fieldname: string, isParentField: boolean) {
260 if (!fieldname) return [];
261
262 if (isParentField) {
263 // if new field dependency, reset child fields
264 if (isNew.value) {
265 state.selectedChildField = ""; // Reset child field when parent changes
266 state.childFieldValues = [];
267 state.currentParentSelection = ""; // Reset current parent selection
268 state.childSelections = {}; // Reset child selections for new parent
269
270 // filter out hidden already madeup field dependencies in the child fields
271 let fieldsToHide = [fieldname, ...hiddenChildFields.value];
272 state.childFields = parentFields.value.filter(
273 (f) => !fieldsToHide.includes(f.value)
274 );
275 } else {
276 state.childFields = parentFields.value.filter(
277 (f) => f.value === state.selectedChildField
278 );
279 }
280 // show the selected child field if editing
281 }
282
283 const field = isParentField
284 ? parentFields.value.find((f) => f.value === fieldname)
285 : state.childFields.find((f) => f.value === fieldname);
286 if (!field) return [];
287 let options = await getFieldOptions(field);
288
289 return options;
290}
291
292const showConfirmDialog = ref(false);
293function handleBackNavigation() {
294 if (createUpdateFieldDependency.loading) return;
295 if (isDirty.value) {
296 showConfirmDialog.value = true;
297
298 return;
299 }
300 emit("update:step", "fd-list");
301}
302
303function handleSubmit() {
304 if (!isDirty.value) return;
305 createUpdateFieldDependency.submit();
306 let successMessage = isNew.value
307 ? __("Field Dependency created successfully")
308 : __("Field Dependency updated successfully");
309 toast.success(successMessage);
310}
311
312// parent field watcher
313watch(
314 () => state.selectedParentField,
315 async (newParentField) => {
316 state.parentFieldValues = await handleFieldValues(newParentField, true);
317 }
318);
319
320// child field watcher
321watch(
322 () => state.selectedChildField,
323 async (newChildField) => {
324 state.childFieldValues = await handleFieldValues(newChildField, false);
325 // Reset child selections when child field changes
326 if (isNew.value) {
327 state.childSelections = {};
328 }
329 state.currentParentSelection = state.parentFieldValues[0] || "";
330 }
331);
332
333watch(
334 () => state.currentParentSelection,
335 () => {
336 state.childSearch = ""; // Reset child search when parent selection changes
337 }
338);
339
340onMounted(() => {
341 // Drop any cached field options so newly added link/select values
342 // (e.g. a freshly created ticket type) show up on each visit.
343 clearFieldOptionsCache();
344});
345
346onUnmounted(() => {
347 disableSettingModalOutsideClick.value = false;
348});
349</script>
350