1import { __ } from "../translation";
2import { useDebounceFn } from "@vueuse/core";
3import { createResource } from "frappe-ui";
4import { parseFilters } from "@framework/ui/components/Filter/filters";
5import type { Filter as Condition } from "@framework/ui/components/Filter/types";
6import { computed, inject, Component, ComputedRef } from "vue";
7import LucideCalendar from "~icons/lucide/calendar";
8import LucideClock from "~icons/lucide/clock";
9import LucideHash from "~icons/lucide/hash";
10import LucideLink from "~icons/lucide/link";
11import LucideList from "~icons/lucide/list";
12import LucideSquareCheck from "~icons/lucide/square-check";
13import LucideStar from "~icons/lucide/star";
14import LucideTag from "~icons/lucide/tag";
15import LucideType from "~icons/lucide/type";
16import LucideUser from "~icons/lucide/user";
17
18export interface FilterField {
19 fieldname: string;
20 fieldtype: string;
21 label: string;
22 name?: string;
23 options?: string;
24 value?: string;
25}
26
27export type FilterCondition = [string, string, any];
28
29export interface ActiveFilter {
30 field: FilterField;
31 fieldname: string;
32 operator: string;
33 value: any;
34 index: number;
35}
36
37export interface Filter {
38 fields: ComputedRef<FilterField[]>;
39 activeFilters: ComputedRef<ActiveFilter[]>;
40 addFilter: (field: FilterField, operator: string, value: any) => number;
41 updateFilter: (
42 index: number,
43 field: FilterField,
44 operator: string,
45 value: any
46 ) => void;
47 removeFilter: (index: number) => void;
48 clearFilters: () => void;
49 getOperators: (
50 fieldtype: string,
51 fieldname?: string
52 ) => Array<{ label: string; value: string }>;
53 getDefaultOperator: (field: FilterField) => string;
54 getSelectOptions: (options?: string) => string[];
55 timespanOptions: Array<{ label: string; value: string }>;
56}
57
58const typeCheck = ["Check"];
59const typeLink = ["Link", "Dynamic Link"];
60const typeNumber = ["Float", "Int", "Currency", "Percent"];
61const typeSelect = ["Select"];
62const typeString = ["Data", "Long Text", "Small Text", "Text Editor", "Text"];
63const typeDate = ["Date", "Datetime"];
64const typeRating = ["Rating"];
65// columns holding a comma-joined list, so "contains" is the only match that
66// works. It matches substrings too: "VIP" also finds tickets tagged "VIP Customer"
67export const multiValueFields = ["_assign", "_user_tags"];
68
69// Injected by the host list (ListViewBuilder on the desk).
70export function useFilter(): Filter {
71 const listViewData = inject<any>("listViewData");
72 const listViewActions = inject<any>("listViewActions");
73 const { list, filterableFields } = listViewData;
74
75 const conditions = computed<FilterCondition[]>(() =>
76 normalizeFilters(list?.params?.filters || list?.data?.params?.filters)
77 );
78
79 const activeFilters = computed<ActiveFilter[]>(() => {
80 if (!filterableFields.data) return [];
81 const filters: ActiveFilter[] = [];
82 conditions.value.forEach((condition, index) => {
83 const field = filterableFields.data.find(
84 (f: FilterField) => f.fieldname === condition[0]
85 );
86 if (!field) return;
87 filters.push({
88 field,
89 fieldname: condition[0],
90 operator: toUiOperator(condition[1]),
91 value: toUiValue(field, condition[2]),
92 index,
93 });
94 });
95 return filters;
96 });
97
98 function addFilter(field: FilterField, operator: string, value: any): number {
99 const next = [...conditions.value, buildCondition(field, operator, value)];
100 apply(next);
101 return next.length - 1;
102 }
103
104 function updateFilter(
105 index: number,
106 field: FilterField,
107 operator: string,
108 value: any
109 ) {
110 const next = [...conditions.value];
111 next[index] = buildCondition(field, operator, value);
112 apply(next);
113 }
114
115 function removeFilter(index: number) {
116 const next = [...conditions.value];
117 next.splice(index, 1);
118 apply(next);
119 }
120
121 function clearFilters() {
122 apply([]);
123 }
124
125 function apply(next: FilterCondition[]) {
126 listViewActions.applyFilters(next);
127 }
128
129 return {
130 fields: computed<FilterField[]>(() => filterableFields.data || []),
131 activeFilters,
132 addFilter,
133 updateFilter,
134 removeFilter,
135 clearFilters,
136 getOperators,
137 getDefaultOperator,
138 getSelectOptions,
139 timespanOptions,
140 };
141}
142
143export function normalizeFilters(raw: any): FilterCondition[] {
144 if (!raw) return [];
145 if (Array.isArray(raw)) {
146 return raw
147 .filter((c) => Array.isArray(c) && c.length >= 3)
148 .map((c) =>
149 c.length === 4
150 ? ([c[1], c[2], c[3]] as FilterCondition)
151 : ([c[0], c[1], c[2]] as FilterCondition)
152 );
153 }
154 return Object.entries(raw).map(([fieldname, value]): FilterCondition => {
155 if (Array.isArray(value)) return [fieldname, value[0], value[1]];
156 return [fieldname, "=", value];
157 });
158}
159
160// parseFilters drops fields it is not given; an unknown one is kept as Data.
161export function toConditions(
162 wire: FilterCondition[],
163 fields: any[] = []
164): Omit<Condition, "field">[] {
165 const known = new Set(fields.map((field) => field.fieldname));
166 const unknown = wire
167 .filter(([fieldname]) => !known.has(fieldname))
168 .map(([fieldname]) => ({ fieldname, fieldtype: "Data" }));
169 return parseFilters([...fields, ...unknown], wire).map(
170 ({ fieldname, operator, value }) => ({ fieldname, operator, value })
171 );
172}
173
174export function useLinkSearch(doctype: string, filters?: Record<string, any>) {
175 const results = createResource({
176 url: "frappe.desk.search.search_link",
177 method: "POST",
178 params: { txt: "", doctype, filters, page_length: 10 },
179 transform: (data: any[]) => {
180 const options = data.map((option) => ({
181 value: option.value,
182 label: option?.label || option.value,
183 }));
184 if (doctype === "User" || doctype === "OD Agent") {
185 options.unshift({ label: "@me", value: "@me" });
186 }
187 return options;
188 },
189 });
190 if (doctype) results.reload();
191
192 const search = useDebounceFn((txt: string) => {
193 if (!doctype) return;
194 results.update({ params: { txt, doctype, filters, page_length: 10 } });
195 results.reload();
196 }, 300);
197
198 return { results, search };
199}
200
201function buildCondition(
202 field: FilterField,
203 operator: string,
204 value: any
205): FilterCondition {
206 if (operator.includes("like") && typeof value === "string") {
207 value = value.includes("%") ? value : `%${value}%`;
208 }
209 if (
210 field.fieldtype === "Check" &&
211 ["equals", "not equals"].includes(operator)
212 ) {
213 value = value === "Yes" ? true : value === "No" ? false : value;
214 }
215 return [
216 field.fieldname,
217 operatorMap[operator.toLowerCase()] || operator,
218 value,
219 ];
220}
221
222function toUiOperator(frappeOperator: string): string {
223 return oppositeOperatorMap[String(frappeOperator)] || frappeOperator;
224}
225
226function toUiValue(field: FilterField, value: any): any {
227 if (field.fieldtype === "Check" && typeof value === "boolean") {
228 return value ? "Yes" : "No";
229 }
230 return value;
231}
232
233function getOperators(fieldtype: string, fieldname?: string) {
234 const equality = [
235 { label: "Equals", value: "equals" },
236 { label: "Not Equals", value: "not equals" },
237 ];
238 const like = [
239 { label: "Like", value: "like" },
240 { label: "Not Like", value: "not like" },
241 ];
242 const inclusion = [
243 { label: "In", value: "in" },
244 { label: "Not In", value: "not in" },
245 ];
246 const is = [{ label: "Is", value: "is" }];
247 const comparison = [
248 { label: "Less Than", value: "<" },
249 { label: "Greater Than", value: ">" },
250 { label: "Less Than or Equal To", value: "<=" },
251 { label: "Greater Than or Equal To", value: ">=" },
252 ];
253
254 if (fieldname && multiValueFields.includes(fieldname))
255 return [...like, ...is];
256 if (typeString.includes(fieldtype) || typeLink.includes(fieldtype)) {
257 return [...equality, ...like, ...inclusion, ...is];
258 }
259 if (typeNumber.includes(fieldtype)) {
260 return [...equality, ...like, ...inclusion, ...is, ...comparison];
261 }
262 if (typeSelect.includes(fieldtype)) return [...equality, ...inclusion, ...is];
263 if (typeCheck.includes(fieldtype)) return equality;
264 if (fieldtype === "Duration") return [...like, ...inclusion, ...is];
265 if (typeDate.includes(fieldtype)) {
266 return [
267 ...equality,
268 ...is,
269 ...comparison,
270 { label: "Between", value: "between" },
271 { label: "Timespan", value: "timespan" },
272 ];
273 }
274 if (typeRating.includes(fieldtype))
275 return [...equality, ...is, ...comparison];
276 return [...equality, ...like, ...is];
277}
278
279function getDefaultOperator(field: FilterField): string {
280 if (multiValueFields.includes(field.fieldname)) return "like";
281 if (typeDate.includes(field.fieldtype)) return "timespan";
282 if (
283 typeSelect.includes(field.fieldtype) ||
284 typeCheck.includes(field.fieldtype) ||
285 typeNumber.includes(field.fieldtype) ||
286 typeLink.includes(field.fieldtype) ||
287 typeRating.includes(field.fieldtype)
288 ) {
289 return "equals";
290 }
291 return "like";
292}
293
294function getSelectOptions(options?: string): string[] {
295 return (options || "").split("\n").filter((o) => o.trim() !== "");
296}
297
298export function fieldIcon(field: FilterField): Component {
299 if (field.fieldname === "_user_tags") return LucideTag;
300 if (["_assign", "owner", "modified_by"].includes(field.fieldname)) {
301 return LucideUser;
302 }
303 const iconMap: Record<string, Component> = {
304 Link: LucideLink,
305 "Dynamic Link": LucideLink,
306 Select: LucideList,
307 Date: LucideCalendar,
308 Datetime: LucideClock,
309 Check: LucideSquareCheck,
310 Rating: LucideStar,
311 Duration: LucideClock,
312 Int: LucideHash,
313 Float: LucideHash,
314 Currency: LucideHash,
315 Percent: LucideHash,
316 };
317 return iconMap[field.fieldtype] || LucideType;
318}
319
320export function filterSummary(filter: ActiveFilter): string {
321 const operatorLabels: Record<string, string> = {
322 equals: "is",
323 "not equals": "is not",
324 like: "contains",
325 "not like": "doesn't contain",
326 in: "in",
327 "not in": "not in",
328 is: "is",
329 between: "between",
330 timespan: "in",
331 ">": "greater than",
332 "<": "less than",
333 ">=": "greater than or equal to",
334 "<=": "less than or equal to",
335 };
336 return `${filter.field.label} ${
337 operatorLabels[filter.operator] || filter.operator
338 } ${displayValue(filter)}`;
339}
340
341function displayValue(filter: ActiveFilter): string {
342 const value = filter.value;
343 // ratings are stored as a 0..1 fraction; show numeric ones as star counts
344 // (×5). "is" uses set / not set, so leave those (and any non-numeric) alone.
345 if (filter.field.fieldtype === "Rating" && filter.operator !== "is") {
346 const toStars = (v: any) => {
347 const n = Number(v);
348 return Number.isFinite(n) ? String(Math.round(n * 5)) : String(v ?? "");
349 };
350 return Array.isArray(value)
351 ? shortList(value.map(toStars))
352 : toStars(value);
353 }
354 if (Array.isArray(value)) return shortList(value);
355 if (typeof value === "string") return value.replaceAll("%", "");
356 return String(value ?? "");
357}
358
359// "1, 2, 3 and 4 others", so long pasted lists stay readable
360function shortList(values: any[]): string {
361 const shown = values.slice(0, 3).join(", ");
362 const hidden = values.length - 3;
363 if (hidden <= 0) return shown;
364 if (hidden === 1) return __("{0} and 1 other", shown);
365 return __("{0} and {1} others", shown, hidden);
366}
367
368const operatorMap: Record<string, string> = {
369 is: "is",
370 "is not": "is not",
371 in: "in",
372 "not in": "not in",
373 equals: "=",
374 "not equals": "!=",
375 like: "LIKE",
376 "not like": "NOT LIKE",
377 ">": ">",
378 "<": "<",
379 ">=": ">=",
380 "<=": "<=",
381 between: "between",
382 timespan: "timespan",
383};
384
385const oppositeOperatorMap: Record<string, string> = Object.fromEntries(
386 Object.entries(operatorMap).map(([ui, frappe]) => [frappe, ui])
387);
388
389const timespanOptions = [
390 { label: "Today", value: "today" },
391 { label: "Yesterday", value: "yesterday" },
392 { label: "Tomorrow", value: "tomorrow" },
393 { label: "Last 7 Days", value: "last 7 days" },
394 { label: "Last 14 Days", value: "last 14 days" },
395 { label: "Last 30 Days", value: "last 30 days" },
396 { label: "Last 90 Days", value: "last 90 days" },
397 { label: "Last Week", value: "last week" },
398 { label: "Last Month", value: "last month" },
399 { label: "Last Quarter", value: "last quarter" },
400 { label: "Last 6 Months", value: "last 6 months" },
401 { label: "Last Year", value: "last year" },
402 { label: "This Week", value: "this week" },
403 { label: "This Month", value: "this month" },
404 { label: "This Quarter", value: "this quarter" },
405 { label: "This Year", value: "this year" },
406 { label: "Next Week", value: "next week" },
407 { label: "Next Month", value: "next month" },
408 { label: "Next Quarter", value: "next quarter" },
409 { label: "Next 6 Months", value: "next 6 months" },
410 { label: "Next Year", value: "next year" },
411 { label: "Next 7 Days", value: "next 7 days" },
412 { label: "Next 14 Days", value: "next 14 days" },
413 { label: "Next 30 Days", value: "next 30 days" },
414];
415