filtercn 0.1.2 → 0.2.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.
@@ -1,110 +1,10 @@
1
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.
2
+ * Auto-generated templates for the FilterCN CLI
7
3
  */
8
4
 
9
5
  const ALIAS = "__ALIAS__";
10
6
 
11
- // ===================== types.ts =====================
12
- const TYPES = `// ===== FIELD DEFINITION =====
13
- export type FieldType = "text" | "number" | "select" | "multiselect"
14
- | "date" | "datetime" | "boolean" | "combobox";
15
-
16
- export interface FilterFieldDefinition {
17
- /** Unique key, also used as the query param name (e.g. "status", "category_id") */
18
- name: string;
19
- /** Label displayed in the UI */
20
- label: string;
21
- /** Input type to render */
22
- type: FieldType;
23
- /** Allowed operators (defaults based on type if not provided) */
24
- operators?: OperatorType[];
25
- /** Static options for select/multiselect */
26
- options?: SelectOption[];
27
- /** Async function to fetch options from a REST API (for combobox/dynamic multiselect) */
28
- fetchOptions?: (search: string) => Promise<SelectOption[]>;
29
- }
30
-
31
- // ===== OPERATORS =====
32
- export type OperatorType =
33
- | "is" // =
34
- | "is_not" // !=
35
- | "contains" // *value*
36
- | "not_contains" // NOT *value*
37
- | "gt" // >
38
- | "gte" // >=
39
- | "lt" // <
40
- | "lte" // <=
41
- | "between" // range [from, to]
42
- | "in" // in list
43
- | "not_in" // not in list
44
- | "is_empty" // NULL/empty check
45
- | "is_not_empty";
46
-
47
- // ===== SELECT OPTION =====
48
- export interface SelectOption {
49
- label: string;
50
- value: string;
51
- }
52
-
53
- // ===== FILTER ROW (runtime state) =====
54
- export interface FilterRow {
55
- id: string; // unique row id
56
- field: FilterFieldDefinition | null; // selected field
57
- operator: OperatorType | null; // selected operator
58
- value: FilterValue; // entered value
59
- }
60
-
61
- export type FilterValue =
62
- | string
63
- | string[]
64
- | number
65
- | [number, number] // range
66
- | [string, string] // date range
67
- | boolean
68
- | null;
69
-
70
- // ===== FILTER STATE =====
71
- export interface FilterState {
72
- rows: FilterRow[];
73
- conjunction: "and" | "or";
74
- }
75
-
76
- // ===== REST QUERY OUTPUT =====
77
- export type RestQueryParams = Record<string, string | string[]>;
78
-
79
- // ===== FILTER CONFIG (passed to Provider) =====
80
- export interface FilterConfig {
81
- /** List of fields available for filtering */
82
- fields: FilterFieldDefinition[];
83
- /** Allow toggling AND/OR. Default: false */
84
- allowConjunctionToggle?: boolean;
85
- /** Max filter rows. Default: 10 */
86
- maxRows?: number;
87
- /** Param style. Default: underscore */
88
- paramStyle?: "underscore" | "bracket" | "custom";
89
- /** Custom builder */
90
- customParamBuilder?: (field: string, operator: OperatorType, value: FilterValue) => Record<string, string>;
91
- /** Locale */
92
- locale?: FilterLocale;
93
- }
94
-
95
- export interface FilterLocale {
96
- addFilter: string;
97
- reset: string;
98
- apply: string;
99
- placeholder: string;
100
- and: string;
101
- or: string;
102
- noFilters: string;
103
- }
104
- `;
105
-
106
- // ===================== constants.ts =====================
107
- const CONSTANTS = `import type { FilterLocale, OperatorType, FieldType } from "./types";
7
+ const TEMPLATE_CONSTANTS_TS = `import type { FieldType, FilterLocale, OperatorType } from "./types";
108
8
 
