improvement: tables, dropdown

This commit is contained in:
Emir Karabeg
2026-03-09 00:13:35 -07:00
parent 12c1ede336
commit a61dc23d43
20 changed files with 926 additions and 222 deletions

View File

@@ -123,6 +123,7 @@
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--selection: #1a5cf6;
--warning: #ea580c;
/* Utility */
@@ -245,6 +246,7 @@
--brand-secondary: #33b4ff;
--brand-tertiary: #22c55e;
--brand-tertiary-2: #32bd7e;
--selection: #4b83f7;
--warning: #ff6600;
/* Utility */

View File

@@ -1 +1,2 @@
export type { ActiveFilter, ColumnOption, FilterConfig, SortConfig } from './resource-options-bar'
export { ResourceOptionsBar } from './resource-options-bar'

View File

@@ -1,25 +1,79 @@
import type { ReactNode } from 'react'
import { ArrowUpDown, Button, ListFilter, Search } from '@/components/emcn'
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
ListFilter,
Search,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
id: string
label: string
type?: string
icon?: React.ElementType
}
export interface SortConfig {
options: ColumnOption[]
active: { column: string; direction: SortDirection } | null
onSort: (column: string, direction: SortDirection) => void
onClear?: () => void
}
export interface ActiveFilter {
column: string
operator: string
}
export interface FilterConfig {
options: ColumnOption[]
active: ActiveFilter[]
onToggle: (column: string, operator: string) => void
onClear?: () => void
}
const DEFAULT_FILTER_OPERATORS = [
{ id: 'empty', label: 'Is empty' },
{ id: 'not_empty', label: 'Is not empty' },
] as const
const BOOLEAN_FILTER_OPERATORS = [
{ id: 'eq_true', label: 'Is true' },
{ id: 'eq_false', label: 'Is false' },
] as const
interface ResourceOptionsBarProps {
search?: {
value: string
onChange: (value: string) => void
placeholder?: string
}
onSort?: () => void
onFilter?: () => void
sort?: SortConfig
filter?: FilterConfig
toolbarActions?: ReactNode
}
export function ResourceOptionsBar({
search,
onSort,
onFilter,
sort,
filter,
toolbarActions,
}: ResourceOptionsBarProps) {
const hasContent = search || onSort || onFilter || toolbarActions
const hasContent = search || sort || filter || toolbarActions
if (!hasContent) return null
return (
@@ -43,21 +97,114 @@ export function ResourceOptionsBar({
</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] text-[var(--text-icon)]' />
Filter
</Button>
)}
{onSort && (
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onSort}>
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]' />
Sort
</Button>
)}
{filter && <FilterDropdown config={filter} />}
{sort && <SortDropdown config={sort} />}
{toolbarActions}
</div>
</div>
</div>
)
}
function SortDropdown({ config }: { config: SortConfig }) {
const { options, active, onSort, onClear } = config
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]'>
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]' />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{options.map((option) => {
const isActive = active?.column === option.id
const Icon = option.icon
const DirectionIcon = isActive ? (active.direction === 'asc' ? ArrowUp : ArrowDown) : null
return (
<DropdownMenuItem
key={option.id}
onSelect={() => {
if (isActive) {
onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc')
} else {
onSort(option.id, 'desc')
}
}}
>
{Icon && <Icon />}
{option.label}
{DirectionIcon && (
<DirectionIcon className='ml-auto h-[12px] w-[12px] text-[var(--text-tertiary)]' />
)}
</DropdownMenuItem>
)
})}
{active && onClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClear} className='text-[var(--text-tertiary)]'>
Clear sort
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
function FilterDropdown({ config }: { config: FilterConfig }) {
const { options, active, onToggle, onClear } = config
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]'>
<ListFilter className='mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]' />
Filter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{options.map((option) => {
const operators =
option.type === 'boolean' ? BOOLEAN_FILTER_OPERATORS : DEFAULT_FILTER_OPERATORS
const activeFilter = active.find((f) => f.column === option.id)
const Icon = option.icon
return (
<DropdownMenuSub key={option.id}>
<DropdownMenuSubTrigger>
{Icon && <Icon />}
{option.label}
{activeFilter && (
<span className='ml-auto h-[6px] w-[6px] rounded-full bg-[var(--text-tertiary)]' />
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{operators.map((op) => (
<DropdownMenuCheckboxItem
key={op.id}
checked={activeFilter?.operator === op.id}
onCheckedChange={() => onToggle(option.id, op.id)}
>
{op.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
{active.length > 0 && onClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClear} className='text-[var(--text-tertiary)]'>
Clear all filters
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -5,6 +5,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Button, Plus, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { ResourceHeader } from './components/resource-header'
import type { SortConfig } from './components/resource-options-bar'
import { ResourceOptionsBar } from './components/resource-options-bar'
export interface ResourceColumn {
@@ -37,8 +38,6 @@ interface ResourceProps {
placeholder?: string
}
defaultSort: string
onSort?: () => void
onFilter?: () => void
toolbarActions?: ReactNode
columns: ResourceColumn[]
rows: ResourceRow[]
@@ -61,8 +60,6 @@ export function Resource({
create,
search,
defaultSort,
onSort,
onFilter,
toolbarActions,
columns,
rows,
@@ -84,13 +81,19 @@ export function Resource({
}
}, [])
const handleColumnSort = useCallback((columnId: string) => {
setSort((prev) => {
if (prev.column !== columnId) return { column: columnId, direction: 'desc' }
return { column: columnId, direction: prev.direction === 'desc' ? 'asc' : 'desc' }
})
const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => {
setSort({ column, direction })
}, [])
const sortConfig = useMemo<SortConfig>(
() => ({
options: columns.map((col) => ({ id: col.id, label: col.header })),
active: sort,
onSort: handleSort,
}),
[columns, sort, handleSort]
)
const sortedRows = useMemo(() => {
return [...rows].sort((a, b) => {
const col = sort.column
@@ -110,12 +113,7 @@ export function Resource({
onContextMenu={onContextMenu}
>
<ResourceHeader icon={icon} title={title} create={create} />
<ResourceOptionsBar
search={search}
onSort={onSort}
onFilter={onFilter}
toolbarActions={toolbarActions}
/>
<ResourceOptionsBar search={search} sort={sortConfig} toolbarActions={toolbarActions} />
{isLoading ? (
<DataTableSkeleton columns={columns} rowCount={loadingRows} />
@@ -127,16 +125,22 @@ export function Resource({
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => {
const isActive = sort.column === col.id
const SortIcon = sort.direction === 'asc' ? ArrowUp : ArrowDown
return (
<th key={col.id} className='h-10 px-[16px] py-[6px] text-left align-middle'>
<Button
variant='subtle'
className='px-[8px] py-[4px] font-base text-[var(--text-muted)] hover:text-[var(--text-muted)]'
onClick={() => handleColumnSort(col.id)}
onClick={() =>
handleSort(
col.id,
isActive ? (sort.direction === 'desc' ? 'asc' : 'desc') : 'desc'
)
}
>
{col.header}
{sort.column === col.id && (
{isActive && (
<SortIcon className='ml-[4px] h-[12px] w-[12px] text-[var(--text-icon)]' />
)}
</Button>

View File

@@ -204,8 +204,6 @@ export function Knowledge() {
placeholder: 'Search knowledge bases...',
}}
defaultSort='created'
onSort={() => {}}
onFilter={() => {}}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}

View File

@@ -189,8 +189,6 @@ export function Schedules() {
placeholder: 'Search schedules...',
}}
defaultSort='nextRun'
onSort={() => {}}
onFilter={() => {}}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}

View File

@@ -177,8 +177,6 @@ export function Tables() {
placeholder: 'Search tables...',
}}
defaultSort='created'
onSort={() => {}}
onFilter={() => {}}
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}

View File

@@ -49,7 +49,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-[8px] rounded-[6px] px-[6px] py-[4px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] data-[state=open]:bg-[var(--border-1)] [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0',
'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 focus:bg-[var(--surface-4)] focus:text-[var(--text-primary)] data-[state=open]:bg-[var(--surface-4)] data-[state=open]:text-[var(--text-primary)] [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0',
inset && 'pl-[28px]',
className
)}
@@ -69,7 +69,7 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
ANIMATION_CLASSES,
'z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-[6px] border border-[var(--border)] bg-white p-[6px] text-[var(--text-primary)] shadow-lg dark:bg-[var(--bg)]',
'z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-[8px] border border-[var(--border)] bg-white p-[6px] text-[var(--text-secondary)] shadow-sm dark:bg-[var(--bg)]',
className
)}
{...props}
@@ -87,7 +87,7 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
ANIMATION_CLASSES,
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-[6px] border border-[var(--border)] bg-white p-[6px] text-[var(--text-primary)] shadow-md dark:bg-[var(--bg)]',
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-[8px] border border-[var(--border)] bg-white p-[6px] text-[var(--text-secondary)] shadow-sm dark:bg-[var(--bg)]',
className
)}
{...props}
@@ -105,7 +105,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-[8px] rounded-[6px] px-[6px] py-[4px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0',
'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 focus:bg-[var(--surface-4)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0',
inset && 'pl-[28px]',
className
)}
@@ -121,13 +121,13 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-[6px] py-[4px] pr-[6px] pl-[28px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-[5px] py-[5px] pr-[8px] pl-[28px] font-medium text-[12px] text-[var(--text-secondary)] outline-none transition-colors focus:bg-[var(--surface-4)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className='absolute left-[6px] flex h-[14px] w-[14px] items-center justify-center'>
<span className='absolute left-[8px] flex h-[14px] w-[14px] items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className='h-[14px] w-[14px]' />
</DropdownMenuPrimitive.ItemIndicator>
@@ -144,12 +144,12 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-[6px] py-[4px] pr-[6px] pl-[28px] text-[13px] text-[var(--text-primary)] outline-none transition-colors focus:bg-[var(--border-1)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-[5px] py-[5px] pr-[8px] pl-[28px] font-medium text-[12px] text-[var(--text-secondary)] outline-none transition-colors focus:bg-[var(--surface-4)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className='absolute left-[6px] flex h-[14px] w-[14px] items-center justify-center'>
<span className='absolute left-[8px] flex h-[14px] w-[14px] items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className='h-[6px] w-[6px] fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
@@ -168,7 +168,7 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-[6px] py-[4px] font-medium text-[11px] text-[var(--text-tertiary)]',
'px-[8px] py-[5px] font-medium text-[11px] text-[var(--text-tertiary)]',
inset && 'pl-[28px]',
className
)}

View File

@@ -0,0 +1,25 @@
import type { SVGProps } from 'react'
/**
* ArrowLeft icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ArrowLeft(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='M9.25 4L3 10.25L9.25 16.5' />
<path d='M3 10.25H17.5' />
</svg>
)
}

View File

@@ -0,0 +1,25 @@
import type { SVGProps } from 'react'
/**
* ArrowRight icon component
* @param props - SVG properties including className, fill, etc.
*/
export function ArrowRight(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='M11.25 4L17.5 10.25L11.25 16.5' />
<path d='M17.5 10.25H3' />
</svg>
)
}

View File

@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Asterisk icon component - required field indicator
* @param props - SVG properties including className, fill, etc.
*/
export function Asterisk(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='M10.25 3V17.5' />
<path d='M4 6.625L16.5 13.875' />
<path d='M4 13.875L16.5 6.625' />
</svg>
)
}

View File

@@ -1,6 +1,9 @@
export { ArrowDown } from './arrow-down'
export { ArrowLeft } from './arrow-left'
export { ArrowRight } from './arrow-right'
export { ArrowUp } from './arrow-up'
export { ArrowUpDown } from './arrow-up-down'
export { Asterisk } from './asterisk'
export { Blimp } from './blimp'
export { BubbleChatClose } from './bubble-chat-close'
export { BubbleChatPreview } from './bubble-chat-preview'
@@ -29,6 +32,7 @@ export { Loader } from './loader'
export { MoreHorizontal } from './more-horizontal'
export { NoWrap } from './no-wrap'
export { PanelLeft } from './panel-left'
export { Pencil } from './pencil'
export { Play, PlayOutline } from './play'
export { Plus } from './plus'
export { Redo } from './redo'

View File

@@ -7,17 +7,21 @@ import type { SVGProps } from 'react'
export function Key(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='16'
height='8'
viewBox='-0.5 -0.5 17 9'
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='M7.92 3.20645H16V6.41206C16 6.84919 15.64 7.20947 15.2 7.20947C14.76 7.20947 14.4 6.84919 14.4 6.41206V4.80765H12.8V6.40886C12.8 6.84919 12.44 7.20947 12 7.20947C11.56 7.20947 11.2 6.84919 11.2 6.40886V4.80765H7.92C7.71926 5.77747 7.16654 6.63843 6.36859 7.22427C5.57064 7.81011 4.58407 8.07925 3.59943 7.97972C2.61479 7.88018 1.70193 7.41903 1.03716 6.68534C0.372398 5.95164 0.00288855 4.99745 2.02229e-06 4.00705C-0.000994899 3.01434 0.366627 2.05667 1.0315 1.31996C1.69637 0.583243 2.61107 0.120042 3.598 0.0202756C4.58494 -0.0794905 5.57372 0.191296 6.37238 0.780068C7.17104 1.36884 7.72261 2.23359 7.92 3.20645ZM4 6.40886C4.63652 6.40886 5.24697 6.15582 5.69706 5.70539C6.14715 5.25496 6.4 4.64405 6.4 4.00705C6.4 3.37005 6.14715 2.75914 5.69706 2.30871C5.24697 1.85829 4.63652 1.60524 4 1.60524C3.36348 1.60524 2.75303 1.85829 2.30295 2.30871C1.85286 2.75914 1.6 3.37005 1.6 4.00705C1.6 4.64405 1.85286 5.25496 2.30295 5.70539C2.75303 6.15582 3.36348 6.40886 4 6.40886Z'
fill='currentColor'
/>
<circle cx='6.75' cy='10.25' r='3.5' />
<path d='M10.25 10.25H19' />
<path d='M14.5 10.25V13.5' />
<path d='M17.5 10.25V12.5' />
</svg>
)
}

View File

@@ -0,0 +1,25 @@
import type { SVGProps } from 'react'
/**
* Pencil icon component - edit/rename indicator
* @param props - SVG properties including className, fill, etc.
*/
export function Pencil(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='M14.75 1.25L19.25 5.75L7.25 17.75H2.75V13.25L14.75 1.25Z' />
<path d='M12 4L16.5 8.5' />
</svg>
)
}

View File

@@ -9,7 +9,7 @@ export function TypeBoolean(props: SVGProps<SVGSVGElement>) {
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
viewBox='-1.75 -1.5 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'

View File

@@ -9,7 +9,7 @@ export function TypeJson(props: SVGProps<SVGSVGElement>) {
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
viewBox='-1.75 -1.75 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'

View File

@@ -9,7 +9,7 @@ export function TypeNumber(props: SVGProps<SVGSVGElement>) {
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
viewBox='-1.75 -1.5 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'

View File

@@ -9,7 +9,7 @@ export function TypeText(props: SVGProps<SVGSVGElement>) {
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
viewBox='-1.75 -1.5 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'

View File

@@ -339,8 +339,8 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
return res.json()
},
onMutate: async ({ rowId, data }) => {
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
onMutate: ({ rowId, data }) => {
void queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
const previousQueries = queryClient.getQueriesData<TableRowsResponse>({
queryKey: tableKeys.rowsRoot(tableId),