mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
doc strings
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
/**
|
||||
* Table management components.
|
||||
*
|
||||
* @module tables/components
|
||||
*/
|
||||
export * from './create-table-modal'
|
||||
export * from './table-card'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user