diff --git a/.changeset/admin-list-filtering.md b/.changeset/admin-list-filtering.md new file mode 100644 index 00000000..be3bfa75 --- /dev/null +++ b/.changeset/admin-list-filtering.md @@ -0,0 +1,36 @@ +--- +'@opensaas/stack-ui': minor +--- + +Add comprehensive filtering system for admin UI list pages with URL state management + +This adds a complete filtering system for admin list views with all filter state persisted in URL query parameters. Developers can use the built-in FilterBar component or build custom filter UIs using the exposed primitives. + +**Features:** + +- URL-based filter state (`filters[field][operator]=value` format) +- Field type-specific filters (text, integer, checkbox, timestamp, select, relationship) +- Operator support per field type (contains, equals, gt, gte, lt, lte, in, etc.) +- FilterBar component with add/remove/clear functionality +- Primitive filter input components for custom implementations +- Server-side Prisma where clause generation +- Client-side URL navigation and state updates +- Full TypeScript type safety + +**New Components:** + +- `FilterBar` - Main filter UI with field/operator selection +- `TextFilterInput`, `NumberFilterInput`, `BooleanFilterInput` - Text, number, and boolean filters +- `DateFilterInput` - Timestamp field filtering +- `SelectFilterInput` - Enum/select field filtering with multi-select +- `RelationshipFilterInput` - Related item filtering with multi-select + +**New Utilities:** + +- `parseFiltersFromURL()` - Parse filter state from URL search params +- `serializeFiltersToURL()` - Serialize filters to URL string +- `filtersToPrismaWhere()` - Convert filters to Prisma where clauses +- `addFilter()`, `removeFilter()`, `clearFilters()` - Filter state management + +**Integration:** +All primitives and utilities are exported from `@opensaas/stack-ui` for developer customization. The FilterBar is automatically included in all admin list pages. diff --git a/.changeset/config.json b/.changeset/config.json index 3bf333da..37c7cd52 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,8 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["opensaas-*-example", "opensaas-stack-docs"] + "ignore": ["opensaas-*-example", "opensaas-stack-docs"], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/examples/blog/next-env.d.ts b/examples/blog/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/examples/blog/next-env.d.ts +++ b/examples/blog/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/cli/src/mcp/lib/generators/feature-generator.ts b/packages/cli/src/mcp/lib/generators/feature-generator.ts index 9c9e0de7..d5be9fc2 100644 --- a/packages/cli/src/mcp/lib/generators/feature-generator.ts +++ b/packages/cli/src/mcp/lib/generators/feature-generator.ts @@ -318,7 +318,6 @@ const currentUser = await context.db.user.findUnique({ const hasStatus = this.answers['post-status'] as boolean const taxonomy = (this.answers['taxonomy'] as string[]) || [] const postFields = (this.answers['post-fields'] as string[]) || [] - const commentsEnabled = this.answers['comments-enabled'] as boolean const useTiptap = contentEditor === 'Rich text editor (Tiptap)' const useMarkdown = contentEditor === 'Markdown' diff --git a/packages/cli/src/mcp/server/stack-mcp-server.ts b/packages/cli/src/mcp/server/stack-mcp-server.ts index 98571f7c..17f195ef 100644 --- a/packages/cli/src/mcp/server/stack-mcp-server.ts +++ b/packages/cli/src/mcp/server/stack-mcp-server.ts @@ -245,7 +245,7 @@ ${s.feature.includes.map((inc) => `- ${inc}`).join('\n')} /** * Validate a feature implementation */ - async validateFeature({ feature, configPath }: { feature: string; configPath?: string }) { + async validateFeature({ feature }: { feature: string; configPath?: string }) { const featureDefinition = getFeature(feature) if (!featureDefinition) { diff --git a/packages/core/tests/field-types.test.ts b/packages/core/tests/field-types.test.ts index 15f6da08..812e6f41 100644 --- a/packages/core/tests/field-types.test.ts +++ b/packages/core/tests/field-types.test.ts @@ -9,7 +9,6 @@ import { relationship, json, } from '../src/fields/index.js' -import { z } from 'zod' describe('Field Types', () => { describe('text field', () => { diff --git a/packages/ui/src/components/AdminUI.tsx b/packages/ui/src/components/AdminUI.tsx index 64b7faee..29d9d045 100644 --- a/packages/ui/src/components/AdminUI.tsx +++ b/packages/ui/src/components/AdminUI.tsx @@ -88,6 +88,7 @@ export function AdminUI({ basePath={basePath} search={search} page={page} + searchParams={searchParams} /> ) } diff --git a/packages/ui/src/components/ListView.tsx b/packages/ui/src/components/ListView.tsx index c58e4f4c..af0a595b 100644 --- a/packages/ui/src/components/ListView.tsx +++ b/packages/ui/src/components/ListView.tsx @@ -8,6 +8,8 @@ import { OpenSaasConfig, type PrismaClientLike, } from '@opensaas/stack-core' +import { parseFiltersFromURL, filtersToPrismaWhere } from '../lib/filter-utils.js' +import type { ListFilters } from '../lib/filter-types.js' export interface ListViewProps { context: AccessContext @@ -18,6 +20,8 @@ export interface ListViewProps } /** @@ -33,11 +37,16 @@ export async function ListView) { const key = getDbKey(listKey) const urlKey = getUrlKey(listKey) const listConfig = config.lists[listKey] + // Parse filters from searchParams if not provided directly + const parsedFilters = filters || parseFiltersFromURL(searchParams) + if (!listConfig) { return (
@@ -60,8 +69,11 @@ export async function ListView | undefined = undefined + let searchWhere: Record | undefined = undefined if (search && search.trim()) { // Find all text fields to search across const searchableFields = Object.entries(listConfig.fields) @@ -69,7 +81,7 @@ export async function ListView fieldName) if (searchableFields.length > 0) { - where = { + searchWhere = { OR: searchableFields.map((fieldName) => ({ [fieldName]: { contains: search.trim(), @@ -79,6 +91,18 @@ export async function ListView | undefined = undefined + if (filterWhere && searchWhere) { + where = { + AND: [filterWhere, searchWhere], + } + } else if (filterWhere) { + where = filterWhere + } else if (searchWhere) { + where = searchWhere + } + // Build include object for relationship fields const include: Record = {} Object.entries(listConfig.fields).forEach(([fieldName, field]) => { @@ -110,6 +134,26 @@ export async function ListView !['id', 'createdAt', 'updatedAt', 'password'].includes(fieldName)) + .map(([fieldName, field]) => { + const baseField = { + name: fieldName, + type: (field as { type: string }).type, + } + + // Add options for select fields + if ('type' in field && field.type === 'select' && 'options' in field && field.options) { + return { + ...baseField, + options: field.options as Array<{ label: string; value: string }>, + } + } + + return baseField + }) + return (
{/* Header */} @@ -147,6 +191,9 @@ export async function ListView
) diff --git a/packages/ui/src/components/ListViewClient.tsx b/packages/ui/src/components/ListViewClient.tsx index aec0f34e..c429e2c9 100644 --- a/packages/ui/src/components/ListViewClient.tsx +++ b/packages/ui/src/components/ListViewClient.tsx @@ -17,6 +17,8 @@ import { Input } from '../primitives/input.js' import { Button } from '../primitives/button.js' import { Card } from '../primitives/card.js' import { getUrlKey } from '@opensaas/stack-core' +import { FilterBar, type FilterableField } from './filters/FilterBar.js' +import type { ListFilters } from '../lib/filter-types.js' export interface ListViewClientProps { items: Array> @@ -30,6 +32,9 @@ export interface ListViewClientProps { pageSize: number total: number search?: string + filters?: ListFilters + searchParams?: Record + filterableFields: FilterableField[] } /** @@ -41,12 +46,16 @@ export function ListViewClient({ fieldTypes, relationshipRefs, columns, + listKey, urlKey, basePath, page, pageSize, total, search: initialSearch, + filters = [], + searchParams = {}, + filterableFields, }: ListViewClientProps) { const router = useRouter() const [sortBy, setSortBy] = useState(null) @@ -170,6 +179,16 @@ export function ListViewClient({ return (
+ {/* Filter Bar */} + + {/* Search Bar */}
diff --git a/packages/ui/src/components/filters/FilterBar.tsx b/packages/ui/src/components/filters/FilterBar.tsx new file mode 100644 index 00000000..9c741b46 --- /dev/null +++ b/packages/ui/src/components/filters/FilterBar.tsx @@ -0,0 +1,356 @@ +/** + * FilterBar component - Main UI for managing list filters + * Provides interface for adding, editing, and removing filters + */ +'use client' + +import * as React from 'react' +import { useState } from 'react' +import { useRouter } from 'next/navigation.js' +import { Button } from '../../primitives/button.js' +import { Card } from '../../primitives/card.js' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../../primitives/select.js' +import { + TextFilterInput, + NumberFilterInput, + BooleanFilterInput, + DateFilterInput, + SelectFilterInput, + RelationshipFilterInput, +} from './FilterInput.js' +import { + serializeFiltersToURL, + addFilter, + removeFilter, + clearFilters, +} from '../../lib/filter-utils.js' +import { FIELD_TYPE_OPERATORS, OPERATOR_LABELS } from '../../lib/filter-types.js' +import type { ListFilters, FilterOperator } from '../../lib/filter-types.js' +import { formatFieldName } from '../../lib/utils.js' + +/** + * Serializable field metadata for filtering + */ +export interface FilterableField { + name: string + type: string + options?: Array<{ label: string; value: string }> // For select fields +} + +export interface FilterBarProps { + listKey: string + fields: FilterableField[] + basePath: string + urlKey: string + currentFilters: ListFilters + searchParams?: Record + className?: string +} + +/** + * FilterBar component for list views + * Manages filter state in URL and provides UI for adding/removing filters + * + * @example + * ```tsx + * + * ``` + */ +export function FilterBar({ + fields, + basePath, + urlKey, + currentFilters, + searchParams = {}, + className, +}: FilterBarProps) { + const router = useRouter() + const [isAddingFilter, setIsAddingFilter] = useState(false) + const [selectedField, setSelectedField] = useState('') + const [selectedOperator, setSelectedOperator] = useState('') + + // Create a lookup map for field metadata + const fieldMap = new Map(fields.map((f) => [f.name, f])) + + const handleUpdateFilters = (newFilters: ListFilters) => { + const params = new URLSearchParams() + + // Preserve existing search param + const search = searchParams.search + if (typeof search === 'string' && search) { + params.set('search', search) + } + + // Add filter params + const filterString = serializeFiltersToURL(newFilters) + if (filterString) { + // Parse and add each filter param + const filterParams = new URLSearchParams(filterString) + filterParams.forEach((value, key) => { + params.set(key, value) + }) + } + + // Reset to page 1 when filters change + params.set('page', '1') + + // Navigate with new params + const url = `${basePath}/${urlKey}?${params.toString()}` + router.push(url) + } + + const handleAddFilter = (field: string, operator: FilterOperator, value: unknown) => { + const newFilters = addFilter( + currentFilters, + field, + operator, + value as string | string[] | boolean | number, + ) + handleUpdateFilters(newFilters) + setIsAddingFilter(false) + setSelectedField('') + setSelectedOperator('') + } + + const handleRemoveFilter = (field: string, operator: FilterOperator) => { + const newFilters = removeFilter(currentFilters, field, operator) + handleUpdateFilters(newFilters) + } + + const handleClearAll = () => { + handleUpdateFilters(clearFilters()) + } + + const getDefaultValue = (fieldType: string, operator: FilterOperator) => { + if (operator === 'is') return true + if (operator === 'in' || operator === 'notIn') return [] + if (fieldType === 'integer') return 0 + if (fieldType === 'timestamp') return new Date().toISOString() + return '' + } + + const renderFilterInput = (filter: (typeof currentFilters)[0]) => { + const field = fieldMap.get(filter.field) + if (!field) return null + + const fieldType = field.type + const label = formatFieldName(filter.field) + + const baseProps = { + field: filter.field, + value: filter.value, + onChange: (value: string | string[] | boolean | number) => { + const newFilters = addFilter(currentFilters, filter.field, filter.operator, value) + handleUpdateFilters(newFilters) + }, + onRemove: () => handleRemoveFilter(filter.field, filter.operator), + label, + } + + switch (fieldType) { + case 'text': + return ( + + ) + + case 'integer': + return ( + + ) + + case 'checkbox': + return ( + { + const newFilters = addFilter(currentFilters, filter.field, filter.operator, value) + handleUpdateFilters(newFilters) + }} + /> + ) + + case 'timestamp': + return ( + + ) + + case 'select': + if (field.options) { + return ( + + ) + } + return null + + case 'relationship': + // For relationship filters, we would need to fetch related items + // This is a simplified version - in production, you'd fetch the related items + return ( + + ) + + default: + return null + } + } + + return ( + +
+ {/* Active Filters */} + {currentFilters.length > 0 && ( +
+
+

Active Filters

+ +
+
+ {currentFilters.map((filter) => renderFilterInput(filter))} +
+
+ )} + + {/* Add Filter Button */} + {!isAddingFilter ? ( + + ) : ( +
+
+ {/* Field Selector */} +
+ +
+ + {/* Operator Selector */} + {selectedField && ( +
+ +
+ )} + + {/* Add/Cancel Buttons */} + {selectedField && selectedOperator && ( + + )} + + +
+
+ )} +
+
+ ) +} diff --git a/packages/ui/src/components/filters/FilterInput.tsx b/packages/ui/src/components/filters/FilterInput.tsx new file mode 100644 index 00000000..a842f15c --- /dev/null +++ b/packages/ui/src/components/filters/FilterInput.tsx @@ -0,0 +1,347 @@ +/** + * Base filter input components for building custom filters + * These are primitives that can be composed into field-specific filters + */ +'use client' + +import * as React from 'react' +import { Input } from '../../primitives/input.js' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../../primitives/select.js' +import { Checkbox } from '../../primitives/checkbox.js' +import { DateTimePicker } from '../../primitives/datetime-picker.js' +import { Button } from '../../primitives/button.js' +import type { FilterOperator } from '../../lib/filter-types.js' +import { OPERATOR_LABELS } from '../../lib/filter-types.js' + +export interface FilterInputBaseProps { + field: string + operator: FilterOperator + value: string | string[] | boolean | number + onChange: (value: string | string[] | boolean | number) => void + onRemove: () => void + label?: string +} + +/** + * Text filter input + * Supports: contains, equals, startsWith, endsWith, not + */ +export interface TextFilterInputProps extends Omit { + operator: 'contains' | 'equals' | 'startsWith' | 'endsWith' | 'not' +} + +export function TextFilterInput({ + field, + operator, + value, + onChange, + onRemove, + label, +}: TextFilterInputProps) { + return ( +
+
+ {label || field} + {OPERATOR_LABELS[operator]} + onChange(e.target.value)} + placeholder="Enter value..." + className="flex-1" + /> +
+ +
+ ) +} + +/** + * Number filter input + * Supports: equals, gt, gte, lt, lte, not + */ +export interface NumberFilterInputProps extends Omit { + operator: 'equals' | 'gt' | 'gte' | 'lt' | 'lte' | 'not' +} + +export function NumberFilterInput({ + field, + operator, + value, + onChange, + onRemove, + label, +}: NumberFilterInputProps) { + return ( +
+
+ {label || field} + {OPERATOR_LABELS[operator]} + onChange(Number(e.target.value))} + placeholder="Enter number..." + className="flex-1" + /> +
+ +
+ ) +} + +/** + * Boolean filter input + * Supports: is + */ +export interface BooleanFilterInputProps { + field: string + operator: 'is' + value: boolean + onChange: (value: boolean) => void + onRemove: () => void + label?: string +} + +export function BooleanFilterInput({ + field, + value, + onChange, + onRemove, + label, +}: BooleanFilterInputProps) { + return ( +
+
+ {label || field} + is +
+ + +
+
+ +
+ ) +} + +/** + * Date/Timestamp filter input + * Supports: equals, gt, gte, lt, lte + */ +export interface DateFilterInputProps extends Omit { + operator: 'equals' | 'gt' | 'gte' | 'lt' | 'lte' +} + +export function DateFilterInput({ + field, + operator, + value, + onChange, + onRemove, + label, +}: DateFilterInputProps) { + const dateValue = typeof value === 'string' ? new Date(value) : new Date() + + return ( +
+
+ {label || field} + {OPERATOR_LABELS[operator]} + { + if (date) { + onChange(date.toISOString()) + } + }} + /> +
+ +
+ ) +} + +/** + * Select filter input (for enum fields) + * Supports: equals, in, not, notIn + */ +export interface SelectFilterInputProps extends Omit { + operator: 'equals' | 'in' | 'not' | 'notIn' + options: Array<{ value: string; label: string }> + multiple?: boolean +} + +export function SelectFilterInput({ + field, + operator, + value, + onChange, + onRemove, + label, + options, + multiple = false, +}: SelectFilterInputProps) { + const isMultiple = operator === 'in' || operator === 'notIn' || multiple + + return ( +
+
+ {label || field} + {OPERATOR_LABELS[operator]} + {isMultiple ? ( +
+ {options.map((option) => { + const values = Array.isArray(value) ? value : [value] + const isSelected = values.includes(option.value) + return ( + + ) + })} +
+ ) : ( +
+ +
+ )} +
+ +
+ ) +} + +/** + * Relationship filter input (filter by related item) + * Supports: equals, in + */ +export interface RelationshipFilterInputProps extends Omit { + operator: 'equals' | 'in' + relatedItems: Array<{ id: string; displayValue: string }> + multiple?: boolean +} + +export function RelationshipFilterInput({ + field, + operator, + value, + onChange, + onRemove, + label, + relatedItems, + multiple = false, +}: RelationshipFilterInputProps) { + const isMultiple = operator === 'in' || multiple + + return ( +
+
+ {label || field} + {OPERATOR_LABELS[operator]} + {isMultiple ? ( +
+ {relatedItems.map((item) => { + const values = Array.isArray(value) ? value : [value] + const isSelected = values.includes(item.id) + return ( + + ) + })} +
+ ) : ( +
+ +
+ )} +
+ +
+ ) +} diff --git a/packages/ui/src/components/filters/index.ts b/packages/ui/src/components/filters/index.ts new file mode 100644 index 00000000..8ee99109 --- /dev/null +++ b/packages/ui/src/components/filters/index.ts @@ -0,0 +1,26 @@ +/** + * Filter components for list views + * Provides primitives and complete filter bar for building filtered lists + */ + +export { FilterBar } from './FilterBar.js' +export type { FilterBarProps, FilterableField } from './FilterBar.js' + +export { + TextFilterInput, + NumberFilterInput, + BooleanFilterInput, + DateFilterInput, + SelectFilterInput, + RelationshipFilterInput, +} from './FilterInput.js' + +export type { + FilterInputBaseProps, + TextFilterInputProps, + NumberFilterInputProps, + BooleanFilterInputProps, + DateFilterInputProps, + SelectFilterInputProps, + RelationshipFilterInputProps, +} from './FilterInput.js' diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 40e88224..44e9fb71 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,3 +72,47 @@ export { cn, formatListName, formatFieldName, getFieldDisplayValue } from './lib // Theme utilities export { generateThemeCSS, getThemeStyleTag, presetThemes } from './lib/theme.js' + +// Filter components and utilities +export { FilterBar } from './components/filters/index.js' +export type { FilterBarProps, FilterableField } from './components/filters/index.js' + +export { + TextFilterInput, + NumberFilterInput, + BooleanFilterInput, + DateFilterInput, + SelectFilterInput, + RelationshipFilterInput, +} from './components/filters/index.js' + +export type { + FilterInputBaseProps, + TextFilterInputProps, + NumberFilterInputProps, + BooleanFilterInputProps, + DateFilterInputProps, + SelectFilterInputProps, + RelationshipFilterInputProps, +} from './components/filters/index.js' + +// Filter types and utilities +export type { + FilterOperator, + FilterCondition, + ListFilters, + FilterURLState, +} from './lib/filter-types.js' + +export { FIELD_TYPE_OPERATORS, OPERATOR_LABELS } from './lib/filter-types.js' + +export { + parseFiltersFromURL, + serializeFiltersToURL, + filtersToPrismaWhere, + addFilter, + removeFilter, + removeFieldFilters, + getFieldFilters, + clearFilters, +} from './lib/filter-utils.js' diff --git a/packages/ui/src/lib/filter-types.ts b/packages/ui/src/lib/filter-types.ts new file mode 100644 index 00000000..f0227b64 --- /dev/null +++ b/packages/ui/src/lib/filter-types.ts @@ -0,0 +1,74 @@ +/** + * Filter types and operator definitions for list filtering + */ + +/** + * Filter operators available for each field type + */ +export type FilterOperator = + // Text operators + | 'contains' + | 'equals' + | 'startsWith' + | 'endsWith' + | 'not' + // Number operators + | 'gt' + | 'gte' + | 'lt' + | 'lte' + // Array operators + | 'in' + | 'notIn' + // Boolean operators + | 'is' + +/** + * Filter operators by field type + */ +export const FIELD_TYPE_OPERATORS: Record = { + text: ['contains', 'equals', 'startsWith', 'endsWith', 'not'], + integer: ['equals', 'gt', 'gte', 'lt', 'lte', 'not'], + checkbox: ['is'], + timestamp: ['equals', 'gt', 'gte', 'lt', 'lte'], + select: ['equals', 'in', 'not', 'notIn'], + relationship: ['equals', 'in'], +} + +/** + * Human-readable labels for operators + */ +export const OPERATOR_LABELS: Record = { + contains: 'contains', + equals: 'equals', + startsWith: 'starts with', + endsWith: 'ends with', + not: 'not equals', + gt: 'greater than', + gte: 'greater than or equal', + lt: 'less than', + lte: 'less than or equal', + in: 'is one of', + notIn: 'is not one of', + is: 'is', +} + +/** + * A single filter condition + */ +export interface FilterCondition { + field: string + operator: FilterOperator + value: string | string[] | boolean | number +} + +/** + * Collection of filters for a list + */ +export type ListFilters = FilterCondition[] + +/** + * Filter state stored in URL + * Format: filters[fieldName][operator]=value + */ +export type FilterURLState = Record> diff --git a/packages/ui/src/lib/filter-utils.ts b/packages/ui/src/lib/filter-utils.ts new file mode 100644 index 00000000..a0657854 --- /dev/null +++ b/packages/ui/src/lib/filter-utils.ts @@ -0,0 +1,191 @@ +/** + * Utilities for managing filter state in URLs and converting to Prisma filters + */ + +import type { FilterOperator, ListFilters } from './filter-types.js' + +/** + * Parse filters from URL search params + * Format: filters[fieldName][operator]=value + * + * @example + * parseFiltersFromURL('filters[title][contains]=hello&filters[status][equals]=published') + * // Returns: [ + * // { field: 'title', operator: 'contains', value: 'hello' }, + * // { field: 'status', operator: 'equals', value: 'published' } + * // ] + */ +export function parseFiltersFromURL( + searchParams: Record, +): ListFilters { + const filters: ListFilters = [] + + // Parse filters[fieldName][operator]=value format + for (const [key, value] of Object.entries(searchParams)) { + // Match pattern: filters[fieldName][operator] + const match = key.match(/^filters\[([^\]]+)\]\[([^\]]+)\]$/) + if (match && value !== undefined) { + const [, field, operator] = match + + // Handle array values (for 'in' and 'notIn' operators) + let parsedValue: string | string[] | boolean | number + if (Array.isArray(value)) { + parsedValue = value + } else if (operator === 'in' || operator === 'notIn') { + // Split comma-separated values for 'in' operators + parsedValue = value.split(',').map((v) => v.trim()) + } else if (operator === 'is') { + // Parse boolean for checkbox fields + parsedValue = value === 'true' + } else if ( + operator === 'gt' || + operator === 'gte' || + operator === 'lt' || + operator === 'lte' + ) { + // Try to parse as number for numeric operators + const num = Number(value) + parsedValue = isNaN(num) ? value : num + } else { + parsedValue = value + } + + filters.push({ + field, + operator: operator as FilterOperator, + value: parsedValue, + }) + } + } + + return filters +} + +/** + * Serialize filters to URL search params + * + * @example + * serializeFiltersToURL([ + * { field: 'title', operator: 'contains', value: 'hello' }, + * { field: 'status', operator: 'equals', value: 'published' } + * ]) + * // Returns: 'filters[title][contains]=hello&filters[status][equals]=published' + */ +export function serializeFiltersToURL(filters: ListFilters): string { + const params = new URLSearchParams() + + for (const filter of filters) { + const key = `filters[${filter.field}][${filter.operator}]` + + if (Array.isArray(filter.value)) { + // Join array values with commas + params.set(key, filter.value.join(',')) + } else { + params.set(key, String(filter.value)) + } + } + + return params.toString() +} + +/** + * Convert filters to Prisma where clause + * + * @example + * filtersToPrismaWhere([ + * { field: 'title', operator: 'contains', value: 'hello' }, + * { field: 'status', operator: 'equals', value: 'published' }, + * { field: 'views', operator: 'gt', value: 100 } + * ]) + * // Returns: { + * // AND: [ + * // { title: { contains: 'hello' } }, + * // { status: { equals: 'published' } }, + * // { views: { gt: 100 } } + * // ] + * // } + */ +export function filtersToPrismaWhere(filters: ListFilters): Record | undefined { + if (filters.length === 0) { + return undefined + } + + const conditions = filters.map((filter) => { + const { field, operator, value } = filter + + // Handle special operators + if (operator === 'is') { + // For checkbox fields: { field: value } + return { [field]: value } + } + + if (operator === 'not') { + // For 'not equals': { field: { not: value } } + return { [field]: { not: value } } + } + + // Standard operators map directly to Prisma + return { + [field]: { + [operator]: value, + }, + } + }) + + // Combine all conditions with AND + if (conditions.length === 1) { + return conditions[0] + } + + return { + AND: conditions, + } +} + +/** + * Add or update a filter in the list + */ +export function addFilter( + filters: ListFilters, + field: string, + operator: FilterOperator, + value: string | string[] | boolean | number, +): ListFilters { + // Remove existing filter for this field+operator + const filtered = filters.filter((f) => !(f.field === field && f.operator === operator)) + + // Add new filter + return [...filtered, { field, operator, value }] +} + +/** + * Remove a filter from the list + */ +export function removeFilter( + filters: ListFilters, + field: string, + operator: FilterOperator, +): ListFilters { + return filters.filter((f) => !(f.field === field && f.operator === operator)) +} + +/** + * Remove all filters for a specific field + */ +export function removeFieldFilters(filters: ListFilters, field: string): ListFilters { + return filters.filter((f) => f.field !== field) +} + +/** + * Get all filters for a specific field + */ +export function getFieldFilters(filters: ListFilters, field: string): ListFilters { + return filters.filter((f) => f.field === field) +} + +/** + * Clear all filters + */ +export function clearFilters(): ListFilters { + return [] +} diff --git a/packages/ui/tests/components/SelectField.test.tsx b/packages/ui/tests/components/SelectField.test.tsx index e9a2407e..c0ff7aef 100644 --- a/packages/ui/tests/components/SelectField.test.tsx +++ b/packages/ui/tests/components/SelectField.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' import { SelectField } from '../../src/components/fields/SelectField.js' describe('SelectField', () => {