filtercn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1196 @@
1
+ /**
2
+ * All template files for the conditional-filter component.
3
+ * Each key is the relative path within the component directory,
4
+ * and each value is the file content.
5
+ *
6
+ * The `ALIAS` placeholder is replaced with the user's actual import alias.
7
+ */
8
+
9
+ const ALIAS = "__ALIAS__";
10
+
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";
108
+
109
+ export const DEFAULT_LOCALE: FilterLocale = {
110
+ addFilter: "+ Add filter",
111
+ reset: "Reset",
112
+ apply: "Apply",
113
+ placeholder: "Select...",
114
+ and: "AND",
115
+ or: "OR",
116
+ noFilters: "No filters active",
117
+ };
118
+
119
+ export const DEFAULT_OPERATORS: Record<FieldType, OperatorType[]> = {
120
+ text: ["is", "is_not", "contains", "not_contains", "is_empty", "is_not_empty"],
121
+ number: ["is", "is_not", "gt", "gte", "lt", "lte", "between", "is_empty", "is_not_empty"],
122
+ select: ["is", "is_not", "is_empty", "is_not_empty"],
123
+ multiselect: ["in", "not_in", "is_empty", "is_not_empty"],
124
+ date: ["is", "is_not", "gt", "gte", "lt", "lte", "between", "is_empty", "is_not_empty"],
125
+ datetime: ["is", "is_not", "gt", "gte", "lt", "lte", "between", "is_empty", "is_not_empty"],
126
+ boolean: ["is"],
127
+ combobox: ["is", "is_not", "is_empty", "is_not_empty"],
128
+ };
129
+ `;
130
+
131
+ // ===================== helpers/operators.ts =====================
132
+ const OPERATORS = `import type { OperatorType } from "../types";
133
+
134
+ export const getOperatorSuffix = (operator: OperatorType, paramStyle: "underscore" | "bracket" | "custom"): string => {
135
+ if (paramStyle === "custom") return "";
136
+
137
+ const mapping: Record<OperatorType, string> = {
138
+ is: "", // usually no suffix for exact match
139
+ is_not: "not",
140
+ contains: "icontains",
141
+ not_contains: "not_icontains",
142
+ gt: "gt",
143
+ gte: "gte",
144
+ lt: "lt",
145
+ lte: "lte",
146
+ between: "range",
147
+ in: "in",
148
+ not_in: "not_in",
149
+ is_empty: "isnull",
150
+ is_not_empty: "isnull",
151
+ };
152
+
153
+ const suffix = mapping[operator];
154
+ if (!suffix) return "";
155
+
156
+ if (paramStyle === "underscore") return \`__\${suffix}\`;
157
+ if (paramStyle === "bracket") return \`[\${suffix}]\`;
158
+ return "";
159
+ };
160
+ `;
161
+
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";
197
+ import { getOperatorSuffix } from "./operators";
198
+ import { getValidFilterRows } from "./validators";
199
+
200
+ export const buildRestQuery = (
201
+ rows: FilterRow[],
202
+ config: FilterConfig
203
+ ): RestQueryParams => {
204
+ const validRows = getValidFilterRows(rows);
205
+ const params: RestQueryParams = {};
206
+
207
+ validRows.forEach((row) => {
208
+ if (!row.field || !row.operator) return;
209
+
210
+ if (config.customParamBuilder) {
211
+ const customParams = config.customParamBuilder(row.field.name, row.operator, row.value);
212
+ Object.assign(params, customParams);
213
+ return;
214
+ }
215
+
216
+ const style = config.paramStyle || "underscore";
217
+ const suffix = getOperatorSuffix(row.operator, style);
218
+
219
+ let paramKey = row.field.name;
220
+ if (style === "underscore" && suffix) {
221
+ paramKey = \`\${row.field.name}\${suffix}\`;
222
+ } else if (style === "bracket" && suffix) {
223
+ paramKey = \`filter[\${row.field.name}]\${suffix}\`;
224
+ } else if (style === "bracket" && !suffix) {
225
+ paramKey = \`filter[\${row.field.name}]\`;
226
+ }
227
+
228
+ let paramValue: string | string[];
229
+
230
+ if (row.operator === "is_empty") {
231
+ paramValue = "true";
232
+ } else if (row.operator === "is_not_empty") {
233
+ paramValue = "false";
234
+ } else if (row.operator === "between" && Array.isArray(row.value)) {
235
+ if (style === "underscore") {
236
+ paramValue = \`\${row.value[0]},\${row.value[1]}\`;
237
+ } else {
238
+ paramValue = [row.value[0].toString(), row.value[1].toString()];
239
+ }
240
+ } else if ((row.operator === "in" || row.operator === "not_in") && Array.isArray(row.value)) {
241
+ if (style === "underscore") {
242
+ paramValue = row.value.join(",");
243
+ } else {
244
+ paramValue = row.value.map(v => v.toString());
245
+ }
246
+ } else {
247
+ paramValue = String(row.value);
248
+ }
249
+
250
+ params[paramKey] = paramValue;
251
+ });
252
+
253
+ return params;
254
+ };
255
+ `;
256
+
257
+ // ===================== helpers/serializer.ts =====================
258
+ const SERIALIZER = `import type { FilterConfig, FilterRow, FilterState, OperatorType } from "../types";
259
+ import { buildRestQuery } from "./query-builder";
260
+
261
+ export const serializeFiltersToUrl = (state: FilterState, config: FilterConfig): URLSearchParams => {
262
+ const params = new URLSearchParams();
263
+ const queryObj = buildRestQuery(state.rows, config);
264
+
265
+ Object.entries(queryObj).forEach(([key, value]) => {
266
+ if (Array.isArray(value)) {
267
+ value.forEach(v => params.append(key, v));
268
+ } else {
269
+ params.set(key, value);
270
+ }
271
+ });
272
+
273
+ if (state.conjunction === "or" && config.allowConjunctionToggle) {
274
+ params.set("conjunction", "or");
275
+ }
276
+
277
+ return params;
278
+ };
279
+
280
+ export const deserializeUrlToFilters = (params: URLSearchParams, config: FilterConfig): FilterState => {
281
+ const rows: FilterRow[] = [];
282
+ const style = config.paramStyle || "underscore";
283
+
284
+ Array.from(params.entries()).forEach(([key, value]) => {
285
+ if (key === "conjunction") return;
286
+
287
+ let fieldName = key;
288
+ let operatorStr = "";
289
+
290
+ if (style === "underscore" && key.includes("__")) {
291
+ const parts = key.split("__");
292
+ operatorStr = parts.pop() || "";
293
+ fieldName = parts.join("__");
294
+ } else if (style === "bracket" && key.startsWith("filter[")) {
295
+ const match = key.match(/filter\\[(.*?)\\](?:\\[(.*?)\\])?/);
296
+ if (match && match[1]) {
297
+ fieldName = match[1];
298
+ operatorStr = match[2] || "";
299
+ }
300
+ }
301
+
302
+ const fieldDef = config.fields.find(f => f.name === fieldName);
303
+ if (!fieldDef) return;
304
+
305
+ const suffixToOp: Record<string, OperatorType> = {
306
+ "": "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"
318
+ };
319
+
320
+ const operator = suffixToOp[operatorStr] || "is";
321
+ let parsedValue: any = value;
322
+
323
+ if (operator === "between" && typeof value === 'string' && value.includes(",")) {
324
+ parsedValue = value.split(",");
325
+ } else if ((operator === "in" || operator === "not_in") && typeof value === 'string' && value.includes(",")) {
326
+ parsedValue = value.split(",");
327
+ }
328
+
329
+ rows.push({
330
+ id: crypto.randomUUID(),
331
+ field: fieldDef,
332
+ operator,
333
+ value: parsedValue
334
+ });
335
+ });
336
+
337
+ return {
338
+ rows,
339
+ conjunction: params.get("conjunction") === "or" ? "or" : "and",
340
+ };
341
+ };
342
+ `;
343
+
344
+ // ===================== hooks/use-filter-state.ts =====================
345
+ const USE_FILTER_STATE = `"use client";
346
+
347
+ import { useState, useCallback } from "react";
348
+ import type { FilterState, FilterFieldDefinition, OperatorType, FilterValue } from "../types";
349
+
350
+ export const useFilterState = (initialState?: FilterState) => {
351
+ const [state, setState] = useState<FilterState>(initialState || { rows: [], conjunction: "and" });
352
+
353
+ const addRow = useCallback(() => {
354
+ setState((prev) => ({
355
+ ...prev,
356
+ rows: [
357
+ ...prev.rows,
358
+ { id: crypto.randomUUID(), field: null, operator: null, value: null },
359
+ ],
360
+ }));
361
+ }, []);
362
+
363
+ const removeRow = useCallback((id: string) => {
364
+ setState((prev) => ({
365
+ ...prev,
366
+ rows: prev.rows.filter((row) => row.id !== id),
367
+ }));
368
+ }, []);
369
+
370
+ const updateField = useCallback((id: string, field: FilterFieldDefinition) => {
371
+ setState((prev) => ({
372
+ ...prev,
373
+ rows: prev.rows.map((row) =>
374
+ row.id === id ? { ...row, field, operator: null, value: null } : row
375
+ ),
376
+ }));
377
+ }, []);
378
+
379
+ const updateOperator = useCallback((id: string, operator: OperatorType) => {
380
+ setState((prev) => ({
381
+ ...prev,
382
+ rows: prev.rows.map((row) =>
383
+ row.id === id ? { ...row, operator, value: null } : row
384
+ ),
385
+ }));
386
+ }, []);
387
+
388
+ const updateValue = useCallback((id: string, value: FilterValue) => {
389
+ setState((prev) => ({
390
+ ...prev,
391
+ rows: prev.rows.map((row) => (row.id === id ? { ...row, value } : row)),
392
+ }));
393
+ }, []);
394
+
395
+ const setConjunction = useCallback((conjunction: "and" | "or") => {
396
+ setState((prev) => ({ ...prev, conjunction }));
397
+ }, []);
398
+
399
+ const reset = useCallback(() => {
400
+ setState({ rows: [], conjunction: "and" });
401
+ }, []);
402
+
403
+ const overrideState = useCallback((newState: FilterState) => {
404
+ setState(newState);
405
+ }, []);
406
+
407
+ return {
408
+ state,
409
+ addRow,
410
+ removeRow,
411
+ updateField,
412
+ updateOperator,
413
+ updateValue,
414
+ setConjunction,
415
+ reset,
416
+ overrideState
417
+ };
418
+ };
419
+ `;
420
+
421
+ // ===================== hooks/use-filter-url-sync.ts =====================
422
+ const USE_FILTER_URL_SYNC = `"use client";
423
+
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";
428
+ import { getValidFilterRows } from "../helpers/validators";
429
+
430
+ interface UseFilterUrlSyncOptions {
431
+ syncMode: "immediate" | "on-apply";
432
+ }
433
+
434
+ export const useFilterUrlSync = (
435
+ config: FilterConfig,
436
+ _options: UseFilterUrlSyncOptions
437
+ ) => {
438
+ const router = useRouter();
439
+ const searchParams = useSearchParams();
440
+ const pathname = usePathname();
441
+ const isInitializing = useRef(true);
442
+
443
+ const [syncedState, setSyncedState] = useState<FilterState>(() => {
444
+ const params = new URLSearchParams(searchParams.toString());
445
+ return deserializeUrlToFilters(params, config);
446
+ });
447
+
448
+ useEffect(() => {
449
+ if (isInitializing.current) {
450
+ isInitializing.current = false;
451
+ return;
452
+ }
453
+ const params = new URLSearchParams(searchParams.toString());
454
+ setSyncedState(deserializeUrlToFilters(params, config));
455
+ }, [searchParams, config]);
456
+
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]);
464
+
465
+ return {
466
+ initialState: syncedState,
467
+ applyChanges,
468
+ };
469
+ };
470
+ `;
471
+
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
+ };
504
+ `;
505
+
506
+ // ===================== provider/filter-context.ts =====================
507
+ const FILTER_CONTEXT = `"use client";
508
+
509
+ import { createContext, useContext } from "react";
510
+ import type { FilterConfig, FilterState, FilterFieldDefinition, OperatorType, FilterValue } from "../types";
511
+
512
+ export interface FilterContextValue {
513
+ config: FilterConfig;
514
+ state: FilterState;
515
+
516
+ // Mutations
517
+ addRow: () => void;
518
+ removeRow: (id: string) => void;
519
+ updateField: (id: string, field: FilterFieldDefinition) => void;
520
+ updateOperator: (id: string, operator: OperatorType) => void;
521
+ updateValue: (id: string, value: FilterValue) => void;
522
+ setConjunction: (conjunction: "and" | "or") => void;
523
+ reset: () => void;
524
+
525
+ // Derived
526
+ isValid: boolean;
527
+ activeCount: number;
528
+
529
+ // Actions
530
+ apply: () => void;
531
+ }
532
+
533
+ export const FilterContext = createContext<FilterContextValue | null>(null);
534
+
535
+ export const useFilterContext = () => {
536
+ const context = useContext(FilterContext);
537
+ if (!context) {
538
+ throw new Error("useFilterContext must be used within a FilterProvider");
539
+ }
540
+ return context;
541
+ };
542
+ `;
543
+
544
+ // ===================== provider/filter-provider.tsx =====================
545
+ const FILTER_PROVIDER = `"use client";
546
+
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";
552
+ import { DEFAULT_LOCALE } from "../constants";
553
+ import { getValidFilterRows, isValidFilterRow } from "../helpers/validators";
554
+
555
+ export interface FilterProviderProps {
556
+ config: FilterConfig;
557
+ children: React.ReactNode;
558
+ }
559
+
560
+ export const FilterProvider: React.FC<FilterProviderProps> = ({ config, children }) => {
561
+ const currentConfig = useMemo(() => {
562
+ return {
563
+ ...config,
564
+ locale: { ...DEFAULT_LOCALE, ...config.locale },
565
+ };
566
+ }, [config]);
567
+
568
+ const { initialState, applyChanges } = useFilterUrlSync(currentConfig, {
569
+ syncMode: "on-apply",
570
+ });
571
+
572
+ const {
573
+ state,
574
+ addRow,
575
+ removeRow,
576
+ updateField,
577
+ updateOperator,
578
+ updateValue,
579
+ setConjunction,
580
+ reset: localReset,
581
+ } = useFilterState(initialState);
582
+
583
+ const isValid = useMemo(() => state.rows.every(isValidFilterRow), [state.rows]);
584
+
585
+ const activeCount = useMemo(() => getValidFilterRows(state.rows).length, [state.rows]);
586
+
587
+ const apply = useCallback(() => {
588
+ applyChanges(state);
589
+ }, [applyChanges, state]);
590
+
591
+ const reset = useCallback(() => {
592
+ localReset();
593
+ applyChanges({ rows: [], conjunction: "and" });
594
+ }, [localReset, applyChanges]);
595
+
596
+ const value = useMemo(
597
+ () => ({
598
+ config: currentConfig,
599
+ state,
600
+ addRow,
601
+ removeRow,
602
+ updateField,
603
+ updateOperator,
604
+ updateValue,
605
+ setConjunction,
606
+ reset,
607
+ isValid,
608
+ activeCount,
609
+ apply,
610
+ }),
611
+ [
612
+ currentConfig,
613
+ state,
614
+ addRow,
615
+ removeRow,
616
+ updateField,
617
+ updateOperator,
618
+ updateValue,
619
+ setConjunction,
620
+ reset,
621
+ isValid,
622
+ activeCount,
623
+ apply,
624
+ ]
625
+ );
626
+
627
+ return (
628
+ <FilterContext.Provider value={value}>
629
+ {children}
630
+ </FilterContext.Provider>
631
+ );
632
+ };
633
+ `;
634
+
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";
651
+
652
+ export { buildRestQuery } from "./helpers/query-builder";
653
+ export { serializeFiltersToUrl, deserializeUrlToFilters } from "./helpers/serializer";
654
+ export { isValidFilterRow, getValidFilterRows } from "./helpers/validators";
655
+ `;
656
+
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);
665
+
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
+ };
681
+ }
682
+
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";
688
+
689
+ import { useState } from "react";
690
+ 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";
705
+ import { useFilterContext } from "../provider/filter-context";
706
+ import type { FilterFieldDefinition } from "../types";
707
+
708
+ interface FieldSelectProps {
709
+ rowId: string;
710
+ }
711
+
712
+ export function FieldSelect({ rowId }: FieldSelectProps) {
713
+ 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
+
722
+ return (
723
+ <Popover open={open} onOpenChange={setOpen}>
724
+ <PopoverTrigger asChild>
725
+ <Button variant="outline" role="combobox" className="w-[180px] justify-between text-sm">
726
+ {row?.field?.label || config.locale?.placeholder || "Select..."}
727
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
728
+ </Button>
729
+ </PopoverTrigger>
730
+ <PopoverContent className="w-[200px] p-0">
731
+ <Command>
732
+ <CommandInput placeholder="Search field..." />
733
+ <CommandList>
734
+ <CommandEmpty>No field found.</CommandEmpty>
735
+ <CommandGroup>
736
+ {config.fields.map((field) => (
737
+ <CommandItem
738
+ key={field.name}
739
+ value={field.name}
740
+ onSelect={() => handleSelect(field)}
741
+ >
742
+ <Check
743
+ className={\`mr-2 h-4 w-4 \${row?.field?.name === field.name ? "opacity-100" : "opacity-0"}\`}
744
+ />
745
+ {field.label}
746
+ </CommandItem>
747
+ ))}
748
+ </CommandGroup>
749
+ </CommandList>
750
+ </Command>
751
+ </PopoverContent>
752
+ </Popover>
753
+ );
754
+ }
755
+ `;
756
+
757
+ const OPERATOR_SELECT = `"use client";
758
+
759
+ import {
760
+ Select,
761
+ SelectContent,
762
+ SelectItem,
763
+ SelectTrigger,
764
+ SelectValue,
765
+ } from "${alias}components/ui/select";
766
+ 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
+
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;
795
+
796
+ const operators = row.field.operators || DEFAULT_OPERATORS[row.field.type] || [];
797
+
798
+ 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>
814
+ );
815
+ }
816
+ `;
817
+
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";
838
+ 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";
842
+
843
+ interface ValueInputProps {
844
+ rowId: string;
845
+ }
846
+
847
+ export function ValueInput({ rowId }: ValueInputProps) {
848
+ const { state, updateValue } = useFilterContext();
849
+ const row = state.rows.find((r) => r.id === rowId);
850
+
851
+ if (!row?.field || !row.operator) return null;
852
+
853
+ // No value needed for empty checks
854
+ if (row.operator === "is_empty" || row.operator === "is_not_empty") {
855
+ return null;
856
+ }
857
+
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
+ }
883
+
884
+ function TextInput({ rowId }: { rowId: string }) {
885
+ const { state, updateValue } = useFilterContext();
886
+ const row = state.rows.find((r) => r.id === rowId);
887
+
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
+ }
898
+
899
+ function NumberInput({ rowId }: { rowId: string }) {
900
+ const { state, updateValue } = useFilterContext();
901
+ const row = state.rows.find((r) => r.id === rowId);
902
+
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 : ["", ""];
918
+
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
+ }
939
+
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 || [];
945
+
946
+ 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>
962
+ );
963
+ }
964
+
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
+ };
977
+
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
+ }
993
+
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;
999
+
1000
+ return (
1001
+ <Popover open={open} onOpenChange={setOpen}>
1002
+ <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"}
1006
+ </Button>
1007
+ </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
+ />
1019
+ </PopoverContent>
1020
+ </Popover>
1021
+ );
1022
+ }
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
+ `;
1068
+
1069
+ const FILTER_ROW = `"use client";
1070
+
1071
+ import { Trash2 } from "lucide-react";
1072
+ import { Button } from "${alias}components/ui/button";
1073
+ import { useFilterContext } from "../provider/filter-context";
1074
+ import { FieldSelect } from "./field-select";
1075
+ import { OperatorSelect } from "./operator-select";
1076
+ import { ValueInput } from "./value-input";
1077
+
1078
+ interface FilterRowProps {
1079
+ rowId: string;
1080
+ }
1081
+
1082
+ export function FilterRowComponent({ rowId }: FilterRowProps) {
1083
+ const { state, removeRow } = useFilterContext();
1084
+ const row = state.rows.find((r) => r.id === rowId);
1085
+ if (!row) return null;
1086
+
1087
+ 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} />}
1092
+ <Button
1093
+ variant="ghost"
1094
+ size="icon"
1095
+ className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
1096
+ onClick={() => removeRow(rowId)}
1097
+ >
1098
+ <Trash2 className="h-4 w-4" />
1099
+ </Button>
1100
+ </div>
1101
+ );
1102
+ }
1103
+ `;
1104
+
1105
+ const FILTER_FOOTER = `"use client";
1106
+
1107
+ import { Plus, RotateCcw, Check } from "lucide-react";
1108
+ import { Button } from "${alias}components/ui/button";
1109
+ import { useFilterContext } from "../provider/filter-context";
1110
+
1111
+ export function FilterFooter() {
1112
+ const { config, state, addRow, reset, apply } = useFilterContext();
1113
+ const maxRows = config.maxRows || 10;
1114
+ const canAdd = state.rows.length < maxRows;
1115
+
1116
+ return (
1117
+ <div className="flex items-center gap-2 pt-2">
1118
+ <Button variant="outline" size="sm" onClick={addRow} disabled={!canAdd}>
1119
+ <Plus className="mr-1 h-4 w-4" />
1120
+ {config.locale?.addFilter || "+ Add filter"}
1121
+ </Button>
1122
+ <Button variant="ghost" size="sm" onClick={reset}>
1123
+ <RotateCcw className="mr-1 h-4 w-4" />
1124
+ {config.locale?.reset || "Reset"}
1125
+ </Button>
1126
+ <Button size="sm" onClick={apply}>
1127
+ <Check className="mr-1 h-4 w-4" />
1128
+ {config.locale?.apply || "Apply"}
1129
+ </Button>
1130
+ </div>
1131
+ );
1132
+ }
1133
+ `;
1134
+
1135
+ const FILTER_BADGE = `"use client";
1136
+
1137
+ import { useFilterContext } from "../provider/filter-context";
1138
+ import { Badge } from "${alias}components/ui/badge";
1139
+
1140
+ export function FilterBadge() {
1141
+ const { activeCount } = useFilterContext();
1142
+ if (activeCount === 0) return null;
1143
+ return <Badge variant="secondary">{activeCount} active</Badge>;
1144
+ }
1145
+ `;
1146
+
1147
+ const FILTER_ROOT = `"use client";
1148
+
1149
+ import { useFilterContext } from "../provider/filter-context";
1150
+ import { FilterRowComponent } from "./filter-row";
1151
+ import { FilterFooter } from "./filter-footer";
1152
+
1153
+ export function FilterRoot() {
1154
+ const { state, config } = useFilterContext();
1155
+
1156
+ return (
1157
+ <div className="space-y-3 rounded-lg border p-4">
1158
+ {state.rows.length === 0 ? (
1159
+ <p className="text-sm text-muted-foreground">
1160
+ {config.locale?.noFilters || "No filters active"}
1161
+ </p>
1162
+ ) : (
1163
+ <div className="space-y-2">
1164
+ {state.rows.map((row, index) => (
1165
+ <div key={row.id} className="flex items-center gap-2">
1166
+ {index > 0 && config.allowConjunctionToggle && (
1167
+ <span className="text-xs font-medium text-muted-foreground w-10 text-center">
1168
+ {state.conjunction.toUpperCase()}
1169
+ </span>
1170
+ )}
1171
+ {index > 0 && !config.allowConjunctionToggle && (
1172
+ <span className="text-xs font-medium text-muted-foreground w-10 text-center">
1173
+ {state.conjunction.toUpperCase()}
1174
+ </span>
1175
+ )}
1176
+ <FilterRowComponent rowId={row.id} />
1177
+ </div>
1178
+ ))}
1179
+ </div>
1180
+ )}
1181
+ <FilterFooter />
1182
+ </div>
1183
+ );
1184
+ }
1185
+ `;
1186
+
1187
+ return {
1188
+ "ui/field-select.tsx": FIELD_SELECT,
1189
+ "ui/operator-select.tsx": OPERATOR_SELECT,
1190
+ "ui/value-input.tsx": VALUE_INPUT,
1191
+ "ui/filter-row.tsx": FILTER_ROW,
1192
+ "ui/filter-footer.tsx": FILTER_FOOTER,
1193
+ "ui/filter-badge.tsx": FILTER_BADGE,
1194
+ "ui/filter-root.tsx": FILTER_ROOT,
1195
+ };
1196
+ }