109
9
  export const DEFAULT_LOCALE: FilterLocale = {
110
10
  addFilter: "+ Add filter",
@@ -128,12 +28,11 @@ export const DEFAULT_OPERATORS: Record<FieldType, OperatorType[]> = {
128
28
  };
129
29
  `;
130
30
 
131
- // ===================== helpers/operators.ts =====================
132
- const OPERATORS = `import type { OperatorType } from "../types";
31
+ const TEMPLATE_HELPERS_OPERATORS_TS = `import type { OperatorType } from "../types";
133
32
 
134
33
  export const getOperatorSuffix = (operator: OperatorType, paramStyle: "underscore" | "bracket" | "custom"): string => {
135
34
  if (paramStyle === "custom") return "";
136
-
35
+
137
36
  const mapping: Record<OperatorType, string> = {
138
37
  is: "", // usually no suffix for exact match
139
38
  is_not: "not",
@@ -149,7 +48,7 @@ export const getOperatorSuffix = (operator: OperatorType, paramStyle: "underscor
149
48
  is_empty: "isnull",
150
49
  is_not_empty: "isnull",
151
50
  };
152
-
51
+
153
52
  const suffix = mapping[operator];
154
53
  if (!suffix) return "";
155
54
 
@@ -159,48 +58,11 @@ export const getOperatorSuffix = (operator: OperatorType, paramStyle: "underscor
159
58
  };
160
59
  `;
161
60
 
162
- // ===================== helpers/validators.ts =====================
163
- const VALIDATORS = `import type { FilterRow } from "../types";
164
-
165
- export const isValidFilterRow = (row: FilterRow): boolean => {
166
- if (!row.field || !row.operator) return false;
167
-
168
- if (row.operator === "is_empty" || row.operator === "is_not_empty") {
169
- return true; // value doesn't matter for empty checks
170
- }
171
-
172
- if (row.operator === "between") {
173
- if (!Array.isArray(row.value) || row.value.length !== 2) return false;
174
- if (row.value[0] === null || row.value[0] === "" || row.value[1] === null || row.value[1] === "") return false;
175
- return true;
176
- }
177
-
178
- if (row.operator === "in" || row.operator === "not_in") {
179
- if (!Array.isArray(row.value) || row.value.length === 0) return false;
180
- return true;
181
- }
182
-
183
- if (row.value === null || row.value === "" || (Array.isArray(row.value) && row.value.length === 0)) {
184
- return false;
185
- }
186
-
187
- return true;
188
- };
189
-
190
- export const getValidFilterRows = (rows: FilterRow[]): FilterRow[] => {
191
- return rows.filter(isValidFilterRow);
192
- };
193
- `;
194
-
195
- // ===================== helpers/query-builder.ts =====================
196
- const QUERY_BUILDER = `import type { FilterConfig, FilterRow, RestQueryParams } from "../types";
61
+ const TEMPLATE_HELPERS_QUERY_BUILDER_TS = `import type { FilterConfig, FilterRow, RestQueryParams } from "../types";
197
62
  import { getOperatorSuffix } from "./operators";
198
63
  import { getValidFilterRows } from "./validators";
199
64
 
200
- export const buildRestQuery = (
201
- rows: FilterRow[],
202
- config: FilterConfig
203
- ): RestQueryParams => {
65
+ export const buildRestQuery = (rows: FilterRow[], config: FilterConfig): RestQueryParams => {
204
66
  const validRows = getValidFilterRows(rows);
205
67
  const params: RestQueryParams = {};
206
68
 
@@ -215,7 +77,8 @@ export const buildRestQuery = (
215
77
 
216
78
  const style = config.paramStyle || "underscore";
217
79
  const suffix = getOperatorSuffix(row.operator, style);
218
-
80
+ const prefix = config.paramPrefix || "";
81
+
219
82
  let paramKey = row.field.name;
220
83
  if (style === "underscore" && suffix) {
221
84
  paramKey = \`\${row.field.name}\${suffix}\`;
@@ -225,6 +88,10 @@ export const buildRestQuery = (
225
88
  paramKey = \`filter[\${row.field.name}]\`;
226
89
  }
227
90
 
91
+ if (prefix) {
92
+ paramKey = \`\${prefix}\${paramKey}\`;
93
+ }
94
+
228
95
  let paramValue: string | string[];
229
96
 
230
97
  if (row.operator === "is_empty") {
@@ -233,15 +100,15 @@ export const buildRestQuery = (
233
100
  paramValue = "false";
234
101
  } else if (row.operator === "between" && Array.isArray(row.value)) {
235
102
  if (style === "underscore") {
236
- paramValue = \`\${row.value[0]},\${row.value[1]}\`;
103
+ paramValue = \`\${row.value[0]},\${row.value[1]}\`;
237
104
  } else {
238
- paramValue = [row.value[0].toString(), row.value[1].toString()];
105
+ paramValue = [row.value[0].toString(), row.value[1].toString()];
239
106
  }
240
107
  } else if ((row.operator === "in" || row.operator === "not_in") && Array.isArray(row.value)) {
241
108
  if (style === "underscore") {
242
109
  paramValue = row.value.join(",");
243
110
  } else {
244
- paramValue = row.value.map(v => v.toString());
111
+ paramValue = row.value.map((v) => v.toString());
245
112
  }
246
113
  } else {
247
114
  paramValue = String(row.value);
@@ -254,23 +121,22 @@ export const buildRestQuery = (
254
121
  };
255
122
  `;
256
123
 
257
- // ===================== helpers/serializer.ts =====================
258
- const SERIALIZER = `import type { FilterConfig, FilterRow, FilterState, OperatorType } from "../types";
124
+ const TEMPLATE_HELPERS_SERIALIZER_TS = `import type { FilterConfig, FilterRow, FilterState, FilterValue, OperatorType } from "../types";
259
125
  import { buildRestQuery } from "./query-builder";
260
126
 
261
127
  export const serializeFiltersToUrl = (state: FilterState, config: FilterConfig): URLSearchParams => {
262
128
  const params = new URLSearchParams();
263
- const queryObj = buildRestQuery(state.rows, config);
129
+ const restQuery = buildRestQuery(state.rows, config);
264
130
 
265
- Object.entries(queryObj).forEach(([key, value]) => {
131
+ Object.entries(restQuery).forEach(([key, value]) => {
266
132
  if (Array.isArray(value)) {
267
- value.forEach(v => params.append(key, v));
133
+ params.append(key, value.join(","));
268
134
  } else {
269
- params.set(key, value);
135
+ params.append(key, value);
270
136
  }
271
137
  });
272
138
 
273
- if (state.conjunction === "or" && config.allowConjunctionToggle) {
139
+ if (state.conjunction === "or") {
274
140
  params.set("conjunction", "or");
275
141
  }
276
142
 
@@ -280,49 +146,59 @@ export const serializeFiltersToUrl = (state: FilterState, config: FilterConfig):
280
146
  export const deserializeUrlToFilters = (params: URLSearchParams, config: FilterConfig): FilterState => {
281
147
  const rows: FilterRow[] = [];
282
148
  const style = config.paramStyle || "underscore";
149
+ const prefix = config.paramPrefix || "";
283
150
 
284
151
  Array.from(params.entries()).forEach(([key, value]) => {
285
152
  if (key === "conjunction") return;
286
153
 
287
- let fieldName = key;
154
+ let processKey = key;
155
+ if (prefix && processKey.startsWith(prefix)) {
156
+ processKey = processKey.slice(prefix.length);
157
+ } else if (prefix && !processKey.startsWith(prefix)) {
158
+ // If a prefix is configured but this key doesn't have it, it's not a filter param
159
+ return;
160
+ }
161
+
162
+ let fieldName = processKey;
288
163
  let operatorStr = "";
289
164
 
290
- if (style === "underscore" && key.includes("__")) {
291
- const parts = key.split("__");
165
+ if (style === "underscore" && processKey.includes("__")) {
166
+ const parts = processKey.split("__");
292
167
  operatorStr = parts.pop() || "";
293
168
  fieldName = parts.join("__");
294
- } else if (style === "bracket" && key.startsWith("filter[")) {
295
- const match = key.match(/filter\\[(.*?)\\](?:\\[(.*?)\\])?/);
296
- if (match && match[1]) {
169
+ } else if (style === "bracket" && processKey.startsWith("filter[")) {
170
+ // Very basic bracket parse assumption
171
+ const match = processKey.match(/filter\\[(.*?)\\](?:\\[(.*?)\\])?/);
172
+ if (match?.[1]) {
297
173
  fieldName = match[1];
298
174
  operatorStr = match[2] || "";
299
175
  }
300
176
  }
301
177
 
302
- const fieldDef = config.fields.find(f => f.name === fieldName);
178
+ const fieldDef = config.fields.find((f) => f.name === fieldName);
303
179
  if (!fieldDef) return;
304
180
 
305
181
  const suffixToOp: Record<string, OperatorType> = {
306
182
  "": "is",
307
- "not": "is_not",
308
- "icontains": "contains",
309
- "not_icontains": "not_contains",
310
- "gt": "gt",
311
- "gte": "gte",
312
- "lt": "lt",
313
- "lte": "lte",
314
- "range": "between",
315
- "in": "in",
316
- "not_in": "not_in",
317
- "isnull": value === "true" ? "is_empty" : "is_not_empty"
183
+ not: "is_not",
184
+ icontains: "contains",
185
+ not_icontains: "not_contains",
186
+ gt: "gt",
187
+ gte: "gte",
188
+ lt: "lt",
189
+ lte: "lte",
190
+ range: "between",
191
+ in: "in",
192
+ not_in: "not_in",
193
+ isnull: value === "true" ? "is_empty" : "is_not_empty",
318
194
  };
319
195
 
320
196
  const operator = suffixToOp[operatorStr] || "is";
321
- let parsedValue: any = value;
197
+ let parsedValue: FilterValue = value;
322
198
 
323
- if (operator === "between" && typeof value === 'string' && value.includes(",")) {
199
+ if (operator === "between" && typeof value === "string" && value.includes(",")) {
324
200
  parsedValue = value.split(",");
325
- } else if ((operator === "in" || operator === "not_in") && typeof value === 'string' && value.includes(",")) {
201
+ } else if ((operator === "in" || operator === "not_in") && typeof value === "string" && value.includes(",")) {
326
202
  parsedValue = value.split(",");
327
203
  }
328
204
 
@@ -330,7 +206,7 @@ export const deserializeUrlToFilters = (params: URLSearchParams, config: FilterC
330
206
  id: crypto.randomUUID(),
331
207
  field: fieldDef,
332
208
  operator,
333
- value: parsedValue
209
+ value: parsedValue,
334
210
  });
335
211
  });
336
212
 
@@ -341,11 +217,109 @@ export const deserializeUrlToFilters = (params: URLSearchParams, config: FilterC
341
217
  };
342
218
  `;
343
219
 
344
- // ===================== hooks/use-filter-state.ts =====================
345
- const USE_FILTER_STATE = `"use client";
220
+ const TEMPLATE_HELPERS_VALIDATORS_TS = `import type { FilterRow } from "../types";
221
+
222
+ export const isValidFilterRow = (row: FilterRow): boolean => {
223
+ if (!row.field || !row.operator) return false;
224
+
225
+ if (row.operator === "is_empty" || row.operator === "is_not_empty") {
226
+ return true; // value doesn't matter for empty checks
227
+ }
228
+
229
+ if (row.operator === "between") {
230
+ if (!Array.isArray(row.value) || row.value.length !== 2) return false;
231
+ if (row.value[0] === null || row.value[0] === "" || row.value[1] === null || row.value[1] === "") return false;
232
+ return true;
233
+ }
234
+
235
+ if (row.operator === "in" || row.operator === "not_in") {
236
+ if (!Array.isArray(row.value) || row.value.length === 0) return false;
237
+ return true;
238
+ }
239
+
240
+ if (row.value === null || row.value === "" || (Array.isArray(row.value) && row.value.length === 0)) {
241
+ return false;
242
+ }
243
+
244
+ return true;
245
+ };
246
+
247
+ export const getValidFilterRows = (rows: FilterRow[]): FilterRow[] => {
248
+ return rows.filter(isValidFilterRow);
249
+ };
250
+ `;
251
+
252
+ const TEMPLATE_HOOKS_USE_FILTER_OPTIONS_TS = `"use client";
253
+
254
+ import { useCallback, useEffect, useRef, useState } from "react";
255
+ import type { SelectOption } from "../types";
256
+
257
+ export const useFilterOptions = (fetchFn?: (search: string) => Promise<SelectOption[]>) => {
258
+ const [options, setOptions] = useState<SelectOption[]>([]);
259
+ const [loading, setLoading] = useState(false);
260
+ const timeoutRef = useRef<NodeJS.Timeout>(null);
261
+
262
+ const search = useCallback(
263
+ (query: string) => {
264
+ if (!fetchFn) return;
265
+
266
+ if (timeoutRef.current) {
267
+ clearTimeout(timeoutRef.current);
268
+ }
269
+
270
+ setLoading(true);
271
+
272
+ timeoutRef.current = setTimeout(async () => {
273
+ try {
274
+ const result = await fetchFn(query);
275
+ setOptions(result);
276
+ } catch (err) {
277
+ console.error("Failed to fetch filter options", err);
278
+ } finally {
279
+ setLoading(false);
280
+ }
281
+ }, 300);
282
+ },
283
+ [fetchFn],
284
+ );
285
+
286
+ // Initial fetch
287
+ useEffect(() => {
288
+ if (fetchFn) {
289
+ search("");
290
+ }
291
+ }, [fetchFn, search]);
292
+
293
+ return { options, loading, search };
294
+ };
295
+ `;
296
+
297
+ const TEMPLATE_HOOKS_USE_FILTER_QUERY_TS = `"use client";
298
+
299
+ import { useMemo } from "react";
300
+ import { buildRestQuery } from "../helpers/query-builder";
301
+ import { useFilterContext } from "../provider/filter-context";
302
+
303
+ export function useFilterQuery() {
304
+ const { state, config, activeCount, isValid } = useFilterContext();
305
+
306
+ const queryParams = useMemo(() => {
307
+ return buildRestQuery(state.rows, config);
308
+ }, [state.rows, config]);
309
+
310
+ return {
311
+ queryParams,
312
+ activeCount,
313
+ isValid,
314
+ conjunction: state.conjunction,
315
+ };
316
+ }
317
+ `;
318
+
319
+ const TEMPLATE_HOOKS_USE_FILTER_STATE_TS = `"use client";
346
320
 
347
- import { useState, useCallback } from "react";
348
- import type { FilterState, FilterFieldDefinition, OperatorType, FilterValue } from "../types";
321
+ import { useCallback, useState } from "react";
322
+ import type { FilterFieldDefinition, FilterState, FilterValue, OperatorType } from "../types";
349
323
 
350
324
  export const useFilterState = (initialState?: FilterState) => {
351
325
  const [state, setState] = useState<FilterState>(initialState || { rows: [], conjunction: "and" });
@@ -353,10 +327,7 @@ export const useFilterState = (initialState?: FilterState) => {
353
327
  const addRow = useCallback(() => {
354
328
  setState((prev) => ({
355
329
  ...prev,
356
- rows: [
357
- ...prev.rows,
358
- { id: crypto.randomUUID(), field: null, operator: null, value: null },
359
- ],
330
+ rows: [...prev.rows, { id: crypto.randomUUID(), field: null, operator: null, value: null }],
360
331
  }));
361
332
  }, []);
362
333
 
@@ -370,18 +341,14 @@ export const useFilterState = (initialState?: FilterState) => {
370
341
  const updateField = useCallback((id: string, field: FilterFieldDefinition) => {
371
342
  setState((prev) => ({
372
343
  ...prev,
373
- rows: prev.rows.map((row) =>
374
- row.id === id ? { ...row, field, operator: null, value: null } : row
375
- ),
344
+ rows: prev.rows.map((row) => (row.id === id ? { ...row, field, operator: null, value: null } : row)),
376
345
  }));
377
346
  }, []);
378
347
 
379
348
  const updateOperator = useCallback((id: string, operator: OperatorType) => {
380
349
  setState((prev) => ({
381
350
  ...prev,
382
- rows: prev.rows.map((row) =>
383
- row.id === id ? { ...row, operator, value: null } : row
384
- ),
351
+ rows: prev.rows.map((row) => (row.id === id ? { ...row, operator, value: null } : row)),
385
352
  }));
386
353
  }, []);
387
354
 
@@ -400,6 +367,8 @@ export const useFilterState = (initialState?: FilterState) => {
400
367
  setState({ rows: [], conjunction: "and" });
401
368
  }, []);
402
369
 
370
+ // Sync internal state with external state if external state changes intentionally
371
+ // Often useful when URL changes
403
372
  const overrideState = useCallback((newState: FilterState) => {
404
373
  setState(newState);
405
374
  }, []);
@@ -413,33 +382,25 @@ export const useFilterState = (initialState?: FilterState) => {
413
382
  updateValue,
414
383
  setConjunction,
415
384
  reset,
416
- overrideState
385
+ overrideState,
417
386
  };
418
387
  };
419
388
  `;
420
389
 
421
- // ===================== hooks/use-filter-url-sync.ts =====================
422
- const USE_FILTER_URL_SYNC = `"use client";
390
+ const TEMPLATE_HOOKS_USE_FILTER_URL_SYNC_TS = `"use client";
423
391
 
424
- import { useEffect, useState, useCallback, useRef } from "react";
425
- import { useRouter, useSearchParams, usePathname } from "next/navigation";
426
- import type { FilterState, FilterConfig } from "../types";
427
- import { serializeFiltersToUrl, deserializeUrlToFilters } from "../helpers/serializer";
392
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
393
+ import { useCallback, useEffect, useRef, useState } from "react";
394
+ import { deserializeUrlToFilters, serializeFiltersToUrl } from "../helpers/serializer";
428
395
  import { getValidFilterRows } from "../helpers/validators";
396
+ import type { FilterConfig, FilterState } from "../types";
429
397
 
430
- interface UseFilterUrlSyncOptions {
431
- syncMode: "immediate" | "on-apply";
432
- }
433
-
434
- export const useFilterUrlSync = (
435
- config: FilterConfig,
436
- _options: UseFilterUrlSyncOptions
437
- ) => {
398
+ export const useFilterUrlSync = (config: FilterConfig) => {
438
399
  const router = useRouter();
439
400
  const searchParams = useSearchParams();
440
401
  const pathname = usePathname();
441
402
  const isInitializing = useRef(true);
442
-
403
+
443
404
  const [syncedState, setSyncedState] = useState<FilterState>(() => {
444
405
  const params = new URLSearchParams(searchParams.toString());
445
406
  return deserializeUrlToFilters(params, config);
@@ -454,13 +415,39 @@ export const useFilterUrlSync = (
454
415
  setSyncedState(deserializeUrlToFilters(params, config));
455
416
  }, [searchParams, config]);
456
417
 
457
- const applyChanges = useCallback((newState: FilterState) => {
458
- const validState = { ...newState, rows: getValidFilterRows(newState.rows) };
459
- const newParams = serializeFiltersToUrl(validState, config);
460
- const searchString = newParams.toString();
461
- const query = searchString ? \`?\${searchString}\` : "";
462
- router.replace(\`\${pathname}\${query}\`, { scroll: false });
463
- }, [config, router, pathname]);
418
+ const handleSync = useCallback(
419
+ (currentState: FilterState) => {
420
+ // preserve current non-filter params + search param
421
+ const currentParams = new URLSearchParams(searchParams.toString());
422
+ const newParams = serializeFiltersToUrl(currentState, config);
423
+
424
+ // Keep searchParamName if it exists
425
+ const searchParamName = config.searchParamName || "q";
426
+ const currentSearchParam = currentParams.get(searchParamName);
427
+
428
+ // We clear all existing filter params (by trusting what serialize gives us)
429
+ // Then we append any non-filter params back (except what's newly generated)
430
+ // Simplest approach: create a fresh URLSearchParams, add all from \`newParams\`,
431
+ // then add anything from \`currentParams\` that isn't managed by the filter.
432
+ // For simplicity, we just keep the search param if provided.
433
+ if (currentSearchParam) {
434
+ newParams.set(searchParamName, currentSearchParam);
435
+ }
436
+
437
+ const queryStr = newParams.toString();
438
+ const newPath = queryStr ? \`\${pathname}?\${queryStr}\` : pathname;
439
+
440
+ router.push(newPath, { scroll: false });
441
+ },
442
+ [config, pathname, router, searchParams],
443
+ );
444
+
445
+ const applyChanges = useCallback(
446
+ (newState: FilterState) => {
447
+ handleSync({ ...newState, rows: getValidFilterRows(newState.rows) });
448
+ },
449
+ [handleSync],
450
+ );
464
451
 
465
452
  return {
466
453
  initialState: syncedState,
@@ -469,50 +456,36 @@ export const useFilterUrlSync = (
469
456
  };
470
457
  `;
471
458
 
472
- // ===================== hooks/use-filter-options.ts =====================
473
- const USE_FILTER_OPTIONS = `"use client";
474
-
475
- import { useState, useEffect, useCallback } from "react";
476
- import type { SelectOption } from "../types";
477
-
478
- export const useFilterOptions = (fetchFn?: (search: string) => Promise<SelectOption[]>) => {
479
- const [options, setOptions] = useState<SelectOption[]>([]);
480
- const [loading, setLoading] = useState(false);
481
-
482
- const search = useCallback(async (query: string) => {
483
- if (!fetchFn) return;
484
- setLoading(true);
485
- try {
486
- const result = await fetchFn(query);
487
- setOptions(result);
488
- } catch (err) {
489
- console.error("Failed to fetch filter options", err);
490
- } finally {
491
- setLoading(false);
492
- }
493
- }, [fetchFn]);
494
-
495
- // Initial fetch
496
- useEffect(() => {
497
- if (fetchFn) {
498
- search("");
499
- }
500
- }, [fetchFn, search]);
501
-
502
- return { options, loading, search };
503
- };
459
+ const TEMPLATE_INDEX_TS = `export * from "./constants";
460
+ export { buildRestQuery } from "./helpers/query-builder";
461
+ export { deserializeUrlToFilters, serializeFiltersToUrl } from "./helpers/serializer";
462
+ export { getValidFilterRows, isValidFilterRow } from "./helpers/validators";
463
+ export * from "./hooks/use-filter-options";
464
+ export * from "./hooks/use-filter-query";
465
+ export * from "./hooks/use-filter-state";
466
+ export * from "./hooks/use-filter-url-sync";
467
+ export * from "./provider/filter-context";
468
+ export * from "./provider/filter-provider";
469
+ export * from "./types";
470
+ export * from "./ui/field-select";
471
+ export * from "./ui/filter-badge";
472
+ export * from "./ui/filter-bar";
473
+ export * from "./ui/filter-footer";
474
+ export * from "./ui/filter-root";
475
+ export * from "./ui/filter-row";
476
+ export * from "./ui/operator-select";
477
+ export * from "./ui/value-input";
504
478
  `;
505
479
 
506
- // ===================== provider/filter-context.ts =====================
507
- const FILTER_CONTEXT = `"use client";
480
+ const TEMPLATE_PROVIDER_FILTER_CONTEXT_TS = `"use client";
508
481
 
509
482
  import { createContext, useContext } from "react";
510
- import type { FilterConfig, FilterState, FilterFieldDefinition, OperatorType, FilterValue } from "../types";
483
+ import type { FilterConfig, FilterFieldDefinition, FilterState, FilterValue, OperatorType } from "../types";
511
484
 
512
485
  export interface FilterContextValue {
513
486
  config: FilterConfig;
514
487
  state: FilterState;
515
-
488
+
516
489
  // Mutations
517
490
  addRow: () => void;
518
491
  removeRow: (id: string) => void;
@@ -541,16 +514,16 @@ export const useFilterContext = () => {
541
514
  };
542
515
  `;
543
516
 
544
- // ===================== provider/filter-provider.tsx =====================
545
- const FILTER_PROVIDER = `"use client";
517
+ const TEMPLATE_PROVIDER_FILTER_PROVIDER_TSX = `"use client";
546
518
 
547
- import React, { useMemo, useCallback } from "react";
548
- import type { FilterConfig } from "../types";
549
- import { FilterContext } from "./filter-context";
550
- import { useFilterState } from "../hooks/use-filter-state";
551
- import { useFilterUrlSync } from "../hooks/use-filter-url-sync";
519
+ import type React from "react";
520
+ import { useCallback, useMemo } from "react";
552
521
  import { DEFAULT_LOCALE } from "../constants";
553
522
  import { getValidFilterRows, isValidFilterRow } from "../helpers/validators";
523
+ import { useFilterState } from "../hooks/use-filter-state";
524
+ import { useFilterUrlSync } from "../hooks/use-filter-url-sync";
525
+ import type { FilterConfig } from "../types";
526
+ import { FilterContext } from "./filter-context";
554
527
 
555
528
  export interface FilterProviderProps {
556
529
  config: FilterConfig;
@@ -565,9 +538,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({ config, children
565
538
  };
566
539
  }, [config]);
567
540
 
568
- const { initialState, applyChanges } = useFilterUrlSync(currentConfig, {
569
- syncMode: "on-apply",
570
- });
541
+ const { initialState, applyChanges } = useFilterUrlSync(currentConfig);
571
542
 
572
543
  const {
573
544
  state,
@@ -581,7 +552,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({ config, children
581
552
  } = useFilterState(initialState);
582
553
 
583
554
  const isValid = useMemo(() => state.rows.every(isValidFilterRow), [state.rows]);
584
-
555
+
585
556
  const activeCount = useMemo(() => getValidFilterRows(state.rows).length, [state.rows]);
586
557
 
587
558
  const apply = useCallback(() => {
@@ -621,113 +592,150 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({ config, children
621
592
  isValid,
622
593
  activeCount,
623
594
  apply,
624
- ]
595
+ ],
625
596
  );
626
597
 
627
- return (
628
- <FilterContext.Provider value={value}>
629
- {children}
630
- </FilterContext.Provider>
631
- );
598
+ return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
632
599
  };
633
600
  `;
634
601
 
635
- // ===================== index.ts =====================
636
- const INDEX = `export * from "./types";
637
- export * from "./constants";
638
- export * from "./hooks/use-filter-state";
639
- export * from "./hooks/use-filter-url-sync";
640
- export * from "./hooks/use-filter-options";
641
- export * from "./provider/filter-context";
642
- export * from "./provider/filter-provider";
643
-
644
- export * from "./ui/field-select";
645
- export * from "./ui/operator-select";
646
- export * from "./ui/value-input";
647
- export * from "./ui/filter-row";
648
- export * from "./ui/filter-footer";
649
- export * from "./ui/filter-badge";
650
- export * from "./ui/filter-root";
602
+ const TEMPLATE_TYPES_TS = `// ===== FIELD DEFINITION =====
603
+ export type FieldType = "text" | "number" | "select" | "multiselect" | "date" | "datetime" | "boolean" | "combobox";
651
604
 
652
- export { buildRestQuery } from "./helpers/query-builder";
653
- export { serializeFiltersToUrl, deserializeUrlToFilters } from "./helpers/serializer";
654
- export { isValidFilterRow, getValidFilterRows } from "./helpers/validators";
655
- `;
605
+ export interface FilterFieldDefinition {
606
+ /** Unique key, cũng tên query param (e.g. "status", "category_id") */
607
+ name: string;
608
+ /** Label hiển thị trên UI */
609
+ label: string;
610
+ /** Loại input sẽ render */
611
+ type: FieldType;
612
+ /**
613
+ * Danh sách operators được phép (nếu không truyền, lấy default)
614
+ */
615
+ operators?: OperatorType[];
616
+ /**
617
+ * Options tĩnh cho select/multiselect
618
+ */
619
+ options?: SelectOption[];
620
+ /**
621
+ * Hàm fetch options từ REST API (cho combobox/multiselect động)
622
+ */
623
+ fetchOptions?: (search: string) => Promise<SelectOption[]>;
624
+ }
656
625
 
657
- /**
658
- * Returns all template files mapped to their relative paths
659
- * within the component directory. The alias placeholder will
660
- * be replaced by the init command.
661
- */
662
- export function getTemplateFiles(alias: string): Record<string, string> {
663
- // UI components use the alias for importing shadcn UI primitives
664
- const replaceAlias = (content: string) => content.replaceAll(ALIAS, alias);
626
+ // ===== OPERATORS =====
627
+ export type OperatorType =
628
+ | "is" // =
629
+ | "is_not" // !=
630
+ | "contains" // *value*
631
+ | "not_contains" // NOT *value*
632
+ | "gt" // >
633
+ | "gte" // >=
634
+ | "lt" // <
635
+ | "lte" // <=
636
+ | "between" // range [from, to]
637
+ | "in" // in list
638
+ | "not_in" // not in list
639
+ | "is_empty" // NULL/empty check
640
+ | "is_not_empty";
665
641
 
666
- return {
667
- "types.ts": replaceAlias(TYPES),
668
- "constants.ts": replaceAlias(CONSTANTS),
669
- "index.ts": replaceAlias(INDEX),
670
- "helpers/operators.ts": replaceAlias(OPERATORS),
671
- "helpers/validators.ts": replaceAlias(VALIDATORS),
672
- "helpers/query-builder.ts": replaceAlias(QUERY_BUILDER),
673
- "helpers/serializer.ts": replaceAlias(SERIALIZER),
674
- "hooks/use-filter-state.ts": replaceAlias(USE_FILTER_STATE),
675
- "hooks/use-filter-url-sync.ts": replaceAlias(USE_FILTER_URL_SYNC),
676
- "hooks/use-filter-options.ts": replaceAlias(USE_FILTER_OPTIONS),
677
- "provider/filter-context.ts": replaceAlias(FILTER_CONTEXT),
678
- "provider/filter-provider.tsx": replaceAlias(FILTER_PROVIDER),
679
- ...getUITemplates(alias),
680
- };
642
+ // ===== SELECT OPTION =====
643
+ export interface SelectOption {
644
+ label: string;
645
+ value: string;
681
646
  }
682
647
 
683
- /**
684
- * UI component templates — these use the alias for shadcn imports
685
- */
686
- function getUITemplates(alias: string): Record<string, string> {
687
- const FIELD_SELECT = `"use client";
648
+ // ===== FILTER ROW (runtime state) =====
649
+ export interface FilterRow {
650
+ id: string; // unique row id
651
+ field: FilterFieldDefinition | null; // field đã chọn
652
+ operator: OperatorType | null; // operator đã chọn
653
+ value: FilterValue; // giá trị đã nhập
654
+ }
655
+
656
+ export type FilterValue =
657
+ | string
658
+ | string[]
659
+ | number
660
+ | [number, number] // range
661
+ | [string, string] // date range
662
+ | boolean
663
+ | null;
664
+
665
+ // ===== FILTER STATE =====
666
+ export interface FilterState {
667
+ rows: FilterRow[];
668
+ conjunction: "and" | "or";
669
+ }
670
+
671
+ // ===== REST QUERY OUTPUT =====
672
+ export type RestQueryParams = Record<string, string | string[]>;
673
+
674
+ // ===== FILTER CONFIG (truyền vào Provider) =====
675
+ export interface FilterConfig {
676
+ /** Danh sách fields có thể filter */
677
+ fields: FilterFieldDefinition[];
678
+ /** Cho phép toggle AND/OR. Default: false */
679
+ allowConjunctionToggle?: boolean;
680
+ /** Max filter rows. Default: 10 */
681
+ maxRows?: number;
682
+ /** Param style. Default: underscore */
683
+ paramStyle?: "underscore" | "bracket" | "custom";
684
+ /** Prefix appended to all query params. e.g "filter_" */
685
+ paramPrefix?: string;
686
+ /** Global search query param name. Default: q */
687
+ searchParamName?: string;
688
+ /** Custom builder */
689
+ customParamBuilder?: (field: string, operator: OperatorType, value: FilterValue) => Record<string, string>;
690
+ /** Locale */
691
+ locale?: FilterLocale;
692
+ }
693
+
694
+ export interface FilterLocale {
695
+ addFilter: string;
696
+ reset: string;
697
+ apply: string;
698
+ placeholder: string;
699
+ and: string;
700
+ or: string;
701
+ noFilters: string;
702
+ }
703
+ `;
704
+
705
+ const TEMPLATE_UI_FIELD_SELECT_TSX = `"use client";
688
706
 
689
- import { useState } from "react";
690
707
  import { Check, ChevronsUpDown } from "lucide-react";
691
- import { Button } from "${alias}components/ui/button";
692
- import {
693
- Command,
694
- CommandEmpty,
695
- CommandGroup,
696
- CommandInput,
697
- CommandItem,
698
- CommandList,
699
- } from "${alias}components/ui/command";
700
- import {
701
- Popover,
702
- PopoverContent,
703
- PopoverTrigger,
704
- } from "${alias}components/ui/popover";
708
+ import { useState } from "react";
709
+ import { Button } from "__ALIAS__components/ui/button";
710
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "__ALIAS__components/ui/command";
711
+ import { Popover, PopoverContent, PopoverTrigger } from "__ALIAS__components/ui/popover";
712
+ import { cn } from "__ALIAS__lib/utils";
705
713
  import { useFilterContext } from "../provider/filter-context";
706
714
  import type { FilterFieldDefinition } from "../types";
707
715
 
708
- interface FieldSelectProps {
716
+ export interface FieldSelectProps {
709
717
  rowId: string;
718
+ selectedField: FilterFieldDefinition | null;
710
719
  }
711
720
 
712
- export function FieldSelect({ rowId }: FieldSelectProps) {
721
+ export function FieldSelect({ rowId, selectedField }: FieldSelectProps) {
722
+ const { config, updateField } = useFilterContext();
713
723
  const [open, setOpen] = useState(false);
714
- const { config, state, updateField } = useFilterContext();
715
- const row = state.rows.find((r) => r.id === rowId);
716
-
717
- const handleSelect = (field: FilterFieldDefinition) => {
718
- updateField(rowId, field);
719
- setOpen(false);
720
- };
721
724
 
722
725
  return (
723
726
  <Popover open={open} onOpenChange={setOpen}>
724
727
  <PopoverTrigger asChild>
725
- <Button variant="outline" role="combobox" className="w-[180px] justify-between text-sm">
726
- {row?.field?.label || config.locale?.placeholder || "Select..."}
728
+ <Button
729
+ variant="outline"
730
+ role="combobox"
731
+ aria-expanded={open}
732
+ className="w-[200px] justify-between font-normal"
733
+ >
734
+ {selectedField ? selectedField.label : "Select field..."}
727
735
  <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
728
736
  </Button>
729
737
  </PopoverTrigger>
730
- <PopoverContent className="w-[200px] p-0">
738
+ <PopoverContent className="w-[200px] p-0" align="start">
731
739
  <Command>
732
740
  <CommandInput placeholder="Search field..." />
733
741
  <CommandList>
@@ -737,10 +745,13 @@ export function FieldSelect({ rowId }: FieldSelectProps) {
737
745
  <CommandItem
738
746
  key={field.name}
739
747
  value={field.name}
740
- onSelect={() => handleSelect(field)}
748
+ onSelect={() => {
749
+ updateField(rowId, field);
750
+ setOpen(false);
751
+ }}
741
752
  >
742
753
  <Check
743
- className={\`mr-2 h-4 w-4 \${row?.field?.name === field.name ? "opacity-100" : "opacity-0"}\`}
754
+ className={cn("mr-2 h-4 w-4", selectedField?.name === field.name ? "opacity-100" : "opacity-0")}
744
755
  />
745
756
  {field.label}
746
757
  </CommandItem>
@@ -754,322 +765,238 @@ export function FieldSelect({ rowId }: FieldSelectProps) {
754
765
  }
755
766
  `;
756
767
 
757
- const OPERATOR_SELECT = `"use client";
768
+ const TEMPLATE_UI_FILTER_BADGE_TSX = `"use client";
758
769
 
759
- import {
760
- Select,
761
- SelectContent,
762
- SelectItem,
763
- SelectTrigger,
764
- SelectValue,
765
- } from "${alias}components/ui/select";
770
+ import { Badge } from "__ALIAS__components/ui/badge";
766
771
  import { useFilterContext } from "../provider/filter-context";
767
- import { DEFAULT_OPERATORS } from "../constants";
768
- import type { OperatorType } from "../types";
769
-
770
- interface OperatorSelectProps {
771
- rowId: string;
772
- }
773
772
 
774
- const OPERATOR_LABELS: Record<OperatorType, string> = {
775
- is: "is",
776
- is_not: "is not",
777
- contains: "contains",
778
- not_contains: "not contains",
779
- gt: "greater than",
780
- gte: "greater or equal",
781
- lt: "less than",
782
- lte: "less or equal",
783
- between: "between",
784
- in: "in",
785
- not_in: "not in",
786
- is_empty: "is empty",
787
- is_not_empty: "is not empty",
788
- };
789
-
790
- export function OperatorSelect({ rowId }: OperatorSelectProps) {
791
- const { state, updateOperator } = useFilterContext();
792
- const row = state.rows.find((r) => r.id === rowId);
793
-
794
- if (!row?.field) return null;
773
+ export function FilterBadge() {
774
+ const { activeCount } = useFilterContext();
795
775
 
796
- const operators = row.field.operators || DEFAULT_OPERATORS[row.field.type] || [];
776
+ if (activeCount === 0) return null;
797
777
 
798
778
  return (
799
- <Select
800
- value={row.operator || ""}
801
- onValueChange={(value) => updateOperator(rowId, value as OperatorType)}
802
- >
803
- <SelectTrigger className="w-[160px] text-sm">
804
- <SelectValue placeholder="Operator..." />
805
- </SelectTrigger>
806
- <SelectContent>
807
- {operators.map((op) => (
808
- <SelectItem key={op} value={op}>
809
- {OPERATOR_LABELS[op] || op}
810
- </SelectItem>
811
- ))}
812
- </SelectContent>
813
- </Select>
779
+ <Badge variant="secondary" className="ml-2 rounded-full px-2">
780
+ {activeCount}
781
+ </Badge>
814
782
  );
815
783
  }
816
784
  `;
817
785
 
818
- const VALUE_INPUT = `"use client";
819
-
820
- import { useState, useEffect } from "react";
821
- import { Input } from "${alias}components/ui/input";
822
- import {
823
- Select,
824
- SelectContent,
825
- SelectItem,
826
- SelectTrigger,
827
- SelectValue,
828
- } from "${alias}components/ui/select";
829
- import {
830
- Popover,
831
- PopoverContent,
832
- PopoverTrigger,
833
- } from "${alias}components/ui/popover";
834
- import { Button } from "${alias}components/ui/button";
835
- import { Calendar } from "${alias}components/ui/calendar";
836
- import { CalendarIcon } from "lucide-react";
837
- import { format } from "date-fns";
786
+ const TEMPLATE_UI_FILTER_BAR_TSX = `"use client";
787
+
788
+ import { Search } from "lucide-react";
789
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
790
+ import * as React from "react";
791
+ import { Input } from "__ALIAS__components/ui/input";
792
+ import { cn } from "__ALIAS__lib/utils";
838
793
  import { useFilterContext } from "../provider/filter-context";
839
- import { useFilterOptions } from "../hooks/use-filter-options";
840
- import { Badge } from "${alias}components/ui/badge";
841
- import type { SelectOption } from "../types";
794
+ import { FilterRoot } from "./filter-root";
842
795
 
843
- interface ValueInputProps {
844
- rowId: string;
796
+ export interface FilterBarProps extends React.HTMLAttributes<HTMLDivElement> {
797
+ searchPlaceholder?: string;
798
+ searchValue?: string;
799
+ onSearchChange?: (value: string) => void;
800
+ hideSearch?: boolean;
845
801
  }
846
802
 
847
- export function ValueInput({ rowId }: ValueInputProps) {
848
- const { state, updateValue } = useFilterContext();
849
- const row = state.rows.find((r) => r.id === rowId);
803
+ export const FilterBar = React.forwardRef<HTMLDivElement, FilterBarProps>(
804
+ (
805
+ {
806
+ className,
807
+ searchPlaceholder = "Search...",
808
+ searchValue: externalSearchValue,
809
+ onSearchChange,
810
+ hideSearch = false,
811
+ ...props
812
+ },
813
+ ref,
814
+ ) => {
815
+ const router = useRouter();
816
+ const pathname = usePathname();
817
+ const searchParams = useSearchParams();
818
+ const { config } = useFilterContext();
819
+
820
+ const searchParamName = config.searchParamName || "q";
821
+ const internalSearchValue = searchParams.get(searchParamName) || "";
822
+
823
+ const [localValue, setLocalValue] = React.useState(
824
+ externalSearchValue !== undefined ? externalSearchValue : internalSearchValue,
825
+ );
826
+
827
+ const [, startTransition] = React.useTransition();
828
+ const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
829
+
830
+ // Sync external changes (e.g. from clear filters) back into local state
831
+ React.useEffect(() => {
832
+ if (externalSearchValue !== undefined) {
833
+ setLocalValue(externalSearchValue);
834
+ } else {
835
+ setLocalValue(internalSearchValue);
836
+ }
837
+ }, [externalSearchValue, internalSearchValue]);
850
838
 
851
- if (!row?.field || !row.operator) return null;
839
+ const handleSearchChange = (value: string) => {
840
+ if (onSearchChange) {
841
+ onSearchChange(value);
842
+ return;
843
+ }
852
844
 
853
- // No value needed for empty checks
854
- if (row.operator === "is_empty" || row.operator === "is_not_empty") {
855
- return null;
856
- }
845
+ setLocalValue(value);
857
846
 
858
- const fieldType = row.field.type;
859
-
860
- switch (fieldType) {
861
- case "text":
862
- return <TextInput rowId={rowId} />;
863
- case "number":
864
- return row.operator === "between"
865
- ? <RangeInput rowId={rowId} />
866
- : <NumberInput rowId={rowId} />;
867
- case "select":
868
- case "combobox":
869
- return <SelectInput rowId={rowId} />;
870
- case "multiselect":
871
- return <MultiSelectInput rowId={rowId} />;
872
- case "date":
873
- case "datetime":
874
- return row.operator === "between"
875
- ? <DateRangeInput rowId={rowId} />
876
- : <DateInput rowId={rowId} />;
877
- case "boolean":
878
- return <BooleanInput rowId={rowId} />;
879
- default:
880
- return <TextInput rowId={rowId} />;
881
- }
882
- }
847
+ if (timeoutRef.current) {
848
+ clearTimeout(timeoutRef.current);
849
+ }
883
850
 
884
- function TextInput({ rowId }: { rowId: string }) {
885
- const { state, updateValue } = useFilterContext();
886
- const row = state.rows.find((r) => r.id === rowId);
851
+ timeoutRef.current = setTimeout(() => {
852
+ const params = new URLSearchParams(searchParams.toString());
853
+ if (value) {
854
+ params.set(searchParamName, value);
855
+ } else {
856
+ params.delete(searchParamName);
857
+ }
858
+ const queryStr = params.toString();
859
+ const newPath = queryStr ? \`\${pathname}?\${queryStr}\` : pathname;
860
+
861
+ startTransition(() => {
862
+ router.replace(newPath, { scroll: false });
863
+ });
864
+ }, 300);
865
+ };
887
866
 
888
- return (
889
- <Input
890
- type="text"
891
- placeholder="Enter value..."
892
- className="w-[200px] text-sm"
893
- value={(row?.value as string) || ""}
894
- onChange={(e) => updateValue(rowId, e.target.value)}
895
- />
896
- );
897
- }
867
+ return (
868
+ <div ref={ref} className={cn("flex items-center justify-between gap-4", className)} {...props}>
869
+ <div className="flex-1 max-w-sm">
870
+ {!hideSearch && (
871
+ <div className="relative">
872
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
873
+ <Input
874
+ type="search"
875
+ placeholder={searchPlaceholder}
876
+ className="pl-8"
877
+ value={localValue}
878
+ onChange={(e) => handleSearchChange(e.target.value)}
879
+ />
880
+ </div>
881
+ )}
882
+ </div>
883
+ <div className="flex items-center gap-2">
884
+ <FilterRoot />
885
+ </div>
886
+ </div>
887
+ );
888
+ },
889
+ );
890
+ FilterBar.displayName = "FilterBar";
891
+ `;
898
892
 
899
- function NumberInput({ rowId }: { rowId: string }) {
900
- const { state, updateValue } = useFilterContext();
901
- const row = state.rows.find((r) => r.id === rowId);
893
+ const TEMPLATE_UI_FILTER_FOOTER_TSX = `"use client";
902
894
 
903
- return (
904
- <Input
905
- type="number"
906
- placeholder="Enter number..."
907
- className="w-[200px] text-sm"
908
- value={(row?.value as string) || ""}
909
- onChange={(e) => updateValue(rowId, e.target.value)}
910
- />
911
- );
912
- }
913
-
914
- function RangeInput({ rowId }: { rowId: string }) {
915
- const { state, updateValue } = useFilterContext();
916
- const row = state.rows.find((r) => r.id === rowId);
917
- const values = Array.isArray(row?.value) ? row.value : ["", ""];
895
+ import { Button } from "__ALIAS__components/ui/button";
896
+ import { PopoverClose } from "__ALIAS__components/ui/popover";
897
+ import { isValidFilterRow } from "../helpers/validators";
898
+ import { useFilterContext } from "../provider/filter-context";
918
899
 
919
- return (
920
- <div className="flex items-center gap-2">
921
- <Input
922
- type="number"
923
- placeholder="From"
924
- className="w-[100px] text-sm"
925
- value={values[0]?.toString() || ""}
926
- onChange={(e) => updateValue(rowId, [e.target.value, values[1]?.toString() || ""])}
927
- />
928
- <span className="text-xs text-muted-foreground">to</span>
929
- <Input
930
- type="number"
931
- placeholder="To"
932
- className="w-[100px] text-sm"
933
- value={values[1]?.toString() || ""}
934
- onChange={(e) => updateValue(rowId, [values[0]?.toString() || "", e.target.value])}
935
- />
936
- </div>
937
- );
938
- }
900
+ export function FilterFooter() {
901
+ const { config, addRow, reset, apply, state, setConjunction } = useFilterContext();
902
+ const maxRows = config.maxRows || 10;
903
+ const canAddMore = state.rows.length < maxRows;
939
904
 
940
- function SelectInput({ rowId }: { rowId: string }) {
941
- const { state, updateValue } = useFilterContext();
942
- const row = state.rows.find((r) => r.id === rowId);
943
- const { options: fetchedOptions } = useFilterOptions(row?.field?.fetchOptions);
944
- const options: SelectOption[] = row?.field?.options || fetchedOptions || [];
905
+ // Determine if there is at least one valid filter row
906
+ const hasValidFilters = state.rows.some(isValidFilterRow);
945
907
 
946
908
  return (
947
- <Select
948
- value={(row?.value as string) || ""}
949
- onValueChange={(value) => updateValue(rowId, value)}
950
- >
951
- <SelectTrigger className="w-[200px] text-sm">
952
- <SelectValue placeholder="Select..." />
953
- </SelectTrigger>
954
- <SelectContent>
955
- {options.map((opt) => (
956
- <SelectItem key={opt.value} value={opt.value}>
957
- {opt.label}
958
- </SelectItem>
959
- ))}
960
- </SelectContent>
961
- </Select>
909
+ <div className="flex items-center justify-between mt-4 pt-4 border-t">
910
+ <div className="flex items-center space-x-2">
911
+ <Button variant="ghost" onClick={addRow} disabled={!canAddMore} className="text-sm font-medium">
912
+ {config.locale?.addFilter || "+ Add filter"}
913
+ </Button>
914
+ {config.allowConjunctionToggle && state.rows.length > 1 && (
915
+ <div className="flex items-center bg-zinc-100 dark:bg-zinc-800 p-0.5 rounded-md">
916
+ <Button
917
+ variant={state.conjunction === "and" ? "default" : "ghost"}
918
+ size="sm"
919
+ onClick={() => setConjunction("and")}
920
+ className="h-7 text-xs px-3"
921
+ >
922
+ {config.locale?.and || "AND"}
923
+ </Button>
924
+ <Button
925
+ variant={state.conjunction === "or" ? "default" : "ghost"}
926
+ size="sm"
927
+ onClick={() => setConjunction("or")}
928
+ className="h-7 text-xs px-3"
929
+ >
930
+ {config.locale?.or || "OR"}
931
+ </Button>
932
+ </div>
933
+ )}
934
+ </div>
935
+ <div className="flex space-x-2">
936
+ <PopoverClose asChild>
937
+ <Button variant="outline" onClick={reset}>
938
+ {config.locale?.reset || "Reset"}
939
+ </Button>
940
+ </PopoverClose>
941
+ {hasValidFilters ? (
942
+ <PopoverClose asChild>
943
+ <Button onClick={apply}>{config.locale?.apply || "Apply"}</Button>
944
+ </PopoverClose>
945
+ ) : (
946
+ <Button disabled>{config.locale?.apply || "Apply"}</Button>
947
+ )}
948
+ </div>
949
+ </div>
962
950
  );
963
951
  }
952
+ `;
964
953
 
965
- function MultiSelectInput({ rowId }: { rowId: string }) {
966
- const { state, updateValue } = useFilterContext();
967
- const row = state.rows.find((r) => r.id === rowId);
968
- const selectedValues = Array.isArray(row?.value) ? (row.value as string[]) : [];
969
- const options: SelectOption[] = row?.field?.options || [];
970
-
971
- const toggleValue = (val: string) => {
972
- const newValues = selectedValues.includes(val)
973
- ? selectedValues.filter((v) => v !== val)
974
- : [...selectedValues, val];
975
- updateValue(rowId, newValues);
976
- };
954
+ const TEMPLATE_UI_FILTER_ROOT_TSX = `"use client";
977
955
 
978
- return (
979
- <div className="flex flex-wrap gap-1 max-w-[300px]">
980
- {options.map((opt) => (
981
- <Badge
982
- key={opt.value}
983
- variant={selectedValues.includes(opt.value) ? "default" : "outline"}
984
- className="cursor-pointer text-xs"
985
- onClick={() => toggleValue(opt.value)}
986
- >
987
- {opt.label}
988
- </Badge>
989
- ))}
990
- </div>
991
- );
992
- }
956
+ import { Filter as FilterIcon } from "lucide-react";
957
+ import { Button } from "__ALIAS__components/ui/button";
958
+ import { Popover, PopoverContent, PopoverTrigger } from "__ALIAS__components/ui/popover";
959
+ import { ScrollArea } from "__ALIAS__components/ui/scroll-area";
960
+ import { useFilterContext } from "../provider/filter-context";
961
+ import { FilterBadge } from "./filter-badge";
962
+ import { FilterFooter } from "./filter-footer";
963
+ import { FilterRowComponent } from "./filter-row";
993
964
 
994
- function DateInput({ rowId }: { rowId: string }) {
995
- const { state, updateValue } = useFilterContext();
996
- const row = state.rows.find((r) => r.id === rowId);
997
- const [open, setOpen] = useState(false);
998
- const dateValue = row?.value ? new Date(row.value as string) : undefined;
965
+ export function FilterRoot() {
966
+ const { state, config } = useFilterContext();
999
967
 
1000
968
  return (
1001
- <Popover open={open} onOpenChange={setOpen}>
969
+ <Popover>
1002
970
  <PopoverTrigger asChild>
1003
- <Button variant="outline" className="w-[200px] justify-start text-sm font-normal">
1004
- <CalendarIcon className="mr-2 h-4 w-4" />
1005
- {dateValue ? format(dateValue, "PPP") : "Pick a date"}
971
+ <Button variant="outline" className="flex items-center gap-2">
972
+ <FilterIcon className="h-4 w-4" />
973
+ <span>Filters</span>
974
+ <FilterBadge />
1006
975
  </Button>
1007
976
  </PopoverTrigger>
1008
- <PopoverContent className="w-auto p-0">
1009
- <Calendar
1010
- mode="single"
1011
- selected={dateValue}
1012
- onSelect={(date) => {
1013
- if (date) {
1014
- updateValue(rowId, format(date, "yyyy-MM-dd"));
1015
- setOpen(false);
1016
- }
1017
- }}
1018
- />
977
+ <PopoverContent className="w-[850px] p-4" align="start">
978
+ <ScrollArea className="max-h-[400px] pr-4">
979
+ <div className="flex flex-col gap-2">
980
+ {state.rows.length === 0 ? (
981
+ <div className="text-center py-4 text-muted-foreground">
982
+ {config.locale?.noFilters || "No filters active"}
983
+ </div>
984
+ ) : (
985
+ state.rows.map((row) => <FilterRowComponent key={row.id} rowId={row.id} />)
986
+ )}
987
+ </div>
988
+ </ScrollArea>
989
+ <FilterFooter />
1019
990
  </PopoverContent>
1020
991
  </Popover>
1021
992
  );
1022
993
  }
1023
-
1024
- function DateRangeInput({ rowId }: { rowId: string }) {
1025
- const { state, updateValue } = useFilterContext();
1026
- const row = state.rows.find((r) => r.id === rowId);
1027
- const values = Array.isArray(row?.value) ? row.value : ["", ""];
1028
-
1029
- return (
1030
- <div className="flex items-center gap-2">
1031
- <Input
1032
- type="date"
1033
- className="w-[150px] text-sm"
1034
- value={(values[0] as string) || ""}
1035
- onChange={(e) => updateValue(rowId, [e.target.value, values[1] as string || ""])}
1036
- />
1037
- <span className="text-xs text-muted-foreground">to</span>
1038
- <Input
1039
- type="date"
1040
- className="w-[150px] text-sm"
1041
- value={(values[1] as string) || ""}
1042
- onChange={(e) => updateValue(rowId, [values[0] as string || "", e.target.value])}
1043
- />
1044
- </div>
1045
- );
1046
- }
1047
-
1048
- function BooleanInput({ rowId }: { rowId: string }) {
1049
- const { state, updateValue } = useFilterContext();
1050
- const row = state.rows.find((r) => r.id === rowId);
1051
-
1052
- return (
1053
- <Select
1054
- value={row?.value?.toString() || ""}
1055
- onValueChange={(value) => updateValue(rowId, value === "true")}
1056
- >
1057
- <SelectTrigger className="w-[120px] text-sm">
1058
- <SelectValue placeholder="Select..." />
1059
- </SelectTrigger>
1060
- <SelectContent>
1061
- <SelectItem value="true">True</SelectItem>
1062
- <SelectItem value="false">False</SelectItem>
1063
- </SelectContent>
1064
- </Select>
1065
- );
1066
- }
1067
994
  `;
1068
995
 
1069
- const FILTER_ROW = `"use client";
996
+ const TEMPLATE_UI_FILTER_ROW_TSX = `"use client";
1070
997
 
1071
998
  import { Trash2 } from "lucide-react";
1072
- import { Button } from "${alias}components/ui/button";
999
+ import { Button } from "__ALIAS__components/ui/button";
1073
1000
  import { useFilterContext } from "../provider/filter-context";
1074
1001
  import { FieldSelect } from "./field-select";
1075
1002
  import { OperatorSelect } from "./operator-select";
@@ -1082,131 +1009,368 @@ interface FilterRowProps {
1082
1009
  export function FilterRowComponent({ rowId }: FilterRowProps) {
1083
1010
  const { state, removeRow } = useFilterContext();
1084
1011
  const row = state.rows.find((r) => r.id === rowId);
1012
+
1085
1013
  if (!row) return null;
1086
1014
 
1087
1015
  return (
1088
- <div className="flex items-center gap-2 flex-wrap">
1089
- <FieldSelect rowId={rowId} />
1090
- {row.field && <OperatorSelect rowId={rowId} />}
1091
- {row.field && row.operator && <ValueInput rowId={rowId} />}
1016
+ <div className="flex items-center space-x-2 w-full py-2">
1017
+ <FieldSelect rowId={row.id} selectedField={row.field} />
1018
+
1019
+ {row.field && <OperatorSelect rowId={row.id} selectedField={row.field.name} selectedOperator={row.operator} />}
1020
+
1021
+ {row.field && row.operator && (
1022
+ <div className="flex-1 flex max-w-[280px]">
1023
+ <ValueInput rowId={row.id} field={row.field} operator={row.operator} value={row.value} />
1024
+ </div>
1025
+ )}
1026
+
1092
1027
  <Button
1093
1028
  variant="ghost"
1094
1029
  size="icon"
1095
- className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
1096
- onClick={() => removeRow(rowId)}
1030
+ onClick={() => removeRow(row.id)}
1031
+ className="ml-auto flex-shrink-0"
1032
+ aria-label="Remove filter"
1097
1033
  >
1098
- <Trash2 className="h-4 w-4" />
1034
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
1099
1035
  </Button>
1100
1036
  </div>
1101
1037
  );
1102
1038
  }
1103
1039
  `;
1104
1040
 
1105
- const FILTER_FOOTER = `"use client";
1041
+ const TEMPLATE_UI_OPERATOR_SELECT_TSX = `"use client";
1106
1042
 
1107
- import { Plus, RotateCcw, Check } from "lucide-react";
1108
- import { Button } from "${alias}components/ui/button";
1043
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "__ALIAS__components/ui/select";
1044
+ import { DEFAULT_OPERATORS } from "../constants";
1109
1045
  import { useFilterContext } from "../provider/filter-context";
1110
- import { PopoverClose } from "${alias}components/ui/popover";
1111
- import { isValidFilterRow } from "../helpers/validators";
1046
+ import type { OperatorType } from "../types";
1112
1047
 
1113
- export function FilterFooter() {
1114
- const { config, state, addRow, reset, apply } = useFilterContext();
1115
- const maxRows = config.maxRows || 10;
1116
- const canAdd = state.rows.length < maxRows;
1048
+ export interface OperatorSelectProps {
1049
+ rowId: string;
1050
+ selectedField: string | null;
1051
+ selectedOperator: OperatorType | null;
1052
+ }
1117
1053
 
1118
- // Determine if there is at least one valid filter row
1119
- const hasValidFilters = state.rows.some(isValidFilterRow);
1054
+ const OPERATOR_LABELS: Record<OperatorType, string> = {
1055
+ is: "is",
1056
+ is_not: "is not",
1057
+ contains: "contains",
1058
+ not_contains: "does not contain",
1059
+ gt: "greater than",
1060
+ gte: "greater or equal",
1061
+ lt: "less than",
1062
+ lte: "less or equal",
1063
+ between: "between",
1064
+ in: "in list",
1065
+ not_in: "not in list",
1066
+ is_empty: "is empty",
1067
+ is_not_empty: "is not empty",
1068
+ };
1069
+
1070
+ export function OperatorSelect({ rowId, selectedField, selectedOperator }: OperatorSelectProps) {
1071
+ const { config, updateOperator } = useFilterContext();
1072
+
1073
+ const fieldDef = config.fields.find((f) => f.name === selectedField);
1074
+
1075
+ if (!fieldDef) {
1076
+ return (
1077
+ <Select disabled>
1078
+ <SelectTrigger className="w-[180px]">
1079
+ <SelectValue placeholder="Operator" />
1080
+ </SelectTrigger>
1081
+ </Select>
1082
+ );
1083
+ }
1084
+
1085
+ const allowedOperators = fieldDef.operators || DEFAULT_OPERATORS[fieldDef.type] || ["is"];
1120
1086
 
1121
1087
  return (
1122
- <div className="flex items-center gap-2 pt-2">
1123
- <Button variant="outline" size="sm" onClick={addRow} disabled={!canAdd}>
1124
- <Plus className="mr-1 h-4 w-4" />
1125
- {config.locale?.addFilter || "+ Add filter"}
1126
- </Button>
1127
- <PopoverClose asChild>
1128
- <Button variant="ghost" size="sm" onClick={reset}>
1129
- <RotateCcw className="mr-1 h-4 w-4" />
1130
- {config.locale?.reset || "Reset"}
1131
- </Button>
1132
- </PopoverClose>
1133
- {hasValidFilters ? (
1134
- <PopoverClose asChild>
1135
- <Button size="sm" onClick={apply}>
1136
- <Check className="mr-1 h-4 w-4" />
1137
- {config.locale?.apply || "Apply"}
1138
- </Button>
1139
- </PopoverClose>
1140
- ) : (
1141
- <Button size="sm" disabled>
1142
- <Check className="mr-1 h-4 w-4" />
1143
- {config.locale?.apply || "Apply"}
1144
- </Button>
1145
- )}
1146
- </div>
1088
+ <Select value={selectedOperator || ""} onValueChange={(val) => updateOperator(rowId, val as OperatorType)}>
1089
+ <SelectTrigger className="w-[180px]">
1090
+ <SelectValue placeholder="Select operator..." />
1091
+ </SelectTrigger>
1092
+ <SelectContent>
1093
+ {allowedOperators.map((op) => (
1094
+ <SelectItem key={op} value={op}>
1095
+ {OPERATOR_LABELS[op] || op}
1096
+ </SelectItem>
1097
+ ))}
1098
+ </SelectContent>
1099
+ </Select>
1147
1100
  );
1148
1101
  }
1149
1102
  `;
1150
1103
 
1151
- const FILTER_BADGE = `"use client";
1104
+ const TEMPLATE_UI_VALUE_INPUT_TSX = `"use client";
1152
1105
 
1106
+ import { format } from "date-fns";
1107
+ import { CalendarIcon, Check, ChevronsUpDown, Loader2 } from "lucide-react";
1108
+ import React from "react";
1109
+ import { Button } from "__ALIAS__components/ui/button";
1110
+ import { Calendar } from "__ALIAS__components/ui/calendar";
1111
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "__ALIAS__components/ui/command";
1112
+ import { Input } from "__ALIAS__components/ui/input";
1113
+ import { Popover, PopoverContent, PopoverTrigger } from "__ALIAS__components/ui/popover";
1114
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "__ALIAS__components/ui/select";
1115
+ import { cn } from "__ALIAS__lib/utils";
1116
+ import { useFilterOptions } from "../hooks/use-filter-options";
1153
1117
  import { useFilterContext } from "../provider/filter-context";
1154
- import { Badge } from "${alias}components/ui/badge";
1118
+ import type { FilterFieldDefinition, FilterValue, OperatorType } from "../types";
1155
1119
 
1156
- export function FilterBadge() {
1157
- const { activeCount } = useFilterContext();
1158
- if (activeCount === 0) return null;
1159
- return <Badge variant="secondary">{activeCount} active</Badge>;
1120
+ interface ValueInputProps {
1121
+ rowId: string;
1122
+ field: FilterFieldDefinition;
1123
+ operator: OperatorType;
1124
+ value: FilterValue;
1160
1125
  }
1161
- `;
1162
1126
 
1163
- const FILTER_ROOT = `"use client";
1127
+ export function ValueInput({ rowId, field, operator, value }: ValueInputProps) {
1128
+ const { updateValue } = useFilterContext();
1164
1129
 
1165
- import { useFilterContext } from "../provider/filter-context";
1166
- import { FilterRowComponent } from "./filter-row";
1167
- import { FilterFooter } from "./filter-footer";
1130
+ if (operator === "is_empty" || operator === "is_not_empty") {
1131
+ return null; // value doesn't matter
1132
+ }
1168
1133
 
1169
- export function FilterRoot() {
1170
- const { state, config } = useFilterContext();
1134
+ const handleChange = (val: FilterValue) => {
1135
+ updateValue(rowId, val);
1136
+ };
1171
1137
 
1172
- return (
1173
- <div className="space-y-3 rounded-lg border p-4">
1174
- {state.rows.length === 0 ? (
1175
- <p className="text-sm text-muted-foreground">
1176
- {config.locale?.noFilters || "No filters active"}
1177
- </p>
1178
- ) : (
1179
- <div className="space-y-2">
1180
- {state.rows.map((row, index) => (
1181
- <div key={row.id} className="flex items-center gap-2">
1182
- {index > 0 && config.allowConjunctionToggle && (
1183
- <span className="text-xs font-medium text-muted-foreground w-10 text-center">
1184
- {state.conjunction.toUpperCase()}
1185
- </span>
1186
- )}
1187
- {index > 0 && !config.allowConjunctionToggle && (
1188
- <span className="text-xs font-medium text-muted-foreground w-10 text-center">
1189
- {state.conjunction.toUpperCase()}
1190
- </span>
1191
- )}
1192
- <FilterRowComponent rowId={row.id} />
1193
- </div>
1194
- ))}
1138
+ const isDateField = field.type === "date" || field.type === "datetime";
1139
+
1140
+ // Range
1141
+ if (operator === "between") {
1142
+ const valArr = Array.isArray(value) ? value : ["", ""];
1143
+
1144
+ if (isDateField) {
1145
+ return (
1146
+ <div className="flex items-center flex-1 space-x-2">
1147
+ <DatePickerInput
1148
+ value={valArr[0] as string}
1149
+ onChange={(dateStr) => handleChange([dateStr, String(valArr[1])] as FilterValue)}
1150
+ placeholder="Start date"
1151
+ />
1152
+ <span className="text-muted-foreground">-</span>
1153
+ <DatePickerInput
1154
+ value={valArr[1] as string}
1155
+ onChange={(dateStr) => handleChange([String(valArr[0]), dateStr] as FilterValue)}
1156
+ placeholder="End date"
1157
+ />
1195
1158
  </div>
1196
- )}
1197
- <FilterFooter />
1198
- </div>
1159
+ );
1160
+ }
1161
+
1162
+ return (
1163
+ <div className="flex items-center flex-1 space-x-2">
1164
+ <Input
1165
+ type={field.type === "number" ? "number" : "text"}
1166
+ placeholder="Min"
1167
+ value={(valArr[0] as string | number) || ""}
1168
+ onChange={(e) => handleChange([e.target.value, String(valArr[1])] as FilterValue)}
1169
+ className="flex-1 min-w-[80px]"
1170
+ />
1171
+ <span className="text-muted-foreground">-</span>
1172
+ <Input
1173
+ type={field.type === "number" ? "number" : "text"}
1174
+ placeholder="Max"
1175
+ value={(valArr[1] as string | number) || ""}
1176
+ onChange={(e) => handleChange([String(valArr[0]), e.target.value] as FilterValue)}
1177
+ className="flex-1 min-w-[80px]"
1178
+ />
1179
+ </div>
1180
+ );
1181
+ }
1182
+
1183
+ // Boolean
1184
+ if (field.type === "boolean") {
1185
+ return (
1186
+ <Select
1187
+ value={value === true ? "true" : value === false ? "false" : ""}
1188
+ onValueChange={(val) => handleChange(val === "true")}
1189
+ >
1190
+ <SelectTrigger className="flex-1 min-w-[120px]">
1191
+ <SelectValue placeholder="Select..." />
1192
+ </SelectTrigger>
1193
+ <SelectContent>
1194
+ <SelectItem value="true">Yes</SelectItem>
1195
+ <SelectItem value="false">No</SelectItem>
1196
+ </SelectContent>
1197
+ </Select>
1198
+ );
1199
+ }
1200
+
1201
+ // Select (Static options)
1202
+ if (field.type === "select" && field.options) {
1203
+ return (
1204
+ <Select value={(value as string) || ""} onValueChange={handleChange}>
1205
+ <SelectTrigger className="flex-1 min-w-[120px]">
1206
+ <SelectValue placeholder="Select option..." />
1207
+ </SelectTrigger>
1208
+ <SelectContent>
1209
+ {field.options.map((opt) => (
1210
+ <SelectItem key={opt.value} value={opt.value}>
1211
+ {opt.label}
1212
+ </SelectItem>
1213
+ ))}
1214
+ </SelectContent>
1215
+ </Select>
1216
+ );
1217
+ }
1218
+
1219
+ // Combobox (Dynamic options)
1220
+ if (field.type === "combobox" && field.fetchOptions) {
1221
+ return <ComboboxValueInput field={field} value={value} onChange={handleChange} />;
1222
+ }
1223
+
1224
+ // Date Picker
1225
+ if (isDateField) {
1226
+ return (
1227
+ <DatePickerInput
1228
+ value={value as string}
1229
+ onChange={handleChange}
1230
+ placeholder="Pick a date"
1231
+ className="flex-1 min-w-[120px]"
1232
+ />
1233
+ );
1234
+ }
1235
+
1236
+ // Default Input (Text, Number)
1237
+ return (
1238
+ <Input
1239
+ type={field.type === "number" ? "number" : "text"}
1240
+ placeholder="Value..."
1241
+ value={(value as string | number) || ""}
1242
+ onChange={(e) => handleChange(e.target.value)}
1243
+ className="flex-1 min-w-[120px]"
1244
+ />
1245
+ );
1246
+ }
1247
+
1248
+ function DatePickerInput({
1249
+ value,
1250
+ onChange,
1251
+ placeholder,
1252
+ className,
1253
+ }: {
1254
+ value?: string;
1255
+ onChange: (val: string) => void;
1256
+ placeholder?: string;
1257
+ className?: string;
1258
+ }) {
1259
+ const date = value ? new Date(value) : undefined;
1260
+
1261
+ return (
1262
+ <Popover>
1263
+ <PopoverTrigger asChild>
1264
+ <Button
1265
+ variant={"outline"}
1266
+ className={cn("justify-start text-left font-normal flex-1", !date && "text-muted-foreground", className)}
1267
+ >
1268
+ <CalendarIcon className="mr-2 h-4 w-4" />
1269
+ {date ? format(date, "PPP") : <span>{placeholder}</span>}
1270
+ </Button>
1271
+ </PopoverTrigger>
1272
+ <PopoverContent className="w-auto p-0" align="start">
1273
+ <Calendar
1274
+ mode="single"
1275
+ selected={date}
1276
+ onSelect={(d) => onChange(d ? format(d, "yyyy-MM-dd") : "")}
1277
+ initialFocus
1278
+ />
1279
+ </PopoverContent>
1280
+ </Popover>
1281
+ );
1282
+ }
1283
+
1284
+ function ComboboxValueInput({
1285
+ field,
1286
+ value,
1287
+ onChange,
1288
+ }: {
1289
+ field: FilterFieldDefinition;
1290
+ value: FilterValue;
1291
+ onChange: (val: FilterValue) => void;
1292
+ }) {
1293
+ const [open, setOpen] = React.useState(false);
1294
+ const { options, loading, search } = useFilterOptions(field.fetchOptions);
1295
+
1296
+ const selectedLabel = React.useMemo(() => {
1297
+ if (!value) return "Select...";
1298
+ const opt = options.find((o) => o.value === value);
1299
+ return opt ? opt.label : value;
1300
+ }, [value, options]);
1301
+
1302
+ return (
1303
+ <Popover open={open} onOpenChange={setOpen}>
1304
+ <PopoverTrigger asChild>
1305
+ <Button
1306
+ variant="outline"
1307
+ role="combobox"
1308
+ aria-expanded={open}
1309
+ className="flex-1 min-w-[120px] justify-between font-normal"
1310
+ >
1311
+ {selectedLabel}
1312
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
1313
+ </Button>
1314
+ </PopoverTrigger>
1315
+ <PopoverContent className="w-[200px] p-0" align="start">
1316
+ <Command shouldFilter={false}>
1317
+ <CommandInput placeholder="Search..." onValueChange={search} />
1318
+ <CommandList>
1319
+ {loading ? (
1320
+ <div className="p-4 text-center text-sm text-muted-foreground flex items-center justify-center">
1321
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1322
+ Loading...
1323
+ </div>
1324
+ ) : options.length === 0 ? (
1325
+ <CommandEmpty>No results found.</CommandEmpty>
1326
+ ) : (
1327
+ <CommandGroup>
1328
+ {options.map((opt) => (
1329
+ <CommandItem
1330
+ key={opt.value}
1331
+ value={opt.value}
1332
+ onSelect={() => {
1333
+ onChange(opt.value);
1334
+ setOpen(false);
1335
+ }}
1336
+ >
1337
+ <Check className={cn("mr-2 h-4 w-4", value === opt.value ? "opacity-100" : "opacity-0")} />
1338
+ {opt.label}
1339
+ </CommandItem>
1340
+ ))}
1341
+ </CommandGroup>
1342
+ )}
1343
+ </CommandList>
1344
+ </Command>
1345
+ </PopoverContent>
1346
+ </Popover>
1199
1347
  );
1200
1348
  }
1201
1349
  `;
1202
1350
 
1351
+ export function getTemplateFiles(alias: string): Record<string, string> {
1352
+ const replaceAlias = (content: string) => content.replaceAll(ALIAS, alias);
1203
1353
  return {
1204
- "ui/field-select.tsx": FIELD_SELECT,
1205
- "ui/operator-select.tsx": OPERATOR_SELECT,
1206
- "ui/value-input.tsx": VALUE_INPUT,
1207
- "ui/filter-row.tsx": FILTER_ROW,
1208
- "ui/filter-footer.tsx": FILTER_FOOTER,
1209
- "ui/filter-badge.tsx": FILTER_BADGE,
1210
- "ui/filter-root.tsx": FILTER_ROOT,
1354
+ "constants.ts": replaceAlias(TEMPLATE_CONSTANTS_TS),
1355
+ "helpers/operators.ts": replaceAlias(TEMPLATE_HELPERS_OPERATORS_TS),
1356
+ "helpers/query-builder.ts": replaceAlias(TEMPLATE_HELPERS_QUERY_BUILDER_TS),
1357
+ "helpers/serializer.ts": replaceAlias(TEMPLATE_HELPERS_SERIALIZER_TS),
1358
+ "helpers/validators.ts": replaceAlias(TEMPLATE_HELPERS_VALIDATORS_TS),
1359
+ "hooks/use-filter-options.ts": replaceAlias(TEMPLATE_HOOKS_USE_FILTER_OPTIONS_TS),
1360
+ "hooks/use-filter-query.ts": replaceAlias(TEMPLATE_HOOKS_USE_FILTER_QUERY_TS),
1361
+ "hooks/use-filter-state.ts": replaceAlias(TEMPLATE_HOOKS_USE_FILTER_STATE_TS),
1362
+ "hooks/use-filter-url-sync.ts": replaceAlias(TEMPLATE_HOOKS_USE_FILTER_URL_SYNC_TS),
1363
+ "index.ts": replaceAlias(TEMPLATE_INDEX_TS),
1364
+ "provider/filter-context.ts": replaceAlias(TEMPLATE_PROVIDER_FILTER_CONTEXT_TS),
1365
+ "provider/filter-provider.tsx": replaceAlias(TEMPLATE_PROVIDER_FILTER_PROVIDER_TSX),
1366
+ "types.ts": replaceAlias(TEMPLATE_TYPES_TS),
1367
+ "ui/field-select.tsx": replaceAlias(TEMPLATE_UI_FIELD_SELECT_TSX),
1368
+ "ui/filter-badge.tsx": replaceAlias(TEMPLATE_UI_FILTER_BADGE_TSX),
1369
+ "ui/filter-bar.tsx": replaceAlias(TEMPLATE_UI_FILTER_BAR_TSX),
1370
+ "ui/filter-footer.tsx": replaceAlias(TEMPLATE_UI_FILTER_FOOTER_TSX),
1371
+ "ui/filter-root.tsx": replaceAlias(TEMPLATE_UI_FILTER_ROOT_TSX),
1372
+ "ui/filter-row.tsx": replaceAlias(TEMPLATE_UI_FILTER_ROW_TSX),
1373
+ "ui/operator-select.tsx": replaceAlias(TEMPLATE_UI_OPERATOR_SELECT_TSX),
1374
+ "ui/value-input.tsx": replaceAlias(TEMPLATE_UI_VALUE_INPUT_TSX),
1211
1375
  };
1212
1376
  }