From 849c7c9ae5b75b1c18c2d04615ae0f11873e9df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 04:29:58 +0000 Subject: [PATCH 1/8] feat(ui): Add comprehensive filtering system with URL state management This commit implements a complete filtering system for the admin UI list pages with all state stored in URL query parameters. The system supports all field types and provides both ready-to-use components and composable primitives for developers. Features: - URL-based filter state management (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 Components Added: - FilterBar: Main filter UI with field/operator selection - FilterInput components: TextFilterInput, NumberFilterInput, BooleanFilterInput, DateFilterInput, SelectFilterInput, RelationshipFilterInput Utilities Added: - 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: - ListView: Server-side filter parsing and Prisma query building - ListViewClient: Client-side FilterBar integration - AdminUI: Pass searchParams to ListView All primitives and utilities are exported from @opensaas/stack-ui for developer customization. --- packages/ui/src/components/AdminUI.tsx | 1 + packages/ui/src/components/ListView.tsx | 31 +- packages/ui/src/components/ListViewClient.tsx | 21 +- .../ui/src/components/filters/FilterBar.tsx | 341 ++++++++++++++++++ .../ui/src/components/filters/FilterInput.tsx | 333 +++++++++++++++++ packages/ui/src/components/filters/index.ts | 26 ++ packages/ui/src/index.ts | 44 +++ packages/ui/src/lib/filter-types.ts | 74 ++++ packages/ui/src/lib/filter-utils.ts | 186 ++++++++++ 9 files changed, 1054 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/filters/FilterBar.tsx create mode 100644 packages/ui/src/components/filters/FilterInput.tsx create mode 100644 packages/ui/src/components/filters/index.ts create mode 100644 packages/ui/src/lib/filter-types.ts create mode 100644 packages/ui/src/lib/filter-utils.ts 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..47e0a470 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]) => { @@ -147,6 +171,9 @@ export async function ListView
) diff --git a/packages/ui/src/components/ListViewClient.tsx b/packages/ui/src/components/ListViewClient.tsx index aec0f34e..05903e96 100644 --- a/packages/ui/src/components/ListViewClient.tsx +++ b/packages/ui/src/components/ListViewClient.tsx @@ -16,7 +16,9 @@ import { 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 { getUrlKey, type OpenSaasConfig } from '@opensaas/stack-core' +import { FilterBar } 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 + config: OpenSaasConfig } /** @@ -41,12 +46,16 @@ export function ListViewClient({ fieldTypes, relationshipRefs, columns, + listKey, urlKey, basePath, page, pageSize, total, search: initialSearch, + filters = [], + searchParams = {}, + config, }: 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..b70288f0 --- /dev/null +++ b/packages/ui/src/components/filters/FilterBar.tsx @@ -0,0 +1,341 @@ +/** + * 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 } from '../../primitives/select.js' +import { Popover } from '../../primitives/popover.js' +import { + TextFilterInput, + NumberFilterInput, + BooleanFilterInput, + DateFilterInput, + SelectFilterInput, + RelationshipFilterInput, +} from './FilterInput.js' +import { + parseFiltersFromURL, + 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 type { OpenSaasConfig } from '@opensaas/stack-core' +import { formatFieldName } from '../../lib/utils.js' + +export interface FilterBarProps { + listKey: string + config: OpenSaasConfig + 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({ + listKey, + config, + basePath, + urlKey, + currentFilters, + searchParams = {}, + className, +}: FilterBarProps) { + const router = useRouter() + const listConfig = config.lists[listKey] + const [isAddingFilter, setIsAddingFilter] = useState(false) + const [selectedField, setSelectedField] = useState('') + const [selectedOperator, setSelectedOperator] = useState('') + + if (!listConfig) { + return null + } + + // Get filterable fields (exclude password, system fields) + const filterableFields = Object.entries(listConfig.fields).filter( + ([fieldName, _]) => !['id', 'createdAt', 'updatedAt', 'password'].includes(fieldName), + ) + + 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 fieldConfig = listConfig.fields[filter.field] + if (!fieldConfig || !('type' in fieldConfig)) return null + + const fieldType = fieldConfig.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 ('options' in fieldConfig && fieldConfig.options) { + const options = Object.entries(fieldConfig.options).map(([value, label]) => ({ + value, + label: String(label), + })) + 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..7728af25 --- /dev/null +++ b/packages/ui/src/components/filters/FilterInput.tsx @@ -0,0 +1,333 @@ +/** + * 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 } 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..4fd895b8 --- /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 } 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..2507cc15 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 } 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..edd12544 --- /dev/null +++ b/packages/ui/src/lib/filter-utils.ts @@ -0,0 +1,186 @@ +/** + * Utilities for managing filter state in URLs and converting to Prisma filters + */ + +import type { FilterCondition, 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 [] +} From b9f0be6873321bd23000bac64b0fa1eed34c6f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 04:41:40 +0000 Subject: [PATCH 2/8] chore: format code with prettier --- packages/ui/src/lib/filter-utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/lib/filter-utils.ts b/packages/ui/src/lib/filter-utils.ts index edd12544..64f8f0f4 100644 --- a/packages/ui/src/lib/filter-utils.ts +++ b/packages/ui/src/lib/filter-utils.ts @@ -37,7 +37,12 @@ export function parseFiltersFromURL( } else if (operator === 'is') { // Parse boolean for checkbox fields parsedValue = value === 'true' - } else if (operator === 'gt' || operator === 'gte' || operator === 'lt' || operator === 'lte') { + } 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 From 54b5b08407e8c199a8d322fae39e04bc37afda0e Mon Sep 17 00:00:00 2001 From: Josh Calder <8251494+borisno2@users.noreply.github.com> Date: Sun, 16 Nov 2025 08:29:34 +1100 Subject: [PATCH 3/8] fix(ui): serialize config data for client components Fixed Next.js error 'Functions cannot be passed directly to Client Components' by extracting only serializable field metadata instead of passing the entire config object (which contains methods) to client components. Changes: - Created FilterableField type with serializable field data (name, type, options) - Updated FilterBar to accept fields array instead of full config - Updated ListView to extract and serialize field metadata - Updated ListViewClient to pass serialized data to FilterBar - Ensured all data crossing server/client boundary is JSON-serializable This maintains the same functionality while respecting Next.js server/client component boundaries. --- examples/blog/next-env.d.ts | 2 +- packages/ui/src/components/ListView.tsx | 22 ++++++- packages/ui/src/components/ListViewClient.tsx | 10 +-- .../ui/src/components/filters/FilterBar.tsx | 66 ++++++++++--------- packages/ui/src/components/filters/index.ts | 2 +- packages/ui/src/index.ts | 2 +- 6 files changed, 64 insertions(+), 40 deletions(-) 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/ui/src/components/ListView.tsx b/packages/ui/src/components/ListView.tsx index 47e0a470..af0a595b 100644 --- a/packages/ui/src/components/ListView.tsx +++ b/packages/ui/src/components/ListView.tsx @@ -134,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 */} @@ -173,7 +193,7 @@ export async function ListView
) diff --git a/packages/ui/src/components/ListViewClient.tsx b/packages/ui/src/components/ListViewClient.tsx index 05903e96..c429e2c9 100644 --- a/packages/ui/src/components/ListViewClient.tsx +++ b/packages/ui/src/components/ListViewClient.tsx @@ -16,8 +16,8 @@ import { import { Input } from '../primitives/input.js' import { Button } from '../primitives/button.js' import { Card } from '../primitives/card.js' -import { getUrlKey, type OpenSaasConfig } from '@opensaas/stack-core' -import { FilterBar } from './filters/FilterBar.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 { @@ -34,7 +34,7 @@ export interface ListViewClientProps { search?: string filters?: ListFilters searchParams?: Record - config: OpenSaasConfig + filterableFields: FilterableField[] } /** @@ -55,7 +55,7 @@ export function ListViewClient({ search: initialSearch, filters = [], searchParams = {}, - config, + filterableFields, }: ListViewClientProps) { const router = useRouter() const [sortBy, setSortBy] = useState(null) @@ -182,7 +182,7 @@ export function ListViewClient({ {/* Filter Bar */} // For select fields +} + export interface FilterBarProps { listKey: string - config: OpenSaasConfig + fields: FilterableField[] basePath: string urlKey: string currentFilters: ListFilters @@ -49,7 +57,14 @@ export interface FilterBarProps { * ```tsx * ('') const [selectedOperator, setSelectedOperator] = useState('') - if (!listConfig) { - return null - } - - // Get filterable fields (exclude password, system fields) - const filterableFields = Object.entries(listConfig.fields).filter( - ([fieldName, _]) => !['id', 'createdAt', 'updatedAt', 'password'].includes(fieldName), - ) + // 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() @@ -139,10 +147,10 @@ export function FilterBar({ } const renderFilterInput = (filter: (typeof currentFilters)[0]) => { - const fieldConfig = listConfig.fields[filter.field] - if (!fieldConfig || !('type' in fieldConfig)) return null + const field = fieldMap.get(filter.field) + if (!field) return null - const fieldType = fieldConfig.type + const fieldType = field.type const label = formatFieldName(filter.field) const baseProps = { @@ -199,17 +207,13 @@ export function FilterBar({ ) case 'select': - if ('options' in fieldConfig && fieldConfig.options) { - const options = Object.entries(fieldConfig.options).map(([value, label]) => ({ - value, - label: String(label), - })) + if (field.options) { return ( ) @@ -275,9 +279,9 @@ export function FilterBar({ }} > - {filterableFields.map(([fieldName, fieldConfig]) => ( - ))} @@ -292,9 +296,9 @@ export function FilterBar({ > {(() => { - const fieldConfig = listConfig.fields[selectedField] - if (!fieldConfig || !('type' in fieldConfig)) return null - const operators = FIELD_TYPE_OPERATORS[fieldConfig.type] || [] + const field = fieldMap.get(selectedField) + if (!field) return null + const operators = FIELD_TYPE_OPERATORS[field.type] || [] return operators.map((op) => (
@@ -294,17 +304,21 @@ export function FilterBar({ value={selectedOperator} onValueChange={(value) => setSelectedOperator(value as FilterOperator)} > - - {(() => { - const field = fieldMap.get(selectedField) - if (!field) return null - const operators = FIELD_TYPE_OPERATORS[field.type] || [] - return operators.map((op) => ( - - )) - })()} + + + + + {(() => { + const field = fieldMap.get(selectedField) + if (!field) return null + const operators = FIELD_TYPE_OPERATORS[field.type] || [] + return operators.map((op) => ( + + {OPERATOR_LABELS[op]} + + )) + })()} + )} diff --git a/packages/ui/src/components/filters/FilterInput.tsx b/packages/ui/src/components/filters/FilterInput.tsx index 7728af25..a842f15c 100644 --- a/packages/ui/src/components/filters/FilterInput.tsx +++ b/packages/ui/src/components/filters/FilterInput.tsx @@ -6,7 +6,13 @@ import * as React from 'react' import { Input } from '../../primitives/input.js' -import { Select } from '../../primitives/select.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' @@ -242,12 +248,16 @@ export function SelectFilterInput({ ) : (
)} @@ -315,12 +325,16 @@ export function RelationshipFilterInput({ ) : (
)} From d6e4461ff9c193416ebee3b4b52acb86814ac038 Mon Sep 17 00:00:00 2001 From: Josh Calder <8251494+borisno2@users.noreply.github.com> Date: Sun, 16 Nov 2025 09:02:46 +1100 Subject: [PATCH 5/8] lint and format --- packages/cli/src/mcp/lib/generators/feature-generator.ts | 1 - packages/cli/src/mcp/server/stack-mcp-server.ts | 2 +- packages/core/tests/field-types.test.ts | 1 - packages/ui/src/components/filters/FilterBar.tsx | 3 --- packages/ui/src/lib/filter-utils.ts | 2 +- packages/ui/tests/components/SelectField.test.tsx | 1 - 6 files changed, 2 insertions(+), 8 deletions(-) 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/filters/FilterBar.tsx b/packages/ui/src/components/filters/FilterBar.tsx index 01f1830a..9c741b46 100644 --- a/packages/ui/src/components/filters/FilterBar.tsx +++ b/packages/ui/src/components/filters/FilterBar.tsx @@ -16,7 +16,6 @@ import { SelectTrigger, SelectValue, } from '../../primitives/select.js' -import { Popover } from '../../primitives/popover.js' import { TextFilterInput, NumberFilterInput, @@ -26,7 +25,6 @@ import { RelationshipFilterInput, } from './FilterInput.js' import { - parseFiltersFromURL, serializeFiltersToURL, addFilter, removeFilter, @@ -79,7 +77,6 @@ export interface FilterBarProps { * ``` */ export function FilterBar({ - listKey, fields, basePath, urlKey, diff --git a/packages/ui/src/lib/filter-utils.ts b/packages/ui/src/lib/filter-utils.ts index 64f8f0f4..a0657854 100644 --- a/packages/ui/src/lib/filter-utils.ts +++ b/packages/ui/src/lib/filter-utils.ts @@ -2,7 +2,7 @@ * Utilities for managing filter state in URLs and converting to Prisma filters */ -import type { FilterCondition, FilterOperator, ListFilters } from './filter-types.js' +import type { FilterOperator, ListFilters } from './filter-types.js' /** * Parse filters from URL search params 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', () => { From 9e201f6f24ba9064935749ad16649904417d0d17 Mon Sep 17 00:00:00 2001 From: Josh Calder <8251494+borisno2@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:57:01 +1100 Subject: [PATCH 6/8] chore: add changeset for filtering feature --- .changeset/admin-list-filtering.md | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/admin-list-filtering.md diff --git a/.changeset/admin-list-filtering.md b/.changeset/admin-list-filtering.md new file mode 100644 index 00000000..5a4e24c3 --- /dev/null +++ b/.changeset/admin-list-filtering.md @@ -0,0 +1,33 @@ +--- +"@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. From d76570354bcb18e00fa9335e17194d89fa4d5586 Mon Sep 17 00:00:00 2001 From: Josh Calder <8251494+borisno2@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:22:45 +1100 Subject: [PATCH 7/8] Update config.json --- .changeset/config.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 + } } From 22829cfe78fe602cd612e5dd7c152d0bab724929 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Mar 2026 23:34:24 +0000 Subject: [PATCH 8/8] chore: format changeset file --- .changeset/admin-list-filtering.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/admin-list-filtering.md b/.changeset/admin-list-filtering.md index 5a4e24c3..be3bfa75 100644 --- a/.changeset/admin-list-filtering.md +++ b/.changeset/admin-list-filtering.md @@ -1,5 +1,5 @@ --- -"@opensaas/stack-ui": minor +'@opensaas/stack-ui': minor --- Add comprehensive filtering system for admin UI list pages with URL state management @@ -7,6 +7,7 @@ Add comprehensive filtering system for admin UI list pages with URL state manage 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.) @@ -17,6 +18,7 @@ This adds a complete filtering system for admin list views with all filter state - 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 @@ -24,6 +26,7 @@ This adds a complete filtering system for admin list views with all filter state - `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