mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-25 09:34:44 +08:00
improvement: logs
This commit is contained in:
@@ -87,6 +87,13 @@ const SECTION_TO_ICON: Record<string, string> = {
|
||||
templates: 'templates',
|
||||
}
|
||||
|
||||
function setFaviconHrefs(url: string) {
|
||||
document.querySelectorAll<HTMLLinkElement>("link[rel*='icon']").forEach((link) => {
|
||||
if (link.rel === 'apple-touch-icon') return
|
||||
link.href = url
|
||||
})
|
||||
}
|
||||
|
||||
export function DynamicFavicon() {
|
||||
const pathname = usePathname()
|
||||
|
||||
@@ -95,17 +102,15 @@ export function DynamicFavicon() {
|
||||
const iconKey = section ? SECTION_TO_ICON[section] : null
|
||||
const url = iconKey ? FAVICONS[iconKey] : DEFAULT_FAVICON
|
||||
|
||||
const links = document.querySelectorAll<HTMLLinkElement>("link[rel*='icon']")
|
||||
links.forEach((link) => {
|
||||
if (link.rel === 'apple-touch-icon') return
|
||||
link.href = url
|
||||
})
|
||||
setFaviconHrefs(url)
|
||||
|
||||
// Re-apply whenever Next.js head reconciliation replaces link elements
|
||||
const observer = new MutationObserver(() => setFaviconHrefs(url))
|
||||
observer.observe(document.head, { childList: true })
|
||||
|
||||
return () => {
|
||||
links.forEach((link) => {
|
||||
if (link.rel === 'apple-touch-icon') return
|
||||
link.href = DEFAULT_FAVICON
|
||||
})
|
||||
observer.disconnect()
|
||||
setFaviconHrefs(DEFAULT_FAVICON)
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
|
||||
@@ -15,26 +15,41 @@ const BROWSER_EXTENSION_ATTRIBUTES = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Client component that intercepts console.error to filter and log hydration errors
|
||||
* while ignoring errors caused by browser extensions.
|
||||
* Checks whether a hydration error is caused by Radix UI's auto-generated IDs
|
||||
* (`aria-controls`, `id`) differing between server and client. This is a known
|
||||
* harmless artifact of React 19's streaming SSR producing different `useId()`
|
||||
* tree paths. The IDs self-correct on first interaction and have no visual or
|
||||
* functional impact since they only link closed (invisible) popover/menu content.
|
||||
*/
|
||||
function isRadixIdMismatch(args: unknown[]): boolean {
|
||||
return args.some(
|
||||
(arg) => typeof arg === 'string' && arg.includes('radix-') && /aria-controls|"\bid\b"/.test(arg)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that intercepts console.error to filter hydration errors
|
||||
* caused by browser extensions or Radix UI's `useId()` mismatch.
|
||||
*/
|
||||
export function HydrationErrorHandler() {
|
||||
useEffect(() => {
|
||||
const originalError = console.error
|
||||
console.error = (...args) => {
|
||||
if (args[0].includes('Hydration')) {
|
||||
if (typeof args[0] === 'string' && args[0].includes('Hydration')) {
|
||||
const isExtensionError = BROWSER_EXTENSION_ATTRIBUTES.some((attr) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes(attr))
|
||||
)
|
||||
|
||||
if (!isExtensionError) {
|
||||
logger.error('Hydration Error', {
|
||||
details: args,
|
||||
componentStack: args.find(
|
||||
(arg) => typeof arg === 'string' && arg.includes('component stack')
|
||||
),
|
||||
})
|
||||
if (isExtensionError || isRadixIdMismatch(args)) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.error('Hydration Error', {
|
||||
details: args,
|
||||
componentStack: args.find(
|
||||
(arg) => typeof arg === 'string' && arg.includes('component stack')
|
||||
),
|
||||
})
|
||||
}
|
||||
originalError.apply(console, args)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
HeaderAction,
|
||||
} from './resource/components/resource-header'
|
||||
export { ResourceHeader } from './resource/components/resource-header'
|
||||
export type { FilterTag, SearchConfig } from './resource/components/resource-options-bar'
|
||||
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'
|
||||
|
||||
+3
-1
@@ -96,7 +96,9 @@ export function ResourceHeader({
|
||||
variant='subtle'
|
||||
className='px-[8px] py-[4px] text-[12px]'
|
||||
>
|
||||
{ActionIcon && <ActionIcon className='mr-[6px] h-[14px] w-[14px]' />}
|
||||
{ActionIcon && (
|
||||
<ActionIcon className={cn('h-[14px] w-[14px]', action.label && 'mr-[6px]')} />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
+7
-1
@@ -1,2 +1,8 @@
|
||||
export type { ColumnOption, SortConfig } from './resource-options-bar'
|
||||
export type {
|
||||
ColumnOption,
|
||||
FilterTag,
|
||||
SearchConfig,
|
||||
SearchTag,
|
||||
SortConfig,
|
||||
} from './resource-options-bar'
|
||||
export { ResourceOptionsBar } from './resource-options-bar'
|
||||
|
||||
+90
-18
@@ -31,18 +31,41 @@ export interface SortConfig {
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
interface ResourceOptionsBarProps {
|
||||
search?: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
sort?: SortConfig
|
||||
filter?: ReactNode
|
||||
export interface FilterTag {
|
||||
label: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function ResourceOptionsBar({ search, sort, filter }: ResourceOptionsBarProps) {
|
||||
const hasContent = search || sort || filter
|
||||
export interface SearchTag {
|
||||
label: string
|
||||
value: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export interface SearchConfig {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
tags?: SearchTag[]
|
||||
highlightedTagIndex?: number | null
|
||||
onClearAll?: () => void
|
||||
dropdown?: ReactNode
|
||||
dropdownRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
interface ResourceOptionsBarProps {
|
||||
search?: SearchConfig
|
||||
sort?: SortConfig
|
||||
filter?: ReactNode
|
||||
filterTags?: FilterTag[]
|
||||
}
|
||||
|
||||
export function ResourceOptionsBar({ search, sort, filter, filterTags }: ResourceOptionsBarProps) {
|
||||
const hasContent = search || sort || filter || (filterTags && filterTags.length > 0)
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
@@ -54,18 +77,67 @@ export function ResourceOptionsBar({ search, sort, filter }: ResourceOptionsBarP
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
{search && (
|
||||
<div className='flex flex-1 items-center'>
|
||||
<div className='relative flex flex-1 items-center'>
|
||||
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
|
||||
<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-[10px] text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
<div className='flex flex-1 items-center gap-[6px] overflow-x-auto pl-[10px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{search.tags?.map((tag, i) => (
|
||||
<Button
|
||||
key={`${tag.label}-${tag.value}-${i}`}
|
||||
variant='subtle'
|
||||
className={cn(
|
||||
'shrink-0 px-[8px] py-[4px] text-[12px]',
|
||||
search.highlightedTagIndex === i &&
|
||||
'ring-1 ring-[var(--border-focus)] ring-offset-1'
|
||||
)}
|
||||
onClick={tag.onRemove}
|
||||
>
|
||||
{tag.label}: {tag.value}
|
||||
<span className='ml-[4px] text-[10px] text-[var(--text-icon)]'>✕</span>
|
||||
</Button>
|
||||
))}
|
||||
<input
|
||||
ref={search.inputRef}
|
||||
type='text'
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
onKeyDown={search.onKeyDown}
|
||||
onFocus={search.onFocus}
|
||||
onBlur={search.onBlur}
|
||||
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
|
||||
className='min-w-[80px] flex-1 bg-transparent py-[4px] text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
{search.tags?.length || search.value ? (
|
||||
<button
|
||||
type='button'
|
||||
className='mr-[2px] flex h-[14px] w-[14px] shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover:text-[var(--text-secondary)]'
|
||||
onClick={search.onClearAll}
|
||||
>
|
||||
<span className='text-[12px]'>✕</span>
|
||||
</button>
|
||||
) : null}
|
||||
{search.dropdown && (
|
||||
<div
|
||||
ref={search.dropdownRef}
|
||||
className='absolute top-full left-0 z-50 mt-[6px] w-full rounded-[8px] border border-[var(--border)] bg-white shadow-sm dark:bg-[var(--bg)]'
|
||||
>
|
||||
{search.dropdown}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
{filterTags?.map((tag) => (
|
||||
<Button
|
||||
key={tag.label}
|
||||
variant='subtle'
|
||||
className='px-[8px] py-[4px] text-[12px]'
|
||||
onClick={tag.onRemove}
|
||||
>
|
||||
{tag.label}
|
||||
<span className='ml-[4px] text-[10px] text-[var(--text-icon)]'>✕</span>
|
||||
</Button>
|
||||
))}
|
||||
{filter && (
|
||||
<PopoverPrimitive.Root>
|
||||
<PopoverPrimitive.Trigger asChild>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, Button, Plus, Skeleton } from '@/components/emcn'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, Button, Loader, Plus, Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { CreateAction, HeaderAction } from './components/resource-header'
|
||||
import { ResourceHeader } from './components/resource-header'
|
||||
import type { SortConfig } from './components/resource-options-bar'
|
||||
import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar'
|
||||
import { ResourceOptionsBar } from './components/resource-options-bar'
|
||||
|
||||
export interface ResourceColumn {
|
||||
@@ -17,6 +17,7 @@ export interface ResourceColumn {
|
||||
export interface ResourceCell {
|
||||
icon?: ReactNode
|
||||
label?: string | null
|
||||
content?: ReactNode
|
||||
}
|
||||
|
||||
export interface ResourceRow {
|
||||
@@ -29,26 +30,33 @@ interface ResourceProps {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
create?: CreateAction
|
||||
search?: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
defaultSort: string
|
||||
search?: SearchConfig
|
||||
defaultSort?: string
|
||||
disableHeaderSort?: boolean
|
||||
headerActions?: HeaderAction[]
|
||||
columns: ResourceColumn[]
|
||||
rows: ResourceRow[]
|
||||
selectedRowId?: string | null
|
||||
onRowClick?: (rowId: string) => void
|
||||
onRowHover?: (rowId: string) => void
|
||||
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
|
||||
isLoading?: boolean
|
||||
loadingRows?: number
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
filter?: ReactNode
|
||||
filterTags?: FilterTag[]
|
||||
onLoadMore?: () => void
|
||||
hasMore?: boolean
|
||||
isLoadingMore?: boolean
|
||||
emptyMessage?: string
|
||||
contentOverride?: ReactNode
|
||||
overlay?: ReactNode
|
||||
}
|
||||
|
||||
const EMPTY_CELL_PLACEHOLDER = '- - -'
|
||||
|
||||
/**
|
||||
* Shared page shell for resource list pages (tables, files, knowledge, schedules).
|
||||
* Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
|
||||
* Renders the header, toolbar with search, and a data table from column/row definitions.
|
||||
*/
|
||||
export function Resource({
|
||||
@@ -57,18 +65,31 @@ export function Resource({
|
||||
create,
|
||||
search,
|
||||
defaultSort,
|
||||
disableHeaderSort,
|
||||
headerActions,
|
||||
columns,
|
||||
rows,
|
||||
selectedRowId,
|
||||
onRowClick,
|
||||
onRowHover,
|
||||
onRowContextMenu,
|
||||
isLoading,
|
||||
loadingRows = 5,
|
||||
onContextMenu,
|
||||
filter,
|
||||
filterTags,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
emptyMessage,
|
||||
contentOverride,
|
||||
overlay,
|
||||
}: ResourceProps) {
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||
const sortEnabled = defaultSort != null && !disableHeaderSort
|
||||
const [sort, setSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({
|
||||
column: defaultSort,
|
||||
column: defaultSort ?? '',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
@@ -82,16 +103,17 @@ export function Resource({
|
||||
setSort({ column, direction })
|
||||
}, [])
|
||||
|
||||
const sortConfig = useMemo<SortConfig>(
|
||||
() => ({
|
||||
const sortConfig = useMemo<SortConfig | undefined>(() => {
|
||||
if (!sortEnabled) return undefined
|
||||
return {
|
||||
options: columns.map((col) => ({ id: col.id, label: col.header })),
|
||||
active: sort,
|
||||
onSort: handleSort,
|
||||
}),
|
||||
[columns, sort, handleSort]
|
||||
)
|
||||
}
|
||||
}, [sortEnabled, columns, sort, handleSort])
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const displayRows = useMemo(() => {
|
||||
if (!sortEnabled) return rows
|
||||
return [...rows].sort((a, b) => {
|
||||
const col = sort.column
|
||||
const aVal = a.sortValues?.[col] ?? a.cells[col]?.label ?? ''
|
||||
@@ -102,7 +124,21 @@ export function Resource({
|
||||
: String(aVal).localeCompare(String(bVal))
|
||||
return sort.direction === 'asc' ? -cmp : cmp
|
||||
})
|
||||
}, [rows, sort])
|
||||
}, [rows, sort, sortEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onLoadMore || !hasMore) return
|
||||
const el = loadMoreRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) onLoadMore()
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [onLoadMore, hasMore])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -110,18 +146,39 @@ export function Resource({
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<ResourceHeader icon={icon} title={title} create={create} actions={headerActions} />
|
||||
<ResourceOptionsBar search={search} sort={sortConfig} />
|
||||
<ResourceOptionsBar
|
||||
search={search}
|
||||
sort={sortConfig}
|
||||
filter={filter}
|
||||
filterTags={filterTags}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
{contentOverride ? (
|
||||
<div className='min-h-0 flex-1 overflow-auto'>{contentOverride}</div>
|
||||
) : isLoading ? (
|
||||
<DataTableSkeleton columns={columns} rowCount={loadingRows} />
|
||||
) : rows.length === 0 && emptyMessage ? (
|
||||
<div className='flex min-h-0 flex-1 items-center justify-center'>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>{emptyMessage}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='relative min-h-0 flex-1 overflow-hidden'>
|
||||
<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) => {
|
||||
if (disableHeaderSort || !sortEnabled) {
|
||||
return (
|
||||
<th
|
||||
key={col.id}
|
||||
className='h-10 px-[24px] py-[6px] text-left align-middle font-base text-[var(--text-muted)]'
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
const isActive = sort.column === col.id
|
||||
const SortIcon = sort.direction === 'asc' ? ArrowUp : ArrowDown
|
||||
return (
|
||||
@@ -152,15 +209,18 @@ export function Resource({
|
||||
<table className='w-full table-fixed text-[13px]'>
|
||||
<ResourceColGroup columns={columns} />
|
||||
<tbody>
|
||||
{sortedRows.map((row) => (
|
||||
{displayRows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-resource-row
|
||||
data-row-id={row.id}
|
||||
className={cn(
|
||||
'transition-colors hover:bg-[var(--surface-3)]',
|
||||
onRowClick && 'cursor-pointer'
|
||||
onRowClick && 'cursor-pointer',
|
||||
selectedRowId === row.id && 'bg-[var(--surface-3)]'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row.id)}
|
||||
onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined}
|
||||
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
|
||||
>
|
||||
{columns.map((col, colIdx) => {
|
||||
@@ -196,14 +256,23 @@ export function Resource({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className='flex items-center justify-center py-[12px]'>
|
||||
{isLoadingMore && (
|
||||
<Loader className='h-[16px] w-[16px] text-[var(--text-secondary)]' animate />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{overlay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) {
|
||||
if (cell.content) return <>{cell.content}</>
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ interface PendingPlaceholder {
|
||||
}
|
||||
|
||||
const EMPTY_COLUMNS: never[] = []
|
||||
const PLACEHOLDER_ROW_COUNT = 50
|
||||
const PLACEHOLDER_ROW_COUNT = 1000
|
||||
const COL_WIDTH = 160
|
||||
const CHECKBOX_COL_WIDTH = 40
|
||||
const ADD_COL_WIDTH = 120
|
||||
@@ -134,8 +134,6 @@ export function Table() {
|
||||
filter: null,
|
||||
sort: null,
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
|
||||
const [deletingRows, setDeletingRows] = useState<string[]>([])
|
||||
@@ -158,11 +156,10 @@ export function Table() {
|
||||
|
||||
const isDraggingRef = useRef(false)
|
||||
|
||||
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows } = useTableData({
|
||||
const { tableData, isLoadingTable, rows, isLoadingRows } = useTableData({
|
||||
workspaceId,
|
||||
tableId,
|
||||
queryOptions,
|
||||
currentPage,
|
||||
})
|
||||
|
||||
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
|
||||
@@ -651,17 +648,14 @@ export function Table() {
|
||||
|
||||
const handleSortChange = useCallback((column: string, direction: SortDirection) => {
|
||||
setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } }))
|
||||
setCurrentPage(0)
|
||||
}, [])
|
||||
|
||||
const handleSortClear = useCallback(() => {
|
||||
setQueryOptions((prev) => ({ ...prev, sort: null }))
|
||||
setCurrentPage(0)
|
||||
}, [])
|
||||
|
||||
const handleFilterApply = useCallback((filter: Filter | null) => {
|
||||
setQueryOptions((prev) => ({ ...prev, filter }))
|
||||
setCurrentPage(0)
|
||||
}, [])
|
||||
const columnOptions = useMemo<ColumnOption[]>(
|
||||
() =>
|
||||
@@ -722,6 +716,11 @@ export function Table() {
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
create={{
|
||||
label: 'New column',
|
||||
onClick: handleAddColumn,
|
||||
disabled: addColumnMutation.isPending,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceOptionsBar
|
||||
@@ -793,7 +792,7 @@ export function Table() {
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Add column
|
||||
New column
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
@@ -845,14 +844,6 @@ export function Table() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalCount={totalCount}
|
||||
onPreviousPage={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
/>
|
||||
|
||||
{showAddModal && tableData && (
|
||||
<RowModal
|
||||
mode='add'
|
||||
@@ -1235,7 +1226,7 @@ function InlineEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
className={cn(
|
||||
'h-full w-full min-w-0 select-text border-none bg-transparent p-0 outline-none',
|
||||
'w-full min-w-0 select-text border-none bg-transparent p-0 outline-none',
|
||||
column.type === 'number'
|
||||
? 'font-mono text-[12px] text-[var(--text-secondary)]'
|
||||
: column.type === 'date'
|
||||
@@ -1246,43 +1237,6 @@ function InlineEditor({
|
||||
)
|
||||
}
|
||||
|
||||
function TablePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
}: {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
onPreviousPage: () => void
|
||||
onNextPage: () => void
|
||||
}) {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
return (
|
||||
<div className='flex h-[40px] shrink-0 items-center justify-between border-[var(--border)] border-t px-[16px]'>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Page {currentPage + 1} of {totalPages} ({totalCount} rows)
|
||||
</span>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button variant='ghost' size='sm' onClick={onPreviousPage} disabled={currentPage === 0}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={onNextPage}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBodySkeleton({ colCount }: { colCount: number }) {
|
||||
return (
|
||||
<>
|
||||
@@ -1388,12 +1342,14 @@ function PlaceholderRows({
|
||||
)}
|
||||
{isAnchor && <div className={cn(SELECTION_OVERLAY, belowHeader && 'top-0')} />}
|
||||
{isEditing ? (
|
||||
<InlineEditor
|
||||
value={hasPendingValue ? pendingValue : null}
|
||||
column={col}
|
||||
onSave={(value) => onSave(i, col.name, value)}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<div className={CELL_CONTENT}>
|
||||
<InlineEditor
|
||||
value={hasPendingValue ? pendingValue : null}
|
||||
column={col}
|
||||
onSave={(value) => onSave(i, col.name, value)}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
) : hasPendingValue ? (
|
||||
<div className={CELL_CONTENT}>
|
||||
<CellContent
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import type { TableDefinition, TableRow } from '@/lib/table'
|
||||
import { useTable, useTableRows } from '@/hooks/queries/tables'
|
||||
import { ROWS_PER_PAGE } from '../constants'
|
||||
import type { QueryOptions } from '../types'
|
||||
|
||||
interface UseTableDataParams {
|
||||
workspaceId: string
|
||||
tableId: string
|
||||
queryOptions: QueryOptions
|
||||
currentPage: number
|
||||
}
|
||||
|
||||
interface UseTableDataReturn {
|
||||
tableData: TableDefinition | undefined
|
||||
isLoadingTable: boolean
|
||||
rows: TableRow[]
|
||||
totalCount: number
|
||||
totalPages: number
|
||||
isLoadingRows: boolean
|
||||
refetchRows: () => void
|
||||
}
|
||||
@@ -24,7 +20,6 @@ export function useTableData({
|
||||
workspaceId,
|
||||
tableId,
|
||||
queryOptions,
|
||||
currentPage,
|
||||
}: UseTableDataParams): UseTableDataReturn {
|
||||
const { data: tableData, isLoading: isLoadingTable } = useTable(workspaceId, tableId)
|
||||
|
||||
@@ -35,23 +30,19 @@ export function useTableData({
|
||||
} = useTableRows({
|
||||
workspaceId,
|
||||
tableId,
|
||||
limit: ROWS_PER_PAGE,
|
||||
offset: currentPage * ROWS_PER_PAGE,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
filter: queryOptions.filter,
|
||||
sort: queryOptions.sort,
|
||||
enabled: Boolean(workspaceId && tableId),
|
||||
})
|
||||
|
||||
const rows = (rowsData?.rows || []) as TableRow[]
|
||||
const totalCount = rowsData?.totalCount || 0
|
||||
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
|
||||
|
||||
return {
|
||||
tableData,
|
||||
isLoadingTable,
|
||||
rows,
|
||||
totalCount,
|
||||
totalPages,
|
||||
isLoadingRows,
|
||||
refetchRows,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Bell icon component - notification bell
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function Bell(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='M15.25 6.75C15.25 5.35761 14.6969 4.02226 13.7123 3.03769C12.7277 2.05312 11.3924 1.5 10 1.5C8.60761 1.5 7.27226 2.05312 6.28769 3.03769C5.30312 4.02226 4.75 5.35761 4.75 6.75C4.75 12.75 2.25 14.5 2.25 14.5H17.75C17.75 14.5 15.25 12.75 15.25 6.75Z' />
|
||||
<path d='M11.4425 17.75C11.2655 18.0547 11.0133 18.3088 10.7101 18.4882C10.4068 18.6676 10.0627 18.7662 9.71 18.7749C9.35735 18.7836 9.00888 18.7022 8.69728 18.5381C8.38568 18.374 8.12138 18.1327 7.92999 17.8375' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { ArrowRight } from './arrow-right'
|
||||
export { ArrowUp } from './arrow-up'
|
||||
export { ArrowUpDown } from './arrow-up-down'
|
||||
export { Asterisk } from './asterisk'
|
||||
export { Bell } from './bell'
|
||||
export { Blimp } from './blimp'
|
||||
export { BubbleChatClose } from './bubble-chat-close'
|
||||
export { BubbleChatPreview } from './bubble-chat-preview'
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@
|
||||
"!**/public/fallback-*.js",
|
||||
"!**/apps/docs/.source/**",
|
||||
"!**/venv/**",
|
||||
"!**/.venv/**"
|
||||
"!**/.venv/**",
|
||||
"!**/uploads/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
Reference in New Issue
Block a user