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