doc strings

This commit is contained in:
Lakee Sivaraya
2026-01-14 12:05:39 -08:00
parent 48250f5ed8
commit c155d8ac6c
17 changed files with 2070 additions and 910 deletions

View File

@@ -6,25 +6,69 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { TableColumnData, TableSchemaData } from '../utils'
import { checkTableAccess, checkTableWriteAccess, verifyTableWorkspace } from '../utils'
const logger = createLogger('TableDetailAPI')
/**
* Schema for getting a table by ID
* Zod schema for validating get table requests.
*
* The workspaceId is optional for backward compatibility but
* is validated via table access checks when provided.
*/
const GetTableSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
})
/**
* GET /api/table/[tableId]?workspaceId=xxx
* Get table details
* Route params for table detail endpoints.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string }> }
) {
interface TableRouteParams {
params: Promise<{ tableId: string }>
}
/**
* Normalizes a column definition ensuring all optional fields have explicit values.
*
* @param col - The column data to normalize
* @returns Normalized column with explicit required and unique values
*/
function normalizeColumn(col: TableColumnData): TableColumnData {
return {
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
}
}
/**
* GET /api/table/[tableId]?workspaceId=xxx
*
* Retrieves details for a specific table.
*
* @param request - The incoming HTTP request
* @param context - Route context containing tableId param
* @returns JSON response with table details or error
*
* @example Response:
* ```json
* {
* "success": true,
* "data": {
* "table": {
* "id": "tbl_abc123",
* "name": "customers",
* "schema": { "columns": [...] },
* "rowCount": 150,
* "maxRows": 10000
* }
* }
* }
* ```
*/
export async function GET(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
@@ -78,6 +122,8 @@ export async function GET(
logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`)
const schemaData = table.schema as TableSchemaData
return NextResponse.json({
success: true,
data: {
@@ -86,12 +132,7 @@ export async function GET(
name: table.name,
description: table.description,
schema: {
columns: (table.schema as any).columns.map((col: any) => ({
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
})),
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
@@ -115,17 +156,23 @@ export async function GET(
/**
* DELETE /api/table/[tableId]?workspaceId=xxx
* Delete a table (hard delete)
*
* Permanently deletes a table and all its rows.
*
* @param request - The incoming HTTP request
* @param context - Route context containing tableId param
* @returns JSON response confirming deletion or error
*
* @remarks
* This performs a hard delete, removing all data permanently.
* The operation requires write access to the table.
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ tableId: string }> }
) {
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(_request)
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized table delete attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })

View File

@@ -13,40 +13,73 @@ import {
validateRowSize,
validateUniqueConstraints,
} from '@/lib/table'
import { checkTableAccess, checkTableWriteAccess, verifyTableWorkspace } from '../../utils'
import { checkTableAccess, checkTableWriteAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableRowAPI')
/**
* Schema for getting a single row by ID
* Type for dynamic row data stored in tables.
* Keys are column names, values can be any JSON-serializable type.
*/
type RowData = Record<string, unknown>
/**
* Zod schema for validating get row requests.
*
* The workspaceId is optional for backward compatibility but
* is validated via table access checks when provided.
*/
const GetRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
})
/**
* Schema for updating a single row
* Zod schema for validating update row requests.
*/
const UpdateRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
data: z.record(z.any(), { required_error: 'Row data is required' }),
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
/**
* Schema for deleting a single row
* Zod schema for validating delete row requests.
*/
const DeleteRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
})
/**
* Route params for single row endpoints.
*/
interface RowRouteParams {
params: Promise<{ tableId: string; rowId: string }>
}
/**
* GET /api/table/[tableId]/rows/[rowId]?workspaceId=xxx
* Get a single row by ID
*
* Retrieves a single row by its ID.
*
* @param request - The incoming HTTP request
* @param context - Route context containing tableId and rowId params
* @returns JSON response with row data or error
*
* @example Response:
* ```json
* {
* "success": true,
* "data": {
* "row": {
* "id": "row_abc123",
* "data": { "name": "John", "email": "john@example.com" },
* "createdAt": "2024-01-01T00:00:00.000Z",
* "updatedAt": "2024-01-01T00:00:00.000Z"
* }
* }
* }
* ```
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string; rowId: string }> }
) {
export async function GET(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
@@ -137,12 +170,25 @@ export async function GET(
/**
* PATCH /api/table/[tableId]/rows/[rowId]
* Update an existing row
*
* Updates an existing row with new data.
*
* @param request - The incoming HTTP request with update data
* @param context - Route context containing tableId and rowId params
* @returns JSON response with updated row or error
*
* @remarks
* The entire row data must be provided; this is a full replacement,
* not a partial update.
*
* @example Request body:
* ```json
* {
* "data": { "name": "Jane", "email": "jane@example.com" }
* }
* ```
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string; rowId: string }> }
) {
export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
@@ -152,7 +198,7 @@ export async function PATCH(
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const body: unknown = await request.json()
const validated = UpdateRowSchema.parse(body)
// Check table write access (centralized access control)
@@ -192,8 +238,10 @@ export async function PATCH(
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}
const rowData = validated.data as RowData
// Validate row size
const sizeValidation = validateRowSize(validated.data)
const sizeValidation = validateRowSize(rowData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
@@ -202,7 +250,7 @@ export async function PATCH(
}
// Validate row against schema
const rowValidation = validateRowAgainstSchema(validated.data, table.schema as TableSchema)
const rowValidation = validateRowAgainstSchema(rowData, table.schema as TableSchema)
if (!rowValidation.valid) {
return NextResponse.json(
{ error: 'Row data does not match schema', details: rowValidation.errors },
@@ -223,9 +271,9 @@ export async function PATCH(
.where(eq(userTableRows.tableId, tableId))
const uniqueValidation = validateUniqueConstraints(
validated.data,
rowData,
table.schema as TableSchema,
existingRows,
existingRows.map((r) => ({ id: r.id, data: r.data as RowData })),
rowId // Exclude the current row being updated
)
@@ -288,12 +336,21 @@ export async function PATCH(
/**
* DELETE /api/table/[tableId]/rows/[rowId]
* Delete a row
*
* Permanently deletes a single row.
*
* @param request - The incoming HTTP request
* @param context - Route context containing tableId and rowId params
* @returns JSON response confirming deletion or error
*
* @example Request body:
* ```json
* {
* "workspaceId": "ws_123"
* }
* ```
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string; rowId: string }> }
) {
export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
@@ -303,7 +360,7 @@ export async function DELETE(
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const body: unknown = await request.json()
const validated = DeleteRowSchema.parse(body)
// Check table write access (centralized access control)

View File

@@ -20,33 +20,50 @@ import { checkTableAccess, checkTableWriteAccess, verifyTableWorkspace } from '.
const logger = createLogger('TableRowsAPI')
/**
* Schema for inserting a single row into a table
* Type for dynamic row data stored in tables.
* Keys are column names, values can be any JSON-serializable type.
*/
type RowData = Record<string, unknown>
/**
* Type for sort direction specification.
*/
type SortDirection = Record<string, 'asc' | 'desc'>
/**
* Zod schema for inserting a single row into a table.
*
* The workspaceId is optional for backward compatibility but is validated
* via table access checks when provided.
*/
const InsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
data: z.record(z.any(), { required_error: 'Row data is required' }),
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
/**
* Schema for batch inserting multiple rows
* Zod schema for batch inserting multiple rows.
*
* Limits:
* - Maximum 1000 rows per batch
* @remarks
* Maximum 1000 rows per batch for performance and safety.
*/
const BatchInsertRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
rows: z
.array(z.record(z.any()), { required_error: 'Rows array is required' })
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
.min(1, 'At least one row is required')
.max(1000, 'Cannot insert more than 1000 rows per batch'),
})
/** Inferred type for batch insert request body */
type BatchInsertBody = z.infer<typeof BatchInsertRowsSchema>
/**
* Schema for querying rows with filtering, sorting, and pagination
* Zod schema for querying rows with filtering, sorting, and pagination.
*/
const QueryRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
filter: z.record(z.any()).optional(),
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
filter: z.record(z.unknown()).optional(),
sort: z.record(z.enum(['asc', 'desc'])).optional(),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
@@ -64,15 +81,15 @@ const QueryRowsSchema = z.object({
})
/**
* Schema for updating multiple rows by filter criteria
* Zod schema for updating multiple rows by filter criteria.
*
* Limits:
* - Maximum 1000 rows can be updated per operation (safety limit)
* @remarks
* Maximum 1000 rows can be updated per operation for safety.
*/
const UpdateRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
filter: z.record(z.any(), { required_error: 'Filter criteria is required' }),
data: z.record(z.any(), { required_error: 'Update data is required' }),
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
@@ -82,14 +99,14 @@ const UpdateRowsByFilterSchema = z.object({
})
/**
* Schema for deleting multiple rows by filter criteria
* Zod schema for deleting multiple rows by filter criteria.
*
* Limits:
* - Maximum 1000 rows can be deleted per operation (safety limit)
* @remarks
* Maximum 1000 rows can be deleted per operation for safety.
*/
const DeleteRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
filter: z.record(z.any(), { required_error: 'Filter criteria is required' }),
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
@@ -99,9 +116,39 @@ const DeleteRowsByFilterSchema = z.object({
})
/**
* Handle batch insert of multiple rows
* Route params for table row endpoints.
*/
async function handleBatchInsert(requestId: string, tableId: string, body: any, userId: string) {
interface TableRowsRouteParams {
params: Promise<{ tableId: string }>
}
/**
* Structure for row validation errors.
*/
interface RowValidationError {
/** Index of the row with errors */
row: number
/** List of validation error messages */
errors: string[]
}
/**
* Handles batch insertion of multiple rows into a table.
*
* @param requestId - Request tracking ID for logging
* @param tableId - ID of the target table
* @param body - Validated batch insert request body
* @param userId - ID of the authenticated user
* @returns NextResponse with inserted rows or error
*
* @internal
*/
async function handleBatchInsert(
requestId: string,
tableId: string,
body: BatchInsertBody,
userId: string
): Promise<NextResponse> {
const validated = BatchInsertRowsSchema.parse(body)
// Check table write access (centralized access control)
@@ -155,10 +202,10 @@ async function handleBatchInsert(requestId: string, tableId: string, body: any,
}
// Validate all rows
const errors: { row: number; errors: string[] }[] = []
const errors: RowValidationError[] = []
for (let i = 0; i < validated.rows.length; i++) {
const rowData = validated.rows[i]
const rowData = validated.rows[i] as RowData
// Validate row size
const sizeValidation = validateRowSize(rowData)
@@ -198,16 +245,16 @@ async function handleBatchInsert(requestId: string, tableId: string, body: any,
// Validate each row for unique constraints
for (let i = 0; i < validated.rows.length; i++) {
const rowData = validated.rows[i]
const rowData = validated.rows[i] as RowData
// Also check against other rows in the batch
const batchRows = validated.rows.slice(0, i).map((data, idx) => ({
id: `batch_${idx}`,
data,
data: data as RowData,
}))
const uniqueValidation = validateUniqueConstraints(rowData, table.schema as TableSchema, [
...existingRows.map((r) => ({ id: r.id, data: r.data as Record<string, any> })),
...existingRows.map((r) => ({ id: r.id, data: r.data as RowData })),
...batchRows,
])
@@ -269,13 +316,34 @@ async function handleBatchInsert(requestId: string, tableId: string, body: any,
/**
* POST /api/table/[tableId]/rows
* Insert a new row into the table
* Supports both single row and batch insert (NDJSON format)
*
* Inserts a new row into the table.
* Supports both single row and batch insert (when `rows` array is provided).
*
* @param request - The incoming HTTP request
* @param context - Route context containing tableId param
* @returns JSON response with inserted row(s) or error
*
* @example Single row insert:
* ```json
* {
* "workspaceId": "ws_123",
* "data": { "name": "John", "email": "john@example.com" }
* }
* ```
*
* @example Batch insert:
* ```json
* {
* "workspaceId": "ws_123",
* "rows": [
* { "name": "John", "email": "john@example.com" },
* { "name": "Jane", "email": "jane@example.com" }
* ]
* }
* ```
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string }> }
) {
export async function POST(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
@@ -285,11 +353,16 @@ export async function POST(
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const body: unknown = await request.json()
// Check if this is a batch insert
if (body.rows && Array.isArray(body.rows)) {
return handleBatchInsert(requestId, tableId, body, authResult.userId)
if (
typeof body === 'object' &&
body !== null &&
'rows' in body &&
Array.isArray((body as Record<string, unknown>).rows)
) {
return handleBatchInsert(requestId, tableId, body as BatchInsertBody, authResult.userId)
}
// Single row insert
@@ -333,9 +406,10 @@ export async function POST(
// Use the workspaceId from the access check (more secure)
const workspaceId = validated.workspaceId || accessCheck.table.workspaceId
const rowData = validated.data as RowData
// Validate row size
const sizeValidation = validateRowSize(validated.data)
const sizeValidation = validateRowSize(rowData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
@@ -344,7 +418,7 @@ export async function POST(
}
// Validate row against schema
const rowValidation = validateRowAgainstSchema(validated.data, table.schema as TableSchema)
const rowValidation = validateRowAgainstSchema(rowData, table.schema as TableSchema)
if (!rowValidation.valid) {
return NextResponse.json(
{ error: 'Row data does not match schema', details: rowValidation.errors },
@@ -365,9 +439,9 @@ export async function POST(
.where(eq(userTableRows.tableId, tableId))
const uniqueValidation = validateUniqueConstraints(
validated.data,
rowData,
table.schema as TableSchema,
existingRows.map((r) => ({ id: r.id, data: r.data as Record<string, any> }))
existingRows.map((r) => ({ id: r.id, data: r.data as RowData }))
)
if (!uniqueValidation.valid) {
@@ -441,12 +515,19 @@ export async function POST(
/**
* GET /api/table/[tableId]/rows?workspaceId=xxx&filter=...&sort=...&limit=100&offset=0
* Query rows from the table with filtering, sorting, and pagination
*
* Queries rows from the table with filtering, sorting, and pagination.
*
* @param request - The incoming HTTP request with query params
* @param context - Route context containing tableId param
* @returns JSON response with matching rows and pagination info
*
* @example Query with filter:
* ```
* GET /api/table/tbl_123/rows?filter={"status":{"eq":"active"}}&limit=50&offset=0
* ```
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string }> }
) {
export async function GET(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
@@ -463,15 +544,15 @@ export async function GET(
const limit = searchParams.get('limit')
const offset = searchParams.get('offset')
let filter
let sort
let filter: Record<string, unknown> | undefined
let sort: SortDirection | undefined
try {
if (filterParam) {
filter = JSON.parse(filterParam)
filter = JSON.parse(filterParam) as Record<string, unknown>
}
if (sortParam) {
sort = JSON.parse(sortParam)
sort = JSON.parse(sortParam) as SortDirection
}
} catch {
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
@@ -542,10 +623,10 @@ export async function GET(
if (validated.sort) {
const sortClause = buildSortClause(validated.sort, 'user_table_rows')
if (sortClause) {
query = query.orderBy(sortClause) as any
query = query.orderBy(sortClause) as typeof query
}
} else {
query = query.orderBy(userTableRows.createdAt) as any
query = query.orderBy(userTableRows.createdAt) as typeof query
}
// Get total count with same filters (without pagination)
@@ -593,13 +674,22 @@ export async function GET(
/**
* PUT /api/table/[tableId]/rows
* Update multiple rows by filter criteria
* Example: Update all rows where name contains "test"
*
* Updates multiple rows matching filter criteria.
*
* @param request - The incoming HTTP request with filter and update data
* @param context - Route context containing tableId param
* @returns JSON response with count of updated rows
*
* @example Update all rows where status is "pending":
* ```json
* {
* "filter": { "status": { "eq": "pending" } },
* "data": { "status": "processed" }
* }
* ```
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string }> }
) {
export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
@@ -609,7 +699,7 @@ export async function PUT(
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const body: unknown = await request.json()
const validated = UpdateRowsByFilterSchema.parse(body)
// Check table write access (centralized access control)
@@ -650,9 +740,10 @@ export async function PUT(
// Use the workspaceId from the access check (more secure)
const actualWorkspaceId = validated.workspaceId || accessCheck.table.workspaceId
const updateData = validated.data as RowData
// Validate new data size
const sizeValidation = validateRowSize(validated.data)
const sizeValidation = validateRowSize(updateData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
@@ -682,7 +773,7 @@ export async function PUT(
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as any
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery
@@ -707,7 +798,8 @@ export async function PUT(
// Validate that merged data matches schema for each row
for (const row of matchingRows) {
const mergedData = { ...(row.data as Record<string, any>), ...validated.data }
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema)
if (!rowValidation.valid) {
return NextResponse.json(
@@ -735,11 +827,12 @@ export async function PUT(
// Validate each updated row for unique constraints
for (const row of matchingRows) {
const mergedData = { ...(row.data as Record<string, any>), ...validated.data }
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const uniqueValidation = validateUniqueConstraints(
mergedData,
table.schema as TableSchema,
allRows.map((r) => ({ id: r.id, data: r.data as Record<string, any> })),
allRows.map((r) => ({ id: r.id, data: r.data as RowData })),
row.id // Exclude the current row being updated
)
@@ -763,15 +856,16 @@ export async function PUT(
for (let i = 0; i < matchingRows.length; i += BATCH_SIZE) {
const batch = matchingRows.slice(i, i + BATCH_SIZE)
const updatePromises = batch.map((row) =>
db
const updatePromises = batch.map((row) => {
const existingData = row.data as RowData
return db
.update(userTableRows)
.set({
data: { ...(row.data as Record<string, any>), ...validated.data },
data: { ...existingData, ...updateData },
updatedAt: now,
})
.where(eq(userTableRows.id, row.id))
)
})
await Promise.all(updatePromises)
totalUpdated += batch.length
logger.info(
@@ -808,13 +902,21 @@ export async function PUT(
/**
* DELETE /api/table/[tableId]/rows
* Delete multiple rows by filter criteria
* Example: Delete all rows where seen is false
*
* Deletes multiple rows matching filter criteria.
*
* @param request - The incoming HTTP request with filter criteria
* @param context - Route context containing tableId param
* @returns JSON response with count of deleted rows
*
* @example Delete all rows where seen is false:
* ```json
* {
* "filter": { "seen": { "eq": false } }
* }
* ```
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string }> }
) {
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
@@ -824,7 +926,7 @@ export async function DELETE(
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const body: unknown = await request.json()
const validated = DeleteRowsByFilterSchema.parse(body)
// Check table write access (centralized access control)
@@ -874,7 +976,7 @@ export async function DELETE(
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as any
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery

View File

@@ -8,30 +8,87 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { TableSchema } from '@/lib/table'
import { getUniqueColumns, validateRowAgainstSchema, validateRowSize } from '@/lib/table'
import { checkTableWriteAccess, verifyTableWorkspace } from '../../utils'
import { checkTableWriteAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableUpsertAPI')
/**
* Schema for upserting a row (insert or update based on unique column constraints)
* Type for dynamic row data stored in tables.
* Keys are column names, values can be any JSON-serializable type.
*/
type RowData = Record<string, unknown>
/**
* Zod schema for validating upsert (insert or update) requests.
*
* If a row with matching unique field(s) exists, it will be updated.
* Otherwise, a new row will be inserted.
*
* @remarks
* The workspaceId is optional for backward compatibility but
* is validated via table access checks when provided.
*/
const UpsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required').optional(), // Optional for backward compatibility, validated via table access
data: z.record(z.any(), { required_error: 'Row data is required' }),
workspaceId: z.string().min(1, 'Workspace ID is required').optional(),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
/**
* POST /api/table/[tableId]/rows/upsert
* Insert or update a row based on unique column constraints
* If a row with matching unique field(s) exists, update it; otherwise insert
* Route params for upsert endpoint.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ tableId: string }> }
) {
interface UpsertRouteParams {
params: Promise<{ tableId: string }>
}
/**
* POST /api/table/[tableId]/rows/upsert
*
* Inserts or updates a row based on unique column constraints.
* If a row with matching unique field(s) exists, it will be updated;
* otherwise, a new row will be inserted.
*
* @param request - The incoming HTTP request with row data
* @param context - Route context containing tableId param
* @returns JSON response with upserted row and operation type
*
* @remarks
* Requires at least one unique column in the table schema.
* The operation is determined by checking existing rows against
* the unique field values provided in the request.
*
* @example Request body:
* ```json
* {
* "workspaceId": "ws_123",
* "data": { "email": "john@example.com", "name": "John Doe" }
* }
* ```
*
* @example Response (insert):
* ```json
* {
* "success": true,
* "data": {
* "row": { ... },
* "operation": "insert",
* "message": "Row inserted successfully"
* }
* }
* ```
*
* @example Response (update):
* ```json
* {
* "success": true,
* "data": {
* "row": { ... },
* "operation": "update",
* "message": "Row updated successfully"
* }
* }
* ```
*/
export async function POST(request: NextRequest, { params }: UpsertRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
@@ -41,7 +98,7 @@ export async function POST(
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const body: unknown = await request.json()
const validated = UpsertRowSchema.parse(body)
// Check table write access (centralized access control)
@@ -82,9 +139,10 @@ export async function POST(
}
const schema = table.schema as TableSchema
const rowData = validated.data as RowData
// Validate row size
const sizeValidation = validateRowSize(validated.data)
const sizeValidation = validateRowSize(rowData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
@@ -93,7 +151,7 @@ export async function POST(
}
// Validate row against schema
const rowValidation = validateRowAgainstSchema(validated.data, schema)
const rowValidation = validateRowAgainstSchema(rowData, schema)
if (!rowValidation.valid) {
return NextResponse.json(
{ error: 'Row data does not match schema', details: rowValidation.errors },
@@ -116,7 +174,7 @@ export async function POST(
// Build filter to find existing row by unique fields
const uniqueFilters = uniqueColumns.map((col) => {
const value = validated.data[col.name]
const value = rowData[col.name]
if (value === undefined || value === null) {
return null
}
@@ -124,7 +182,7 @@ export async function POST(
})
// Filter out null conditions (for optional unique fields that weren't provided)
const validUniqueFilters = uniqueFilters.filter((f) => f !== null)
const validUniqueFilters = uniqueFilters.filter((f): f is Exclude<typeof f, null> => f !== null)
if (validUniqueFilters.length === 0) {
return NextResponse.json(

View File

@@ -8,11 +8,14 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { TABLE_LIMITS, validateTableName, validateTableSchema } from '@/lib/table'
import type { TableSchema } from '@/lib/table/validation'
import type { TableColumnData, TableSchemaData } from './utils'
const logger = createLogger('TableAPI')
/**
* Schema for table column definition
* Zod schema for validating a table column definition.
*
* Columns must have a name, type, and optional required/unique flags.
*/
const ColumnSchema = z.object({
name: z
@@ -36,7 +39,9 @@ const ColumnSchema = z.object({
})
/**
* Schema for creating a new table
* Zod schema for validating create table requests.
*
* Requires a name, schema with columns, and workspace ID.
*/
const CreateTableSchema = z.object({
name: z
@@ -70,16 +75,33 @@ const CreateTableSchema = z.object({
})
/**
* Schema for listing tables in a workspace
* Zod schema for validating list tables requests.
*/
const ListTablesSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
/**
* Check if user has write access to workspace
* Result of a workspace access check.
*/
async function checkWorkspaceAccess(workspaceId: string, userId: string) {
interface WorkspaceAccessResult {
/** Whether the user has any access to the workspace */
hasAccess: boolean
/** Whether the user can write (modify tables) in the workspace */
canWrite: boolean
}
/**
* Checks if a user has access to a workspace and determines their permission level.
*
* @param workspaceId - The workspace to check access for
* @param userId - The user requesting access
* @returns Access result with read and write permissions
*/
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceAccessResult> {
const [workspaceData] = await db
.select({
id: workspace.id,
@@ -125,9 +147,53 @@ async function checkWorkspaceAccess(workspaceId: string, userId: string) {
}
}
/**
* Column input type that accepts both Zod-inferred columns and database columns.
*/
interface ColumnInput {
name: string
type: 'string' | 'number' | 'boolean' | 'date' | 'json'
required?: boolean
unique?: boolean
}
/**
* 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
*/
function normalizeColumn(col: ColumnInput): TableColumnData {
return {
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
}
}
/**
* POST /api/table
* Create a new user-defined table
*
* Creates a new user-defined table in a workspace.
*
* @param request - The incoming HTTP request containing table definition
* @returns JSON response with the created table or error
*
* @example Request body:
* ```json
* {
* "name": "customers",
* "description": "Customer records",
* "workspaceId": "ws_123",
* "schema": {
* "columns": [
* { "name": "email", "type": "string", "required": true, "unique": true },
* { "name": "name", "type": "string", "required": true }
* ]
* }
* }
* ```
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
@@ -138,7 +204,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const body: unknown = await request.json()
const params = CreateTableSchema.parse(body)
// Validate table name
@@ -210,13 +276,8 @@ export async function POST(request: NextRequest) {
}
// Normalize schema to ensure all fields have explicit defaults
const normalizedSchema = {
columns: params.schema.columns.map((col) => ({
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
})),
const normalizedSchema: TableSchemaData = {
columns: params.schema.columns.map(normalizeColumn),
}
// Create table
@@ -272,7 +333,11 @@ export async function POST(request: NextRequest) {
/**
* GET /api/table?workspaceId=xxx
* List all tables in a workspace
*
* Lists all tables in a workspace.
*
* @param request - The incoming HTTP request with workspaceId query param
* @returns JSON response with array of tables or error
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
@@ -329,19 +394,17 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
success: true,
data: {
tables: tables.map((t) => ({
...t,
schema: {
columns: (t.schema as any).columns.map((col: any) => ({
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
})),
},
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
})),
tables: tables.map((t) => {
const schemaData = t.schema as TableSchemaData
return {
...t,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
}
}),
totalCount: tables.length,
},
})

View File

@@ -3,38 +3,105 @@ import { userTableDefinitions } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
/**
* Represents the core data structure for a user-defined table.
*/
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
schema: unknown
/** JSON schema defining the table's column structure */
schema: TableSchemaData
/** Maximum number of rows allowed in this table */
maxRows: number
/** Current number of rows in the table */
rowCount: number
/** Timestamp when the table was soft-deleted, if applicable */
deletedAt?: Date | null
/** Timestamp when the table was created */
createdAt: Date
/** Timestamp when the table was last updated */
updatedAt: Date
}
/**
* Schema structure for table columns stored in the database.
*/
export interface TableSchemaData {
/** Array of column definitions */
columns: TableColumnData[]
}
/**
* Represents a single column definition in the table schema.
*/
export interface TableColumnData {
/** Name of the column */
name: string
/** Data type of the column */
type: 'string' | 'number' | 'boolean' | 'date' | 'json'
/** Whether this column is required */
required?: boolean
/** Whether this column must have unique values */
unique?: boolean
}
/**
* 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 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
}
/**
* Union type for table access check results.
*/
export type TableAccessCheck = TableAccessResult | TableAccessDenied
/**
* Check if a user has access to a table
* 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<TableAccessCheck> {
const table = await db
@@ -68,10 +135,24 @@ export async function checkTableAccess(tableId: string, userId: string): Promise
}
/**
* Check if a user has write access to a table
* 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,
@@ -108,9 +189,25 @@ export async function checkTableWriteAccess(
}
/**
* Verify 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
* 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

View File

@@ -15,68 +15,138 @@ import {
Textarea,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import type { TableSchema } from '@/lib/table'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
const logger = createLogger('AddRowModal')
/**
* Table metadata needed for the add row modal.
*/
interface TableInfo {
/** Unique identifier for the table */
id: string
/** Table name for display */
name: string
/** Schema defining columns */
schema: TableSchema
}
/**
* Props for the AddRowModal component.
*/
interface AddRowModalProps {
/** Whether the modal is open */
isOpen: boolean
/** Callback when the modal should close */
onClose: () => void
table: any
/** Table to add the row to */
table: TableInfo
/** Callback when row is successfully added */
onSuccess: () => void
}
/** Row data being edited in the form */
type RowFormData = Record<string, string | boolean>
/**
* Creates initial form data for columns.
*
* @param columns - Column definitions
* @returns Initial row data with default values
*/
function createInitialRowData(columns: ColumnDefinition[]): RowFormData {
const initial: RowFormData = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = ''
}
})
return initial
}
/**
* Cleans and transforms form data for API submission.
*
* @param columns - Column definitions
* @param rowData - Form data to clean
* @returns Cleaned data object ready for API
* @throws Error if JSON parsing fails
*/
function cleanRowData(columns: ColumnDefinition[], rowData: RowFormData): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.required || (value !== '' && value !== null && value !== undefined)) {
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
if (value === '') {
cleanData[col.name] = null
} else {
try {
cleanData[col.name] = JSON.parse(value as string)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
}
})
return cleanData
}
/**
* Modal component for adding a new row to a table.
*
* @remarks
* Generates form fields based on the table schema and validates
* input before submission.
*
* @example
* ```tsx
* <AddRowModal
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
* table={tableData}
* onSuccess={() => refetchRows()}
* />
* ```
*/
export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const schema = table?.schema as TableSchema | undefined
const schema = table?.schema
const columns = schema?.columns || []
const [rowData, setRowData] = useState<Record<string, any>>({})
const [rowData, setRowData] = useState<RowFormData>({})
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (isOpen && columns.length > 0) {
const initial: Record<string, any> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = ''
}
})
setRowData(initial)
setRowData(createInitialRowData(columns))
}
}, [isOpen, columns])
/**
* Handles form submission.
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
// Clean up data - remove empty optional fields
const cleanData: Record<string, any> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.required || (value !== '' && value !== null && value !== undefined)) {
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
try {
cleanData[col.name] = value === '' ? null : JSON.parse(value)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
}
})
const cleanData = cleanRowData(columns, rowData)
const res = await fetch(`/api/table/${table?.id}/rows`, {
method: 'POST',
@@ -87,7 +157,7 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
}),
})
const result = await res.json()
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to add row')
@@ -102,6 +172,9 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
}
}
/**
* Handles modal close and resets state.
*/
const handleClose = () => {
setRowData({})
setError(null)
@@ -128,66 +201,12 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
)}
{columns.map((column) => (
<div key={column.name} className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
</span>
)}
</Label>
{column.type === 'boolean' ? (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={rowData[column.name] ?? false}
onCheckedChange={(checked) =>
setRowData((prev) => ({ ...prev, [column.name]: checked === true }))
}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{rowData[column.name] ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
<Textarea
id={column.name}
value={rowData[column.name] ?? ''}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={
column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'
}
value={rowData[column.name] ?? ''}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
</div>
</div>
<ColumnField
key={column.name}
column={column}
value={rowData[column.name]}
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
/>
))}
</form>
</ModalBody>
@@ -215,3 +234,75 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
</Modal>
)
}
/**
* Props for the ColumnField component.
*/
interface ColumnFieldProps {
/** Column definition */
column: ColumnDefinition
/** Current field value */
value: string | boolean | undefined
/** Callback when value changes */
onChange: (value: string | boolean) => void
}
/**
* Renders an input field for a column based on its type.
*/
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
return (
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
</span>
)}
</Label>
{column.type === 'boolean' ? (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{value ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
<Textarea
id={column.name}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
</div>
</div>
)
}

View File

@@ -8,14 +8,40 @@ import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from
const logger = createLogger('DeleteRowModal')
/**
* Props for the DeleteRowModal component.
*/
interface DeleteRowModalProps {
/** Whether the modal is open */
isOpen: boolean
/** Callback when the modal should close */
onClose: () => void
/** ID of the table containing the rows */
tableId: string
/** Array of row IDs to delete */
rowIds: string[]
/** Callback when deletion is successful */
onSuccess: () => void
}
/**
* Modal component for confirming row deletion.
*
* @remarks
* Supports both single row and batch deletion. Shows a confirmation
* dialog before performing the delete operation.
*
* @example
* ```tsx
* <DeleteRowModal
* isOpen={isDeleting}
* onClose={() => setIsDeleting(false)}
* tableId="tbl_123"
* rowIds={selectedRowIds}
* onSuccess={() => refetchRows()}
* />
* ```
*/
export function DeleteRowModal({
isOpen,
onClose,
@@ -29,13 +55,16 @@ export function DeleteRowModal({
const [error, setError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
/**
* Handles the delete operation.
*/
const handleDelete = async () => {
setError(null)
setIsDeleting(true)
try {
// Delete rows one by one or in batch
if (rowIds.length === 1) {
// Single row deletion
const res = await fetch(`/api/table/${tableId}/rows/${rowIds[0]}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
@@ -43,11 +72,11 @@ export function DeleteRowModal({
})
if (!res.ok) {
const result = await res.json()
const result: { error?: string } = await res.json()
throw new Error(result.error || 'Failed to delete row')
}
} else {
// Batch delete - you might want to implement a batch delete endpoint
// Batch deletion - delete rows in parallel
await Promise.all(
rowIds.map((rowId) =>
fetch(`/api/table/${tableId}/rows/${rowId}`, {
@@ -68,11 +97,16 @@ export function DeleteRowModal({
}
}
/**
* Handles modal close and resets state.
*/
const handleClose = () => {
setError(null)
onClose()
}
const isSingleRow = rowIds.length === 1
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[480px]'>
@@ -82,7 +116,7 @@ export function DeleteRowModal({
<AlertCircle className='h-[18px] w-[18px]' />
</div>
<h2 className='font-semibold text-[16px]'>
Delete {rowIds.length === 1 ? 'Row' : `${rowIds.length} Rows`}
Delete {isSingleRow ? 'Row' : `${rowIds.length} Rows`}
</h2>
</div>
</ModalHeader>
@@ -95,8 +129,8 @@ export function DeleteRowModal({
)}
<p className='text-[14px] text-[var(--text-secondary)]'>
Are you sure you want to delete {rowIds.length === 1 ? 'this row' : 'these rows'}?
This action cannot be undone.
Are you sure you want to delete {isSingleRow ? 'this row' : 'these rows'}? This action
cannot be undone.
</p>
</div>
</ModalBody>

View File

@@ -15,33 +15,139 @@ import {
Textarea,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import type { TableSchema } from '@/lib/table'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
const logger = createLogger('EditRowModal')
/**
* Represents row data from the table.
*/
interface TableRowData {
/** Unique identifier for the row */
id: string
data: Record<string, any>
/** Row field values keyed by column name */
data: Record<string, unknown>
/** ISO timestamp when the row was created */
createdAt: string
/** ISO timestamp when the row was last updated */
updatedAt: string
}
/**
* Table metadata needed for the edit row modal.
*/
interface TableInfo {
/** Unique identifier for the table */
id: string
/** Table name for display */
name: string
/** Schema defining columns */
schema: TableSchema
}
/**
* Props for the EditRowModal component.
*/
interface EditRowModalProps {
/** Whether the modal is open */
isOpen: boolean
/** Callback when the modal should close */
onClose: () => void
table: any
/** Table containing the row */
table: TableInfo
/** Row being edited */
row: TableRowData
/** Callback when row is successfully updated */
onSuccess: () => void
}
/** Row data being edited in the form */
type RowFormData = Record<string, unknown>
/**
* Cleans and transforms form data for API submission.
*
* @param columns - Column definitions
* @param rowData - Form data to clean
* @returns Cleaned data object ready for API
* @throws Error if JSON parsing fails
*/
function cleanRowData(columns: ColumnDefinition[], rowData: RowFormData): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
if (typeof value === 'string') {
try {
cleanData[col.name] = JSON.parse(value)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
} else {
cleanData[col.name] = value
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
})
return cleanData
}
/**
* Formats a value for display in the input field.
*
* @param value - The value to format
* @param type - The column type
* @returns Formatted string value
*/
function formatValueForInput(value: unknown, type: string): string {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
if (type === 'date' && value) {
try {
const date = new Date(String(value))
return date.toISOString().split('T')[0]
} catch {
return String(value)
}
}
return String(value)
}
/**
* Modal component for editing an existing table row.
*
* @remarks
* Generates form fields based on the table schema and validates
* input before submission.
*
* @example
* ```tsx
* <EditRowModal
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
* table={tableData}
* row={selectedRow}
* onSuccess={() => refetchRows()}
* />
* ```
*/
export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const schema = table?.schema as TableSchema | undefined
const schema = table?.schema
const columns = schema?.columns || []
const [rowData, setRowData] = useState<Record<string, any>>(row.data)
const [rowData, setRowData] = useState<RowFormData>(row.data)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -49,30 +155,16 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
setRowData(row.data)
}, [row.data])
/**
* Handles form submission.
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
// Clean up data
const cleanData: Record<string, any> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
try {
cleanData[col.name] = typeof value === 'string' ? JSON.parse(value) : value
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
})
const cleanData = cleanRowData(columns, rowData)
const res = await fetch(`/api/table/${table?.id}/rows/${row.id}`, {
method: 'PATCH',
@@ -83,7 +175,7 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
}),
})
const result = await res.json()
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to update row')
@@ -98,27 +190,14 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
}
}
/**
* Handles modal close and resets state.
*/
const handleClose = () => {
setError(null)
onClose()
}
const formatValueForInput = (value: any, type: string): string => {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
if (type === 'date' && value) {
try {
const date = new Date(value)
return date.toISOString().split('T')[0]
} catch {
return String(value)
}
}
return String(value)
}
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[600px]'>
@@ -139,66 +218,12 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
)}
{columns.map((column) => (
<div key={column.name} className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
</span>
)}
</Label>
{column.type === 'boolean' ? (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={Boolean(rowData[column.name])}
onCheckedChange={(checked) =>
setRowData((prev) => ({ ...prev, [column.name]: checked === true }))
}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{rowData[column.name] ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
<Textarea
id={column.name}
value={formatValueForInput(rowData[column.name], column.type)}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={
column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'
}
value={formatValueForInput(rowData[column.name], column.type)}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
</div>
</div>
<ColumnField
key={column.name}
column={column}
value={rowData[column.name]}
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
/>
))}
</form>
</ModalBody>
@@ -226,3 +251,75 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
</Modal>
)
}
/**
* Props for the ColumnField component.
*/
interface ColumnFieldProps {
/** Column definition */
column: ColumnDefinition
/** Current field value */
value: unknown
/** Callback when value changes */
onChange: (value: unknown) => void
}
/**
* Renders an input field for a column based on its type.
*/
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
return (
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
</span>
)}
</Label>
{column.type === 'boolean' ? (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{value ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
<Textarea
id={column.name}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
</div>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { ArrowDownAZ, ArrowUpAZ, Plus, X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
/**
* Available comparison operators for filter conditions
* Available comparison operators for filter conditions.
*/
const COMPARISON_OPERATORS = [
{ value: 'eq', label: 'equals' },
@@ -19,7 +19,7 @@ const COMPARISON_OPERATORS = [
] as const
/**
* Logical operators for combining conditions (for subsequent filters)
* Logical operators for combining filter conditions.
*/
const LOGICAL_OPERATORS = [
{ value: 'and', label: 'and' },
@@ -27,7 +27,7 @@ const LOGICAL_OPERATORS = [
] as const
/**
* Sort direction options
* Sort direction options.
*/
const SORT_DIRECTIONS = [
{ value: 'asc', label: 'ascending' },
@@ -35,80 +35,129 @@ const SORT_DIRECTIONS = [
] as const
/**
* Represents a single filter condition
* Represents a single filter condition.
*/
export interface FilterCondition {
/** Unique identifier for the condition */
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
* Represents a sort configuration.
*/
export interface SortConfig {
/** Column to sort by */
column: string
/** Sort direction */
direction: 'asc' | 'desc'
}
/**
* Query options for the table
* Filter value structure for API queries.
*/
type FilterValue = string | number | boolean | null | FilterValue[] | { [key: string]: FilterValue }
/**
* Query options for the table API.
*/
export interface QueryOptions {
filter: Record<string, any> | null
/** Filter criteria or null for no filter */
filter: Record<string, FilterValue> | null
/** Sort configuration or null for default sort */
sort: SortConfig | null
}
/**
* Column definition for filter building.
*/
interface Column {
/** Column name */
name: string
/** Column data type */
type: string
}
/**
* Props for the FilterBuilder component.
*/
interface FilterBuilderProps {
/** Available columns for filtering */
columns: Column[]
/** Callback when query options should be applied */
onApply: (options: QueryOptions) => void
/** Callback to add a new row */
onAddRow: () => void
}
/**
* Generates a unique ID for filter conditions
* Generates a unique ID for filter conditions.
*
* @returns A random string ID
*/
function generateId(): string {
return Math.random().toString(36).substring(2, 9)
}
/**
* Converts filter conditions to MongoDB-style filter object
* Parses a string value into its appropriate type.
*
* @param value - String value to parse
* @returns Parsed value (boolean, null, number, or string)
*/
function conditionsToFilter(conditions: FilterCondition[]): Record<string, any> | null {
function parseValue(value: string): string | number | boolean | null {
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (!Number.isNaN(Number(value)) && value !== '') return Number(value)
return value
}
/**
* Parses a comma-separated string into an array of values.
*
* @param value - Comma-separated string
* @returns Array of parsed values
*/
function parseArrayValue(value: string): FilterValue[] {
return value.split(',').map((v) => {
const trimmed = v.trim()
return parseValue(trimmed)
})
}
/**
* Converts filter conditions to MongoDB-style filter object.
*
* @param conditions - Array of filter conditions
* @returns Filter object for API or null if no conditions
*/
function conditionsToFilter(conditions: FilterCondition[]): Record<string, FilterValue> | null {
if (conditions.length === 0) return null
const orGroups: Record<string, any>[] = []
let currentAndGroup: Record<string, any> = {}
const orGroups: Record<string, FilterValue>[] = []
let currentAndGroup: Record<string, FilterValue> = {}
conditions.forEach((condition, index) => {
const { column, operator, value } = condition
const operatorKey = `$${operator}`
let parsedValue: any = value
if (value === 'true') parsedValue = true
else if (value === 'false') parsedValue = false
else if (value === 'null') parsedValue = null
else if (!Number.isNaN(Number(value)) && value !== '') parsedValue = Number(value)
else if (operator === 'in') {
parsedValue = value.split(',').map((v) => {
const trimmed = v.trim()
if (trimmed === 'true') return true
if (trimmed === 'false') return false
if (trimmed === 'null') return null
if (!Number.isNaN(Number(trimmed)) && trimmed !== '') return Number(trimmed)
return trimmed
})
let parsedValue: FilterValue = value
if (operator === 'in') {
parsedValue = parseArrayValue(value)
} else {
parsedValue = parseValue(value)
}
const conditionObj = operator === 'eq' ? parsedValue : { [operatorKey]: parsedValue }
const conditionObj: FilterValue =
operator === 'eq' ? parsedValue : { [operatorKey]: parsedValue }
if (index === 0 || condition.logicalOperator === 'and') {
currentAndGroup[column] = conditionObj
@@ -131,6 +180,24 @@ function conditionsToFilter(conditions: FilterCondition[]): Record<string, any>
return orGroups[0] || null
}
/**
* Component for building filter and sort queries for table data.
*
* @remarks
* Provides a visual interface for:
* - Adding multiple filter conditions with AND/OR logic
* - Configuring sort column and direction
* - Applying or clearing the query
*
* @example
* ```tsx
* <FilterBuilder
* columns={tableColumns}
* onApply={(options) => setQueryOptions(options)}
* onAddRow={() => setShowAddModal(true)}
* />
* ```
*/
export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps) {
const [conditions, setConditions] = useState<FilterCondition[]>([])
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null)
@@ -155,6 +222,9 @@ export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps
[]
)
/**
* Adds a new filter condition.
*/
const handleAddCondition = useCallback(() => {
const newCondition: FilterCondition = {
id: generateId(),
@@ -166,10 +236,16 @@ export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps
setConditions((prev) => [...prev, newCondition])
}, [columns])
/**
* Removes a filter condition by ID.
*/
const handleRemoveCondition = useCallback((id: string) => {
setConditions((prev) => prev.filter((c) => c.id !== id))
}, [])
/**
* Updates a filter condition field.
*/
const handleUpdateCondition = useCallback(
(id: string, field: keyof FilterCondition, value: string) => {
setConditions((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)))
@@ -177,6 +253,9 @@ export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps
[]
)
/**
* Adds a sort configuration.
*/
const handleAddSort = useCallback(() => {
setSortConfig({
column: columns[0]?.name || '',
@@ -184,10 +263,16 @@ export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps
})
}, [columns])
/**
* Removes the sort configuration.
*/
const handleRemoveSort = useCallback(() => {
setSortConfig(null)
}, [])
/**
* Applies the current filter and sort configuration.
*/
const handleApply = useCallback(() => {
const filter = conditionsToFilter(conditions)
onApply({
@@ -196,6 +281,9 @@ export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps
})
}, [conditions, sortConfig, onApply])
/**
* Clears all filters and sort configuration.
*/
const handleClear = useCallback(() => {
setConditions([])
setSortConfig(null)
@@ -211,123 +299,28 @@ export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps
<div className='flex flex-col gap-[8px]'>
{/* Filter Conditions */}
{conditions.map((condition, index) => (
<div key={condition.id} className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveCondition(condition.id)}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
{index === 0 ? (
<Combobox
size='sm'
options={[{ value: 'where', label: 'where' }]}
value='where'
disabled
/>
) : (
<Combobox
size='sm'
options={logicalOptions}
value={condition.logicalOperator}
onChange={(value) =>
handleUpdateCondition(condition.id, 'logicalOperator', value as 'and' | 'or')
}
/>
)}
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={condition.column}
onChange={(value) => handleUpdateCondition(condition.id, 'column', value)}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={condition.operator}
onChange={(value) => handleUpdateCondition(condition.id, 'operator', value)}
/>
</div>
<Input
className='h-[28px] min-w-[200px] flex-1 text-[12px]'
value={condition.value}
onChange={(e) => handleUpdateCondition(condition.id, 'value', e.target.value)}
placeholder='Value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleApply()
}
}}
/>
</div>
<FilterConditionRow
key={condition.id}
condition={condition}
index={index}
columnOptions={columnOptions}
comparisonOptions={comparisonOptions}
logicalOptions={logicalOptions}
onUpdate={handleUpdateCondition}
onRemove={handleRemoveCondition}
onApply={handleApply}
/>
))}
{/* Sort Row */}
{sortConfig && (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={handleRemoveSort}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
<Combobox
size='sm'
options={[{ value: 'order', label: 'order' }]}
value='order'
disabled
/>
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={sortConfig.column}
onChange={(value) =>
setSortConfig((prev) => (prev ? { ...prev, column: value } : null))
}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={sortDirectionOptions}
value={sortConfig.direction}
onChange={(value) =>
setSortConfig((prev) =>
prev ? { ...prev, direction: value as 'asc' | 'desc' } : null
)
}
/>
</div>
<div className='flex items-center text-[12px] text-[var(--text-tertiary)]'>
{sortConfig.direction === 'asc' ? (
<ArrowUpAZ className='h-[14px] w-[14px]' />
) : (
<ArrowDownAZ className='h-[14px] w-[14px]' />
)}
</div>
</div>
<SortConfigRow
sortConfig={sortConfig}
columnOptions={columnOptions}
sortDirectionOptions={sortDirectionOptions}
onChange={setSortConfig}
onRemove={handleRemoveSort}
/>
)}
{/* Action Buttons */}
@@ -367,3 +360,172 @@ export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps
</div>
)
}
/**
* Props for the FilterConditionRow component.
*/
interface FilterConditionRowProps {
/** The filter condition */
condition: FilterCondition
/** Index in the conditions array */
index: number
/** Available column options */
columnOptions: Array<{ value: string; label: string }>
/** Available comparison operator options */
comparisonOptions: Array<{ value: string; label: string }>
/** Available logical operator options */
logicalOptions: Array<{ value: string; label: string }>
/** Callback to update a condition field */
onUpdate: (id: string, field: keyof FilterCondition, value: string) => void
/** Callback to remove the condition */
onRemove: (id: string) => void
/** Callback to apply filters */
onApply: () => void
}
/**
* A single filter condition row.
*/
function FilterConditionRow({
condition,
index,
columnOptions,
comparisonOptions,
logicalOptions,
onUpdate,
onRemove,
onApply,
}: FilterConditionRowProps) {
return (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(condition.id)}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
{index === 0 ? (
<Combobox
size='sm'
options={[{ value: 'where', label: 'where' }]}
value='where'
disabled
/>
) : (
<Combobox
size='sm'
options={logicalOptions}
value={condition.logicalOperator}
onChange={(value) => onUpdate(condition.id, 'logicalOperator', value as 'and' | 'or')}
/>
)}
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={condition.column}
onChange={(value) => onUpdate(condition.id, 'column', value)}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={condition.operator}
onChange={(value) => onUpdate(condition.id, 'operator', value)}
/>
</div>
<Input
className='h-[28px] min-w-[200px] flex-1 text-[12px]'
value={condition.value}
onChange={(e) => onUpdate(condition.id, 'value', e.target.value)}
placeholder='Value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
onApply()
}
}}
/>
</div>
)
}
/**
* Props for the SortConfigRow component.
*/
interface SortConfigRowProps {
/** The sort configuration */
sortConfig: SortConfig
/** Available column options */
columnOptions: Array<{ value: string; label: string }>
/** Available sort direction options */
sortDirectionOptions: Array<{ value: string; label: string }>
/** Callback to update the sort configuration */
onChange: (config: SortConfig | null) => void
/** Callback to remove the sort */
onRemove: () => void
}
/**
* Sort configuration row component.
*/
function SortConfigRow({
sortConfig,
columnOptions,
sortDirectionOptions,
onChange,
onRemove,
}: SortConfigRowProps) {
return (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={onRemove}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
<Combobox size='sm' options={[{ value: 'order', label: 'order' }]} value='order' disabled />
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={sortConfig.column}
onChange={(value) => onChange({ ...sortConfig, column: value })}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={sortDirectionOptions}
value={sortConfig.direction}
onChange={(value) => onChange({ ...sortConfig, direction: value as 'asc' | 'desc' })}
/>
</div>
<div className='flex items-center text-[12px] text-[var(--text-tertiary)]'>
{sortConfig.direction === 'asc' ? (
<ArrowUpAZ className='h-[14px] w-[14px]' />
) : (
<ArrowDownAZ className='h-[14px] w-[14px]' />
)}
</div>
</div>
)
}

View File

@@ -1,4 +1,10 @@
/**
* Table data viewer components.
*
* @module tables/[tableId]/components
*/
export * from './add-row-modal'
export * from './delete-row-modal'
export * from './edit-row-modal'
export * from './filter-builder'
export * from './table-action-bar'

View File

@@ -3,12 +3,34 @@
import { Trash2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
/**
* Props for the TableActionBar component.
*/
interface TableActionBarProps {
/** Number of currently selected rows */
selectedCount: number
/** Callback when delete action is triggered */
onDelete: () => void
/** Callback when selection should be cleared */
onClearSelection: () => void
}
/**
* Action bar displayed when rows are selected in the table.
*
* @remarks
* Shows the count of selected rows and provides actions for
* bulk operations like deletion.
*
* @example
* ```tsx
* <TableActionBar
* selectedCount={selectedRows.size}
* onDelete={handleDeleteSelected}
* onClearSelection={() => setSelectedRows(new Set())}
* />
* ```
*/
export function TableActionBar({ selectedCount, onDelete, onClearSelection }: TableActionBarProps) {
return (
<div className='flex h-[36px] shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-[16px]'>

View File

@@ -27,7 +27,7 @@ import {
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn'
import type { TableSchema } from '@/lib/table'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
import { AddRowModal } from './components/add-row-modal'
import { DeleteRowModal } from './components/delete-row-modal'
import { EditRowModal } from './components/edit-row-modal'
@@ -36,40 +36,112 @@ import { TableActionBar } from './components/table-action-bar'
const logger = createLogger('TableDataViewer')
/** Number of rows to fetch per page */
const ROWS_PER_PAGE = 100
/** Maximum length for string display before truncation */
const STRING_TRUNCATE_LENGTH = 50
/**
* Represents row data stored in a table.
*/
interface TableRowData {
/** Unique identifier for the row */
id: string
data: Record<string, any>
/** Row field values keyed by column name */
data: Record<string, unknown>
/** ISO timestamp when the row was created */
createdAt: string
/** ISO timestamp when the row was last updated */
updatedAt: string
}
/**
* Represents table metadata.
*/
interface TableData {
/** Unique identifier for the table */
id: string
/** Table name */
name: string
/** Optional description */
description?: string
/** Schema defining columns */
schema: TableSchema
/** Current number of rows */
rowCount: number
/** Maximum allowed rows */
maxRows: number
/** ISO timestamp when created */
createdAt: string
/** ISO timestamp when last updated */
updatedAt: string
}
/**
* Data for the cell viewer modal.
*/
interface CellViewerData {
/** Name of the column being viewed */
columnName: string
value: any
/** Value being displayed */
value: unknown
/** Display type for formatting */
type: 'json' | 'text' | 'date'
}
const STRING_TRUNCATE_LENGTH = 50
/**
* State for the right-click context menu.
*/
interface ContextMenuState {
/** Whether the menu is visible */
isOpen: boolean
/** Screen position of the menu */
position: { x: number; y: number }
/** Row the menu was opened on */
row: TableRowData | null
}
/**
* Gets the badge variant for a column type.
*
* @param type - The column type
* @returns Badge variant name
*/
function getTypeBadgeVariant(
type: string
): 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' {
switch (type) {
case 'string':
return 'green'
case 'number':
return 'blue'
case 'boolean':
return 'purple'
case 'json':
return 'orange'
case 'date':
return 'teal'
default:
return 'gray'
}
}
/**
* Main component for viewing and managing table data.
*
* @remarks
* Provides functionality for:
* - Viewing rows with pagination
* - Filtering and sorting
* - Adding, editing, and deleting rows
* - Viewing cell details for long/complex values
*
* @example
* ```tsx
* <TableDataViewer />
* ```
*/
export function TableDataViewer() {
const params = useParams()
const router = useRouter()
@@ -104,9 +176,9 @@ export function TableDataViewer() {
queryFn: async () => {
const res = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`)
if (!res.ok) throw new Error('Failed to fetch table')
const json = await res.json()
const json: { data?: { table: TableData }; table?: TableData } = await res.json()
const data = json.data || json
return data.table as TableData
return (data as { table: TableData }).table
},
})
@@ -118,25 +190,29 @@ export function TableDataViewer() {
} = useQuery({
queryKey: ['table-rows', tableId, queryOptions, currentPage],
queryFn: async () => {
const params = new URLSearchParams({
const searchParams = new URLSearchParams({
workspaceId,
limit: String(ROWS_PER_PAGE),
offset: String(currentPage * ROWS_PER_PAGE),
})
if (queryOptions.filter) {
params.set('filter', JSON.stringify(queryOptions.filter))
searchParams.set('filter', JSON.stringify(queryOptions.filter))
}
if (queryOptions.sort) {
// Convert from {column, direction} to {column: direction} format expected by API
const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction }
params.set('sort', JSON.stringify(sortParam))
searchParams.set('sort', JSON.stringify(sortParam))
}
const res = await fetch(`/api/table/${tableId}/rows?${params}`)
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`)
if (!res.ok) throw new Error('Failed to fetch rows')
const json = await res.json()
const json: {
data?: { rows: TableRowData[]; totalCount: number }
rows?: TableRowData[]
totalCount?: number
} = await res.json()
return json.data || json
},
enabled: !!tableData,
@@ -147,11 +223,17 @@ export function TableDataViewer() {
const totalCount = rowsData?.totalCount || 0
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
/**
* Applies new query options and resets pagination.
*/
const handleApplyQueryOptions = useCallback((options: QueryOptions) => {
setQueryOptions(options)
setCurrentPage(0)
}, [])
/**
* Toggles selection of all visible rows.
*/
const handleSelectAll = useCallback(() => {
if (selectedRows.size === rows.length) {
setSelectedRows(new Set())
@@ -160,6 +242,9 @@ export function TableDataViewer() {
}
}, [rows, selectedRows.size])
/**
* Toggles selection of a single row.
*/
const handleSelectRow = useCallback((rowId: string) => {
setSelectedRows((prev) => {
const newSet = new Set(prev)
@@ -172,15 +257,23 @@ export function TableDataViewer() {
})
}, [])
/**
* Refreshes the rows data.
*/
const handleRefresh = useCallback(() => {
refetchRows()
}, [refetchRows])
/**
* Opens the delete modal for selected rows.
*/
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
// Context menu handlers
/**
* Opens the context menu for a row.
*/
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => {
e.preventDefault()
e.stopPropagation()
@@ -191,10 +284,16 @@ export function TableDataViewer() {
})
}, [])
/**
* Closes the context menu.
*/
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
/**
* Handles edit action from context menu.
*/
const handleContextMenuEdit = useCallback(() => {
if (contextMenu.row) {
setEditingRow(contextMenu.row)
@@ -202,6 +301,9 @@ export function TableDataViewer() {
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
/**
* Handles delete action from context menu.
*/
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
setDeletingRows([contextMenu.row.id])
@@ -209,13 +311,15 @@ export function TableDataViewer() {
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
/**
* Copies the current cell value to clipboard.
*/
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
// Copy ISO format for dates (parseable)
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
@@ -226,29 +330,11 @@ export function TableDataViewer() {
}
}, [cellViewer])
const formatValue = (value: any, type: string): string => {
if (value === null || value === undefined) return '—'
switch (type) {
case 'boolean':
return value ? 'true' : 'false'
case 'date':
try {
return new Date(value).toLocaleDateString()
} catch {
return String(value)
}
case 'json':
return JSON.stringify(value)
case 'number':
return String(value)
default:
return String(value)
}
}
/**
* Opens the cell viewer modal.
*/
const handleCellClick = useCallback(
(e: React.MouseEvent, columnName: string, value: any, type: 'json' | 'text' | 'date') => {
(e: React.MouseEvent, columnName: string, value: unknown, type: 'json' | 'text' | 'date') => {
e.preventDefault()
e.stopPropagation()
setCellViewer({ columnName, value, type })
@@ -256,7 +342,10 @@ export function TableDataViewer() {
[]
)
const renderCellValue = (value: any, column: { name: string; type: string }) => {
/**
* Renders a cell value with appropriate formatting.
*/
const renderCellValue = (value: unknown, column: ColumnDefinition) => {
const isNull = value === null || value === undefined
if (isNull) {
@@ -278,9 +367,10 @@ export function TableDataViewer() {
}
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<span className={value ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{value ? 'true' : 'false'}
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{boolValue ? 'true' : 'false'}
</span>
)
}
@@ -293,7 +383,7 @@ export function TableDataViewer() {
if (column.type === 'date') {
try {
const date = new Date(value)
const date = new Date(String(value))
const formatted = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
@@ -448,59 +538,13 @@ export function TableDataViewer() {
</TableHeader>
<TableBody>
{isLoadingRows ? (
Array.from({ length: 25 }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{columns.map((col, colIndex) => {
// Vary skeleton width based on column type and add some randomness
const baseWidth =
col.type === 'json'
? 200
: col.type === 'string'
? 160
: col.type === 'number'
? 80
: col.type === 'boolean'
? 50
: col.type === 'date'
? 100
: 120
// Add some variation per row
const variation = ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
return (
<TableCell key={col.name}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</TableCell>
)
})}
<TableCell>
<div className='flex gap-[4px]'>
<Skeleton className='h-[24px] w-[24px]' />
<Skeleton className='h-[24px] w-[24px]' />
</div>
</TableCell>
</TableRow>
))
<LoadingRows columns={columns} />
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 2} className='h-[160px] text-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{queryOptions.filter ? 'No rows match your filter' : 'No data'}
</span>
{!queryOptions.filter && (
<Button variant='default' size='sm' onClick={() => setShowAddModal(true)}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</TableCell>
</TableRow>
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={() => setShowAddModal(true)}
/>
) : (
rows.map((row) => (
<TableRow
@@ -623,166 +667,19 @@ export function TableDataViewer() {
)}
{/* Schema Viewer Modal */}
<Modal open={showSchemaModal} onOpenChange={setShowSchemaModal}>
<ModalContent className='w-[500px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
Table Schema
</span>
<Badge variant='gray' size='sm'>
{columns.length} columns
</Badge>
</div>
<Button variant='ghost' size='sm' onClick={() => setShowSchemaModal(false)}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
<ModalBody className='p-0'>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[180px]'>Column</TableHead>
<TableHead className='w-[100px]'>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono text-[12px] text-[var(--text-primary)]'>
{column.name}
</TableCell>
<TableCell>
<Badge
variant={
column.type === 'string'
? 'green'
: column.type === 'number'
? 'blue'
: column.type === 'boolean'
? 'purple'
: column.type === 'json'
? 'orange'
: column.type === 'date'
? 'teal'
: 'gray'
}
size='sm'
>
{column.type}
</Badge>
</TableCell>
<TableCell className='text-[12px]'>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ModalBody>
</ModalContent>
</Modal>
<SchemaViewerModal
isOpen={showSchemaModal}
onClose={() => setShowSchemaModal(false)}
columns={columns}
/>
{/* Cell Viewer Modal */}
<Modal open={!!cellViewer} onOpenChange={(open) => !open && setCellViewer(null)}>
<ModalContent className='w-[640px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer?.columnName}
</span>
<Badge
variant={
cellViewer?.type === 'json'
? 'blue'
: cellViewer?.type === 'date'
? 'purple'
: 'gray'
}
size='sm'
>
{cellViewer?.type === 'json'
? 'JSON'
: cellViewer?.type === 'date'
? 'Date'
: 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
<Button
variant={copied ? 'tertiary' : 'default'}
size='sm'
onClick={handleCopyCellValue}
>
<Copy className='mr-[4px] h-[12px] w-[12px]' />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant='ghost' size='sm' onClick={() => setCellViewer(null)}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<ModalBody className='p-0'>
{cellViewer?.type === 'json' ? (
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{cellViewer ? JSON.stringify(cellViewer.value, null, 2) : ''}
</pre>
) : cellViewer?.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{cellViewer
? new Date(cellViewer.value).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})
: ''}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{cellViewer ? String(cellViewer.value) : ''}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{cellViewer ? String(cellViewer.value) : ''}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
{/* Row Context Menu */}
<Popover
@@ -823,3 +720,242 @@ export function TableDataViewer() {
</div>
)
}
/**
* Loading skeleton for table rows.
*/
function LoadingRows({ columns }: { columns: ColumnDefinition[] }) {
return (
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{columns.map((col, colIndex) => {
const baseWidth =
col.type === 'json'
? 200
: col.type === 'string'
? 160
: col.type === 'number'
? 80
: col.type === 'boolean'
? 50
: col.type === 'date'
? 100
: 120
const variation = ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
return (
<TableCell key={col.name}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</TableCell>
)
})}
<TableCell>
<div className='flex gap-[4px]'>
<Skeleton className='h-[24px] w-[24px]' />
<Skeleton className='h-[24px] w-[24px]' />
</div>
</TableCell>
</TableRow>
))}
</>
)
}
/**
* Empty state for table rows.
*/
function EmptyRows({
columnCount,
hasFilter,
onAddRow,
}: {
columnCount: number
hasFilter: boolean
onAddRow: () => void
}) {
return (
<TableRow>
<TableCell colSpan={columnCount + 2} className='h-[160px] text-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{hasFilter ? 'No rows match your filter' : 'No data'}
</span>
{!hasFilter && (
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}
/**
* Modal for viewing table schema.
*/
function SchemaViewerModal({
isOpen,
onClose,
columns,
}: {
isOpen: boolean
onClose: () => void
columns: ColumnDefinition[]
}) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent className='w-[500px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Table Schema</span>
<Badge variant='gray' size='sm'>
{columns.length} columns
</Badge>
</div>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
<ModalBody className='p-0'>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[180px]'>Column</TableHead>
<TableHead className='w-[100px]'>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono text-[12px] text-[var(--text-primary)]'>
{column.name}
</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell className='text-[12px]'>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}
/**
* Modal for viewing cell details.
*/
function CellViewerModal({
cellViewer,
onClose,
onCopy,
copied,
}: {
cellViewer: CellViewerData | null
onClose: () => void
onCopy: () => void
copied: boolean
}) {
if (!cellViewer) return null
return (
<Modal open={!!cellViewer} onOpenChange={(open) => !open && onClose()}>
<ModalContent className='w-[640px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer.columnName}
</span>
<Badge
variant={
cellViewer.type === 'json' ? 'blue' : cellViewer.type === 'date' ? 'purple' : 'gray'
}
size='sm'
>
{cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
<Button variant={copied ? 'tertiary' : 'default'} size='sm' onClick={onCopy}>
<Copy className='mr-[4px] h-[12px] w-[12px]' />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<ModalBody className='p-0'>
{cellViewer.type === 'json' ? (
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{JSON.stringify(cellViewer.value, null, 2)}
</pre>
) : cellViewer.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{new Date(String(cellViewer.value)).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{String(cellViewer.value)}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{String(cellViewer.value)}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -21,19 +21,39 @@ import { useCreateTable } from '@/hooks/queries/use-tables'
const logger = createLogger('CreateTableModal')
/**
* Supported column data types for table schemas.
*/
type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
/**
* Definition for a single table column.
*/
interface ColumnDefinition {
/** Name of the column */
name: string
type: 'string' | 'number' | 'boolean' | 'date' | 'json'
/** Data type of the column */
type: ColumnType
/** Whether this column is required */
required: boolean
/** Whether this column must have unique values */
unique: boolean
}
/**
* Props for the CreateTableModal component.
*/
interface CreateTableModalProps {
/** Whether the modal is open */
isOpen: boolean
/** Callback when the modal should close */
onClose: () => void
}
const COLUMN_TYPES = [
/**
* Available column type options for the combobox.
*/
const COLUMN_TYPES: Array<{ value: ColumnType; label: string }> = [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
@@ -41,29 +61,66 @@ const COLUMN_TYPES = [
{ value: 'json', label: 'JSON' },
]
/**
* Creates an empty column definition with default values.
*/
function createEmptyColumn(): ColumnDefinition {
return { name: '', type: 'string', required: true, unique: false }
}
/**
* Modal component for creating a new table in a workspace.
*
* @remarks
* This modal allows users to:
* - Set a table name and description
* - Define columns with name, type, and constraints
* - Create the table via the API
*
* @example
* ```tsx
* <CreateTableModal
* isOpen={isModalOpen}
* onClose={() => setIsModalOpen(false)}
* />
* ```
*/
export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [tableName, setTableName] = useState('')
const [description, setDescription] = useState('')
const [columns, setColumns] = useState<ColumnDefinition[]>([
{ name: '', type: 'string', required: true, unique: false },
])
const [columns, setColumns] = useState<ColumnDefinition[]>([createEmptyColumn()])
const [error, setError] = useState<string | null>(null)
const createTable = useCreateTable(workspaceId)
/**
* Adds a new empty column to the schema.
*/
const handleAddColumn = () => {
setColumns([...columns, { name: '', type: 'string', required: true, unique: false }])
setColumns([...columns, createEmptyColumn()])
}
/**
* Removes a column from the schema by index.
*
* @param index - Index of the column to remove
*/
const handleRemoveColumn = (index: number) => {
if (columns.length > 1) {
setColumns(columns.filter((_, i) => i !== index))
}
}
/**
* Updates a column field at the specified index.
*
* @param index - Index of the column to update
* @param field - Field name to update
* @param value - New value for the field
*/
const handleColumnChange = (
index: number,
field: keyof ColumnDefinition,
@@ -74,6 +131,9 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
setColumns(newColumns)
}
/**
* Validates and submits the form to create the table.
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
@@ -108,10 +168,7 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
})
// Reset form
setTableName('')
setDescription('')
setColumns([{ name: '', type: 'string', required: true, unique: false }])
setError(null)
resetForm()
onClose()
} catch (err) {
logger.error('Failed to create table:', err)
@@ -119,12 +176,21 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
}
}
const handleClose = () => {
// Reset form on close
/**
* Resets all form fields to their initial state.
*/
const resetForm = () => {
setTableName('')
setDescription('')
setColumns([{ name: '', type: 'string', required: true, unique: false }])
setColumns([createEmptyColumn()])
setError(null)
}
/**
* Handles modal close and resets form state.
*/
const handleClose = () => {
resetForm()
onClose()
}
@@ -210,69 +276,14 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
{/* Column Rows */}
<div className='flex flex-col gap-[10px]'>
{columns.map((column, index) => (
<div key={index} className='flex items-center gap-[10px]'>
{/* Column Name */}
<div className='flex-1'>
<Input
value={column.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleColumnChange(index, 'name', e.target.value)
}
placeholder='column_name'
className='h-[36px]'
/>
</div>
{/* Column Type */}
<div className='w-[110px]'>
<Combobox
options={COLUMN_TYPES}
value={column.type}
selectedValue={column.type}
onChange={(value) =>
handleColumnChange(index, 'type', value as ColumnDefinition['type'])
}
placeholder='Type'
editable={false}
filterOptions={false}
className='h-[36px]'
/>
</div>
{/* Required Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.required}
onCheckedChange={(checked) =>
handleColumnChange(index, 'required', checked === true)
}
/>
</div>
{/* Unique Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.unique}
onCheckedChange={(checked) =>
handleColumnChange(index, 'unique', checked === true)
}
/>
</div>
{/* Delete Button */}
<div className='w-[36px]'>
<Button
type='button'
size='sm'
variant='ghost'
onClick={() => handleRemoveColumn(index)}
disabled={columns.length === 1}
className='h-[36px] w-[36px] p-0 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-error)] hover:text-[var(--text-error)]'
>
<Trash2 className='h-[15px] w-[15px]' />
</Button>
</div>
</div>
<ColumnRow
key={index}
column={column}
index={index}
isRemovable={columns.length > 1}
onChange={handleColumnChange}
onRemove={handleRemoveColumn}
/>
))}
</div>
@@ -307,3 +318,84 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
</Modal>
)
}
/**
* Props for the ColumnRow component.
*/
interface ColumnRowProps {
/** The column definition */
column: ColumnDefinition
/** Index of this column in the list */
index: number
/** Whether the remove button should be enabled */
isRemovable: boolean
/** Callback when a column field changes */
onChange: (index: number, field: keyof ColumnDefinition, value: string | boolean) => void
/** Callback to remove this column */
onRemove: (index: number) => void
}
/**
* A single row in the column definition list.
*/
function ColumnRow({ column, index, isRemovable, onChange, onRemove }: ColumnRowProps) {
return (
<div className='flex items-center gap-[10px]'>
{/* Column Name */}
<div className='flex-1'>
<Input
value={column.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(index, 'name', e.target.value)
}
placeholder='column_name'
className='h-[36px]'
/>
</div>
{/* Column Type */}
<div className='w-[110px]'>
<Combobox
options={COLUMN_TYPES}
value={column.type}
selectedValue={column.type}
onChange={(value) => onChange(index, 'type', value as ColumnType)}
placeholder='Type'
editable={false}
filterOptions={false}
className='h-[36px]'
/>
</div>
{/* Required Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.required}
onCheckedChange={(checked) => onChange(index, 'required', checked === true)}
/>
</div>
{/* Unique Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.unique}
onCheckedChange={(checked) => onChange(index, 'unique', checked === true)}
/>
</div>
{/* Delete Button */}
<div className='w-[36px]'>
<Button
type='button'
size='sm'
variant='ghost'
onClick={() => onRemove(index)}
disabled={!isRemovable}
className='h-[36px] w-[36px] p-0 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-error)] hover:text-[var(--text-error)]'
>
<Trash2 className='h-[15px] w-[15px]' />
</Button>
</div>
</div>
)
}

View File

@@ -1,2 +1,7 @@
/**
* Table management components.
*
* @module tables/components
*/
export * from './create-table-modal'
export * from './table-card'

View File

@@ -29,13 +29,21 @@ import type { TableDefinition } from '@/tools/table/types'
const logger = createLogger('TableCard')
/**
* Props for the TableCard component.
*/
interface TableCardProps {
/** The table definition to display */
table: TableDefinition
/** ID of the workspace containing this table */
workspaceId: string
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
* Formats a date string to relative time (e.g., "2h ago", "3d ago").
*
* @param dateString - ISO date string to format
* @returns Human-readable relative time string
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
@@ -52,7 +60,10 @@ function formatRelativeTime(dateString: string): string {
}
/**
* Formats a date string to absolute format for tooltip display
* Formats a date string to absolute format for tooltip display.
*
* @param dateString - ISO date string to format
* @returns Formatted date string (e.g., "Jan 15, 2024, 10:30 AM")
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
@@ -65,6 +76,43 @@ function formatAbsoluteDate(dateString: string): string {
})
}
/**
* Gets the badge variant for a column type.
*
* @param type - The column type
* @returns Badge variant name
*/
function getTypeBadgeVariant(
type: string
): 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' {
switch (type) {
case 'string':
return 'green'
case 'number':
return 'blue'
case 'boolean':
return 'purple'
case 'json':
return 'orange'
case 'date':
return 'teal'
default:
return 'gray'
}
}
/**
* Card component for displaying a table summary.
*
* @remarks
* Shows table name, column/row counts, description, and provides
* actions for viewing schema and deleting the table.
*
* @example
* ```tsx
* <TableCard table={tableData} workspaceId="ws_123" />
* ```
*/
export function TableCard({ table, workspaceId }: TableCardProps) {
const router = useRouter()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
@@ -73,6 +121,9 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
const deleteTable = useDeleteTable(workspaceId)
/**
* Handles table deletion.
*/
const handleDelete = async () => {
try {
await deleteTable.mutateAsync(table.id)
@@ -82,6 +133,13 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
}
}
/**
* Navigates to the table detail page.
*/
const navigateToTable = () => {
router.push(`/workspace/${workspaceId}/tables/${table.id}`)
}
const columnCount = table.schema.columns.length
return (
@@ -91,11 +149,11 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
tabIndex={0}
data-table-card
className='h-full cursor-pointer'
onClick={() => router.push(`/workspace/${workspaceId}/tables/${table.id}`)}
onClick={navigateToTable}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
router.push(`/workspace/${workspaceId}/tables/${table.id}`)
navigateToTable()
}
}}
>
@@ -176,6 +234,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
</div>
</div>
{/* Delete Confirmation Modal */}
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<ModalContent className='w-[400px]'>
<ModalHeader>Delete Table</ModalHeader>
@@ -207,6 +266,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
</ModalContent>
</Modal>
{/* Schema Viewer Modal */}
<Modal open={isSchemaModalOpen} onOpenChange={setIsSchemaModalOpen}>
<ModalContent className='w-[500px] duration-100'>
<ModalHeader>
@@ -235,22 +295,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
{column.name}
</TableCell>
<TableCell>
<Badge
variant={
column.type === 'string'
? 'green'
: column.type === 'number'
? 'blue'
: column.type === 'boolean'
? 'purple'
: column.type === 'json'
? 'orange'
: column.type === 'date'
? 'teal'
: 'gray'
}
size='sm'
>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
{column.type}
</Badge>
</TableCell>

View File

@@ -14,6 +14,20 @@ import { TableCard } from './components/table-card'
const logger = createLogger('Tables')
/**
* Tables page component that displays a list of all tables in a workspace.
*
* @remarks
* This component provides functionality to:
* - View all tables in the workspace
* - Search tables by name or description
* - Create new tables (with write permission)
*
* @example
* ```tsx
* <Tables />
* ```
*/
export function Tables() {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -87,56 +101,11 @@ export function Tables() {
{/* Content */}
<div className='mt-[24px] grid grid-cols-1 gap-[20px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{isLoading ? (
// Loading skeleton matching the new card style
Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className='flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] dark:bg-[var(--surface-4)]'
>
<div className='flex items-center justify-between gap-[8px]'>
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-[15px] w-[60px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<div className='flex h-[36px] flex-col gap-[6px]'>
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
</div>
</div>
))
<LoadingSkeletons />
) : error ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-[4px] bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Error loading tables
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
</div>
<ErrorState error={error} />
) : filteredTables.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-[4px] bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{searchQuery ? 'No tables found' : 'No tables yet'}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{searchQuery
? 'Try adjusting your search query'
: 'Create your first table to store structured data for your workflows'}
</p>
</div>
</div>
<EmptyState hasSearchQuery={!!searchQuery} />
) : (
filteredTables.map((table) => (
<TableCard key={table.id} table={table} workspaceId={workspaceId} />
@@ -151,3 +120,80 @@ export function Tables() {
</>
)
}
/**
* Loading skeleton component for table cards.
*/
function LoadingSkeletons() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className='flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] dark:bg-[var(--surface-4)]'
>
<div className='flex items-center justify-between gap-[8px]'>
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-[15px] w-[60px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<div className='flex h-[36px] flex-col gap-[6px]'>
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
</div>
</div>
))}
</>
)
}
/**
* Error state component displayed when table loading fails.
*
* @param props - Component props
* @param props.error - The error that occurred
*/
function ErrorState({ error }: { error: unknown }) {
return (
<div className='col-span-full flex h-64 items-center justify-center rounded-[4px] bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Error loading tables</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
</div>
)
}
/**
* Empty state component displayed when no tables exist or match the search.
*
* @param props - Component props
* @param props.hasSearchQuery - Whether a search query is active
*/
function EmptyState({ hasSearchQuery }: { hasSearchQuery: boolean }) {
return (
<div className='col-span-full flex h-64 items-center justify-center rounded-[4px] bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{hasSearchQuery ? 'No tables found' : 'No tables yet'}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{hasSearchQuery
? 'Try adjusting your search query'
: 'Create your first table to store structured data for your workflows'}
</p>
</div>
</div>
)
}