Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/admin-list-filtering.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion examples/blog/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
1 change: 0 additions & 1 deletion packages/cli/src/mcp/lib/generators/feature-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/mcp/server/stack-mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion packages/core/tests/field-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
relationship,
json,
} from '../src/fields/index.js'
import { z } from 'zod'

describe('Field Types', () => {
describe('text field', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/AdminUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function AdminUI<TPrisma>({
basePath={basePath}
search={search}
page={page}
searchParams={searchParams}
/>
)
}
Expand Down
51 changes: 49 additions & 2 deletions packages/ui/src/components/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TPrisma extends PrismaClientLike = PrismaClientLike> {
context: AccessContext<TPrisma>
Expand All @@ -18,6 +20,8 @@ export interface ListViewProps<TPrisma extends PrismaClientLike = PrismaClientLi
page?: number
pageSize?: number
search?: string
filters?: ListFilters
searchParams?: Record<string, string | string[] | undefined>
}

/**
Expand All @@ -33,11 +37,16 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
page = 1,
pageSize = 50,
search,
filters,
searchParams = {},
}: ListViewProps<TPrisma>) {
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 (
<div className="p-8">
Expand All @@ -60,16 +69,19 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
throw new Error(`Context for ${listKey} not found`)
}

// Build filter where clause from URL filters
const filterWhere = filtersToPrismaWhere(parsedFilters)

// Build search filter if search term provided
let where: Record<string, unknown> | undefined = undefined
let searchWhere: Record<string, unknown> | undefined = undefined
if (search && search.trim()) {
// Find all text fields to search across
const searchableFields = Object.entries(listConfig.fields)
.filter(([_, field]) => (field as { type: string }).type === 'text')
.map(([fieldName]) => fieldName)

if (searchableFields.length > 0) {
where = {
searchWhere = {
OR: searchableFields.map((fieldName) => ({
[fieldName]: {
contains: search.trim(),
Expand All @@ -79,6 +91,18 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
}
}

// Combine filter and search where clauses
let where: Record<string, unknown> | 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<string, boolean> = {}
Object.entries(listConfig.fields).forEach(([fieldName, field]) => {
Expand Down Expand Up @@ -110,6 +134,26 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
}
})

// Extract serializable field metadata for FilterBar
const filterableFields = Object.entries(listConfig.fields)
.filter(([fieldName]) => !['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 (
<div className="p-8">
{/* Header */}
Expand Down Expand Up @@ -147,6 +191,9 @@ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLi
pageSize={pageSize}
total={total || 0}
search={search}
filters={parsedFilters}
searchParams={searchParams}
filterableFields={filterableFields}
/>
</div>
)
Expand Down
19 changes: 19 additions & 0 deletions packages/ui/src/components/ListViewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>
Expand All @@ -30,6 +32,9 @@ export interface ListViewClientProps {
pageSize: number
total: number
search?: string
filters?: ListFilters
searchParams?: Record<string, string | string[] | undefined>
filterableFields: FilterableField[]
}

/**
Expand All @@ -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<string | null>(null)
Expand Down Expand Up @@ -170,6 +179,16 @@ export function ListViewClient({

return (
<div className="space-y-4">
{/* Filter Bar */}
<FilterBar
listKey={listKey}
fields={filterableFields}
basePath={basePath}
urlKey={urlKey}
currentFilters={filters}
searchParams={searchParams}
/>

{/* Search Bar */}
<Card className="p-4">
<form onSubmit={handleSearch} className="flex gap-2">
Expand Down
Loading