filtercn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +88 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/templates/index.d.ts +14 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +1174 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/utils/dependencies.d.ts +5 -0
- package/dist/utils/dependencies.d.ts.map +1 -0
- package/dist/utils/dependencies.js +57 -0
- package/dist/utils/dependencies.js.map +1 -0
- package/dist/utils/detect-project.d.ts +12 -0
- package/dist/utils/detect-project.d.ts.map +1 -0
- package/dist/utils/detect-project.js +52 -0
- package/dist/utils/detect-project.js.map +1 -0
- package/dist/utils/file-writer.d.ts +9 -0
- package/dist/utils/file-writer.d.ts.map +1 -0
- package/dist/utils/file-writer.js +24 -0
- package/dist/utils/file-writer.js.map +1 -0
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +14 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +29 -0
- package/src/commands/init.ts +126 -0
- package/src/index.ts +29 -0
- package/src/templates/index.ts +1196 -0
- package/src/utils/dependencies.ts +68 -0
- package/src/utils/detect-project.ts +69 -0
- package/src/utils/file-writer.ts +40 -0
- package/src/utils/logger.ts +14 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All template files for the conditional-filter component.
|
|
3
|
+
* Each key is the relative path within the component directory,
|
|
4
|
+
* and each value is the file content.
|
|
5
|
+
*
|
|
6
|
+
* The `ALIAS` placeholder is replaced with the user's actual import alias.
|
|
7
|
+
*/
|
|
8
|
+
const ALIAS = "__ALIAS__";
|
|
9
|
+
// ===================== types.ts =====================
|
|
10
|
+
const TYPES = `// ===== FIELD DEFINITION =====
|
|
11
|
+
export type FieldType = "text" | "number" | "select" | "multiselect"
|
|
12
|
+
| "date" | "datetime" | "boolean" | "combobox";
|
|
13
|
+
|
|
14
|
+
export interface FilterFieldDefinition {
|
|
15
|
+
/** Unique key, also used as the query param name (e.g. "status", "category_id") */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Label displayed in the UI */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Input type to render */
|
|
20
|
+
type: FieldType;
|
|
21
|
+
/** Allowed operators (defaults based on type if not provided) */
|
|
22
|
+
operators?: OperatorType[];
|
|
23
|
+
/** Static options for select/multiselect */
|
|
24
|
+
options?: SelectOption[];
|
|
25
|
+
/** Async function to fetch options from a REST API (for combobox/dynamic multiselect) */
|
|
26
|
+
fetchOptions?: (search: string) => Promise<SelectOption[]>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ===== OPERATORS =====
|
|
30
|
+
export type OperatorType =
|
|
31
|
+
| "is" // =
|
|
32
|
+
| "is_not" // !=
|
|
33
|
+
| "contains" // *value*
|
|
34
|
+
| "not_contains" // NOT *value*
|
|
35
|
+
| "gt" // >
|
|
36
|
+
| "gte" // >=
|
|
37
|
+
| "lt" // <
|
|
38
|
+
| "lte" // <=
|
|
39
|
+
| "between" // range [from, to]
|
|
40
|
+
| "in" // in list
|
|
41
|
+
| "not_in" // not in list
|
|
42
|
+
| "is_empty" // NULL/empty check
|
|
43
|
+
| "is_not_empty";
|
|
44
|
+
|
|
45
|
+
// ===== SELECT OPTION =====
|
|
46
|
+
export interface SelectOption {
|
|
47
|
+
label: string;
|
|
48
|
+
value: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ===== FILTER ROW (runtime state) =====
|
|
52
|
+
export interface FilterRow {
|
|
53
|
+
id: string; // unique row id
|
|
54
|
+
field: FilterFieldDefinition | null; // selected field
|
|
55
|
+
operator: OperatorType | null; // selected operator
|
|
56
|
+
value: FilterValue; // entered value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type FilterValue =
|
|
60
|
+
| string
|
|
61
|
+
| string[]
|
|
62
|
+
| number
|
|
63
|
+
| [number, number] // range
|
|
64
|
+
| [string, string] // date range
|
|
65
|
+
| boolean
|
|
66
|
+
| null;
|
|
67
|
+
|
|
68
|
+
// ===== FILTER STATE =====
|
|
69
|
+
export interface FilterState {
|
|
70
|
+
rows: FilterRow[];
|
|
71
|
+
conjunction: "and" | "or";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ===== REST QUERY OUTPUT =====
|
|
75
|
+
export type RestQueryParams = Record<string, string | string[]>;
|
|
76
|
+
|
|
77
|
+
// ===== FILTER CONFIG (passed to Provider) =====
|
|
78
|
+
export interface FilterConfig {
|
|
79
|
+
/** List of fields available for filtering */
|
|
80
|
+
fields: FilterFieldDefinition[];
|
|
81
|
+
/** Allow toggling AND/OR. Default: false */
|
|
82
|
+
allowConjunctionToggle?: boolean;
|
|
83
|
+
/** Max filter rows. Default: 10 */
|
|
84
|
+
maxRows?: number;
|
|
85
|
+
/** Param style. Default: underscore */
|
|
86
|
+
paramStyle?: "underscore" | "bracket" | "custom";
|
|
87
|
+
/** Custom builder */
|
|
88
|
+
customParamBuilder?: (field: string, operator: OperatorType, value: FilterValue) => Record<string, string>;
|
|
89
|
+
/** Locale */
|
|
90
|
+
locale?: FilterLocale;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface FilterLocale {
|
|
94
|
+
addFilter: string;
|
|
95
|
+
reset: string;
|
|
96
|
+
apply: string;
|
|
97
|
+
placeholder: string;
|
|
98
|
+
and: string;
|
|
99
|
+
or: string;
|
|
100
|
+
noFilters: string;
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
// ===================== constants.ts =====================
|
|
104
|
+
const CONSTANTS = `import type { FilterLocale, OperatorType, FieldType } from "./types";
|
|
105
|
+
|
|
106
|
+
export const DEFAULT_LOCALE: FilterLocale = {
|
|
107
|
+
addFilter: "+ Add filter",
|
|
108
|
+
reset: "Reset",
|
|
109
|
+
apply: "Apply",
|
|
110
|
+
placeholder: "Select...",
|
|
111
|
+
and: "AND",
|
|
112
|
+
or: "OR",
|
|
113
|
+
noFilters: "No filters active",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const DEFAULT_OPERATORS: Record<FieldType, OperatorType[]> = {
|
|
117
|
+
text: ["is", "is_not", "contains", "not_contains", "is_empty", "is_not_empty"],
|
|
118
|
+
number: ["is", "is_not", "gt", "gte", "lt", "lte", "between", "is_empty", "is_not_empty"],
|
|
119
|
+
select: ["is", "is_not", "is_empty", "is_not_empty"],
|
|
120
|
+
multiselect: ["in", "not_in", "is_empty", "is_not_empty"],
|
|
121
|
+
date: ["is", "is_not", "gt", "gte", "lt", "lte", "between", "is_empty", "is_not_empty"],
|
|
122
|
+
datetime: ["is", "is_not", "gt", "gte", "lt", "lte", "between", "is_empty", "is_not_empty"],
|
|
123
|
+
boolean: ["is"],
|
|
124
|
+
combobox: ["is", "is_not", "is_empty", "is_not_empty"],
|
|
125
|
+
};
|
|
126
|
+
`;
|
|
127
|
+
// ===================== helpers/operators.ts =====================
|
|
128
|
+
const OPERATORS = `import type { OperatorType } from "../types";
|
|
129
|
+
|
|
130
|
+
export const getOperatorSuffix = (operator: OperatorType, paramStyle: "underscore" | "bracket" | "custom"): string => {
|
|
131
|
+
if (paramStyle === "custom") return "";
|
|
132
|
+
|
|
133
|
+
const mapping: Record<OperatorType, string> = {
|
|
134
|
+
is: "", // usually no suffix for exact match
|
|
135
|
+
is_not: "not",
|
|
136
|
+
contains: "icontains",
|
|
137
|
+
not_contains: "not_icontains",
|
|
138
|
+
gt: "gt",
|
|
139
|
+
gte: "gte",
|
|
140
|
+
lt: "lt",
|
|
141
|
+
lte: "lte",
|
|
142
|
+
between: "range",
|
|
143
|
+
in: "in",
|
|
144
|
+
not_in: "not_in",
|
|
145
|
+
is_empty: "isnull",
|
|
146
|
+
is_not_empty: "isnull",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const suffix = mapping[operator];
|
|
150
|
+
if (!suffix) return "";
|
|
151
|
+
|
|
152
|
+
if (paramStyle === "underscore") return \`__\${suffix}\`;
|
|
153
|
+
if (paramStyle === "bracket") return \`[\${suffix}]\`;
|
|
154
|
+
return "";
|
|
155
|
+
};
|
|
156
|
+
`;
|
|
157
|
+
// ===================== helpers/validators.ts =====================
|
|
158
|
+
const VALIDATORS = `import type { FilterRow } from "../types";
|
|
159
|
+
|
|
160
|
+
export const isValidFilterRow = (row: FilterRow): boolean => {
|
|
161
|
+
if (!row.field || !row.operator) return false;
|
|
162
|
+
|
|
163
|
+
if (row.operator === "is_empty" || row.operator === "is_not_empty") {
|
|
164
|
+
return true; // value doesn't matter for empty checks
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (row.operator === "between") {
|
|
168
|
+
if (!Array.isArray(row.value) || row.value.length !== 2) return false;
|
|
169
|
+
if (row.value[0] === null || row.value[0] === "" || row.value[1] === null || row.value[1] === "") return false;
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (row.operator === "in" || row.operator === "not_in") {
|
|
174
|
+
if (!Array.isArray(row.value) || row.value.length === 0) return false;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (row.value === null || row.value === "" || (Array.isArray(row.value) && row.value.length === 0)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const getValidFilterRows = (rows: FilterRow[]): FilterRow[] => {
|
|
186
|
+
return rows.filter(isValidFilterRow);
|
|
187
|
+
};
|
|
188
|
+
`;
|
|
189
|
+
// ===================== helpers/query-builder.ts =====================
|
|
190
|
+
const QUERY_BUILDER = `import type { FilterConfig, FilterRow, RestQueryParams } from "../types";
|
|
191
|
+
import { getOperatorSuffix } from "./operators";
|
|
192
|
+
import { getValidFilterRows } from "./validators";
|
|
193
|
+
|
|
194
|
+
export const buildRestQuery = (
|
|
195
|
+
rows: FilterRow[],
|
|
196
|
+
config: FilterConfig
|
|
197
|
+
): RestQueryParams => {
|
|
198
|
+
const validRows = getValidFilterRows(rows);
|
|
199
|
+
const params: RestQueryParams = {};
|
|
200
|
+
|
|
201
|
+
validRows.forEach((row) => {
|
|
202
|
+
if (!row.field || !row.operator) return;
|
|
203
|
+
|
|
204
|
+
if (config.customParamBuilder) {
|
|
205
|
+
const customParams = config.customParamBuilder(row.field.name, row.operator, row.value);
|
|
206
|
+
Object.assign(params, customParams);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const style = config.paramStyle || "underscore";
|
|
211
|
+
const suffix = getOperatorSuffix(row.operator, style);
|
|
212
|
+
|
|
213
|
+
let paramKey = row.field.name;
|
|
214
|
+
if (style === "underscore" && suffix) {
|
|
215
|
+
paramKey = \`\${row.field.name}\${suffix}\`;
|
|
216
|
+
} else if (style === "bracket" && suffix) {
|
|
217
|
+
paramKey = \`filter[\${row.field.name}]\${suffix}\`;
|
|
218
|
+
} else if (style === "bracket" && !suffix) {
|
|
219
|
+
paramKey = \`filter[\${row.field.name}]\`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let paramValue: string | string[];
|
|
223
|
+
|
|
224
|
+
if (row.operator === "is_empty") {
|
|
225
|
+
paramValue = "true";
|
|
226
|
+
} else if (row.operator === "is_not_empty") {
|
|
227
|
+
paramValue = "false";
|
|
228
|
+
} else if (row.operator === "between" && Array.isArray(row.value)) {
|
|
229
|
+
if (style === "underscore") {
|
|
230
|
+
paramValue = \`\${row.value[0]},\${row.value[1]}\`;
|
|
231
|
+
} else {
|
|
232
|
+
paramValue = [row.value[0].toString(), row.value[1].toString()];
|
|
233
|
+
}
|
|
234
|
+
} else if ((row.operator === "in" || row.operator === "not_in") && Array.isArray(row.value)) {
|
|
235
|
+
if (style === "underscore") {
|
|
236
|
+
paramValue = row.value.join(",");
|
|
237
|
+
} else {
|
|
238
|
+
paramValue = row.value.map(v => v.toString());
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
paramValue = String(row.value);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
params[paramKey] = paramValue;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return params;
|
|
248
|
+
};
|
|
249
|
+
`;
|
|
250
|
+
// ===================== helpers/serializer.ts =====================
|
|
251
|
+
const SERIALIZER = `import type { FilterConfig, FilterRow, FilterState, OperatorType } from "../types";
|
|
252
|
+
import { buildRestQuery } from "./query-builder";
|
|
253
|
+
|
|
254
|
+
export const serializeFiltersToUrl = (state: FilterState, config: FilterConfig): URLSearchParams => {
|
|
255
|
+
const params = new URLSearchParams();
|
|
256
|
+
const queryObj = buildRestQuery(state.rows, config);
|
|
257
|
+
|
|
258
|
+
Object.entries(queryObj).forEach(([key, value]) => {
|
|
259
|
+
if (Array.isArray(value)) {
|
|
260
|
+
value.forEach(v => params.append(key, v));
|
|
261
|
+
} else {
|
|
262
|
+
params.set(key, value);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (state.conjunction === "or" && config.allowConjunctionToggle) {
|
|
267
|
+
params.set("conjunction", "or");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return params;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export const deserializeUrlToFilters = (params: URLSearchParams, config: FilterConfig): FilterState => {
|
|
274
|
+
const rows: FilterRow[] = [];
|
|
275
|
+
const style = config.paramStyle || "underscore";
|
|
276
|
+
|
|
277
|
+
Array.from(params.entries()).forEach(([key, value]) => {
|
|
278
|
+
if (key === "conjunction") return;
|
|
279
|
+
|
|
280
|
+
let fieldName = key;
|
|
281
|
+
let operatorStr = "";
|
|
282
|
+
|
|
283
|
+
if (style === "underscore" && key.includes("__")) {
|
|
284
|
+
const parts = key.split("__");
|
|
285
|
+
operatorStr = parts.pop() || "";
|
|
286
|
+
fieldName = parts.join("__");
|
|
287
|
+
} else if (style === "bracket" && key.startsWith("filter[")) {
|
|
288
|
+
const match = key.match(/filter\\[(.*?)\\](?:\\[(.*?)\\])?/);
|
|
289
|
+
if (match && match[1]) {
|
|
290
|
+
fieldName = match[1];
|
|
291
|
+
operatorStr = match[2] || "";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fieldDef = config.fields.find(f => f.name === fieldName);
|
|
296
|
+
if (!fieldDef) return;
|
|
297
|
+
|
|
298
|
+
const suffixToOp: Record<string, OperatorType> = {
|
|
299
|
+
"": "is",
|
|
300
|
+
"not": "is_not",
|
|
301
|
+
"icontains": "contains",
|
|
302
|
+
"not_icontains": "not_contains",
|
|
303
|
+
"gt": "gt",
|
|
304
|
+
"gte": "gte",
|
|
305
|
+
"lt": "lt",
|
|
306
|
+
"lte": "lte",
|
|
307
|
+
"range": "between",
|
|
308
|
+
"in": "in",
|
|
309
|
+
"not_in": "not_in",
|
|
310
|
+
"isnull": value === "true" ? "is_empty" : "is_not_empty"
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const operator = suffixToOp[operatorStr] || "is";
|
|
314
|
+
let parsedValue: any = value;
|
|
315
|
+
|
|
316
|
+
if (operator === "between" && typeof value === 'string' && value.includes(",")) {
|
|
317
|
+
parsedValue = value.split(",");
|
|
318
|
+
} else if ((operator === "in" || operator === "not_in") && typeof value === 'string' && value.includes(",")) {
|
|
319
|
+
parsedValue = value.split(",");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
rows.push({
|
|
323
|
+
id: crypto.randomUUID(),
|
|
324
|
+
field: fieldDef,
|
|
325
|
+
operator,
|
|
326
|
+
value: parsedValue
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
rows,
|
|
332
|
+
conjunction: params.get("conjunction") === "or" ? "or" : "and",
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
`;
|
|
336
|
+
// ===================== hooks/use-filter-state.ts =====================
|
|
337
|
+
const USE_FILTER_STATE = `"use client";
|
|
338
|
+
|
|
339
|
+
import { useState, useCallback } from "react";
|
|
340
|
+
import type { FilterState, FilterFieldDefinition, OperatorType, FilterValue } from "../types";
|
|
341
|
+
|
|
342
|
+
export const useFilterState = (initialState?: FilterState) => {
|
|
343
|
+
const [state, setState] = useState<FilterState>(initialState || { rows: [], conjunction: "and" });
|
|
344
|
+
|
|
345
|
+
const addRow = useCallback(() => {
|
|
346
|
+
setState((prev) => ({
|
|
347
|
+
...prev,
|
|
348
|
+
rows: [
|
|
349
|
+
...prev.rows,
|
|
350
|
+
{ id: crypto.randomUUID(), field: null, operator: null, value: null },
|
|
351
|
+
],
|
|
352
|
+
}));
|
|
353
|
+
}, []);
|
|
354
|
+
|
|
355
|
+
const removeRow = useCallback((id: string) => {
|
|
356
|
+
setState((prev) => ({
|
|
357
|
+
...prev,
|
|
358
|
+
rows: prev.rows.filter((row) => row.id !== id),
|
|
359
|
+
}));
|
|
360
|
+
}, []);
|
|
361
|
+
|
|
362
|
+
const updateField = useCallback((id: string, field: FilterFieldDefinition) => {
|
|
363
|
+
setState((prev) => ({
|
|
364
|
+
...prev,
|
|
365
|
+
rows: prev.rows.map((row) =>
|
|
366
|
+
row.id === id ? { ...row, field, operator: null, value: null } : row
|
|
367
|
+
),
|
|
368
|
+
}));
|
|
369
|
+
}, []);
|
|
370
|
+
|
|
371
|
+
const updateOperator = useCallback((id: string, operator: OperatorType) => {
|
|
372
|
+
setState((prev) => ({
|
|
373
|
+
...prev,
|
|
374
|
+
rows: prev.rows.map((row) =>
|
|
375
|
+
row.id === id ? { ...row, operator, value: null } : row
|
|
376
|
+
),
|
|
377
|
+
}));
|
|
378
|
+
}, []);
|
|
379
|
+
|
|
380
|
+
const updateValue = useCallback((id: string, value: FilterValue) => {
|
|
381
|
+
setState((prev) => ({
|
|
382
|
+
...prev,
|
|
383
|
+
rows: prev.rows.map((row) => (row.id === id ? { ...row, value } : row)),
|
|
384
|
+
}));
|
|
385
|
+
}, []);
|
|
386
|
+
|
|
387
|
+
const setConjunction = useCallback((conjunction: "and" | "or") => {
|
|
388
|
+
setState((prev) => ({ ...prev, conjunction }));
|
|
389
|
+
}, []);
|
|
390
|
+
|
|
391
|
+
const reset = useCallback(() => {
|
|
392
|
+
setState({ rows: [], conjunction: "and" });
|
|
393
|
+
}, []);
|
|
394
|
+
|
|
395
|
+
const overrideState = useCallback((newState: FilterState) => {
|
|
396
|
+
setState(newState);
|
|
397
|
+
}, []);
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
state,
|
|
401
|
+
addRow,
|
|
402
|
+
removeRow,
|
|
403
|
+
updateField,
|
|
404
|
+
updateOperator,
|
|
405
|
+
updateValue,
|
|
406
|
+
setConjunction,
|
|
407
|
+
reset,
|
|
408
|
+
overrideState
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
`;
|
|
412
|
+
// ===================== hooks/use-filter-url-sync.ts =====================
|
|
413
|
+
const USE_FILTER_URL_SYNC = `"use client";
|
|
414
|
+
|
|
415
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
416
|
+
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
|
417
|
+
import type { FilterState, FilterConfig } from "../types";
|
|
418
|
+
import { serializeFiltersToUrl, deserializeUrlToFilters } from "../helpers/serializer";
|
|
419
|
+
import { getValidFilterRows } from "../helpers/validators";
|
|
420
|
+
|
|
421
|
+
interface UseFilterUrlSyncOptions {
|
|
422
|
+
syncMode: "immediate" | "on-apply";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export const useFilterUrlSync = (
|
|
426
|
+
config: FilterConfig,
|
|
427
|
+
_options: UseFilterUrlSyncOptions
|
|
428
|
+
) => {
|
|
429
|
+
const router = useRouter();
|
|
430
|
+
const searchParams = useSearchParams();
|
|
431
|
+
const pathname = usePathname();
|
|
432
|
+
const isInitializing = useRef(true);
|
|
433
|
+
|
|
434
|
+
const [syncedState, setSyncedState] = useState<FilterState>(() => {
|
|
435
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
436
|
+
return deserializeUrlToFilters(params, config);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
if (isInitializing.current) {
|
|
441
|
+
isInitializing.current = false;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
445
|
+
setSyncedState(deserializeUrlToFilters(params, config));
|
|
446
|
+
}, [searchParams, config]);
|
|
447
|
+
|
|
448
|
+
const applyChanges = useCallback((newState: FilterState) => {
|
|
449
|
+
const validState = { ...newState, rows: getValidFilterRows(newState.rows) };
|
|
450
|
+
const newParams = serializeFiltersToUrl(validState, config);
|
|
451
|
+
const searchString = newParams.toString();
|
|
452
|
+
const query = searchString ? \`?\${searchString}\` : "";
|
|
453
|
+
router.replace(\`\${pathname}\${query}\`, { scroll: false });
|
|
454
|
+
}, [config, router, pathname]);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
initialState: syncedState,
|
|
458
|
+
applyChanges,
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
`;
|
|
462
|
+
// ===================== hooks/use-filter-options.ts =====================
|
|
463
|
+
const USE_FILTER_OPTIONS = `"use client";
|
|
464
|
+
|
|
465
|
+
import { useState, useEffect, useCallback } from "react";
|
|
466
|
+
import type { SelectOption } from "../types";
|
|
467
|
+
|
|
468
|
+
export const useFilterOptions = (fetchFn?: (search: string) => Promise<SelectOption[]>) => {
|
|
469
|
+
const [options, setOptions] = useState<SelectOption[]>([]);
|
|
470
|
+
const [loading, setLoading] = useState(false);
|
|
471
|
+
|
|
472
|
+
const search = useCallback(async (query: string) => {
|
|
473
|
+
if (!fetchFn) return;
|
|
474
|
+
setLoading(true);
|
|
475
|
+
try {
|
|
476
|
+
const result = await fetchFn(query);
|
|
477
|
+
setOptions(result);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
console.error("Failed to fetch filter options", err);
|
|
480
|
+
} finally {
|
|
481
|
+
setLoading(false);
|
|
482
|
+
}
|
|
483
|
+
}, [fetchFn]);
|
|
484
|
+
|
|
485
|
+
// Initial fetch
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
if (fetchFn) {
|
|
488
|
+
search("");
|
|
489
|
+
}
|
|
490
|
+
}, [fetchFn, search]);
|
|
491
|
+
|
|
492
|
+
return { options, loading, search };
|
|
493
|
+
};
|
|
494
|
+
`;
|
|
495
|
+
// ===================== provider/filter-context.ts =====================
|
|
496
|
+
const FILTER_CONTEXT = `"use client";
|
|
497
|
+
|
|
498
|
+
import { createContext, useContext } from "react";
|
|
499
|
+
import type { FilterConfig, FilterState, FilterFieldDefinition, OperatorType, FilterValue } from "../types";
|
|
500
|
+
|
|
501
|
+
export interface FilterContextValue {
|
|
502
|
+
config: FilterConfig;
|
|
503
|
+
state: FilterState;
|
|
504
|
+
|
|
505
|
+
// Mutations
|
|
506
|
+
addRow: () => void;
|
|
507
|
+
removeRow: (id: string) => void;
|
|
508
|
+
updateField: (id: string, field: FilterFieldDefinition) => void;
|
|
509
|
+
updateOperator: (id: string, operator: OperatorType) => void;
|
|
510
|
+
updateValue: (id: string, value: FilterValue) => void;
|
|
511
|
+
setConjunction: (conjunction: "and" | "or") => void;
|
|
512
|
+
reset: () => void;
|
|
513
|
+
|
|
514
|
+
// Derived
|
|
515
|
+
isValid: boolean;
|
|
516
|
+
activeCount: number;
|
|
517
|
+
|
|
518
|
+
// Actions
|
|
519
|
+
apply: () => void;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export const FilterContext = createContext<FilterContextValue | null>(null);
|
|
523
|
+
|
|
524
|
+
export const useFilterContext = () => {
|
|
525
|
+
const context = useContext(FilterContext);
|
|
526
|
+
if (!context) {
|
|
527
|
+
throw new Error("useFilterContext must be used within a FilterProvider");
|
|
528
|
+
}
|
|
529
|
+
return context;
|
|
530
|
+
};
|
|
531
|
+
`;
|
|
532
|
+
// ===================== provider/filter-provider.tsx =====================
|
|
533
|
+
const FILTER_PROVIDER = `"use client";
|
|
534
|
+
|
|
535
|
+
import React, { useMemo, useCallback } from "react";
|
|
536
|
+
import type { FilterConfig } from "../types";
|
|
537
|
+
import { FilterContext } from "./filter-context";
|
|
538
|
+
import { useFilterState } from "../hooks/use-filter-state";
|
|
539
|
+
import { useFilterUrlSync } from "../hooks/use-filter-url-sync";
|
|
540
|
+
import { DEFAULT_LOCALE } from "../constants";
|
|
541
|
+
import { getValidFilterRows, isValidFilterRow } from "../helpers/validators";
|
|
542
|
+
|
|
543
|
+
export interface FilterProviderProps {
|
|
544
|
+
config: FilterConfig;
|
|
545
|
+
children: React.ReactNode;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export const FilterProvider: React.FC<FilterProviderProps> = ({ config, children }) => {
|
|
549
|
+
const currentConfig = useMemo(() => {
|
|
550
|
+
return {
|
|
551
|
+
...config,
|
|
552
|
+
locale: { ...DEFAULT_LOCALE, ...config.locale },
|
|
553
|
+
};
|
|
554
|
+
}, [config]);
|
|
555
|
+
|
|
556
|
+
const { initialState, applyChanges } = useFilterUrlSync(currentConfig, {
|
|
557
|
+
syncMode: "on-apply",
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const {
|
|
561
|
+
state,
|
|
562
|
+
addRow,
|
|
563
|
+
removeRow,
|
|
564
|
+
updateField,
|
|
565
|
+
updateOperator,
|
|
566
|
+
updateValue,
|
|
567
|
+
setConjunction,
|
|
568
|
+
reset: localReset,
|
|
569
|
+
} = useFilterState(initialState);
|
|
570
|
+
|
|
571
|
+
const isValid = useMemo(() => state.rows.every(isValidFilterRow), [state.rows]);
|
|
572
|
+
|
|
573
|
+
const activeCount = useMemo(() => getValidFilterRows(state.rows).length, [state.rows]);
|
|
574
|
+
|
|
575
|
+
const apply = useCallback(() => {
|
|
576
|
+
applyChanges(state);
|
|
577
|
+
}, [applyChanges, state]);
|
|
578
|
+
|
|
579
|
+
const reset = useCallback(() => {
|
|
580
|
+
localReset();
|
|
581
|
+
applyChanges({ rows: [], conjunction: "and" });
|
|
582
|
+
}, [localReset, applyChanges]);
|
|
583
|
+
|
|
584
|
+
const value = useMemo(
|
|
585
|
+
() => ({
|
|
586
|
+
config: currentConfig,
|
|
587
|
+
state,
|
|
588
|
+
addRow,
|
|
589
|
+
removeRow,
|
|
590
|
+
updateField,
|
|
591
|
+
updateOperator,
|
|
592
|
+
updateValue,
|
|
593
|
+
setConjunction,
|
|
594
|
+
reset,
|
|
595
|
+
isValid,
|
|
596
|
+
activeCount,
|
|
597
|
+
apply,
|
|
598
|
+
}),
|
|
599
|
+
[
|
|
600
|
+
currentConfig,
|
|
601
|
+
state,
|
|
602
|
+
addRow,
|
|
603
|
+
removeRow,
|
|
604
|
+
updateField,
|
|
605
|
+
updateOperator,
|
|
606
|
+
updateValue,
|
|
607
|
+
setConjunction,
|
|
608
|
+
reset,
|
|
609
|
+
isValid,
|
|
610
|
+
activeCount,
|
|
611
|
+
apply,
|
|
612
|
+
]
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<FilterContext.Provider value={value}>
|
|
617
|
+
{children}
|
|
618
|
+
</FilterContext.Provider>
|
|
619
|
+
);
|
|
620
|
+
};
|
|
621
|
+
`;
|
|
622
|
+
// ===================== index.ts =====================
|
|
623
|
+
const INDEX = `export * from "./types";
|
|
624
|
+
export * from "./constants";
|
|
625
|
+
export * from "./hooks/use-filter-state";
|
|
626
|
+
export * from "./hooks/use-filter-url-sync";
|
|
627
|
+
export * from "./hooks/use-filter-options";
|
|
628
|
+
export * from "./provider/filter-context";
|
|
629
|
+
export * from "./provider/filter-provider";
|
|
630
|
+
|
|
631
|
+
export * from "./ui/field-select";
|
|
632
|
+
export * from "./ui/operator-select";
|
|
633
|
+
export * from "./ui/value-input";
|
|
634
|
+
export * from "./ui/filter-row";
|
|
635
|
+
export * from "./ui/filter-footer";
|
|
636
|
+
export * from "./ui/filter-badge";
|
|
637
|
+
export * from "./ui/filter-root";
|
|
638
|
+
|
|
639
|
+
export { buildRestQuery } from "./helpers/query-builder";
|
|
640
|
+
export { serializeFiltersToUrl, deserializeUrlToFilters } from "./helpers/serializer";
|
|
641
|
+
export { isValidFilterRow, getValidFilterRows } from "./helpers/validators";
|
|
642
|
+
`;
|
|
643
|
+
/**
|
|
644
|
+
* Returns all template files mapped to their relative paths
|
|
645
|
+
* within the component directory. The alias placeholder will
|
|
646
|
+
* be replaced by the init command.
|
|
647
|
+
*/
|
|
648
|
+
export function getTemplateFiles(alias) {
|
|
649
|
+
// UI components use the alias for importing shadcn UI primitives
|
|
650
|
+
const replaceAlias = (content) => content.replaceAll(ALIAS, alias);
|
|
651
|
+
return {
|
|
652
|
+
"types.ts": replaceAlias(TYPES),
|
|
653
|
+
"constants.ts": replaceAlias(CONSTANTS),
|
|
654
|
+
"index.ts": replaceAlias(INDEX),
|
|
655
|
+
"helpers/operators.ts": replaceAlias(OPERATORS),
|
|
656
|
+
"helpers/validators.ts": replaceAlias(VALIDATORS),
|
|
657
|
+
"helpers/query-builder.ts": replaceAlias(QUERY_BUILDER),
|
|
658
|
+
"helpers/serializer.ts": replaceAlias(SERIALIZER),
|
|
659
|
+
"hooks/use-filter-state.ts": replaceAlias(USE_FILTER_STATE),
|
|
660
|
+
"hooks/use-filter-url-sync.ts": replaceAlias(USE_FILTER_URL_SYNC),
|
|
661
|
+
"hooks/use-filter-options.ts": replaceAlias(USE_FILTER_OPTIONS),
|
|
662
|
+
"provider/filter-context.ts": replaceAlias(FILTER_CONTEXT),
|
|
663
|
+
"provider/filter-provider.tsx": replaceAlias(FILTER_PROVIDER),
|
|
664
|
+
...getUITemplates(alias),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* UI component templates — these use the alias for shadcn imports
|
|
669
|
+
*/
|
|
670
|
+
function getUITemplates(alias) {
|
|
671
|
+
const FIELD_SELECT = `"use client";
|
|
672
|
+
|
|
673
|
+
import { useState } from "react";
|
|
674
|
+
import { Check, ChevronsUpDown } from "lucide-react";
|
|
675
|
+
import { Button } from "${alias}components/ui/button";
|
|
676
|
+
import {
|
|
677
|
+
Command,
|
|
678
|
+
CommandEmpty,
|
|
679
|
+
CommandGroup,
|
|
680
|
+
CommandInput,
|
|
681
|
+
CommandItem,
|
|
682
|
+
CommandList,
|
|
683
|
+
} from "${alias}components/ui/command";
|
|
684
|
+
import {
|
|
685
|
+
Popover,
|
|
686
|
+
PopoverContent,
|
|
687
|
+
PopoverTrigger,
|
|
688
|
+
} from "${alias}components/ui/popover";
|
|
689
|
+
import { useFilterContext } from "../provider/filter-context";
|
|
690
|
+
import type { FilterFieldDefinition } from "../types";
|
|
691
|
+
|
|
692
|
+
interface FieldSelectProps {
|
|
693
|
+
rowId: string;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export function FieldSelect({ rowId }: FieldSelectProps) {
|
|
697
|
+
const [open, setOpen] = useState(false);
|
|
698
|
+
const { config, state, updateField } = useFilterContext();
|
|
699
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
700
|
+
|
|
701
|
+
const handleSelect = (field: FilterFieldDefinition) => {
|
|
702
|
+
updateField(rowId, field);
|
|
703
|
+
setOpen(false);
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
708
|
+
<PopoverTrigger asChild>
|
|
709
|
+
<Button variant="outline" role="combobox" className="w-[180px] justify-between text-sm">
|
|
710
|
+
{row?.field?.label || config.locale?.placeholder || "Select..."}
|
|
711
|
+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
712
|
+
</Button>
|
|
713
|
+
</PopoverTrigger>
|
|
714
|
+
<PopoverContent className="w-[200px] p-0">
|
|
715
|
+
<Command>
|
|
716
|
+
<CommandInput placeholder="Search field..." />
|
|
717
|
+
<CommandList>
|
|
718
|
+
<CommandEmpty>No field found.</CommandEmpty>
|
|
719
|
+
<CommandGroup>
|
|
720
|
+
{config.fields.map((field) => (
|
|
721
|
+
<CommandItem
|
|
722
|
+
key={field.name}
|
|
723
|
+
value={field.name}
|
|
724
|
+
onSelect={() => handleSelect(field)}
|
|
725
|
+
>
|
|
726
|
+
<Check
|
|
727
|
+
className={\`mr-2 h-4 w-4 \${row?.field?.name === field.name ? "opacity-100" : "opacity-0"}\`}
|
|
728
|
+
/>
|
|
729
|
+
{field.label}
|
|
730
|
+
</CommandItem>
|
|
731
|
+
))}
|
|
732
|
+
</CommandGroup>
|
|
733
|
+
</CommandList>
|
|
734
|
+
</Command>
|
|
735
|
+
</PopoverContent>
|
|
736
|
+
</Popover>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
`;
|
|
740
|
+
const OPERATOR_SELECT = `"use client";
|
|
741
|
+
|
|
742
|
+
import {
|
|
743
|
+
Select,
|
|
744
|
+
SelectContent,
|
|
745
|
+
SelectItem,
|
|
746
|
+
SelectTrigger,
|
|
747
|
+
SelectValue,
|
|
748
|
+
} from "${alias}components/ui/select";
|
|
749
|
+
import { useFilterContext } from "../provider/filter-context";
|
|
750
|
+
import { DEFAULT_OPERATORS } from "../constants";
|
|
751
|
+
import type { OperatorType } from "../types";
|
|
752
|
+
|
|
753
|
+
interface OperatorSelectProps {
|
|
754
|
+
rowId: string;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const OPERATOR_LABELS: Record<OperatorType, string> = {
|
|
758
|
+
is: "is",
|
|
759
|
+
is_not: "is not",
|
|
760
|
+
contains: "contains",
|
|
761
|
+
not_contains: "not contains",
|
|
762
|
+
gt: "greater than",
|
|
763
|
+
gte: "greater or equal",
|
|
764
|
+
lt: "less than",
|
|
765
|
+
lte: "less or equal",
|
|
766
|
+
between: "between",
|
|
767
|
+
in: "in",
|
|
768
|
+
not_in: "not in",
|
|
769
|
+
is_empty: "is empty",
|
|
770
|
+
is_not_empty: "is not empty",
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
export function OperatorSelect({ rowId }: OperatorSelectProps) {
|
|
774
|
+
const { state, updateOperator } = useFilterContext();
|
|
775
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
776
|
+
|
|
777
|
+
if (!row?.field) return null;
|
|
778
|
+
|
|
779
|
+
const operators = row.field.operators || DEFAULT_OPERATORS[row.field.type] || [];
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<Select
|
|
783
|
+
value={row.operator || ""}
|
|
784
|
+
onValueChange={(value) => updateOperator(rowId, value as OperatorType)}
|
|
785
|
+
>
|
|
786
|
+
<SelectTrigger className="w-[160px] text-sm">
|
|
787
|
+
<SelectValue placeholder="Operator..." />
|
|
788
|
+
</SelectTrigger>
|
|
789
|
+
<SelectContent>
|
|
790
|
+
{operators.map((op) => (
|
|
791
|
+
<SelectItem key={op} value={op}>
|
|
792
|
+
{OPERATOR_LABELS[op] || op}
|
|
793
|
+
</SelectItem>
|
|
794
|
+
))}
|
|
795
|
+
</SelectContent>
|
|
796
|
+
</Select>
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
`;
|
|
800
|
+
const VALUE_INPUT = `"use client";
|
|
801
|
+
|
|
802
|
+
import { useState, useEffect } from "react";
|
|
803
|
+
import { Input } from "${alias}components/ui/input";
|
|
804
|
+
import {
|
|
805
|
+
Select,
|
|
806
|
+
SelectContent,
|
|
807
|
+
SelectItem,
|
|
808
|
+
SelectTrigger,
|
|
809
|
+
SelectValue,
|
|
810
|
+
} from "${alias}components/ui/select";
|
|
811
|
+
import {
|
|
812
|
+
Popover,
|
|
813
|
+
PopoverContent,
|
|
814
|
+
PopoverTrigger,
|
|
815
|
+
} from "${alias}components/ui/popover";
|
|
816
|
+
import { Button } from "${alias}components/ui/button";
|
|
817
|
+
import { Calendar } from "${alias}components/ui/calendar";
|
|
818
|
+
import { CalendarIcon } from "lucide-react";
|
|
819
|
+
import { format } from "date-fns";
|
|
820
|
+
import { useFilterContext } from "../provider/filter-context";
|
|
821
|
+
import { useFilterOptions } from "../hooks/use-filter-options";
|
|
822
|
+
import { Badge } from "${alias}components/ui/badge";
|
|
823
|
+
import type { SelectOption } from "../types";
|
|
824
|
+
|
|
825
|
+
interface ValueInputProps {
|
|
826
|
+
rowId: string;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export function ValueInput({ rowId }: ValueInputProps) {
|
|
830
|
+
const { state, updateValue } = useFilterContext();
|
|
831
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
832
|
+
|
|
833
|
+
if (!row?.field || !row.operator) return null;
|
|
834
|
+
|
|
835
|
+
// No value needed for empty checks
|
|
836
|
+
if (row.operator === "is_empty" || row.operator === "is_not_empty") {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const fieldType = row.field.type;
|
|
841
|
+
|
|
842
|
+
switch (fieldType) {
|
|
843
|
+
case "text":
|
|
844
|
+
return <TextInput rowId={rowId} />;
|
|
845
|
+
case "number":
|
|
846
|
+
return row.operator === "between"
|
|
847
|
+
? <RangeInput rowId={rowId} />
|
|
848
|
+
: <NumberInput rowId={rowId} />;
|
|
849
|
+
case "select":
|
|
850
|
+
case "combobox":
|
|
851
|
+
return <SelectInput rowId={rowId} />;
|
|
852
|
+
case "multiselect":
|
|
853
|
+
return <MultiSelectInput rowId={rowId} />;
|
|
854
|
+
case "date":
|
|
855
|
+
case "datetime":
|
|
856
|
+
return row.operator === "between"
|
|
857
|
+
? <DateRangeInput rowId={rowId} />
|
|
858
|
+
: <DateInput rowId={rowId} />;
|
|
859
|
+
case "boolean":
|
|
860
|
+
return <BooleanInput rowId={rowId} />;
|
|
861
|
+
default:
|
|
862
|
+
return <TextInput rowId={rowId} />;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function TextInput({ rowId }: { rowId: string }) {
|
|
867
|
+
const { state, updateValue } = useFilterContext();
|
|
868
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
<Input
|
|
872
|
+
type="text"
|
|
873
|
+
placeholder="Enter value..."
|
|
874
|
+
className="w-[200px] text-sm"
|
|
875
|
+
value={(row?.value as string) || ""}
|
|
876
|
+
onChange={(e) => updateValue(rowId, e.target.value)}
|
|
877
|
+
/>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function NumberInput({ rowId }: { rowId: string }) {
|
|
882
|
+
const { state, updateValue } = useFilterContext();
|
|
883
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
884
|
+
|
|
885
|
+
return (
|
|
886
|
+
<Input
|
|
887
|
+
type="number"
|
|
888
|
+
placeholder="Enter number..."
|
|
889
|
+
className="w-[200px] text-sm"
|
|
890
|
+
value={(row?.value as string) || ""}
|
|
891
|
+
onChange={(e) => updateValue(rowId, e.target.value)}
|
|
892
|
+
/>
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function RangeInput({ rowId }: { rowId: string }) {
|
|
897
|
+
const { state, updateValue } = useFilterContext();
|
|
898
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
899
|
+
const values = Array.isArray(row?.value) ? row.value : ["", ""];
|
|
900
|
+
|
|
901
|
+
return (
|
|
902
|
+
<div className="flex items-center gap-2">
|
|
903
|
+
<Input
|
|
904
|
+
type="number"
|
|
905
|
+
placeholder="From"
|
|
906
|
+
className="w-[100px] text-sm"
|
|
907
|
+
value={values[0]?.toString() || ""}
|
|
908
|
+
onChange={(e) => updateValue(rowId, [e.target.value, values[1]?.toString() || ""])}
|
|
909
|
+
/>
|
|
910
|
+
<span className="text-xs text-muted-foreground">to</span>
|
|
911
|
+
<Input
|
|
912
|
+
type="number"
|
|
913
|
+
placeholder="To"
|
|
914
|
+
className="w-[100px] text-sm"
|
|
915
|
+
value={values[1]?.toString() || ""}
|
|
916
|
+
onChange={(e) => updateValue(rowId, [values[0]?.toString() || "", e.target.value])}
|
|
917
|
+
/>
|
|
918
|
+
</div>
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function SelectInput({ rowId }: { rowId: string }) {
|
|
923
|
+
const { state, updateValue } = useFilterContext();
|
|
924
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
925
|
+
const { options: fetchedOptions } = useFilterOptions(row?.field?.fetchOptions);
|
|
926
|
+
const options: SelectOption[] = row?.field?.options || fetchedOptions || [];
|
|
927
|
+
|
|
928
|
+
return (
|
|
929
|
+
<Select
|
|
930
|
+
value={(row?.value as string) || ""}
|
|
931
|
+
onValueChange={(value) => updateValue(rowId, value)}
|
|
932
|
+
>
|
|
933
|
+
<SelectTrigger className="w-[200px] text-sm">
|
|
934
|
+
<SelectValue placeholder="Select..." />
|
|
935
|
+
</SelectTrigger>
|
|
936
|
+
<SelectContent>
|
|
937
|
+
{options.map((opt) => (
|
|
938
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
939
|
+
{opt.label}
|
|
940
|
+
</SelectItem>
|
|
941
|
+
))}
|
|
942
|
+
</SelectContent>
|
|
943
|
+
</Select>
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function MultiSelectInput({ rowId }: { rowId: string }) {
|
|
948
|
+
const { state, updateValue } = useFilterContext();
|
|
949
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
950
|
+
const selectedValues = Array.isArray(row?.value) ? (row.value as string[]) : [];
|
|
951
|
+
const options: SelectOption[] = row?.field?.options || [];
|
|
952
|
+
|
|
953
|
+
const toggleValue = (val: string) => {
|
|
954
|
+
const newValues = selectedValues.includes(val)
|
|
955
|
+
? selectedValues.filter((v) => v !== val)
|
|
956
|
+
: [...selectedValues, val];
|
|
957
|
+
updateValue(rowId, newValues);
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
return (
|
|
961
|
+
<div className="flex flex-wrap gap-1 max-w-[300px]">
|
|
962
|
+
{options.map((opt) => (
|
|
963
|
+
<Badge
|
|
964
|
+
key={opt.value}
|
|
965
|
+
variant={selectedValues.includes(opt.value) ? "default" : "outline"}
|
|
966
|
+
className="cursor-pointer text-xs"
|
|
967
|
+
onClick={() => toggleValue(opt.value)}
|
|
968
|
+
>
|
|
969
|
+
{opt.label}
|
|
970
|
+
</Badge>
|
|
971
|
+
))}
|
|
972
|
+
</div>
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function DateInput({ rowId }: { rowId: string }) {
|
|
977
|
+
const { state, updateValue } = useFilterContext();
|
|
978
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
979
|
+
const [open, setOpen] = useState(false);
|
|
980
|
+
const dateValue = row?.value ? new Date(row.value as string) : undefined;
|
|
981
|
+
|
|
982
|
+
return (
|
|
983
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
984
|
+
<PopoverTrigger asChild>
|
|
985
|
+
<Button variant="outline" className="w-[200px] justify-start text-sm font-normal">
|
|
986
|
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
987
|
+
{dateValue ? format(dateValue, "PPP") : "Pick a date"}
|
|
988
|
+
</Button>
|
|
989
|
+
</PopoverTrigger>
|
|
990
|
+
<PopoverContent className="w-auto p-0">
|
|
991
|
+
<Calendar
|
|
992
|
+
mode="single"
|
|
993
|
+
selected={dateValue}
|
|
994
|
+
onSelect={(date) => {
|
|
995
|
+
if (date) {
|
|
996
|
+
updateValue(rowId, format(date, "yyyy-MM-dd"));
|
|
997
|
+
setOpen(false);
|
|
998
|
+
}
|
|
999
|
+
}}
|
|
1000
|
+
/>
|
|
1001
|
+
</PopoverContent>
|
|
1002
|
+
</Popover>
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function DateRangeInput({ rowId }: { rowId: string }) {
|
|
1007
|
+
const { state, updateValue } = useFilterContext();
|
|
1008
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
1009
|
+
const values = Array.isArray(row?.value) ? row.value : ["", ""];
|
|
1010
|
+
|
|
1011
|
+
return (
|
|
1012
|
+
<div className="flex items-center gap-2">
|
|
1013
|
+
<Input
|
|
1014
|
+
type="date"
|
|
1015
|
+
className="w-[150px] text-sm"
|
|
1016
|
+
value={(values[0] as string) || ""}
|
|
1017
|
+
onChange={(e) => updateValue(rowId, [e.target.value, values[1] as string || ""])}
|
|
1018
|
+
/>
|
|
1019
|
+
<span className="text-xs text-muted-foreground">to</span>
|
|
1020
|
+
<Input
|
|
1021
|
+
type="date"
|
|
1022
|
+
className="w-[150px] text-sm"
|
|
1023
|
+
value={(values[1] as string) || ""}
|
|
1024
|
+
onChange={(e) => updateValue(rowId, [values[0] as string || "", e.target.value])}
|
|
1025
|
+
/>
|
|
1026
|
+
</div>
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function BooleanInput({ rowId }: { rowId: string }) {
|
|
1031
|
+
const { state, updateValue } = useFilterContext();
|
|
1032
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
1033
|
+
|
|
1034
|
+
return (
|
|
1035
|
+
<Select
|
|
1036
|
+
value={row?.value?.toString() || ""}
|
|
1037
|
+
onValueChange={(value) => updateValue(rowId, value === "true")}
|
|
1038
|
+
>
|
|
1039
|
+
<SelectTrigger className="w-[120px] text-sm">
|
|
1040
|
+
<SelectValue placeholder="Select..." />
|
|
1041
|
+
</SelectTrigger>
|
|
1042
|
+
<SelectContent>
|
|
1043
|
+
<SelectItem value="true">True</SelectItem>
|
|
1044
|
+
<SelectItem value="false">False</SelectItem>
|
|
1045
|
+
</SelectContent>
|
|
1046
|
+
</Select>
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
`;
|
|
1050
|
+
const FILTER_ROW = `"use client";
|
|
1051
|
+
|
|
1052
|
+
import { Trash2 } from "lucide-react";
|
|
1053
|
+
import { Button } from "${alias}components/ui/button";
|
|
1054
|
+
import { useFilterContext } from "../provider/filter-context";
|
|
1055
|
+
import { FieldSelect } from "./field-select";
|
|
1056
|
+
import { OperatorSelect } from "./operator-select";
|
|
1057
|
+
import { ValueInput } from "./value-input";
|
|
1058
|
+
|
|
1059
|
+
interface FilterRowProps {
|
|
1060
|
+
rowId: string;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export function FilterRowComponent({ rowId }: FilterRowProps) {
|
|
1064
|
+
const { state, removeRow } = useFilterContext();
|
|
1065
|
+
const row = state.rows.find((r) => r.id === rowId);
|
|
1066
|
+
if (!row) return null;
|
|
1067
|
+
|
|
1068
|
+
return (
|
|
1069
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
1070
|
+
<FieldSelect rowId={rowId} />
|
|
1071
|
+
{row.field && <OperatorSelect rowId={rowId} />}
|
|
1072
|
+
{row.field && row.operator && <ValueInput rowId={rowId} />}
|
|
1073
|
+
<Button
|
|
1074
|
+
variant="ghost"
|
|
1075
|
+
size="icon"
|
|
1076
|
+
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
|
1077
|
+
onClick={() => removeRow(rowId)}
|
|
1078
|
+
>
|
|
1079
|
+
<Trash2 className="h-4 w-4" />
|
|
1080
|
+
</Button>
|
|
1081
|
+
</div>
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
`;
|
|
1085
|
+
const FILTER_FOOTER = `"use client";
|
|
1086
|
+
|
|
1087
|
+
import { Plus, RotateCcw, Check } from "lucide-react";
|
|
1088
|
+
import { Button } from "${alias}components/ui/button";
|
|
1089
|
+
import { useFilterContext } from "../provider/filter-context";
|
|
1090
|
+
|
|
1091
|
+
export function FilterFooter() {
|
|
1092
|
+
const { config, state, addRow, reset, apply } = useFilterContext();
|
|
1093
|
+
const maxRows = config.maxRows || 10;
|
|
1094
|
+
const canAdd = state.rows.length < maxRows;
|
|
1095
|
+
|
|
1096
|
+
return (
|
|
1097
|
+
<div className="flex items-center gap-2 pt-2">
|
|
1098
|
+
<Button variant="outline" size="sm" onClick={addRow} disabled={!canAdd}>
|
|
1099
|
+
<Plus className="mr-1 h-4 w-4" />
|
|
1100
|
+
{config.locale?.addFilter || "+ Add filter"}
|
|
1101
|
+
</Button>
|
|
1102
|
+
<Button variant="ghost" size="sm" onClick={reset}>
|
|
1103
|
+
<RotateCcw className="mr-1 h-4 w-4" />
|
|
1104
|
+
{config.locale?.reset || "Reset"}
|
|
1105
|
+
</Button>
|
|
1106
|
+
<Button size="sm" onClick={apply}>
|
|
1107
|
+
<Check className="mr-1 h-4 w-4" />
|
|
1108
|
+
{config.locale?.apply || "Apply"}
|
|
1109
|
+
</Button>
|
|
1110
|
+
</div>
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
`;
|
|
1114
|
+
const FILTER_BADGE = `"use client";
|
|
1115
|
+
|
|
1116
|
+
import { useFilterContext } from "../provider/filter-context";
|
|
1117
|
+
import { Badge } from "${alias}components/ui/badge";
|
|
1118
|
+
|
|
1119
|
+
export function FilterBadge() {
|
|
1120
|
+
const { activeCount } = useFilterContext();
|
|
1121
|
+
if (activeCount === 0) return null;
|
|
1122
|
+
return <Badge variant="secondary">{activeCount} active</Badge>;
|
|
1123
|
+
}
|
|
1124
|
+
`;
|
|
1125
|
+
const FILTER_ROOT = `"use client";
|
|
1126
|
+
|
|
1127
|
+
import { useFilterContext } from "../provider/filter-context";
|
|
1128
|
+
import { FilterRowComponent } from "./filter-row";
|
|
1129
|
+
import { FilterFooter } from "./filter-footer";
|
|
1130
|
+
|
|
1131
|
+
export function FilterRoot() {
|
|
1132
|
+
const { state, config } = useFilterContext();
|
|
1133
|
+
|
|
1134
|
+
return (
|
|
1135
|
+
<div className="space-y-3 rounded-lg border p-4">
|
|
1136
|
+
{state.rows.length === 0 ? (
|
|
1137
|
+
<p className="text-sm text-muted-foreground">
|
|
1138
|
+
{config.locale?.noFilters || "No filters active"}
|
|
1139
|
+
</p>
|
|
1140
|
+
) : (
|
|
1141
|
+
<div className="space-y-2">
|
|
1142
|
+
{state.rows.map((row, index) => (
|
|
1143
|
+
<div key={row.id} className="flex items-center gap-2">
|
|
1144
|
+
{index > 0 && config.allowConjunctionToggle && (
|
|
1145
|
+
<span className="text-xs font-medium text-muted-foreground w-10 text-center">
|
|
1146
|
+
{state.conjunction.toUpperCase()}
|
|
1147
|
+
</span>
|
|
1148
|
+
)}
|
|
1149
|
+
{index > 0 && !config.allowConjunctionToggle && (
|
|
1150
|
+
<span className="text-xs font-medium text-muted-foreground w-10 text-center">
|
|
1151
|
+
{state.conjunction.toUpperCase()}
|
|
1152
|
+
</span>
|
|
1153
|
+
)}
|
|
1154
|
+
<FilterRowComponent rowId={row.id} />
|
|
1155
|
+
</div>
|
|
1156
|
+
))}
|
|
1157
|
+
</div>
|
|
1158
|
+
)}
|
|
1159
|
+
<FilterFooter />
|
|
1160
|
+
</div>
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
`;
|
|
1164
|
+
return {
|
|
1165
|
+
"ui/field-select.tsx": FIELD_SELECT,
|
|
1166
|
+
"ui/operator-select.tsx": OPERATOR_SELECT,
|
|
1167
|
+
"ui/value-input.tsx": VALUE_INPUT,
|
|
1168
|
+
"ui/filter-row.tsx": FILTER_ROW,
|
|
1169
|
+
"ui/filter-footer.tsx": FILTER_FOOTER,
|
|
1170
|
+
"ui/filter-badge.tsx": FILTER_BADGE,
|
|
1171
|
+
"ui/filter-root.tsx": FILTER_ROOT,
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
//# sourceMappingURL=index.js.map
|