1<template>
2 <div class="flex gap-2 leading-5 items-center">
3 <FieldLabel :label="field.label" :required="field.required" />
4 <!-- p-1, not p-0.5: the Link pulls itself 2px left, so 2px of padding
5 leaves its rounded corner shaved by overflow-hidden -->
6 <div
7 class="-m-1 min-h-[28px] flex-1 items-center overflow-hidden p-1 text-base"
8 >
9 <component
10 :is="component"
11 :key="field.fieldname"
12 :readonly="field.readonly"
13 :disabled="field.disabled"
14 class="form-control"
15 :placeholder="placeholder"
16 :model-value="transValue"
17 autocomplete="off"
18 v-on="listeners"
19 />
20 </div>
21 </div>
22</template>
23
24<script setup lang="ts">
25import FieldLabel from "@/components/FieldLabel.vue";
26import TicketPriority from "@/components/TicketPriority.vue";
27import { APIOptions, Field, FieldValue } from "@/types";
28import { parseApiOptions } from "@/utils";
29import { Link } from "@framework/ui";
30import {
31 Combobox,
32 createResource,
33 DatePicker,
34 DateTimePicker,
35 dayjs,
36 Select,
37 TextInput,
38} from "frappe-ui";
39import { computed, h, nextTick } from "vue";
40
41interface P {
42 field: Field;
43 value: FieldValue;
44}
45
46interface R {
47 fieldname: Field["fieldname"];
48 value: FieldValue;
49}
50
51interface E {
52 (event: "change", value: R);
53}
54
55const props = defineProps<P>();
56const emit = defineEmits<E>();
57
58const apiOptions = createResource({
59 url: props.field.url_method,
60 auto: !!props.field.url_method,
61 transform: (data: APIOptions) => {
62 return parseApiOptions(data);
63 },
64});
65
66const textFields = ["Long Text", "Small Text", "Text", "Text Editor", "Data"];
67const numberFields = ["Int", "Float", "Currency", "Percent"];
68
69// Link doctypes whose records open on a opendesk route. Add an entry to make
70// another Link field redirectable.
71const REDIRECT_ROUTES: Record<string, string> = {
72 "OD Customer": "customers",
73};
74
75// past this many options, the field gets a search box
76const SEARCHABLE_FROM = 10;
77
78// same overrides the Link fields carry: fill the row, stay flat on hover
79const ghostControl = {
80 variant: "ghost" as const,
81 class: "!w-full !bg-surface-base",
82};
83
84type Option = { label: string; value: string | number };
85
86const selectOptions = computed<Option[]>(() =>
87 (props.field.fieldtype === "Select"
88 ? props.field.options.split("\n")
89 : []
90 ).map((option) => ({ label: option, value: option }))
91);
92
93const isSearchableSelect = computed(
94 () => selectOptions.value.length > SEARCHABLE_FROM
95);
96
97// the Combobox feeds one placeholder to both trigger and search box, so it
98// gets none and its prefix slot carries the empty state instead
99const emptyLabel = computed(
100 () => props.field.placeholder || `Add ${props.field.label}`
101);
102
103const usesCombobox = computed(
104 () => !!props.field.url_method || isSearchableSelect.value
105);
106
107const placeholder = computed(() =>
108 usesCombobox.value ? "" : emptyLabel.value
109);
110
111function select(options: Option[]) {
112 return h(Select, { ...ghostControl, options });
113}
114
115// ghost opts out of the focus outline, and `class` lands on TextInput's
116// wrapper, so the ring has to be re-applied to the input itself
117function textInput() {
118 return h(TextInput, {
119 variant: "ghost" as const,
120 class: "[&_input:focus]:focus-ring",
121 });
122}
123
124// the trigger renders a matched option's label, so an unlisted value needs one
125function withSavedValue(options: Option[]): Option[] {
126 const value = props.value;
127 if (!value || options.some((option) => option.value === value)) {
128 return options;
129 }
130 return [{ label: String(value), value: value as string }, ...options];
131}
132
133// trigger: "button" keeps the search inside the popover, so the row still
134// reads as a value and not a text input
135function combobox(options: Option[]) {
136 return h(
137 Combobox,
138 {
139 ...ghostControl,
140 trigger: "button",
141 options: withSavedValue(options),
142 },
143 {
144 prefix: () =>
145 h("span", { class: "truncate text-ink-gray-4" }, emptyLabel.value),
146 }
147 );
148}
149
150const component = computed(() => {
151 if (props.field.url_method) {
152 return combobox(apiOptions.data || []);
153 } else if (props.field.fieldtype === "Link" && props.field.options) {
154 const linkProps = {
155 doctype: props.field.options,
156 redirectable: props.field.options in REDIRECT_ROUTES,
157 class: "!w-full !bg-surface-base !border-transparent !text-base",
158 onRedirect: handleRedirect,
159 };
160 // item-prefix drives both the options and the control, so one slot puts
161 // the level icon everywhere
162 if (props.field.options === "OD Ticket Priority") {
163 return h(Link, linkProps, {
164 "item-prefix": ({ item }: { item: { value: string } }) =>
165 h(TicketPriority, { priority: item.value, iconOnly: true }),
166 // typing can drop the committed value from the options, so the
167 // control falls back here; keep its icon until it changes
168 prefix: () =>
169 props.value
170 ? h(TicketPriority, {
171 priority: String(props.value),
172 iconOnly: true,
173 })
174 : null,
175 });
176 }
177 return h(Link, linkProps);
178 } else if (props.field.fieldtype === "Select") {
179 return isSearchableSelect.value
180 ? combobox(selectOptions.value)
181 : select(selectOptions.value);
182 } else if (props.field.fieldtype === "Check") {
183 return select([
184 { label: "Yes", value: 1 },
185 { label: "No", value: 0 },
186 ]);
187 } else if (textFields.includes(props.field.fieldtype)) {
188 return textInput();
189 } else if (props.field.fieldtype === "Datetime") {
190 return h(DateTimePicker, {
191 format: `${window.date_format.toUpperCase()} ${window.time_format}`,
192 });
193 } else if (props.field.fieldtype === "Date") {
194 return h(DatePicker, {
195 id: props.field.fieldname,
196 format: window.date_format.toUpperCase(),
197 });
198 }
199 // else if (props.field.fieldtype === "Duration") {
200 // // console.log("HERE TIME");
201 // return h(DurationField, { showSeconds: false });
202 // }
203 else {
204 return textInput();
205 }
206});
207
208// the Link nulls its model when the input is emptied, so an empty commit
209// waits for the picker to close still empty
210let linkPickerOpen = false;
211let linkModel: FieldValue = null;
212
213const listeners = computed(() => {
214 const fieldtype = props.field.fieldtype;
215 if ([...textFields, ...numberFields].includes(fieldtype)) {
216 return {
217 blur: (event: FocusEvent) =>
218 emitUpdate(
219 props.field.fieldname,
220 (event.target as HTMLInputElement).value
221 ),
222 };
223 }
224 if (fieldtype === "Link") {
225 return {
226 "update:modelValue": (value: FieldValue) => {
227 linkModel = value;
228 // only the clear (x) button nulls the model while the picker is closed
229 if (!linkPickerOpen && !value) emitUpdate(props.field.fieldname, "");
230 },
231 "update:selectedOption": (option: { value: string } | null) => {
232 if (!option) return;
233 linkModel = option.value;
234 emitUpdate(props.field.fieldname, option.value);
235 // a mouse commit blurs the input already, a keyboard one doesn't
236 (document.activeElement as HTMLElement | null)?.blur();
237 },
238 "update:open": (open: boolean) => {
239 linkPickerOpen = open;
240 if (open) {
241 linkModel = props.value ?? null;
242 return;
243 }
244 // picking closes the picker before it commits, so the clear waits a
245 // tick for the pick to land
246 nextTick(() => !linkModel && emitUpdate(props.field.fieldname, ""));
247 },
248 // Escape keeps focus on the input; blur so it deselects like a commit
249 keydown: (event: KeyboardEvent) => {
250 if (event.key === "Escape") {
251 (event.target as HTMLElement | null)?.blur();
252 }
253 },
254 };
255 }
256 // Camel, not hyphenated: Vue only recognises the camel form as a component's
257 // declared emit. The hyphenated one falls through as an attr and the Combobox
258 // re-binds it onto its search input, committing every keystroke
259 return {
260 "update:modelValue": (event: any) =>
261 emitUpdate(
262 props.field.fieldname,
263 event?.value || event?.target?.value || event
264 ),
265 };
266});
267
268const transValue = computed(() => {
269 const fieldtype = props.field.fieldtype;
270 if (fieldtype === "Check") {
271 // the Select matches on option value, so keep the stored 1 / 0
272 return props.value ? 1 : 0;
273 } else if (fieldtype === "Date") {
274 if (!props.value) return props.value;
275 return dayjs(props.value).format(window.date_format.toUpperCase());
276 }
277 // else if (fieldtype === "Duration") {
278 // if (!props.value) return null;
279 // }
280 return props.value;
281});
282
283function emitUpdate(fieldname: Field["fieldname"], value: FieldValue) {
284 emit("change", { fieldname, value });
285}
286
287function handleRedirect(value: string) {
288 const route = REDIRECT_ROUTES[props.field.options];
289 if (!route) return;
290 window.open(`${window.location.origin}/opendesk/${route}/${value}`, "_blank");
291}
292</script>
293<style scoped>
294/* a read-only row still reads as plain text: TextInput swaps ghost for its
295 filled disabled variant, and the pickers stay flat, so match them */
296:deep(.form-control input[data-slot="control"]:disabled) {
297 border-color: transparent;
298 background: var(--surface-base);
299}
300
301/* PickerShell keeps its chevron on a disabled picker, and the chevron opens
302 the popover on its own mousedown, so hide the whole suffix */
303:deep(.form-control input[data-slot="control"]:disabled ~ div) {
304 display: none;
305}
306
307/* TextInput brings its own variant; only the picker inputs get flattened */
308:deep(.form-control input:not([type="checkbox"]):not([data-slot="control"])),
309:deep(.form-control select),
310:deep(.form-control textarea),
311:deep(.form-control button) {
312 border-color: transparent;
313 background: var(--surface-base);
314 color: var(--ink-gray-7);
315}
316
317:deep(.form-control button) {
318 gap: 0;
319}
320
321/* Select and Combobox reveal their chevron on hover like the Link fields.
322 visibility, not display, so the row never reflows. */
323:deep(button.form-control[data-slot="trigger"] .lucide-chevron-down) {
324 visibility: hidden;
325}
326
327:deep(button.form-control[data-slot="trigger"]:hover .lucide-chevron-down),
328:deep(button.form-control[data-slot="trigger"]:focus .lucide-chevron-down),
329:deep(button.form-control[data-slot="trigger"][data-state="open"]
330 .lucide-chevron-down) {
331 visibility: visible;
332}
333:deep(.form-control [type="checkbox"]) {
334 margin-left: 9px;
335 cursor: pointer;
336}
337
338:deep(.form-control button > div) {
339 overflow: hidden;
340 text-overflow: ellipsis;
341 white-space: nowrap;
342}
343</style>
344