mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(resources): segmented API
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
interface ErrorAction {
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'ghost'
|
||||
}
|
||||
|
||||
export interface ErrorStateProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
title: string
|
||||
description: string
|
||||
loggerName: string
|
||||
secondaryAction?: ErrorAction
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
error,
|
||||
reset,
|
||||
title,
|
||||
description,
|
||||
loggerName,
|
||||
secondaryAction,
|
||||
}: ErrorStateProps) {
|
||||
const logger = createLogger(loggerName)
|
||||
|
||||
useEffect(() => {
|
||||
logger.error(`${loggerName} error:`, { error: error.message, digest: error.digest })
|
||||
}, [error, logger, loggerName])
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<div className='flex flex-col items-center gap-[16px] text-center'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>{title}</h2>
|
||||
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>{description}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
variant={secondaryAction.variant ?? 'ghost'}
|
||||
size='sm'
|
||||
onClick={secondaryAction.onClick}
|
||||
>
|
||||
{secondaryAction.icon}
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant='default' size='sm' onClick={reset}>
|
||||
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ErrorState, type ErrorStateProps } from './error'
|
||||
@@ -1,4 +1,8 @@
|
||||
export { ErrorState, type ErrorStateProps } from './error'
|
||||
export { ownerCell } from './resource/components/owner-cell/owner-cell'
|
||||
export type { BreadcrumbItem } from './resource/components/resource-header'
|
||||
export { ResourceHeader } from './resource/components/resource-header'
|
||||
export { ResourceOptionsBar } from './resource/components/resource-options-bar'
|
||||
export { timeCell } from './resource/components/time-cell/time-cell'
|
||||
export type { ResourceCell, ResourceColumn, ResourceRow } from './resource/resource'
|
||||
export { Resource } from './resource/resource'
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './owner-cell'
|
||||
export * from './resource-header'
|
||||
export * from './resource-options-bar'
|
||||
export * from './time-cell'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BreadcrumbItem } from './resource-header'
|
||||
export { ResourceHeader } from './resource-header'
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface ResourceHeaderProps {
|
||||
icon?: React.ElementType
|
||||
title?: string
|
||||
breadcrumbs?: BreadcrumbItem[]
|
||||
create?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function ResourceHeader({ icon: Icon, title, breadcrumbs, create }: ResourceHeaderProps) {
|
||||
const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-[var(--border)] border-b',
|
||||
hasBreadcrumbs ? 'px-[16px] py-[8.5px]' : 'px-[24px] py-[10px]'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
{hasBreadcrumbs ? (
|
||||
breadcrumbs.map((crumb, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<span className='select-none text-[14px] text-[var(--text-icon)]'>/</span>
|
||||
)}
|
||||
<BreadcrumbSegment
|
||||
icon={i === 0 ? Icon : undefined}
|
||||
label={crumb.label}
|
||||
onClick={crumb.onClick}
|
||||
/>
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{Icon && <Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />}
|
||||
{title && (
|
||||
<h1 className='font-medium text-[14px] text-[var(--text-body)]'>{title}</h1>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{create && (
|
||||
<Button
|
||||
onClick={create.onClick}
|
||||
disabled={create.disabled}
|
||||
variant='subtle'
|
||||
className='px-[8px] py-[4px] text-[12px]'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[14px] w-[14px]' />
|
||||
{create.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSegment({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon?: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
{Icon && <Icon className='mr-[12px] h-[14px] w-[14px] text-[var(--text-icon)]' />}
|
||||
{label}
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<Button
|
||||
variant='subtle'
|
||||
className='px-[8px] py-[4px] font-medium text-[14px]'
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='inline-flex items-center px-[8px] py-[4px] font-medium text-[14px] text-[var(--text-body)]'>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ResourceOptionsBar } from './resource-options-bar'
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowUpDown, ListFilter, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
interface ResourceOptionsBarProps {
|
||||
search?: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
onSort?: () => void
|
||||
onFilter?: () => void
|
||||
toolbarActions?: ReactNode
|
||||
}
|
||||
|
||||
export function ResourceOptionsBar({
|
||||
search,
|
||||
onSort,
|
||||
onFilter,
|
||||
toolbarActions,
|
||||
}: ResourceOptionsBarProps) {
|
||||
const hasContent = search || onSort || onFilter
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-[var(--border)] border-b py-[10px]',
|
||||
search ? 'px-[24px]' : 'px-[16px]'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
{search && (
|
||||
<div className='relative flex-1'>
|
||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-0 h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
placeholder={search.placeholder ?? 'Search...'}
|
||||
className='w-full bg-transparent py-[4px] pl-[24px] font-base text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
{onFilter && (
|
||||
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onFilter}>
|
||||
<ListFilter className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Filter
|
||||
</Button>
|
||||
)}
|
||||
{onSort && (
|
||||
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onSort}>
|
||||
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Sort
|
||||
</Button>
|
||||
)}
|
||||
{toolbarActions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowUpDown, ListFilter, Plus, Search } from 'lucide-react'
|
||||
import { Button, Skeleton } from '@/components/emcn'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ResourceHeader } from './components/resource-header'
|
||||
import { ResourceOptionsBar } from './components/resource-options-bar'
|
||||
|
||||
export interface ResourceColumn {
|
||||
id: string
|
||||
@@ -52,7 +55,7 @@ const EMPTY_CELL_PLACEHOLDER = '- - -'
|
||||
* Renders the header, toolbar with search, and a data table from column/row definitions.
|
||||
*/
|
||||
export function Resource({
|
||||
icon: Icon,
|
||||
icon,
|
||||
title,
|
||||
create,
|
||||
search,
|
||||
@@ -67,90 +70,49 @@ export function Resource({
|
||||
loadingRows = 5,
|
||||
onContextMenu,
|
||||
}: ResourceProps) {
|
||||
const hasOptionsBar = search || onSort || onFilter
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleBodyScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (headerRef.current) {
|
||||
headerRef.current.scrollLeft = e.currentTarget.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex h-full flex-1 flex-col overflow-hidden bg-white dark:bg-[var(--bg)]'
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<h1 className='font-medium text-[14px] text-[var(--text-body)]'>{title}</h1>
|
||||
</div>
|
||||
{create && (
|
||||
<Button
|
||||
onClick={create.onClick}
|
||||
disabled={create.disabled}
|
||||
variant='subtle'
|
||||
className='px-[8px] py-[4px] text-[12px]'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[14px] w-[14px]' />
|
||||
{create.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasOptionsBar && (
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{search && (
|
||||
<div className='relative flex-1'>
|
||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-0 h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
placeholder={search.placeholder ?? 'Search...'}
|
||||
className='w-full bg-transparent py-[4px] pl-[24px] font-base text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
{onFilter && (
|
||||
<Button
|
||||
variant='subtle'
|
||||
className='px-[8px] py-[4px] text-[12px]'
|
||||
onClick={onFilter}
|
||||
>
|
||||
<ListFilter className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Filter
|
||||
</Button>
|
||||
)}
|
||||
{onSort && (
|
||||
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onSort}>
|
||||
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Sort
|
||||
</Button>
|
||||
)}
|
||||
{toolbarActions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ResourceHeader icon={icon} title={title} create={create} />
|
||||
<ResourceOptionsBar
|
||||
search={search}
|
||||
onSort={onSort}
|
||||
onFilter={onFilter}
|
||||
toolbarActions={toolbarActions}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<DataTableSkeleton columns={columns} rowCount={loadingRows} />
|
||||
) : (
|
||||
<>
|
||||
<table className='w-full table-fixed text-[13px]'>
|
||||
<ResourceColGroup columns={columns} />
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.id}
|
||||
className='h-10 px-[24px] py-[10px] text-left align-middle font-base text-[var(--text-muted)]'
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<div ref={headerRef} className='overflow-hidden'>
|
||||
<table className='w-full table-fixed text-[13px]'>
|
||||
<ResourceColGroup columns={columns} />
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.id}
|
||||
className='h-10 px-[24px] py-[10px] text-left align-middle font-base text-[var(--text-muted)]'
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto' onScroll={handleBodyScroll}>
|
||||
<table className='w-full table-fixed text-[13px]'>
|
||||
<ResourceColGroup columns={columns} />
|
||||
<tbody>
|
||||
@@ -182,7 +144,9 @@ export function Resource({
|
||||
<tr
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
create.disabled ? 'opacity-40' : 'cursor-pointer hover:bg-[var(--surface-3)]'
|
||||
create.disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover:bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={create.disabled ? undefined : create.onClick}
|
||||
>
|
||||
@@ -221,7 +185,7 @@ function ResourceColGroup({ columns }: { columns: ResourceColumn[] }) {
|
||||
return (
|
||||
<colgroup>
|
||||
{columns.map((col, colIdx) => (
|
||||
<col key={col.id} className={colIdx === 0 ? undefined : 'w-[160px]'} />
|
||||
<col key={col.id} className={colIdx === 0 ? 'min-w-[200px]' : 'w-[160px]'} />
|
||||
))}
|
||||
</colgroup>
|
||||
)
|
||||
@@ -230,23 +194,25 @@ function ResourceColGroup({ columns }: { columns: ResourceColumn[] }) {
|
||||
function DataTableSkeleton({ columns, rowCount }: { columns: ResourceColumn[]; rowCount: number }) {
|
||||
return (
|
||||
<>
|
||||
<table className='w-full table-fixed text-[13px]'>
|
||||
<ResourceColGroup columns={columns} />
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.id}
|
||||
className='h-10 px-[24px] py-[10px] text-left align-middle font-base text-[var(--text-muted)]'
|
||||
>
|
||||
<div className='flex min-h-[20px] items-center'>
|
||||
<Skeleton className='h-[12px] w-[56px]' />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div className='overflow-hidden'>
|
||||
<table className='w-full table-fixed text-[13px]'>
|
||||
<ResourceColGroup columns={columns} />
|
||||
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.id}
|
||||
className='h-10 px-[24px] py-[10px] text-left align-middle font-base text-[var(--text-muted)]'
|
||||
>
|
||||
<div className='flex min-h-[20px] items-center'>
|
||||
<Skeleton className='h-[12px] w-[56px]' />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
<table className='w-full table-fixed text-[13px]'>
|
||||
<ResourceColGroup columns={columns} />
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
const logger = createLogger('WorkspaceError')
|
||||
import { ErrorState } from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
interface WorkspaceErrorProps {
|
||||
error: Error & { digest?: string }
|
||||
@@ -13,26 +8,13 @@ interface WorkspaceErrorProps {
|
||||
}
|
||||
|
||||
export default function WorkspaceError({ error, reset }: WorkspaceErrorProps) {
|
||||
useEffect(() => {
|
||||
logger.error('Workspace error:', { error: error.message, digest: error.digest })
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 items-center justify-center bg-white dark:bg-[var(--bg)]'>
|
||||
<div className='flex flex-col items-center gap-[16px] text-center'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
An unexpected error occurred. Please try again or refresh the page.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='default' size='sm' onClick={reset}>
|
||||
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState
|
||||
error={error}
|
||||
reset={reset}
|
||||
title='Something went wrong'
|
||||
description='An unexpected error occurred. Please try again or refresh the page.'
|
||||
loggerName='WorkspaceError'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Info, RefreshCw } from 'lucide-react'
|
||||
import { Badge, Button, Tooltip } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface HeaderBarProps {
|
||||
tableName: string
|
||||
totalCount: number
|
||||
isLoading: boolean
|
||||
onNavigateBack: () => void
|
||||
onShowSchema: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function HeaderBar({
|
||||
tableName,
|
||||
totalCount,
|
||||
isLoading,
|
||||
onNavigateBack,
|
||||
onShowSchema,
|
||||
onRefresh,
|
||||
}: HeaderBarProps) {
|
||||
return (
|
||||
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b px-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Tables
|
||||
</button>
|
||||
<span className='text-[var(--text-muted)]'>/</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-[18px] w-[60px] rounded-full' />
|
||||
) : (
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' size='sm' onClick={onShowSchema}>
|
||||
<Info className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>View Schema</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' size='sm' onClick={onRefresh}>
|
||||
<RefreshCw className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Refresh</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { HeaderBar } from './header-bar'
|
||||
@@ -3,11 +3,10 @@ export * from './body-states'
|
||||
export * from './cell-renderer'
|
||||
export * from './cell-viewer-modal'
|
||||
export * from './context-menu'
|
||||
export * from './header-bar'
|
||||
export * from './inline-cell-editor'
|
||||
export * from './pagination'
|
||||
export * from './query-builder'
|
||||
export * from './row-modal'
|
||||
export * from './schema-modal'
|
||||
export * from './table'
|
||||
export * from './table-row-cells'
|
||||
export * from './table-viewer'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { TableViewer } from './table-viewer'
|
||||
@@ -0,0 +1 @@
|
||||
export { Table } from './table'
|
||||
@@ -5,13 +5,15 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Checkbox,
|
||||
Table,
|
||||
Table as EmcnTable,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/emcn'
|
||||
import { Table as TableIcon } from '@/components/emcn/icons'
|
||||
import type { TableRow as TableRowType } from '@/lib/table'
|
||||
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useUpdateTableRow } from '@/hooks/queries/tables'
|
||||
import { useContextMenu, useRowSelection, useTableData } from '../../hooks'
|
||||
import type { CellViewerData, EditingCell, QueryOptions } from '../../types'
|
||||
@@ -19,16 +21,14 @@ import { ActionBar } from '../action-bar'
|
||||
import { EmptyRows, LoadingRows } from '../body-states'
|
||||
import { CellViewerModal } from '../cell-viewer-modal'
|
||||
import { ContextMenu } from '../context-menu'
|
||||
import { HeaderBar } from '../header-bar'
|
||||
import { Pagination } from '../pagination'
|
||||
import { QueryBuilder } from '../query-builder'
|
||||
import { RowModal } from '../row-modal'
|
||||
import { SchemaModal } from '../schema-modal'
|
||||
import { TableRowCells } from '../table-row-cells'
|
||||
|
||||
const EMPTY_COLUMNS: never[] = []
|
||||
|
||||
export function TableViewer() {
|
||||
export function Table() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -50,13 +50,12 @@ export function TableViewer() {
|
||||
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } =
|
||||
useTableData({
|
||||
workspaceId,
|
||||
tableId,
|
||||
queryOptions,
|
||||
currentPage,
|
||||
})
|
||||
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({
|
||||
workspaceId,
|
||||
tableId,
|
||||
queryOptions,
|
||||
currentPage,
|
||||
})
|
||||
|
||||
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
|
||||
|
||||
@@ -90,18 +89,13 @@ export function TableViewer() {
|
||||
router.push(`/workspace/${workspaceId}/tables`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const handleShowSchema = useCallback(() => {
|
||||
setShowSchemaModal(true)
|
||||
}, [])
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
|
||||
const handleApplyQueryOptions = useCallback((options: QueryOptions) => {
|
||||
setQueryOptions(options)
|
||||
setCurrentPage(0)
|
||||
}, [])
|
||||
const handleSort = useCallback(() => {}, [])
|
||||
|
||||
const handleFilter = useCallback(() => {}, [])
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
setDeletingRows(Array.from(selectedRows))
|
||||
@@ -214,26 +208,12 @@ export function TableViewer() {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<HeaderBar
|
||||
tableName={tableData.name}
|
||||
totalCount={totalCount}
|
||||
isLoading={isLoadingRows}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onShowSchema={handleShowSchema}
|
||||
onRefresh={refetchRows}
|
||||
<ResourceHeader
|
||||
icon={TableIcon}
|
||||
breadcrumbs={[{ label: 'Tables', onClick: handleNavigateBack }, { label: tableData.name }]}
|
||||
/>
|
||||
|
||||
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
|
||||
<QueryBuilder
|
||||
columns={columns}
|
||||
onApply={handleApplyQueryOptions}
|
||||
onAddRow={handleAddRow}
|
||||
isLoading={isLoadingRows}
|
||||
/>
|
||||
{hasSelection && (
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>{selectedCount} selected</span>
|
||||
)}
|
||||
</div>
|
||||
<ResourceOptionsBar onSort={handleSort} onFilter={handleFilter} />
|
||||
|
||||
{hasSelection && (
|
||||
<ActionBar
|
||||
@@ -244,7 +224,7 @@ export function TableViewer() {
|
||||
)}
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<Table>
|
||||
<EmcnTable>
|
||||
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
|
||||
<TableRow>
|
||||
<TableHead className='w-[40px]'>
|
||||
@@ -293,7 +273,7 @@ export function TableViewer() {
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</EmcnTable>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
@@ -1,68 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { ErrorState } from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
const logger = createLogger('TableViewerError')
|
||||
|
||||
interface TableViewerErrorProps {
|
||||
interface TableErrorProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export default function TableViewerError({ error, reset }: TableViewerErrorProps) {
|
||||
export default function TableError({ error, reset }: TableErrorProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
useEffect(() => {
|
||||
logger.error('Table viewer error:', { error: error.message, digest: error.digest })
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
{/* Header */}
|
||||
<div className='flex h-[48px] shrink-0 items-center border-[var(--border)] border-b px-[16px]'>
|
||||
<button
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
|
||||
className='flex items-center gap-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<ArrowLeft className='h-[14px] w-[14px]' />
|
||||
Back to Tables
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Content */}
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<div className='flex flex-col items-center gap-[16px] text-center'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
|
||||
Failed to load table
|
||||
</h2>
|
||||
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
Something went wrong while loading this table. The table may have been deleted or you
|
||||
may not have permission to view it.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
|
||||
>
|
||||
<ArrowLeft className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Go back
|
||||
</Button>
|
||||
<Button variant='default' size='sm' onClick={reset}>
|
||||
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState
|
||||
error={error}
|
||||
reset={reset}
|
||||
title='Failed to load table'
|
||||
description='Something went wrong while loading this table. The table may have been deleted or you may not have permission to view it.'
|
||||
loggerName='TableError'
|
||||
secondaryAction={{
|
||||
label: 'Go back',
|
||||
icon: <ArrowLeft className='mr-[6px] h-[14px] w-[14px]' />,
|
||||
onClick: () => router.push(`/workspace/${workspaceId}/tables`),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TableViewer } from './components'
|
||||
import { Table } from './components'
|
||||
|
||||
export default function TablePage() {
|
||||
return <TableViewer />
|
||||
return <Table />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
const logger = createLogger('TablesError')
|
||||
import { ErrorState } from '@/app/workspace/[workspaceId]/components'
|
||||
|
||||
interface TablesErrorProps {
|
||||
error: Error & { digest?: string }
|
||||
@@ -13,26 +8,13 @@ interface TablesErrorProps {
|
||||
}
|
||||
|
||||
export default function TablesError({ error, reset }: TablesErrorProps) {
|
||||
useEffect(() => {
|
||||
logger.error('Tables error:', { error: error.message, digest: error.digest })
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-1 items-center justify-center bg-white dark:bg-[var(--bg)]'>
|
||||
<div className='flex flex-col items-center gap-[16px] text-center'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
|
||||
Failed to load tables
|
||||
</h2>
|
||||
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
Something went wrong while loading the tables. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='default' size='sm' onClick={reset}>
|
||||
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState
|
||||
error={error}
|
||||
reset={reset}
|
||||
title='Failed to load tables'
|
||||
description='Something went wrong while loading the tables. Please try again.'
|
||||
loggerName='TablesError'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Columns, Rows3 } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Table as TableIcon } from '@/components/emcn/icons'
|
||||
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
|
||||
@@ -80,7 +79,7 @@ export function Tables() {
|
||||
label: table.name,
|
||||
},
|
||||
columns: {
|
||||
icon: <Columns className='h-[14px] w-[14px]' />,
|
||||
icon: <Columns3 className='h-[14px] w-[14px]' />,
|
||||
label: String(table.schema.columns.length),
|
||||
},
|
||||
rows: {
|
||||
|
||||
@@ -20,7 +20,7 @@ const buttonVariants = cva(
|
||||
tertiary:
|
||||
'!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:brightness-106 hover:!text-[var(--text-inverse)] ![transition-property:background-color,border-color,fill,stroke]',
|
||||
ghost: '',
|
||||
subtle: 'text-[var(--text-body)] hover:text-[var(--text-body)] hover:bg-[var(--surface-5)]',
|
||||
subtle: 'text-[var(--text-body)] hover:text-[var(--text-body)] hover:bg-[var(--surface-4)]',
|
||||
'ghost-secondary': 'text-[var(--text-muted)]',
|
||||
/** Branded button - requires branded-button-gradient or branded-button-custom class for colors */
|
||||
branded:
|
||||
|
||||
@@ -15,13 +15,13 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--c-2A2A2A)] data-[disabled]:opacity-50',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--text-primary)] data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb className='pointer-events-none block h-4 w-4 rounded-full bg-[var(--white)] shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5' />
|
||||
<SwitchPrimitives.Thumb className='pointer-events-none block h-4 w-4 rounded-full bg-[var(--surface-2)] shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5' />
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
|
||||
|
||||
26
apps/sim/components/emcn/icons/columns3.tsx
Normal file
26
apps/sim/components/emcn/icons/columns3.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Columns3 icon component - displays three vertical columns in a rounded container
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function Columns3(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='-1 -2 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M0.75 3.25C0.75 1.86929 1.86929 0.75 3.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V3.25Z' />
|
||||
<path d='M7.25 0.75V18.75' />
|
||||
<path d='M13.25 0.75V18.75' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { BubbleChatPreview } from './bubble-chat-preview'
|
||||
export { Calendar } from './calendar'
|
||||
export { Card } from './card'
|
||||
export { ChevronDown } from './chevron-down'
|
||||
export { Columns3 } from './columns3'
|
||||
export { Connections } from './connections'
|
||||
export { Copy } from './copy'
|
||||
export { Cursor } from './cursor'
|
||||
@@ -27,6 +28,7 @@ export { PanelLeft } from './panel-left'
|
||||
export { Play, PlayOutline } from './play'
|
||||
export { Redo } from './redo'
|
||||
export { Rocket } from './rocket'
|
||||
export { Rows3 } from './rows3'
|
||||
export { Table } from './table'
|
||||
export { TerminalWindow } from './terminal-window'
|
||||
export { Trash } from './trash'
|
||||
|
||||
26
apps/sim/components/emcn/icons/rows3.tsx
Normal file
26
apps/sim/components/emcn/icons/rows3.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Rows3 icon component - displays three horizontal rows in a rounded container
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function Rows3(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='-1 -2 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path d='M0.75 3.25C0.75 1.86929 1.86929 0.75 3.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V3.25Z' />
|
||||
<path d='M0.75 6.75H19.75' />
|
||||
<path d='M0.75 12.75H19.75' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user