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