This commit is contained in:
Lakee Sivaraya
2026-01-16 11:28:25 -08:00
parent a940dd6351
commit 271375df9b
10 changed files with 279 additions and 466 deletions

View File

@@ -3,17 +3,171 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('TableUtils')
/** Permission hierarchy: read < write < admin */
type PermissionLevel = 'read' | 'write' | 'admin'
/**
* Checks if a user's permission meets or exceeds the required level.
* @deprecated Use TableDefinition from '@/lib/table' instead
*/
export type TableData = TableDefinition
export interface TableAccessResult {
hasAccess: true
table: Pick<TableDefinition, 'id' | 'workspaceId' | 'createdBy'>
}
export interface TableAccessResultFull {
hasAccess: true
table: TableDefinition
}
export interface TableAccessDenied {
hasAccess: false
notFound?: boolean
reason?: string
}
export interface ApiErrorResponse {
error: string
details?: unknown
}
export async function checkTableAccess(
tableId: string,
userId: string
): Promise<TableAccessResult | TableAccessDenied> {
return checkTableAccessInternal(tableId, userId, 'read')
}
export async function checkTableWriteAccess(
tableId: string,
userId: string
): Promise<TableAccessResult | TableAccessDenied> {
return checkTableAccessInternal(tableId, userId, 'write')
}
export async function checkAccessOrRespond(
tableId: string,
userId: string,
requestId: string,
level: 'read' | 'write' | 'admin' = 'write'
): Promise<TableAccessResult | NextResponse> {
const checkFn = level === 'read' ? checkTableAccess : checkTableWriteAccess
const accessCheck = await checkFn(tableId, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Table not found: ${tableId}`)
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
return accessCheck
}
export async function checkAccessWithFullTable(
tableId: string,
userId: string,
requestId: string,
level: 'read' | 'write' | 'admin' = 'write'
): Promise<TableAccessResultFull | NextResponse> {
const [tableData] = await db
.select()
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (!tableData) {
logger.warn(`[${requestId}] Table not found: ${tableId}`)
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
const rowCount = await getTableRowCount(tableId)
const table = { ...tableData, rowCount } as unknown as TableDefinition
if (table.createdBy === userId) {
return { hasAccess: true, table }
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (!hasPermissionLevel(userPermission, level)) {
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
return { hasAccess: true, table }
}
export async function getTableById(tableId: string): Promise<TableDefinition | null> {
const [table] = await db
.select()
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (!table) {
return null
}
const rowCount = await getTableRowCount(tableId)
return { ...table, rowCount } as unknown as TableDefinition
}
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
const table = await db
.select({ workspaceId: userTableDefinitions.workspaceId })
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (table.length === 0) {
return false
}
return table[0].workspaceId === workspaceId
}
async function checkTableAccessInternal(
tableId: string,
userId: string,
requiredLevel: 'read' | 'write' | 'admin'
): Promise<TableAccessResult | TableAccessDenied> {
const table = await db
.select({
id: userTableDefinitions.id,
createdBy: userTableDefinitions.createdBy,
workspaceId: userTableDefinitions.workspaceId,
})
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (table.length === 0) {
return { hasAccess: false, notFound: true }
}
const tableData = table[0]
if (tableData.createdBy === userId) {
return { hasAccess: true, table: tableData }
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', tableData.workspaceId)
if (hasPermissionLevel(userPermission, requiredLevel)) {
return { hasAccess: true, table: tableData }
}
return { hasAccess: false }
}
function hasPermissionLevel(
userPermission: 'read' | 'write' | 'admin' | null,
requiredLevel: PermissionLevel
@@ -41,361 +195,6 @@ async function getTableRowCount(tableId: string): Promise<number> {
return Number(result?.count ?? 0)
}
/**
* Represents the core data structure for a user-defined table as stored in the database.
*
* This extends the base TableDefinition with DB-specific fields like createdBy.
*/
export interface TableData {
/** Unique identifier for the table */
id: string
/** ID of the workspace this table belongs to */
workspaceId: string
/** ID of the user who created this table */
createdBy: string
/** Human-readable name of the table */
name: string
/** Optional description of the table's purpose */
description?: string | null
/** JSON schema defining the table's column structure */
schema: TableSchema
/** Maximum number of rows allowed in this table */
maxRows: number
/** Current number of rows in the table */
rowCount: number
/** Timestamp when the table was created */
createdAt: Date
/** Timestamp when the table was last updated */
updatedAt: Date
}
/**
* Result returned when a user has access to a table.
*/
export interface TableAccessResult {
/** Indicates the user has access */
hasAccess: true
/** Core table information needed for access control */
table: Pick<TableData, 'id' | 'workspaceId' | 'createdBy'>
}
/**
* Result returned when a user has access to a table with full data.
*/
export interface TableAccessResultFull {
/** Indicates the user has access */
hasAccess: true
/** Full table data */
table: TableData
}
/**
* Result returned when a user is denied access to a table.
*/
export interface TableAccessDenied {
/** Indicates the user does not have access */
hasAccess: false
/** True if the table was not found */
notFound?: boolean
/** Optional reason for denial */
reason?: string
}
/**
* Internal function to check if a user has the required permission level for a table.
*
* Access is granted if:
* 1. User created the table directly, OR
* 2. User has the required permission level on the table's workspace
*
* @param tableId - The unique identifier of the table to check
* @param userId - The unique identifier of the user requesting access
* @param requiredLevel - The minimum permission level required ('read', 'write', or 'admin')
* @returns A promise resolving to the access check result
*
* @internal
*/
async function checkTableAccessInternal(
tableId: string,
userId: string,
requiredLevel: 'read' | 'write' | 'admin'
): Promise<TableAccessResult | TableAccessDenied> {
// Fetch table data
const table = await db
.select({
id: userTableDefinitions.id,
createdBy: userTableDefinitions.createdBy,
workspaceId: userTableDefinitions.workspaceId,
})
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (table.length === 0) {
return { hasAccess: false, notFound: true }
}
const tableData = table[0]
// Case 1: User created the table directly (always has full access)
if (tableData.createdBy === userId) {
return { hasAccess: true, table: tableData }
}
// Case 2: Check workspace permissions
const userPermission = await getUserEntityPermissions(userId, 'workspace', tableData.workspaceId)
if (hasPermissionLevel(userPermission, requiredLevel)) {
return { hasAccess: true, table: tableData }
}
return { hasAccess: false }
}
/**
* Checks if a user has read access to a table.
*
* Access is granted if:
* 1. User created the table directly, OR
* 2. User has any permission (read/write/admin) on the table's workspace
*
* @param tableId - The unique identifier of the table to check
* @param userId - The unique identifier of the user requesting access
* @returns A promise resolving to the access check result
*
* @example
* ```typescript
* const accessCheck = await checkTableAccess(tableId, userId)
* if (!accessCheck.hasAccess) {
* if ('notFound' in accessCheck && accessCheck.notFound) {
* return NotFoundResponse()
* }
* return ForbiddenResponse()
* }
* // User has access, proceed with operation
* ```
*/
export async function checkTableAccess(
tableId: string,
userId: string
): Promise<TableAccessResult | TableAccessDenied> {
return checkTableAccessInternal(tableId, userId, 'read')
}
/**
* Checks if a user has write access to a table.
*
* Write access is granted if:
* 1. User created the table directly, OR
* 2. User has write or admin permissions on the table's workspace
*
* @param tableId - The unique identifier of the table to check
* @param userId - The unique identifier of the user requesting write access
* @returns A promise resolving to the access check result
*
* @example
* ```typescript
* const accessCheck = await checkTableWriteAccess(tableId, userId)
* if (!accessCheck.hasAccess) {
* return ForbiddenResponse()
* }
* // User has write access, proceed with modification
* ```
*/
export async function checkTableWriteAccess(
tableId: string,
userId: string
): Promise<TableAccessResult | TableAccessDenied> {
return checkTableAccessInternal(tableId, userId, 'write')
}
/**
* Checks table access and returns either the access result or an error response.
*
* This is a convenience function that combines access checking with automatic
* error response generation, reducing boilerplate in route handlers.
*
* @param tableId - The unique identifier of the table to check
* @param userId - The unique identifier of the user requesting access
* @param requestId - Request ID for logging
* @param level - Permission level required ('read' or 'write')
* @returns Either a TableAccessResult with table info, or a NextResponse with error
*
* @example
* ```typescript
* const accessResult = await checkAccessOrRespond(tableId, userId, requestId, 'write')
* if (accessResult instanceof NextResponse) return accessResult
*
* // Access granted - use accessResult.table
* const { table } = accessResult
* ```
*/
export async function checkAccessOrRespond(
tableId: string,
userId: string,
requestId: string,
level: 'read' | 'write' | 'admin' = 'write'
): Promise<TableAccessResult | NextResponse> {
const checkFn = level === 'read' ? checkTableAccess : checkTableWriteAccess
const accessCheck = await checkFn(tableId, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Table not found: ${tableId}`)
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
return accessCheck
}
/**
* Checks table access and returns full table data or an error response.
*
* This is an optimized version of checkAccessOrRespond that fetches the full
* table data in a single query, avoiding a redundant getTableById call.
*
* @param tableId - The unique identifier of the table to check
* @param userId - The unique identifier of the user requesting access
* @param requestId - Request ID for logging
* @param level - Permission level required ('read' or 'write')
* @returns Either a TableAccessResultFull with full table data, or a NextResponse with error
*
* @example
* ```typescript
* const result = await checkAccessWithFullTable(tableId, userId, requestId, 'write')
* if (result instanceof NextResponse) return result
*
* // Access granted - use result.table which has full table data
* const schema = result.table.schema
* const rowCount = result.table.rowCount
* ```
*/
export async function checkAccessWithFullTable(
tableId: string,
userId: string,
requestId: string,
level: 'read' | 'write' | 'admin' = 'write'
): Promise<TableAccessResultFull | NextResponse> {
// Fetch full table data in one query
const [tableData] = await db
.select()
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (!tableData) {
logger.warn(`[${requestId}] Table not found: ${tableId}`)
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
const rowCount = await getTableRowCount(tableId)
const table = { ...tableData, rowCount } as unknown as TableData
// Case 1: User created the table directly (always has full access)
if (table.createdBy === userId) {
return { hasAccess: true, table }
}
// Case 2: Check workspace permissions
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (!hasPermissionLevel(userPermission, level)) {
logger.warn(`[${requestId}] User ${userId} denied ${level} access to table ${tableId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
return { hasAccess: true, table }
}
/**
* Fetches a table by ID.
*
* @param tableId - The unique identifier of the table to fetch
* @returns Promise resolving to table data or null if not found
*
* @example
* ```typescript
* const table = await getTableById(tableId)
* if (!table) {
* return NextResponse.json({ error: 'Table not found' }, { status: 404 })
* }
* ```
*/
export async function getTableById(tableId: string): Promise<TableData | null> {
const [table] = await db
.select()
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (!table) {
return null
}
const rowCount = await getTableRowCount(tableId)
return { ...table, rowCount } as unknown as TableData
}
/**
* Verifies that a table belongs to a specific workspace.
*
* This is a security check to prevent workspace ID spoofing.
* Use this when workspaceId is provided as a parameter to ensure
* it matches the table's actual workspace.
*
* @param tableId - The unique identifier of the table
* @param workspaceId - The workspace ID to verify against
* @returns A promise resolving to true if the table belongs to the workspace
*
* @example
* ```typescript
* if (providedWorkspaceId) {
* const isValid = await verifyTableWorkspace(tableId, providedWorkspaceId)
* if (!isValid) {
* return BadRequestResponse('Invalid workspace ID')
* }
* }
* ```
*/
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
const table = await db
.select({ workspaceId: userTableDefinitions.workspaceId })
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (table.length === 0) {
return false
}
return table[0].workspaceId === workspaceId
}
/**
* Standard error response structure for table API routes.
*/
export interface ApiErrorResponse {
error: string
details?: unknown
}
/**
* Creates a standardized error response.
*
* @param message - Error message to display
* @param status - HTTP status code
* @param details - Optional additional error details
* @returns NextResponse with standardized error format
*
* @example
* ```typescript
* return errorResponse('Table not found', 404)
* return errorResponse('Validation error', 400, zodError.errors)
* ```
*/
export function errorResponse(
message: string,
status: number,
@@ -408,9 +207,6 @@ export function errorResponse(
return NextResponse.json(body, { status })
}
/**
* Creates a 400 Bad Request error response.
*/
export function badRequestResponse(
message: string,
details?: unknown
@@ -418,44 +214,26 @@ export function badRequestResponse(
return errorResponse(message, 400, details)
}
/**
* Creates a 401 Unauthorized error response.
*/
export function unauthorizedResponse(
message = 'Authentication required'
): NextResponse<ApiErrorResponse> {
return errorResponse(message, 401)
}
/**
* Creates a 403 Forbidden error response.
*/
export function forbiddenResponse(message = 'Access denied'): NextResponse<ApiErrorResponse> {
return errorResponse(message, 403)
}
/**
* Creates a 404 Not Found error response.
*/
export function notFoundResponse(message = 'Resource not found'): NextResponse<ApiErrorResponse> {
return errorResponse(message, 404)
}
/**
* Creates a 500 Internal Server Error response.
*/
export function serverErrorResponse(
message = 'Internal server error'
): NextResponse<ApiErrorResponse> {
return errorResponse(message, 500)
}
/**
* Normalizes a column definition by ensuring all optional fields have explicit values.
*
* @param col - The column definition to normalize
* @returns A normalized column with explicit required and unique values
*/
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
name: col.name,

View File

@@ -6,23 +6,24 @@ import { nanoid } from 'nanoid'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterCondition, SortCondition } from '@/lib/table/filters/constants'
import { useFilterBuilder } from '@/lib/table/filters/use-builder'
import { conditionsToFilter } from '@/lib/table/filters/utils'
import type { JsonValue } from '@/lib/table/types'
import { conditionsToFilter, sortConditionToSort } from '@/lib/table/filters/utils'
import type { ColumnDefinition, Filter, Sort } from '@/lib/table/types'
export interface QueryOptions {
filter: Record<string, JsonValue> | null
sort: SortCondition | null
/**
* Result of applying query builder filters and sorts.
* Contains the converted API-ready filter and sort objects.
*/
export interface BuilderQueryResult {
/** MongoDB-style filter object for API queries */
filter: Filter | null
/** Sort specification for API queries */
sort: Sort | null
}
/**
* Column definition for filter building.
* Column definition for filter building (subset of ColumnDefinition).
*/
interface Column {
/** Column name */
name: string
/** Column data type */
type: 'string' | 'number' | 'boolean' | 'json' | 'date'
}
type Column = Pick<ColumnDefinition, 'name' | 'type'>
/**
* Props for the TableQueryBuilder component.
@@ -31,7 +32,7 @@ interface TableQueryBuilderProps {
/** Available columns for filtering */
columns: Column[]
/** Callback when query options should be applied */
onApply: (options: QueryOptions) => void
onApply: (options: BuilderQueryResult) => void
/** Callback to add a new row */
onAddRow: () => void
/** Whether a query is currently loading */
@@ -89,10 +90,8 @@ export function TableQueryBuilder({
const handleApply = useCallback(() => {
const filter = conditionsToFilter(conditions)
onApply({
filter,
sort: sortCondition,
})
const sort = sortConditionToSort(sortCondition)
onApply({ filter, sort })
}, [conditions, sortCondition, onApply])
const handleClear = useCallback(() => {

View File

@@ -16,16 +16,10 @@ import {
Textarea,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import type { ColumnDefinition, TableRow, TableSchema } from '@/lib/table'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
const logger = createLogger('TableRowModal')
export interface TableInfo {
id: string
name: string
schema: TableSchema
}
export interface TableRowModalProps {
mode: 'add' | 'edit' | 'delete'
isOpen: boolean

View File

@@ -4,13 +4,13 @@
import { useQuery } from '@tanstack/react-query'
import type { TableDefinition, TableRow } from '@/lib/table'
import type { QueryOptions } from '../../components/table-query-builder'
import type { BuilderQueryResult } from '../../components/table-query-builder'
import { ROWS_PER_PAGE } from '../constants'
interface UseTableDataParams {
workspaceId: string
tableId: string
queryOptions: QueryOptions
queryOptions: BuilderQueryResult
currentPage: number
}
@@ -65,8 +65,8 @@ export function useTableData({
}
if (queryOptions.sort) {
const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction }
searchParams.set('sort', JSON.stringify(sortParam))
// sort is already in the correct format: { column: direction }
searchParams.set('sort', JSON.stringify(queryOptions.sort))
}
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`)

View File

@@ -2,7 +2,6 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { X } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
@@ -74,9 +73,7 @@ const DialogContent = React.forwardRef<
}}
{...props}
>
<VisuallyHidden.Root>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden.Root>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close

View File

@@ -1,8 +1,14 @@
/**
* Shared constants and types for table filtering and sorting.
* Shared constants for table filtering and sorting UI.
*
* Types (FilterCondition, SortCondition) are defined in ../types.ts
* and re-exported here for convenience.
*/
/** Comparison operators for filter conditions (maps to query-builder.ts) */
// Re-export UI builder types from central types file
export type { FilterCondition, SortCondition } from '../types'
/** Comparison operators for filter conditions (maps to ConditionOperators in types.ts) */
export const COMPARISON_OPERATORS = [
{ value: 'eq', label: 'equals' },
{ value: 'ne', label: 'not equals' },
@@ -23,35 +29,9 @@ export const LOGICAL_OPERATORS = [
] as const
/**
* Sort direction options.
* Sort direction options for UI dropdowns.
*/
export const SORT_DIRECTIONS = [
{ value: 'asc', label: 'ascending' },
{ value: 'desc', label: 'descending' },
] as const
/** Single filter condition used by filter builder UI */
export interface FilterCondition {
/** Unique identifier for the condition (used as React key) */
id: string
/** How this condition combines with the previous one */
logicalOperator: 'and' | 'or'
/** Column to filter on */
column: string
/** Comparison operator */
operator: string
/** Value to compare against */
value: string
}
/**
* Represents a sort configuration.
*/
export interface SortCondition {
/** Unique identifier for the condition (used as React key) */
id: string
/** Column to sort by */
column: string
/** Sort direction */
direction: 'asc' | 'desc'
}

View File

@@ -7,6 +7,7 @@
import { useCallback, useMemo } from 'react'
import { nanoid } from 'nanoid'
import type { ColumnOption } from '../types'
import {
COMPARISON_OPERATORS,
type FilterCondition,
@@ -15,6 +16,9 @@ import {
type SortCondition,
} from './constants'
// Re-export ColumnOption for consumers of this module
export type { ColumnOption }
/**
* Hook that provides filter builder logic for managing filter conditions.
*
@@ -173,11 +177,6 @@ export function useSortBuilder({
}
}
export interface ColumnOption {
value: string
label: string
}
export interface UseFilterBuilderProps {
columns: ColumnOption[]
conditions: FilterCondition[]

View File

@@ -1,10 +1,19 @@
/**
* Shared utilities for filter builder UI components.
*
* These utilities convert between UI builder types (FilterCondition, SortCondition)
* and API types (Filter, Sort).
*/
import { nanoid } from 'nanoid'
import type { JsonValue } from '../types'
import type { FilterCondition, SortCondition } from './constants'
import type {
Filter,
FilterCondition,
JsonValue,
Sort,
SortCondition,
SortDirection,
} from '../types'
/**
* Converts builder filter conditions to MongoDB-style filter object.
@@ -12,9 +21,7 @@ import type { FilterCondition, SortCondition } from './constants'
* @param conditions - Array of filter conditions from the builder UI
* @returns Filter object or null if no conditions
*/
export function conditionsToFilter(
conditions: FilterCondition[]
): Record<string, JsonValue> | null {
export function conditionsToFilter(conditions: FilterCondition[]): Filter | null {
if (conditions.length === 0) return null
const orGroups: Record<string, JsonValue>[] = []
@@ -45,12 +52,12 @@ export function conditionsToFilter(
* @param filter - Filter object to convert
* @returns Array of filter conditions for the builder UI
*/
export function filterToConditions(filter: Record<string, JsonValue> | null): FilterCondition[] {
export function filterToConditions(filter: Filter | null): FilterCondition[] {
if (!filter) return []
if (filter.$or && Array.isArray(filter.$or)) {
const groups = filter.$or
.map((orGroup) => parseFilterGroup(orGroup as Record<string, JsonValue>))
.map((orGroup) => parseFilterGroup(orGroup as Filter))
.filter((group) => group.length > 0)
return applyLogicalOperators(groups)
}
@@ -59,15 +66,26 @@ export function filterToConditions(filter: Record<string, JsonValue> | null): Fi
}
/**
* Converts builder sort conditions to sort object.
* Converts a single builder sort condition to Sort object.
*
* @param condition - Single sort condition from the builder UI
* @returns Sort object or null if no condition
*/
export function sortConditionToSort(condition: SortCondition | null): Sort | null {
if (!condition || !condition.column) return null
return { [condition.column]: condition.direction }
}
/**
* Converts builder sort conditions (array) to Sort object.
*
* @param conditions - Array of sort conditions from the builder UI
* @returns Sort object or null if no conditions
*/
export function sortConditionsToSort(conditions: SortCondition[]): Record<string, string> | null {
export function sortConditionsToSort(conditions: SortCondition[]): Sort | null {
if (conditions.length === 0) return null
const sort: Record<string, string> = {}
const sort: Sort = {}
for (const condition of conditions) {
if (condition.column) {
sort[condition.column] = condition.direction
@@ -78,12 +96,12 @@ export function sortConditionsToSort(conditions: SortCondition[]): Record<string
}
/**
* Converts sort object to builder conditions.
* Converts Sort object to builder conditions.
*
* @param sort - Sort object to convert
* @returns Array of sort conditions for the builder UI
*/
export function sortToConditions(sort: Record<string, string> | null): SortCondition[] {
export function sortToConditions(sort: Sort | null): SortCondition[] {
if (!sort) return []
return Object.entries(sort).map(([column, direction]) => ({
@@ -137,7 +155,7 @@ function parseScalar(value: string): JsonValue {
return value
}
function parseFilterGroup(group: Record<string, JsonValue>): FilterCondition[] {
function parseFilterGroup(group: Filter): FilterCondition[] {
if (!group || typeof group !== 'object' || Array.isArray(group)) return []
const conditions: FilterCondition[] = []
@@ -153,7 +171,7 @@ function parseFilterGroup(group: Record<string, JsonValue>): FilterCondition[] {
logicalOperator: 'and',
column,
operator: op.substring(1),
value: formatValueForBuilder(opValue),
value: formatValueForBuilder(opValue as JsonValue),
})
}
}
@@ -165,7 +183,7 @@ function parseFilterGroup(group: Record<string, JsonValue>): FilterCondition[] {
logicalOperator: 'and',
column,
operator: 'eq',
value: formatValueForBuilder(value),
value: formatValueForBuilder(value as JsonValue),
})
}
@@ -179,6 +197,6 @@ function formatValueForBuilder(value: JsonValue): string {
return String(value)
}
function normalizeSortDirection(direction: string): 'asc' | 'desc' {
function normalizeSortDirection(direction: string): SortDirection {
return direction === 'desc' ? 'desc' : 'asc'
}

View File

@@ -1,9 +1,5 @@
import { useEffect, useRef, useState } from 'react'
interface ColumnOption {
value: string
label: string
}
import type { ColumnOption } from '../types'
interface UseTableColumnsOptions {
tableId: string | null | undefined

View File

@@ -21,6 +21,15 @@ export type SortDirection = 'asc' | 'desc'
* key is the column name and value is the sort direction */
export type Sort = Record<string, SortDirection>
/**
* Option for column/dropdown selection in UI components.
* Used by filter builders, sort builders, and column selectors.
*/
export interface ColumnOption {
value: string
label: string
}
/**
* Column definition within a table schema.
*/
@@ -49,10 +58,16 @@ export interface TableDefinition {
rowCount: number
maxRows: number
workspaceId: string
createdBy: string
createdAt: Date | string
updatedAt: Date | string
}
/**
* Subset of TableDefinition for UI components that only need basic info.
*/
export type TableInfo = Pick<TableDefinition, 'id' | 'name' | 'schema'>
/**
* Row stored in a user-defined table.
*/
@@ -137,6 +152,43 @@ export interface ValidationResult {
errors: string[]
}
// ============================================================================
// UI Builder Types
// These types represent the state of filter/sort builder UI components.
// They have `id` fields for React keys and string values for form inputs.
// Use the conversion utilities in filters/utils.ts to convert to API types.
// ============================================================================
/**
* Single filter condition in the UI builder.
* This is the UI representation - use `Filter` for API queries.
*/
export interface FilterCondition {
/** Unique identifier for the condition (used as React key) */
id: string
/** How this condition combines with the previous one */
logicalOperator: 'and' | 'or'
/** Column to filter on */
column: string
/** Comparison operator (eq, ne, gt, gte, lt, lte, contains, in) */
operator: string
/** Value to compare against (as string for form input) */
value: string
}
/**
* Single sort condition in the UI builder.
* This is the UI representation - use `Sort` for API queries.
*/
export interface SortCondition {
/** Unique identifier for the condition (used as React key) */
id: string
/** Column to sort by */
column: string
/** Sort direction */
direction: SortDirection
}
/**
* Options for querying table rows.
*/