mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix: copilot, improvement: tables, mothership
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -123,14 +123,20 @@ export async function POST(req: NextRequest) {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await db
|
||||
const [updated] = await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: [...conversationHistory, userMsg],
|
||||
messages: sql`${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb`,
|
||||
conversationId: userMessageId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId))
|
||||
.returning({ messages: copilotChats.messages })
|
||||
|
||||
if (updated) {
|
||||
const freshMessages: any[] = Array.isArray(updated.messages) ? updated.messages : []
|
||||
conversationHistory = freshMessages.filter((m: any) => m.id !== userMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
const [workspaceContext, userPermission] = await Promise.all([
|
||||
@@ -177,13 +183,6 @@ export async function POST(req: NextRequest) {
|
||||
onComplete: async (result: OrchestratorResult) => {
|
||||
if (!actualChatId) return
|
||||
|
||||
const userMessage = {
|
||||
id: userMessageId,
|
||||
role: 'user' as const,
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant' as const,
|
||||
@@ -194,16 +193,29 @@ export async function POST(req: NextRequest) {
|
||||
assistantMessage.toolCalls = result.toolCalls
|
||||
}
|
||||
|
||||
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
conversationId: null,
|
||||
})
|
||||
const [row] = await db
|
||||
.select({ messages: copilotChats.messages })
|
||||
.from(copilotChats)
|
||||
.where(eq(copilotChats.id, actualChatId))
|
||||
.limit(1)
|
||||
|
||||
const msgs: any[] = Array.isArray(row?.messages) ? row.messages : []
|
||||
const userIdx = msgs.findIndex((m: any) => m.id === userMessageId)
|
||||
const alreadyHasResponse =
|
||||
userIdx >= 0 &&
|
||||
userIdx + 1 < msgs.length &&
|
||||
(msgs[userIdx + 1] as any)?.role === 'assistant'
|
||||
|
||||
if (!alreadyHasResponse) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`,
|
||||
conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${userMessageId} THEN NULL ELSE ${copilotChats.conversationId} END`,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Failed to persist chat messages`, {
|
||||
chatId: actualChatId,
|
||||
|
||||
65
apps/sim/app/api/mothership/chat/stop/route.ts
Normal file
65
apps/sim/app/api/mothership/chat/stop/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('MothershipChatStopAPI')
|
||||
|
||||
const StopSchema = z.object({
|
||||
chatId: z.string(),
|
||||
streamId: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/mothership/chat/stop
|
||||
* Persists partial assistant content when the user stops a stream mid-response.
|
||||
* Clears conversationId so the server-side onComplete won't duplicate the message.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { chatId, streamId, content } = StopSchema.parse(await req.json())
|
||||
|
||||
const setClause: Record<string, unknown> = {
|
||||
conversationId: null,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (content.trim()) {
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`
|
||||
}
|
||||
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set(setClause)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, session.user.id),
|
||||
eq(copilotChats.conversationId, streamId)
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
|
||||
}
|
||||
logger.error('Error stopping chat stream:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { lazy, Suspense, useMemo } from 'react'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import {
|
||||
FileViewer,
|
||||
isPreviewable,
|
||||
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
|
||||
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
|
||||
@@ -68,7 +71,13 @@ function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden'>
|
||||
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
|
||||
<FileViewer
|
||||
key={file.id}
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={true}
|
||||
showPreview={isPreviewable(file)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { ElementType } from 'react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Table as TableIcon } from '@/components/emcn/icons'
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type {
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
interface ResourceTabsProps {
|
||||
resources: MothershipResource[]
|
||||
@@ -9,28 +17,44 @@ interface ResourceTabsProps {
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
const RESOURCE_ICONS: Record<Exclude<MothershipResourceType, 'file'>, ElementType> = {
|
||||
table: TableIcon,
|
||||
workflow: WorkflowIcon,
|
||||
}
|
||||
|
||||
function getResourceIcon(resource: MothershipResource): ElementType {
|
||||
if (resource.type === 'file') {
|
||||
return getDocumentIcon('', resource.title)
|
||||
}
|
||||
return RESOURCE_ICONS[resource.type]
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal tab bar for switching between mothership resources.
|
||||
* Mirrors the role of ResourceHeader in the Resource abstraction.
|
||||
* Renders each resource as a subtle Button matching ResourceHeader actions.
|
||||
*/
|
||||
export function ResourceTabs({ resources, activeId, onSelect }: ResourceTabsProps) {
|
||||
return (
|
||||
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
|
||||
{resources.map((resource) => (
|
||||
<button
|
||||
key={resource.id}
|
||||
type='button'
|
||||
onClick={() => onSelect(resource.id)}
|
||||
className={cn(
|
||||
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
|
||||
activeId === resource.id
|
||||
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
|
||||
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{resource.title}
|
||||
</button>
|
||||
))}
|
||||
<div className='flex shrink-0 items-center gap-[6px] overflow-x-auto border-[var(--border)] border-b px-[16px] py-[8.5px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{resources.map((resource) => {
|
||||
const Icon = getResourceIcon(resource)
|
||||
const isActive = activeId === resource.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={resource.id}
|
||||
variant='subtle'
|
||||
onClick={() => onSelect(resource.id)}
|
||||
className={cn(
|
||||
'shrink-0 border border-transparent bg-transparent px-[8px] py-[4px] text-[12px]',
|
||||
isActive && 'border-[var(--border)] bg-[var(--surface-4)]'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]')} />
|
||||
{resource.title}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface UseChatReturn {
|
||||
size: number
|
||||
}>
|
||||
) => Promise<void>
|
||||
stopGeneration: () => void
|
||||
stopGeneration: () => Promise<void>
|
||||
chatBottomRef: React.RefObject<HTMLDivElement | null>
|
||||
resources: MothershipResource[]
|
||||
activeResourceId: string | null
|
||||
@@ -140,6 +140,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
const sendingRef = useRef(false)
|
||||
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
|
||||
const streamGenRef = useRef(0)
|
||||
const streamingContentRef = useRef('')
|
||||
|
||||
const isHomePage = pathname.endsWith('/home')
|
||||
|
||||
@@ -212,6 +213,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
let lastWorkflowId: string | null = null
|
||||
let runningText = ''
|
||||
|
||||
streamingContentRef.current = ''
|
||||
toolArgsMapRef.current.clear()
|
||||
|
||||
const ensureTextBlock = (): ContentBlock => {
|
||||
@@ -283,6 +285,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
const tb = ensureTextBlock()
|
||||
tb.content = (tb.content ?? '') + chunk
|
||||
runningText += chunk
|
||||
streamingContentRef.current = runningText
|
||||
flush()
|
||||
}
|
||||
break
|
||||
@@ -427,6 +430,24 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
[workspaceId, queryClient, addResource]
|
||||
)
|
||||
|
||||
const persistPartialResponse = useCallback(async () => {
|
||||
const chatId = chatIdRef.current
|
||||
const streamId = streamIdRef.current
|
||||
if (!chatId || !streamId) return
|
||||
|
||||
const content = streamingContentRef.current
|
||||
try {
|
||||
const res = await fetch('/api/mothership/chat/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId, streamId, content }),
|
||||
})
|
||||
if (res.ok) streamingContentRef.current = ''
|
||||
} catch (err) {
|
||||
logger.warn('Failed to persist partial response', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const invalidateChatQueries = useCallback(() => {
|
||||
const activeChatId = chatIdRef.current
|
||||
if (activeChatId) {
|
||||
@@ -495,6 +516,9 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
) => {
|
||||
if (!message.trim() || !workspaceId) return
|
||||
|
||||
if (sendingRef.current) {
|
||||
await persistPartialResponse()
|
||||
}
|
||||
abortControllerRef.current?.abort()
|
||||
|
||||
const gen = ++streamGenRef.current
|
||||
@@ -566,17 +590,20 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
}
|
||||
}
|
||||
},
|
||||
[workspaceId, queryClient, processSSEStream, finalize]
|
||||
[workspaceId, queryClient, processSSEStream, finalize, persistPartialResponse]
|
||||
)
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
const stopGeneration = useCallback(async () => {
|
||||
if (sendingRef.current) {
|
||||
await persistPartialResponse()
|
||||
}
|
||||
streamGenRef.current++
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
invalidateChatQueries()
|
||||
}, [invalidateChatQueries])
|
||||
}, [invalidateChatQueries, persistPartialResponse])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# Tables UI Capabilities
|
||||
|
||||
## Main Page (List View)
|
||||
|
||||
### Header
|
||||
- Icon (blue table icon in rounded badge) + title "Tables"
|
||||
- Description subtitle: "Create and manage data tables for your workflows."
|
||||
|
||||
### Actions Bar
|
||||
- **Search**: Debounced (300ms) text input filtering by table name and description
|
||||
- **Create Table**: Button with permission gate (`canEdit`), tooltip when disabled
|
||||
|
||||
### Content Grid
|
||||
- Responsive card grid: 1 col (mobile) → 2 → 3 → 4 (xl)
|
||||
- States: loading (8 skeleton cards), error (message), empty (search-aware message)
|
||||
|
||||
### Context Menu (Background)
|
||||
- Right-click on empty area → "Create table" (permission-gated)
|
||||
|
||||
---
|
||||
|
||||
## Table Card
|
||||
|
||||
### Display
|
||||
- **Name**: Truncated, bold
|
||||
- **Short ID**: Badge showing `tb-{first 8 chars}`
|
||||
- **Column count**: Icon + count
|
||||
- **Row count**: Icon + count
|
||||
- **Last updated**: Relative time, absolute date on tooltip hover
|
||||
- **Description**: 2-line clamp or "No description"
|
||||
|
||||
### Interactions
|
||||
- Click → navigate to table detail (`/workspace/{id}/tables/{tableId}`)
|
||||
- Right-click → context menu
|
||||
|
||||
### Context Menu (Card)
|
||||
- View Schema → opens schema modal
|
||||
- Copy ID → clipboard
|
||||
- Delete → confirmation modal (permission-gated)
|
||||
|
||||
### Modals
|
||||
- **Delete Confirmation**: Shows table name + row count warning, "cannot be undone"
|
||||
- **Schema Viewer**: Read-only table of columns with name, type badge, constraints (required/unique)
|
||||
|
||||
---
|
||||
|
||||
## Create Table Modal
|
||||
|
||||
### Form Fields
|
||||
- **Name**: Required, enforces lowercase + underscores pattern
|
||||
- **Description**: Optional textarea
|
||||
- **Columns**: Dynamic list (minimum 1)
|
||||
|
||||
### Column Editor
|
||||
- Column name (text input)
|
||||
- Type selector: `string` | `number` | `boolean` | `date` | `json`
|
||||
- Required toggle (checkbox)
|
||||
- Unique toggle (checkbox)
|
||||
- Add/remove columns (minimum 1 enforced)
|
||||
|
||||
### Validation
|
||||
- Name required
|
||||
- At least one column
|
||||
- No duplicate column names
|
||||
|
||||
---
|
||||
|
||||
## Table Detail Page (Inner View)
|
||||
|
||||
### Header Bar
|
||||
- Breadcrumb: "Tables" (back link) → table name
|
||||
- Row count badge
|
||||
- View Schema button (Info icon)
|
||||
- Refresh button (RefreshCw icon)
|
||||
|
||||
### Query Builder
|
||||
- **Add Filter**: Column selector → operator → value, logical AND/OR chaining
|
||||
- **Add Sort**: Column selector → direction (asc/desc)
|
||||
- **Apply**: Execute query
|
||||
- **Clear All**: Reset filters/sorts
|
||||
- **Add Row**: Opens add row modal
|
||||
|
||||
### Data Grid
|
||||
- Sticky header row with column names, type badges, required markers
|
||||
- Row selection via checkboxes
|
||||
- Select-all checkbox in header
|
||||
- Cell rendering by type:
|
||||
- **String**: Truncated at 50 chars, click to view full, double-click to inline edit
|
||||
- **Number**: Double-click to inline edit
|
||||
- **Date**: Double-click to inline edit
|
||||
- **Boolean**: Single-click to toggle
|
||||
- **JSON**: Click to view formatted, double-click to edit in modal
|
||||
- **Null**: Shows "—", double-click to edit
|
||||
|
||||
### Inline Cell Editor
|
||||
- Input field for string/number/date
|
||||
- Enter to save, Escape to cancel, blur to save
|
||||
|
||||
### Action Bar (Selection)
|
||||
- Shows selected count
|
||||
- Clear selection button
|
||||
- Delete selected button
|
||||
|
||||
### Row Context Menu
|
||||
- Edit row
|
||||
- Delete row
|
||||
|
||||
### Pagination
|
||||
- Previous/Next buttons
|
||||
- "Page X of Y (N rows)" label
|
||||
- Hidden when single page
|
||||
- 100 rows per page
|
||||
|
||||
### Modals (Detail)
|
||||
- **Add Row**: Form with field per column, typed inputs (text/textarea/checkbox)
|
||||
- **Edit Row**: Pre-filled form, update button
|
||||
- **Delete Row(s)**: Confirmation dialog
|
||||
- **Cell Viewer**: Full content display with copy button (JSON pretty-printed, date formatted)
|
||||
|
||||
---
|
||||
|
||||
## Underlying Considerations
|
||||
|
||||
### Data Layer
|
||||
- React Query for all data fetching (no Zustand store)
|
||||
- Hooks: `useTablesList`, `useTable`, `useTableRows`, `useCreateTable`, `useDeleteTable`, `useCreateTableRow`, `useUpdateTableRow`, `useDeleteTableRow`, `useDeleteTableRows`
|
||||
- Optimistic updates on row mutations
|
||||
- Query key factory pattern (`tableKeys`)
|
||||
|
||||
### API Surface
|
||||
| Endpoint | Methods | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `/api/table` | GET, POST | List tables, create table |
|
||||
| `/api/table/[tableId]` | GET, DELETE | Get table, delete table |
|
||||
| `/api/table/[tableId]/rows` | GET, POST, PUT, DELETE | Query/insert/update/delete rows |
|
||||
| `/api/table/[tableId]/rows/[rowId]` | GET, PATCH, DELETE | Single row CRUD |
|
||||
| `/api/table/[tableId]/rows/upsert` | POST | Upsert by unique column |
|
||||
|
||||
### Table Service (`lib/table/service.ts`)
|
||||
- Used by executor, background jobs, and tests
|
||||
- Full CRUD: `getTableById`, `listTables`, `countTables`, `createTable`, `deleteTable`
|
||||
- Row operations: `insertRow`, `batchInsertRows`, `upsertRow`, `queryRows`, `getRowById`, `updateRow`, `deleteRow`
|
||||
- Bulk operations: `updateRowsByFilter`, `deleteRowsByFilter`, `deleteRowsByIds`
|
||||
|
||||
### Permissions
|
||||
- Create/delete gated by `canEdit` from `useUserPermissionsContext()`
|
||||
- API routes enforce workspace access via `checkAccess()`
|
||||
- Page-level: `hideTablesTab` permission config
|
||||
|
||||
### File Structure
|
||||
```
|
||||
tables/
|
||||
├── page.tsx # Server component (auth, permissions)
|
||||
├── tables.tsx # Client list view
|
||||
├── layout.tsx # Flex layout wrapper
|
||||
├── lib/
|
||||
│ └── utils.ts # formatRelativeTime, formatAbsoluteDate
|
||||
├── components/
|
||||
│ ├── index.ts # Barrel exports
|
||||
│ ├── table-card.tsx # Card component
|
||||
│ ├── table-context-menu.tsx # Card context menu
|
||||
│ ├── tables-list-context-menu.tsx # Background context menu
|
||||
│ ├── create-modal.tsx # Create table form
|
||||
│ ├── empty-state.tsx # Empty state display
|
||||
│ ├── error-state.tsx # Error state display
|
||||
│ └── loading-state.tsx # Skeleton loading
|
||||
└── [tableId]/
|
||||
├── page.tsx # Detail server component
|
||||
├── lib/
|
||||
│ ├── constants.ts # ROWS_PER_PAGE, STRING_TRUNCATE_LENGTH
|
||||
│ └── utils.ts # getTypeBadgeVariant, cleanCellValue, formatValueForInput
|
||||
├── hooks/
|
||||
│ ├── use-table-data.ts # Combined table + rows hook
|
||||
│ ├── use-row-selection.ts # Selection state
|
||||
│ └── use-context-menu.ts # Context menu state
|
||||
└── components/
|
||||
├── index.ts
|
||||
├── table-viewer.tsx # Main detail view
|
||||
├── header-bar.tsx
|
||||
├── action-bar.tsx
|
||||
├── query-builder/
|
||||
│ ├── index.tsx
|
||||
│ ├── filter-row.tsx
|
||||
│ └── sort-row.tsx
|
||||
├── cell-renderer.tsx
|
||||
├── inline-cell-editor.tsx
|
||||
├── cell-viewer-modal.tsx
|
||||
├── row-modal.tsx
|
||||
├── schema-modal.tsx
|
||||
├── context-menu.tsx
|
||||
├── table-row-cells.tsx
|
||||
├── body-states.tsx
|
||||
└── pagination.tsx
|
||||
```
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
|
||||
import { ArrowDown, ArrowUp, Pencil, Trash } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash } from '@/components/emcn/icons'
|
||||
import type { ContextMenuState } from '../../types'
|
||||
|
||||
const MENU_ITEM =
|
||||
'relative flex cursor-default select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-secondary)] outline-none transition-colors hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)] [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0'
|
||||
|
||||
const MENU_SEPARATOR = '-mx-[6px] my-[6px] h-px bg-[var(--border-1)]'
|
||||
|
||||
interface ContextMenuProps {
|
||||
contextMenu: ContextMenuState
|
||||
onClose: () => void
|
||||
onEditCell: () => void
|
||||
onAddData: () => void
|
||||
onDelete: () => void
|
||||
onInsertAbove: () => void
|
||||
onInsertBelow: () => void
|
||||
@@ -22,55 +23,64 @@ export function ContextMenu({
|
||||
contextMenu,
|
||||
onClose,
|
||||
onEditCell,
|
||||
onAddData,
|
||||
onDelete,
|
||||
onInsertAbove,
|
||||
onInsertBelow,
|
||||
selectedRowCount = 1,
|
||||
}: ContextMenuProps) {
|
||||
const isEmptyCell = !contextMenu.row
|
||||
const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row'
|
||||
|
||||
return (
|
||||
<Popover open={contextMenu.isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenu.position.x}px`,
|
||||
top: `${contextMenu.position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
border
|
||||
className='!min-w-[160px] !rounded-[8px] !bg-[var(--bg)] !p-[6px] shadow-sm'
|
||||
>
|
||||
{contextMenu.columnName && (
|
||||
<div className={MENU_ITEM} onClick={onEditCell} role='menuitem'>
|
||||
<Pencil />
|
||||
Edit cell
|
||||
</div>
|
||||
)}
|
||||
<div className={MENU_ITEM} onClick={onInsertAbove} role='menuitem'>
|
||||
<ArrowUp />
|
||||
Insert row above
|
||||
</div>
|
||||
<div className={MENU_ITEM} onClick={onInsertBelow} role='menuitem'>
|
||||
<ArrowDown />
|
||||
Insert row below
|
||||
</div>
|
||||
<div className={MENU_SEPARATOR} role='separator' />
|
||||
<DropdownMenu open={contextMenu.isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(MENU_ITEM, 'text-[var(--text-error)] hover:text-[var(--text-error)]')}
|
||||
onClick={onDelete}
|
||||
role='menuitem'
|
||||
>
|
||||
<Trash />
|
||||
{deleteLabel}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenu.position.x}px`,
|
||||
top: `${contextMenu.position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' side='bottom' sideOffset={4} className='min-w-[160px]'>
|
||||
{isEmptyCell ? (
|
||||
<DropdownMenuItem onSelect={onAddData}>
|
||||
<Plus />
|
||||
Add data
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{contextMenu.columnName && (
|
||||
<DropdownMenuItem onSelect={onEditCell}>
|
||||
<Pencil />
|
||||
Edit cell
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={onInsertAbove}>
|
||||
<ArrowUp />
|
||||
Insert row above
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onInsertBelow}>
|
||||
<ArrowDown />
|
||||
Insert row below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className='text-[var(--text-error)] focus:text-[var(--text-error)]'
|
||||
onSelect={onDelete}
|
||||
>
|
||||
<Trash />
|
||||
{deleteLabel}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './context-menu'
|
||||
export * from './row-modal'
|
||||
export * from './schema-modal'
|
||||
export * from './table'
|
||||
export * from './table-filter'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { SchemaModal } from './schema-modal'
|
||||
@@ -1,94 +0,0 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/emcn'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
import { getTypeBadgeVariant } from '../../utils'
|
||||
|
||||
interface SchemaModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
columns: ColumnDefinition[]
|
||||
tableName?: string
|
||||
}
|
||||
|
||||
export function SchemaModal({ isOpen, onClose, columns, tableName }: SchemaModalProps) {
|
||||
const columnCount = columns.length
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Table Schema</ModalHeader>
|
||||
<ModalBody className='max-h-[60vh] overflow-y-auto'>
|
||||
<div className='mb-[10px] flex items-center justify-between gap-[8px]'>
|
||||
{tableName ? (
|
||||
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tableName}
|
||||
</span>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Badge variant='gray' size='sm'>
|
||||
{columnCount} {columnCount === 1 ? 'column' : 'columns'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Column</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Constraints</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{columns.map((column) => (
|
||||
<TableRow key={column.name}>
|
||||
<TableCell className='font-mono'>{column.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
|
||||
{column.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='flex items-center 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)]'>None</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -199,6 +199,7 @@ export function Table({
|
||||
const {
|
||||
contextMenu,
|
||||
handleRowContextMenu: baseHandleRowContextMenu,
|
||||
handleEmptyCellContextMenu: baseHandleEmptyCellContextMenu,
|
||||
closeContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
@@ -371,18 +372,49 @@ export function Table({
|
||||
closeContextMenu()
|
||||
}, [contextMenu.row, closeContextMenu])
|
||||
|
||||
const handleAddData = useCallback(() => {
|
||||
if (contextMenu.rowIndex === null || !contextMenu.columnName) {
|
||||
closeContextMenu()
|
||||
return
|
||||
}
|
||||
const column = columnsRef.current.find((c) => c.name === contextMenu.columnName)
|
||||
if (!column || column.type === 'boolean') {
|
||||
closeContextMenu()
|
||||
return
|
||||
}
|
||||
setSelectionAnchor({
|
||||
rowIndex: contextMenu.rowIndex,
|
||||
colIndex: columnsRef.current.findIndex((c) => c.name === contextMenu.columnName),
|
||||
})
|
||||
setSelectionFocus(null)
|
||||
setEditingEmptyCell({ rowIndex: contextMenu.rowIndex, columnName: contextMenu.columnName })
|
||||
setInitialCharacter(null)
|
||||
closeContextMenu()
|
||||
}, [contextMenu.rowIndex, contextMenu.columnName, closeContextMenu])
|
||||
|
||||
const resolveColumnFromEvent = useCallback((e: React.MouseEvent) => {
|
||||
const td = (e.target as HTMLElement).closest('td[data-col]') as HTMLElement | null
|
||||
const colIndex = td ? Number.parseInt(td.getAttribute('data-col') || '-1', 10) : -1
|
||||
return colIndex >= 0 && colIndex < columnsRef.current.length
|
||||
? columnsRef.current[colIndex].name
|
||||
: null
|
||||
}, [])
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, row: TableRowType) => {
|
||||
setEditingCell(null)
|
||||
const td = (e.target as HTMLElement).closest('td[data-col]') as HTMLElement | null
|
||||
const colIndex = td ? Number.parseInt(td.getAttribute('data-col') || '-1', 10) : -1
|
||||
const columnName =
|
||||
colIndex >= 0 && colIndex < columnsRef.current.length
|
||||
? columnsRef.current[colIndex].name
|
||||
: null
|
||||
baseHandleRowContextMenu(e, row, columnName)
|
||||
baseHandleRowContextMenu(e, row, resolveColumnFromEvent(e))
|
||||
},
|
||||
[baseHandleRowContextMenu]
|
||||
[baseHandleRowContextMenu, resolveColumnFromEvent]
|
||||
)
|
||||
|
||||
const handleEmptyCellRightClick = useCallback(
|
||||
(e: React.MouseEvent, rowIndex: number) => {
|
||||
setEditingCell(null)
|
||||
setEditingEmptyCell(null)
|
||||
baseHandleEmptyCellContextMenu(e, rowIndex, resolveColumnFromEvent(e))
|
||||
},
|
||||
[baseHandleEmptyCellContextMenu, resolveColumnFromEvent]
|
||||
)
|
||||
|
||||
const handleCellMouseDown = useCallback(
|
||||
@@ -811,19 +843,9 @@ export function Table({
|
||||
createRef.current(
|
||||
{ data: mergedData, position: rowIndex },
|
||||
{
|
||||
onSuccess: (response: Record<string, unknown>) => {
|
||||
const data = response?.data as Record<string, unknown> | undefined
|
||||
const row = data?.row as Record<string, unknown> | undefined
|
||||
const newRowId = row?.id as string | undefined
|
||||
if (newRowId) {
|
||||
setPendingPlaceholders((prev) => {
|
||||
if (!prev[rowIndex]) return prev
|
||||
return { ...prev, [rowIndex]: { ...prev[rowIndex], rowId: newRowId } }
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
onSettled: () => {
|
||||
setPendingPlaceholders((prev) => {
|
||||
if (!prev[rowIndex]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[rowIndex]
|
||||
return next
|
||||
@@ -1178,6 +1200,7 @@ export function Table({
|
||||
onDoubleClick={handleEmptyRowDoubleClick}
|
||||
onSave={handleEmptyRowSave}
|
||||
onCancel={handleEmptyRowCancel}
|
||||
onContextMenu={handleEmptyCellRightClick}
|
||||
onCellMouseDown={handleCellMouseDown}
|
||||
onCellMouseEnter={handleCellMouseEnter}
|
||||
onRowMouseDown={handleRowMouseDown}
|
||||
@@ -1222,6 +1245,7 @@ export function Table({
|
||||
onDoubleClick={handleEmptyRowDoubleClick}
|
||||
onSave={handleEmptyRowSave}
|
||||
onCancel={handleEmptyRowCancel}
|
||||
onContextMenu={handleEmptyCellRightClick}
|
||||
onCellMouseDown={handleCellMouseDown}
|
||||
onCellMouseEnter={handleCellMouseEnter}
|
||||
onRowMouseDown={handleRowMouseDown}
|
||||
@@ -1269,6 +1293,7 @@ export function Table({
|
||||
contextMenu={contextMenu}
|
||||
onClose={closeContextMenu}
|
||||
onEditCell={handleContextMenuEditCell}
|
||||
onAddData={handleAddData}
|
||||
onDelete={handleContextMenuDelete}
|
||||
onInsertAbove={handleInsertRowAbove}
|
||||
onInsertBelow={handleInsertRowBelow}
|
||||
@@ -1352,6 +1377,7 @@ interface PositionGapRowsProps {
|
||||
onDoubleClick: (rowIndex: number, columnName: string) => void
|
||||
onSave: (rowIndex: number, columnName: string, value: unknown, reason: SaveReason) => void
|
||||
onCancel: () => void
|
||||
onContextMenu: (e: React.MouseEvent, rowIndex: number) => void
|
||||
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
|
||||
onCellMouseEnter: (rowIndex: number, colIndex: number) => void
|
||||
onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void
|
||||
@@ -1371,6 +1397,7 @@ const PositionGapRows = React.memo(function PositionGapRows({
|
||||
onDoubleClick,
|
||||
onSave,
|
||||
onCancel,
|
||||
onContextMenu,
|
||||
onCellMouseDown,
|
||||
onCellMouseEnter,
|
||||
onRowMouseDown,
|
||||
@@ -1385,7 +1412,7 @@ const PositionGapRows = React.memo(function PositionGapRows({
|
||||
{Array.from({ length: capped }).map((_, i) => {
|
||||
const position = startPosition + i
|
||||
return (
|
||||
<tr key={`gap-${position}`}>
|
||||
<tr key={`gap-${position}`} onContextMenu={(e) => onContextMenu(e, position)}>
|
||||
<td
|
||||
className={GAP_CHECKBOX_CLASS}
|
||||
onMouseDown={(e) => {
|
||||
@@ -1920,6 +1947,7 @@ interface PlaceholderRowsProps {
|
||||
onDoubleClick: (rowIndex: number, columnName: string) => void
|
||||
onSave: (rowIndex: number, columnName: string, value: unknown, reason: SaveReason) => void
|
||||
onCancel: () => void
|
||||
onContextMenu: (e: React.MouseEvent, rowIndex: number) => void
|
||||
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
|
||||
onCellMouseEnter: (rowIndex: number, colIndex: number) => void
|
||||
onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void
|
||||
@@ -1938,6 +1966,7 @@ function placeholderPropsAreEqual(prev: PlaceholderRowsProps, next: PlaceholderR
|
||||
prev.onDoubleClick !== next.onDoubleClick ||
|
||||
prev.onSave !== next.onSave ||
|
||||
prev.onCancel !== next.onCancel ||
|
||||
prev.onContextMenu !== next.onContextMenu ||
|
||||
prev.onCellMouseDown !== next.onCellMouseDown ||
|
||||
prev.onCellMouseEnter !== next.onCellMouseEnter ||
|
||||
prev.onRowMouseDown !== next.onRowMouseDown ||
|
||||
@@ -1985,6 +2014,7 @@ const PlaceholderRows = React.memo(function PlaceholderRows({
|
||||
onDoubleClick,
|
||||
onSave,
|
||||
onCancel,
|
||||
onContextMenu,
|
||||
onCellMouseDown,
|
||||
onCellMouseEnter,
|
||||
onRowMouseDown,
|
||||
@@ -2083,7 +2113,7 @@ const PlaceholderRows = React.memo(function PlaceholderRows({
|
||||
const globalRowIndex = dataRowCount + i
|
||||
const pending = pendingPlaceholders[globalRowIndex]
|
||||
return (
|
||||
<tr key={`placeholder-${i}`}>
|
||||
<tr key={`placeholder-${i}`} onContextMenu={(e) => onContextMenu(e, globalRowIndex)}>
|
||||
<td
|
||||
className={cn(CELL_CHECKBOX, 'group/checkbox cursor-pointer text-center')}
|
||||
onMouseDown={(e) => {
|
||||
|
||||
@@ -5,6 +5,11 @@ import type { ContextMenuState } from '../types'
|
||||
interface UseContextMenuReturn {
|
||||
contextMenu: ContextMenuState
|
||||
handleRowContextMenu: (e: React.MouseEvent, row: TableRow, columnName?: string | null) => void
|
||||
handleEmptyCellContextMenu: (
|
||||
e: React.MouseEvent,
|
||||
rowIndex: number,
|
||||
columnName: string | null
|
||||
) => void
|
||||
closeContextMenu: () => void
|
||||
}
|
||||
|
||||
@@ -13,6 +18,7 @@ export function useContextMenu(): UseContextMenuReturn {
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
row: null,
|
||||
rowIndex: null,
|
||||
columnName: null,
|
||||
})
|
||||
|
||||
@@ -24,12 +30,28 @@ export function useContextMenu(): UseContextMenuReturn {
|
||||
isOpen: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
row,
|
||||
rowIndex: row.position,
|
||||
columnName: columnName ?? null,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleEmptyCellContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowIndex: number, columnName: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
row: null,
|
||||
rowIndex,
|
||||
columnName,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu((prev) => ({ ...prev, isOpen: false }))
|
||||
}, [])
|
||||
@@ -37,6 +59,7 @@ export function useContextMenu(): UseContextMenuReturn {
|
||||
return {
|
||||
contextMenu,
|
||||
handleRowContextMenu,
|
||||
handleEmptyCellContextMenu,
|
||||
closeContextMenu,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ export interface QueryOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the row context menu (right-click)
|
||||
* State for the row context menu (right-click).
|
||||
* When `row` is null and `rowIndex` is set, the menu targets an empty cell.
|
||||
*/
|
||||
export interface ContextMenuState {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
row: TableRow | null
|
||||
rowIndex: number | null
|
||||
columnName: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Copy, Trash } from '@/components/emcn/icons'
|
||||
|
||||
interface TableContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
onRename?: () => void
|
||||
onViewSchema?: () => void
|
||||
onCopyId?: () => void
|
||||
onDelete?: () => void
|
||||
disableDelete?: boolean
|
||||
@@ -23,75 +21,42 @@ interface TableContextMenuProps {
|
||||
export function TableContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onRename,
|
||||
onViewSchema,
|
||||
onCopyId,
|
||||
onDelete,
|
||||
disableDelete = false,
|
||||
}: TableContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{onViewSchema && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewSchema()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
View Schema
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onRename && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onRename()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</PopoverItem>
|
||||
)}
|
||||
{(onViewSchema || onRename) && (onCopyId || onDelete) && <PopoverDivider />}
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
|
||||
{onCopyId && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopyId()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onSelect={onCopyId}>
|
||||
<Copy />
|
||||
Copy ID
|
||||
</PopoverItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCopyId && onDelete && <PopoverDivider />}
|
||||
{onCopyId && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && (
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</PopoverItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Plus } from '@/components/emcn/icons'
|
||||
|
||||
interface TablesListContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
onCreateTable?: () => void
|
||||
disableCreate?: boolean
|
||||
@@ -14,40 +19,34 @@ interface TablesListContextMenuProps {
|
||||
export function TablesListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onCreateTable,
|
||||
disableCreate = false,
|
||||
}: TablesListContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
|
||||
{onCreateTable && (
|
||||
<PopoverItem
|
||||
disabled={disableCreate}
|
||||
onClick={() => {
|
||||
onCreateTable()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateTable}>
|
||||
<Plus />
|
||||
Create table
|
||||
</PopoverItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,25 +8,13 @@ import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
import { generateUniqueTableName } from '@/lib/table/constants'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
InlineRenameInput,
|
||||
ownerCell,
|
||||
Resource,
|
||||
timeCell,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
|
||||
import { TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
|
||||
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import {
|
||||
useCreateTable,
|
||||
useDeleteTable,
|
||||
useRenameTable,
|
||||
useTablesList,
|
||||
} from '@/hooks/queries/tables'
|
||||
import { useCreateTable, useDeleteTable, useTablesList } from '@/hooks/queries/tables'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
|
||||
const logger = createLogger('Tables')
|
||||
|
||||
@@ -53,21 +41,14 @@ export function Tables() {
|
||||
}
|
||||
const deleteTable = useDeleteTable(workspaceId)
|
||||
const createTable = useCreateTable(workspaceId)
|
||||
const renameTable = useRenameTable(workspaceId)
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
|
||||
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const tableRename = useInlineRename({
|
||||
onSave: (tableId, name) => renameTable.mutate({ tableId, name }),
|
||||
})
|
||||
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
position: listContextMenuPosition,
|
||||
menuRef: listMenuRef,
|
||||
handleContextMenu: handleListContextMenu,
|
||||
closeMenu: closeListContextMenu,
|
||||
} = useContextMenu()
|
||||
@@ -75,7 +56,6 @@ export function Tables() {
|
||||
const {
|
||||
isOpen: isRowContextMenuOpen,
|
||||
position: rowContextMenuPosition,
|
||||
menuRef: rowMenuRef,
|
||||
handleContextMenu: handleRowCtxMenu,
|
||||
closeMenu: closeRowContextMenu,
|
||||
} = useContextMenu()
|
||||
@@ -94,20 +74,6 @@ export function Tables() {
|
||||
name: {
|
||||
icon: <TableIcon className='h-[14px] w-[14px]' />,
|
||||
label: table.name,
|
||||
content:
|
||||
tableRename.editingId === table.id ? (
|
||||
<span className='flex min-w-0 items-center gap-[12px] font-medium text-[14px] text-[var(--text-body)]'>
|
||||
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||
<TableIcon className='h-[14px] w-[14px]' />
|
||||
</span>
|
||||
<InlineRenameInput
|
||||
value={tableRename.editValue}
|
||||
onChange={tableRename.setEditValue}
|
||||
onSubmit={tableRename.submitRename}
|
||||
onCancel={tableRename.cancelRename}
|
||||
/>
|
||||
</span>
|
||||
) : undefined,
|
||||
},
|
||||
columns: {
|
||||
icon: <Columns3 className='h-[14px] w-[14px]' />,
|
||||
@@ -128,7 +94,7 @@ export function Tables() {
|
||||
updated: -new Date(table.updatedAt).getTime(),
|
||||
},
|
||||
})),
|
||||
[filteredTables, members, tableRename.editingId, tableRename.editValue]
|
||||
[filteredTables, members]
|
||||
)
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
@@ -147,11 +113,11 @@ export function Tables() {
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
if (!isRowContextMenuOpen && tableRename.editingId !== rowId) {
|
||||
if (!isRowContextMenuOpen) {
|
||||
router.push(`/workspace/${workspaceId}/tables/${rowId}`)
|
||||
}
|
||||
},
|
||||
[isRowContextMenuOpen, tableRename.editingId, router, workspaceId]
|
||||
[isRowContextMenuOpen, router, workspaceId]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
@@ -218,7 +184,6 @@ export function Tables() {
|
||||
<TablesListContextMenu
|
||||
isOpen={isListContextMenuOpen}
|
||||
position={listContextMenuPosition}
|
||||
menuRef={listMenuRef}
|
||||
onClose={closeListContextMenu}
|
||||
onCreateTable={handleCreateTable}
|
||||
disableCreate={userPermissions.canEdit !== true || createTable.isPending}
|
||||
@@ -227,12 +192,7 @@ export function Tables() {
|
||||
<TableContextMenu
|
||||
isOpen={isRowContextMenuOpen}
|
||||
position={rowContextMenuPosition}
|
||||
menuRef={rowMenuRef}
|
||||
onClose={closeRowContextMenu}
|
||||
onRename={() => {
|
||||
if (activeTable) tableRename.startRename(activeTable.id, activeTable.name)
|
||||
}}
|
||||
onViewSchema={() => setIsSchemaModalOpen(true)}
|
||||
onCopyId={() => {
|
||||
if (activeTable) navigator.clipboard.writeText(activeTable.id)
|
||||
}}
|
||||
@@ -262,24 +222,12 @@ export function Tables() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='default' onClick={handleDelete} disabled={deleteTable.isPending}>
|
||||
<Button variant='destructive' onClick={handleDelete} disabled={deleteTable.isPending}>
|
||||
{deleteTable.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{activeTable && (
|
||||
<SchemaModal
|
||||
isOpen={isSchemaModalOpen}
|
||||
onClose={() => {
|
||||
setIsSchemaModalOpen(false)
|
||||
setActiveTable(null)
|
||||
}}
|
||||
columns={activeTable.schema.columns}
|
||||
tableName={activeTable.name}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ export const Panel = memo(function Panel() {
|
||||
<>
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className='panel-container relative shrink-0 overflow-hidden bg-[var(--surface-1)]'
|
||||
className='panel-container relative shrink-0 overflow-hidden bg-[var(--bg)]'
|
||||
aria-label='Workflow panel'
|
||||
>
|
||||
<div className='flex h-full flex-col border-[var(--border)] border-l pt-[14px]'>
|
||||
|
||||
@@ -64,7 +64,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
|
||||
code={code}
|
||||
showGutter
|
||||
language={language}
|
||||
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
|
||||
className='m-0 min-h-full rounded-none border-0 bg-[var(--bg)] dark:bg-[var(--bg)]'
|
||||
paddingLeft={8}
|
||||
gutterStyle={{ backgroundColor: 'transparent' }}
|
||||
wrapText={wrapText}
|
||||
@@ -295,7 +295,7 @@ export const OutputPanel = React.memo(function OutputPanel({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--surface-1)]'
|
||||
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--bg)]'
|
||||
style={{ width: `${outputPanelWidth}px` }}
|
||||
>
|
||||
{/* Horizontal Resize Handle */}
|
||||
@@ -309,7 +309,7 @@ export const OutputPanel = React.memo(function OutputPanel({
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[10px]'
|
||||
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--bg)] pr-[16px] pl-[10px]'
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
@@ -529,7 +529,7 @@ export const OutputPanel = React.memo(function OutputPanel({
|
||||
{/* Search Overlay */}
|
||||
{isOutputSearchActive && (
|
||||
<div
|
||||
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--surface-1)] px-[6px] shadow-sm'
|
||||
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--bg)] px-[6px] shadow-sm'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-toolbar-root
|
||||
data-search-active='true'
|
||||
|
||||
@@ -1279,7 +1279,7 @@ export const Terminal = memo(function Terminal() {
|
||||
<aside
|
||||
ref={terminalRef}
|
||||
className={clsx(
|
||||
'terminal-container relative shrink-0 overflow-hidden border-[var(--border)] border-t bg-[var(--surface-1)]',
|
||||
'terminal-container relative shrink-0 overflow-hidden border-[var(--border)] border-t bg-[var(--bg)]',
|
||||
isToggling && 'transition-[height] duration-100 ease-out'
|
||||
)}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
@@ -1305,7 +1305,7 @@ export const Terminal = memo(function Terminal() {
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[16px]'
|
||||
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--bg)] pr-[16px] pl-[16px]'
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
{/* Left side - Logs label */}
|
||||
|
||||
@@ -334,6 +334,8 @@ export function useDeleteTable(workspaceId: string) {
|
||||
|
||||
/**
|
||||
* Create a row in a table.
|
||||
* Populates the cache on success so the new row is immediately available
|
||||
* without waiting for the background refetch triggered by invalidation.
|
||||
*/
|
||||
export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -353,6 +355,20 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
|
||||
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
const row = (response as { data?: { row?: TableRow } })?.data?.row as TableRow | undefined
|
||||
if (!row) return
|
||||
|
||||
queryClient.setQueriesData<TableRowsResponse>(
|
||||
{ queryKey: tableKeys.rowsRoot(tableId) },
|
||||
(old) => {
|
||||
if (!old) return old
|
||||
if (old.rows.some((r) => r.id === row.id)) return old
|
||||
const rows: TableRow[] = [...old.rows, row].sort((a, b) => a.position - b.position)
|
||||
return { ...old, rows, totalCount: old.totalCount + 1 }
|
||||
}
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateRowCount(queryClient, workspaceId, tableId)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user