1<template>
2 <div class="flex flex-col">
3 <div
4 class="flex h-9 items-center justify-between gap-1 border-b border-outline-gray-1 pe-2 ps-1"
5 >
6 <BackButton
7 :label="field.label"
8 size="sm"
9 class="min-w-0"
10 @back="goBack"
11 />
12 <Dropdown
13 v-model:open="operatorMenuOpen"
14 side="bottom"
15 :options="operatorOptions"
16 :portal-to="operatorMenuTarget || 'body'"
17 >
18 <Button
19 class="flex h-6 max-w-[150px] shrink-0 items-center gap-1 rounded-4 bg-surface-gray-2 ps-2 pe-1 text-sm text-ink-gray-7 hover:bg-surface-gray-3"
20 variant="ghost"
21 icon-right="lucide-chevron-down"
22 :tooltip="operatorTooltip"
23 >
24 <span class="min-w-0 truncate">{{ operatorLabel }}</span>
25 </Button>
26 <template #item-suffix="{ selected }">
27 <LucideCheck v-if="selected" class="size-4 text-ink-gray-7" />
28 </template>
29 </Dropdown>
30 </div>
31 <template v-if="isListMode">
32 <div v-if="showSearch" class="px-2 pt-2">
33 <TextInput
34 ref="searchInput"
35 v-model="search"
36 type="text"
37 variant="outline"
38 :placeholder="__('Search...')"
39 autocomplete="off"
40 @keydown="onKeydown"
41 @paste="onSearchPaste"
42 >
43 <template #prefix>
44 <LucideSearch class="size-4 text-ink-gray-5" />
45 </template>
46 <template v-if="isSearching" #suffix>
47 <LucideLoaderCircle class="size-4 animate-spin text-ink-gray-5" />
48 </template>
49 </TextInput>
50 </div>
51 <div
52 ref="listEl"
53 role="listbox"
54 :aria-label="field.label"
55 class="mt-1 max-h-52 overflow-y-auto p-1.5 py-1"
56 >
57 <button
58 v-for="(option, index) in options"
59 :key="option.value"
60 :data-index="index"
61 role="option"
62 :aria-selected="isSelected(option.value)"
63 :class="[
64 'flex h-8 w-full items-center gap-2 rounded-4 px-1.5 text-base text-ink-gray-8',
65 rowBackground(option.value, index),
66 ]"
67 @mousemove="activeIndex = index"
68 @click="pickOption(option.value)"
69 >
70 <Checkbox
71 v-if="isMultiple"
72 :model-value="isSelected(option.value)"
73 size="sm"
74 tabindex="-1"
75 aria-hidden="true"
76 class="pointer-events-none"
77 />
78 <span :title="option.label" class="flex-1 truncate text-start">{{
79 option.label
80 }}</span>
81 <LucideCheck
82 v-if="!isMultiple && isSelected(option.value)"
83 class="size-4 text-ink-gray-7"
84 />
85 </button>
86 <div
87 v-if="isSearching && !options.length"
88 class="flex h-8 items-center gap-2 px-2 text-base text-ink-gray-5"
89 >
90 <LucideLoaderCircle class="size-4 animate-spin" />
91 {{ __("Searching...") }}
92 </div>
93 <div
94 v-else-if="!options.length"
95 class="flex h-8 items-center px-2 text-base text-ink-gray-5"
96 >
97 {{ __("No results") }}
98 </div>
99 </div>
100 <div
101 v-if="isMultiple && selectedCount"
102 class="flex items-center border-t border-outline-gray-1 px-2 py-1.5"
103 >
104 <Button
105 variant="ghost"
106 size="sm"
107 :label="__('Clear All')"
108 @click="clearSelection"
109 />
110 </div>
111 </template>
112 <div v-else class="flex flex-col gap-2 p-2">
113 <div v-if="isRatingPicker" class="px-2 py-1">
114 <Rating v-model="ratingStars" size="md" />
115 </div>
116 <DateRangePicker
117 v-else-if="isDate && operator === 'between'"
118 :model-value="dateRangeValue"
119 icon-left=""
120 @change="handleDateRange"
121 />
122 <component
123 v-else-if="isDate"
124 :is="field.fieldtype === 'Date' ? DatePicker : DateTimePicker"
125 :model-value="value"
126 icon-left=""
127 @change="(date) => commitAndClose(date)"
128 />
129 <TextInput
130 v-else
131 ref="valueInput"
132 :type="isNumber && !isMultiple ? 'number' : 'text'"
133 v-model="textValue"
134 :placeholder="isMultiple ? __('Comma separated values') : __('Value')"
135 @update:model-value="onTextChange"
136 @keydown="onTextKeydown"
137 />
138 </div>
139 </div>
140</template>
141
142<script setup lang="ts">
143import BackButton from "./BackButton.vue";
144import { useDevice } from "../device";
145import { __ } from "../translation";
146import { useDebounceFn, useEventListener } from "@vueuse/core";
147import {
148 Checkbox,
149 DatePicker,
150 DateRangePicker,
151 DateTimePicker,
152 Dropdown,
153 Rating,
154 TextInput,
155} from "frappe-ui";
156import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
157import {
158 ActiveFilter,
159 FilterField,
160 multiValueFields,
161 useFilter,
162 useLinkSearch,
163} from "./filter";
164
165interface P {
166 field: FilterField;
167 filter?: ActiveFilter | null;
168 // The high-z layer the operator dropdown teleports into, so its menu renders
169 // above the filter popover panel instead of behind it.
170 operatorMenuTarget?: HTMLElement | null;
171}
172
173interface E {
174 (event: "apply", operator: string, value: any): void;
175 (event: "clear"): void;
176 (event: "back"): void;
177 (event: "done"): void;
178}
179
180const props = withDefaults(defineProps<P>(), { filter: null });
181const emit = defineEmits<E>();
182
183const { getOperators, getDefaultOperator, getSelectOptions, timespanOptions } =
184 useFilter();
185
186const operator = ref(props.filter?.operator || getDefaultOperator(props.field));
187const value = ref(props.filter?.value ?? defaultValueFor(operator.value));
188const textValue = ref(plainText(value.value));
189const search = ref("");
190const activeIndex = ref(0);
191const operatorMenuOpen = ref(false);
192let textApplyPending = false;
193const searchInput = ref(null);
194const valueInput = ref(null);
195const listEl = ref<HTMLElement | null>(null);
196
197const operators = computed(() =>
198 getOperators(props.field.fieldtype, props.field.fieldname)
199);
200const operatorLabel = computed(
201 () =>
202 operators.value.find((option) => option.value === operator.value)?.label ||
203 operator.value
204);
205const { isMac } = useDevice();
206const operatorTooltip = __("Change operator ({0})", isMac ? "⌥↓/↑" : "Alt+↓/↑");
207const operatorOptions = computed(() =>
208 operators.value.map((option) => ({
209 label: option.label,
210 selected: option.value === operator.value,
211 onClick: () => (operator.value = option.value),
212 }))
213);
214const isDate = computed(() =>
215 ["Date", "Datetime"].includes(props.field.fieldtype)
216);
217const isNumber = computed(() =>
218 ["Float", "Int", "Currency", "Percent"].includes(props.field.fieldtype)
219);
220const isLink = computed(
221 () => props.field.fieldtype === "Link" || props.field.fieldname === "_assign"
222);
223const isMultiple = computed(() => ["in", "not in"].includes(operator.value));
224
225// Rating uses the star picker for every operator except "is" (set / not set).
226// Ratings are stored as a 0..1 fraction, so convert to/from star units (×5).
227const isRatingPicker = computed(
228 () => props.field.fieldtype === "Rating" && operator.value !== "is"
229);
230const ratingStars = computed({
231 get: () => Math.round((Number(value.value) || 0) * 5),
232 set: (stars: number) => applyValue(stars / 5),
233});
234
235const isListMode = computed(() => {
236 if (operator.value === "is") return true;
237 if (isDate.value) return operator.value === "timespan";
238 if (["Select", "Check"].includes(props.field.fieldtype)) {
239 return true;
240 }
241 // "like" matches free text, so it gets a plain input; the comma-joined
242 // columns keep their list since "like" is their primary operator
243 if (multiValueFields.includes(props.field.fieldname)) return true;
244 return isLink.value && !operator.value.includes("like");
245});
246
247// "is" only offers Set / Not Set, so the search box is just noise there
248const showSearch = computed(() => isListMode.value && operator.value !== "is");
249
250const options = computed<Array<{ label: string; value: string }>>(() => {
251 const matches = (label: string) =>
252 label.toLowerCase().includes(search.value.toLowerCase());
253
254 if (operator.value === "is") {
255 return [
256 { label: __("Set"), value: "set" },
257 { label: __("Not Set"), value: "not set" },
258 ].filter((option) => matches(option.label));
259 }
260 if (isDate.value)
261 return timespanOptions.filter((option) => matches(option.label));
262 if (isLink.value) return withSelectedOptions(linkSearch.results.data || []);
263 const values =
264 props.field.fieldtype === "Check"
265 ? ["Yes", "No"]
266 : getSelectOptions(props.field.options);
267 return values
268 .filter(matches)
269 .map((option) => ({ label: option, value: option }));
270});
271
272// Selected values outside the search results (pasted, or past the first page)
273// are pinned on top so they stay visible and can be unselected
274function withSelectedOptions(results: Array<{ label: string; value: string }>) {
275 if (!Array.isArray(value.value)) return results;
276 const resultValues = new Set(results.map((option) => option.value));
277 const query = search.value.toLowerCase();
278 const pinned = value.value
279 .filter((selected: string) => !resultValues.has(selected))
280 .filter((selected: string) =>
281 String(selected).toLowerCase().includes(query)
282 )
283 .map((selected: string) => ({ label: selected, value: selected }));
284 return [...pinned, ...results];
285}
286
287const selectedCount = computed(() =>
288 Array.isArray(value.value) ? value.value.length : 0
289);
290
291const dateRangeValue = computed(() =>
292 Array.isArray(value.value) ? value.value : []
293);
294
295// desk's sidebar mints Tag masters too; the picker only lists opendesk's
296const linkSearch = useLinkSearch(
297 linkDoctype(props.field),
298 props.field.options === "Tag" ? { app: "opendesk" } : undefined
299);
300
301// Link options are fetched from the server, so reflect the in-flight request
302// instead of briefly showing "No results" while the search is loading.
303const isSearching = computed(() => isLink.value && linkSearch.results.loading);
304
305function linkDoctype(field: FilterField): string {
306 if (field.fieldname === "_assign") return "User";
307 return field.fieldtype === "Link" ? field.options || "" : "";
308}
309
310function defaultValueFor(operatorName: string): any {
311 return ["in", "not in"].includes(operatorName) ? [] : "";
312}
313
314function plainText(rawValue: any): string {
315 if (Array.isArray(rawValue)) return rawValue.join(", ");
316 if (typeof rawValue === "string") return rawValue.replaceAll("%", "");
317 return rawValue == null ? "" : String(rawValue);
318}
319
320function isSelected(option: string): boolean {
321 if (Array.isArray(value.value)) return value.value.includes(option);
322 return plainText(value.value) === String(option);
323}
324
325function rowBackground(option: string, index: number): string {
326 const active = index === activeIndex.value;
327 if (isMultiple.value && isSelected(option))
328 return active ? "bg-surface-gray-3" : "bg-surface-gray-2";
329 return active ? "bg-surface-gray-2" : "";
330}
331
332function clearSelection() {
333 value.value = [];
334 emit("clear");
335 focusSearch();
336}
337
338function pickOption(option: string) {
339 if (!isMultiple.value) {
340 // A single-select pick (Equals/Not Equals, Is, Timespan, single assignee)
341 // completes the filter in one click, so return to the overview.
342 commitAndClose(option);
343 return;
344 }
345 const selectedValues = Array.isArray(value.value) ? [...value.value] : [];
346 const index = selectedValues.indexOf(option);
347 index === -1 ? selectedValues.push(option) : selectedValues.splice(index, 1);
348 value.value = selectedValues;
349 if (!selectedValues.length) {
350 emit("clear");
351 return;
352 }
353 emit("apply", operator.value, [...selectedValues]);
354 search.value = "";
355 focusSearch();
356}
357
358// Pasting a list (e.g. ticket IDs) into the search box selects every value
359function onSearchPaste(event: ClipboardEvent) {
360 const pastedValues = splitValues(event.clipboardData?.getData("text") || "");
361 if (!isMultiple.value || pastedValues.length < 2) return;
362 event.preventDefault();
363 const selectedValues = Array.isArray(value.value) ? value.value : [];
364 applyValue([...new Set([...selectedValues, ...pastedValues])]);
365 search.value = "";
366}
367
368function splitValues(text: string): string[] {
369 return text
370 .split(/[,\n]/)
371 .map((part) => part.trim())
372 .filter(Boolean);
373}
374
375function applyValue(nextValue: any) {
376 value.value = nextValue;
377 emit("apply", operator.value, nextValue);
378}
379
380// A discrete pick (single list option, date, or range) fully sets the value, so
381// the filter is complete — apply it and return to the overview. Incremental
382// inputs (multi-select, typing, rating) keep using applyValue and stay put.
383function commitAndClose(nextValue: any) {
384 applyValue(nextValue);
385 emit("done");
386}
387
388// The range picker emits an empty array when its selection is cleared
389function handleDateRange(emittedRange: any) {
390 const range = splitRange(emittedRange);
391 if (!Array.isArray(range) || !range[0] || !range[1]) {
392 emit("clear");
393 return;
394 }
395 commitAndClose(range);
396}
397
398function splitRange(rawRange: any): string[] {
399 if (typeof rawRange !== "string") return rawRange;
400 const [start, end] = rawRange.split(",");
401 return [start, end];
402}
403
404function moveActive(delta: number) {
405 const total = options.value.length;
406 if (total) activeIndex.value = (activeIndex.value + delta + total) % total;
407}
408
409function selectActive() {
410 const option = options.value[activeIndex.value];
411 if (option) pickOption(option.value);
412}
413
414function onKeydown(event: KeyboardEvent) {
415 if (event.isComposing) return;
416 // Let ⌥↑/⌥↓ bubble to the operator-cycle handler instead of moving the list.
417 if (event.altKey) return;
418 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
419 event.preventDefault();
420 event.stopPropagation();
421 moveActive(event.key === "ArrowDown" ? 1 : -1);
422 } else if (event.key === "Enter") {
423 event.preventDefault();
424 event.stopPropagation();
425 if (event.repeat) return;
426 selectActive();
427 } else if (event.key === "Backspace" && !search.value) {
428 event.stopPropagation();
429 goBack();
430 }
431}
432
433function focusSearch() {
434 // preventScroll: the panel mounts mid-swipe, still translated; a default
435 // focus would scroll it into view and visually cancel the slide
436 nextTick(() =>
437 (searchInput.value || valueInput.value)?.focus({ preventScroll: true })
438 );
439}
440
441function onTextKeydown(event: KeyboardEvent) {
442 if (event.key === "Backspace" && !textValue.value) {
443 event.stopPropagation();
444 goBack();
445 }
446}
447
448function onTextChange() {
449 if (!textValue.value) {
450 // an empty value removes the condition right away, so going back
451 // before the debounce fires can't leave a stale filter behind
452 value.value = defaultValueFor(operator.value);
453 emit("clear");
454 return;
455 }
456 textApplyPending = true;
457 debouncedTextApply();
458}
459
460// Leaving flushes typed text first, so a pending debounce can never fire
461// after another filter has been opened and land on that one
462function goBack() {
463 applyPendingText();
464 emit("back");
465}
466
467const debouncedTextApply = useDebounceFn(applyPendingText, 500);
468
469// Called before clearing every filter, so closing the editor can't bring one back
470function discardPendingText() {
471 textApplyPending = false;
472}
473
474function applyPendingText() {
475 if (!textApplyPending) return;
476 textApplyPending = false;
477 if (!textValue.value) return;
478 const nextValue = isMultiple.value
479 ? splitValues(textValue.value)
480 : textValue.value;
481 if (!hasValue(nextValue)) {
482 value.value = defaultValueFor(operator.value);
483 emit("clear");
484 return;
485 }
486 applyValue(nextValue);
487}
488
489function hasValue(candidate: any): boolean {
490 if (Array.isArray(candidate)) return candidate.length > 0;
491 return candidate !== "" && candidate != null;
492}
493
494watch(search, () => {
495 activeIndex.value = 0;
496 if (isLink.value) linkSearch.search(search.value);
497});
498
499watch(activeIndex, (index) => {
500 nextTick(() => {
501 listEl.value
502 ?.querySelector(`[data-index="${index}"]`)
503 ?.scrollIntoView({ block: "nearest" });
504 });
505});
506
507watch(operator, (newOperator, oldOperator) => {
508 // typed text belongs to the old operator, so drop it instead of applying it late
509 discardPendingText();
510 search.value = "";
511 activeIndex.value = 0;
512 if (newOperator === "is") {
513 applyValue("set");
514 return;
515 }
516 if (oldOperator === "is") {
517 value.value = defaultValueFor(newOperator);
518 textValue.value = "";
519 return;
520 }
521 if (
522 isDate.value &&
523 dateValueKind(newOperator) !== dateValueKind(oldOperator)
524 ) {
525 value.value = defaultValueFor(newOperator);
526 textValue.value = "";
527 return;
528 }
529 if (
530 switchesLinkInput(newOperator, oldOperator) &&
531 !readsSameAsText(value.value)
532 ) {
533 value.value = defaultValueFor(newOperator);
534 textValue.value = "";
535 return;
536 }
537 value.value = convertValue(value.value);
538 textValue.value = plainText(value.value);
539 if (hasValue(value.value)) emit("apply", newOperator, value.value);
540});
541
542// Date value shape per operator; a value can only carry across same-shape switches.
543function dateValueKind(operatorName: string): string {
544 if (operatorName === "timespan") return "timespan";
545 if (operatorName === "between") return "range";
546 return "scalar";
547}
548
549// A Link field picks a docname from a list but "like" takes free text. Crossing
550// between them would show a docname as text (e.g. a ticket number instead of its
551// subject), or apply a typed fragment as an exact match.
552function switchesLinkInput(newOperator: string, oldOperator: string): boolean {
553 if (!isLink.value || multiValueFields.includes(props.field.fieldname))
554 return false;
555 return newOperator.includes("like") !== oldOperator.includes("like");
556}
557
558// Carry a value across only when its docname is also what the user reads
559// ponytail: checks loaded search results only; unlisted values are reset
560function readsSameAsText(rawValue: any): boolean {
561 const values = Array.isArray(rawValue) ? rawValue : [plainText(rawValue)];
562 const results = linkSearch.results.data || [];
563 return values.every((candidate: string) =>
564 results.some(
565 (option: { label: string; value: string }) =>
566 option.value === candidate && option.label === candidate
567 )
568 );
569}
570
571function convertValue(rawValue: any): any {
572 if (!isMultiple.value)
573 return Array.isArray(rawValue) ? rawValue[0] || "" : plainText(rawValue);
574 if (Array.isArray(rawValue)) return rawValue;
575 const single = plainText(rawValue);
576 return single ? [single] : [];
577}
578
579// Moving focus while the operator menu is open makes the menu close the whole
580// popover, so wait for the menu to close first
581watch(
582 () => isListMode.value,
583 () => {
584 if (!operatorMenuOpen.value) focusSearch();
585 },
586 { immediate: true }
587);
588
589watch(operatorMenuOpen, (open) => {
590 if (!open) focusSearch();
591});
592
593onBeforeUnmount(applyPendingText);
594
595function cycleOperator(direction: number) {
596 const operatorList = operators.value;
597 if (operatorList.length < 2) return;
598 const currentIndex = operatorList.findIndex(
599 (option) => option.value === operator.value
600 );
601 const total = operatorList.length;
602 operator.value =
603 operatorList[(currentIndex + direction + total) % total].value;
604}
605
606useEventListener(document, "keydown", (event: KeyboardEvent) => {
607 // ⌥↓ / ⌥↑ (Alt on Windows) steps to the next / previous operator. A modifier
608 // is required because a value input is usually focused and would swallow a
609 // bare arrow key.
610 if (event.altKey && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
611 event.preventDefault();
612 cycleOperator(event.key === "ArrowDown" ? 1 : -1);
613 return;
614 }
615 // List modes without a search box ("Is" → Set / Not Set) have no input to
616 // catch arrow keys, so drive the option list straight from the document.
617 if (isListMode.value && !showSearch.value) {
618 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
619 event.preventDefault();
620 moveActive(event.key === "ArrowDown" ? 1 : -1);
621 return;
622 }
623 if (event.key === "Enter" && !event.repeat) {
624 event.preventDefault();
625 selectActive();
626 return;
627 }
628 }
629 // Backspace goes back — except while typing in a field, where the input's own
630 // handler manages an empty-Backspace and otherwise deletes characters.
631 if (event.key === "Backspace") {
632 const el = document.activeElement;
633 if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
634 return;
635 }
636 event.preventDefault();
637 goBack();
638 }
639});
640
641defineExpose({ discardPendingText });
642</script>
643