This commit is contained in:
Lakee Sivaraya
2026-01-14 18:49:31 -08:00
parent df3e869f22
commit 96a3fe59ff
10 changed files with 440 additions and 345 deletions

View File

@@ -0,0 +1,19 @@
import { Plus } from 'lucide-react'
import { Button } from '@/components/emcn'
interface EmptyStateProps {
onAdd: () => void
disabled: boolean
label: string
}
export function EmptyState({ onAdd, disabled, label }: EmptyStateProps) {
return (
<div className='flex items-center justify-center rounded-[4px] border border-[var(--border-1)] border-dashed py-[16px]'>
<Button variant='ghost' size='sm' onClick={onAdd} disabled={disabled}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
{label}
</Button>
</div>
)
}

View File

@@ -0,0 +1,137 @@
import { X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption, Input } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { FilterCondition } from '@/lib/table/filters/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
interface FilterConditionRowProps {
blockId: string
subBlockId: string
condition: FilterCondition
index: number
columns: ComboboxOption[]
comparisonOptions: ComboboxOption[]
logicalOptions: ComboboxOption[]
isReadOnly: boolean
isPreview: boolean
disabled: boolean
onRemove: (id: string) => void
onUpdate: (id: string, field: keyof FilterCondition, value: string) => void
}
export function FilterConditionRow({
blockId,
subBlockId,
condition,
index,
columns,
comparisonOptions,
logicalOptions,
isReadOnly,
isPreview,
disabled,
onRemove,
onUpdate,
}: FilterConditionRowProps) {
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
return (
<div className='flex items-center gap-[6px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(condition.id)}
disabled={isReadOnly}
className='h-[24px] w-[24px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
{index === 0 ? (
<Combobox
size='sm'
options={[{ value: 'where', label: 'where' }]}
value='where'
disabled
/>
) : (
<Combobox
size='sm'
options={logicalOptions}
value={condition.logicalOperator}
onChange={(v) => onUpdate(condition.id, 'logicalOperator', v as 'and' | 'or')}
disabled={isReadOnly}
/>
)}
</div>
<div className='w-[100px] shrink-0'>
<Combobox
size='sm'
options={columns}
value={condition.column}
onChange={(v) => onUpdate(condition.id, 'column', v)}
placeholder='Column'
disabled={isReadOnly}
/>
</div>
<div className='w-[110px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={condition.operator}
onChange={(v) => onUpdate(condition.id, 'operator', v)}
disabled={isReadOnly}
/>
</div>
<div className='relative min-w-[80px] flex-1'>
<SubBlockInputController
blockId={blockId}
subBlockId={`${subBlockId}_filter_${condition.id}`}
config={{ id: `filter_value_${condition.id}`, type: 'short-input' }}
value={condition.value}
onChange={(newValue) => onUpdate(condition.id, 'value', newValue)}
isPreview={isPreview}
disabled={disabled}
>
{({ ref, value: ctrlValue, onChange, onKeyDown, onDrop, onDragOver }) => {
const formattedText = formatDisplayText(ctrlValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})
return (
<div className='relative'>
<Input
ref={ref as React.RefObject<HTMLInputElement>}
className='h-[28px] w-full overflow-auto text-[12px] text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
value={ctrlValue}
onChange={onChange as (e: React.ChangeEvent<HTMLInputElement>) => void}
onKeyDown={onKeyDown as (e: React.KeyboardEvent<HTMLInputElement>) => void}
onDrop={onDrop as (e: React.DragEvent<HTMLInputElement>) => void}
onDragOver={onDragOver as (e: React.DragEvent<HTMLInputElement>) => void}
placeholder='Value'
disabled={isReadOnly}
autoComplete='off'
/>
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-[12px] text-foreground [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
(isPreview || disabled) && 'opacity-50'
)}
>
<div className='min-w-fit whitespace-pre'>{formattedText}</div>
</div>
</div>
)
}}
</SubBlockInputController>
</div>
</div>
)
}

View File

@@ -1,16 +1,15 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption, Input } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useMemo } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@/components/emcn'
import { conditionsToJsonString, jsonStringToConditions } from '@/lib/table/filters/builder-utils'
import type { FilterCondition } from '@/lib/table/filters/constants'
import { useFilterBuilder } from '@/lib/table/filters/use-builder'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
import { useBuilderJsonSync, useTableColumns } from '@/lib/table/hooks'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { EmptyState } from './components/empty-state'
import { FilterConditionRow } from './components/filter-condition-row'
interface FilterFormatProps {
blockId: string
@@ -20,19 +19,15 @@ interface FilterFormatProps {
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
/** SubBlock ID for the mode dropdown (e.g., 'filterMode') - enables builder ↔ JSON sync */
modeSubBlockId?: string
/** SubBlock ID for the JSON filter field (e.g., 'filter') - target for JSON output */
jsonSubBlockId?: string
}
/**
* Visual builder for filter conditions with optional JSON sync.
*
* When `modeSubBlockId` and `jsonSubBlockId` are provided, this component handles
* bidirectional conversion between builder conditions and JSON format:
* - Builder → JSON: Conditions sync to JSON when modified in builder mode
* - JSON → Builder: JSON parses to conditions when switching to builder mode
* When `modeSubBlockId` and `jsonSubBlockId` are provided, handles bidirectional
* conversion between builder conditions and JSON format.
*/
export function FilterFormat({
blockId,
@@ -47,86 +42,13 @@ export function FilterFormat({
}: FilterFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<FilterCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [dynamicColumns, setDynamicColumns] = useState<ComboboxOption[]>([])
const fetchedTableIdRef = useRef<string | null>(null)
// Mode sync state - only used when modeSubBlockId and jsonSubBlockId are provided
const [modeValue] = useSubBlockValue<string>(blockId, modeSubBlockId || '_unused_mode')
const [jsonValue, setJsonValue] = useSubBlockValue<string>(
blockId,
jsonSubBlockId || '_unused_json'
)
const prevModeRef = useRef<string | null>(null)
const isSyncingRef = useRef(false)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
/**
* Syncs JSON → Builder when mode switches to 'builder'.
* Uses refs to prevent sync loops and only triggers on actual mode transitions.
*/
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview) return
const switchingToBuilder =
prevModeRef.current !== null && prevModeRef.current !== 'builder' && modeValue === 'builder'
if (switchingToBuilder && jsonValue?.trim()) {
isSyncingRef.current = true
const conditions = jsonStringToConditions(jsonValue)
if (conditions.length > 0) {
setStoreValue(conditions)
}
isSyncingRef.current = false
}
prevModeRef.current = modeValue
}, [modeValue, jsonValue, modeSubBlockId, jsonSubBlockId, setStoreValue, isPreview])
/**
* Syncs Builder → JSON when conditions change while in builder mode.
* Skips sync when isSyncingRef is true to prevent loops.
*/
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview || isSyncingRef.current) return
if (modeValue !== 'builder') return
const conditions = Array.isArray(storeValue) ? storeValue : []
if (conditions.length > 0) {
const newJson = conditionsToJsonString(conditions)
if (newJson !== jsonValue) {
setJsonValue(newJson)
}
}
}, [storeValue, modeValue, modeSubBlockId, jsonSubBlockId, jsonValue, setJsonValue, isPreview])
/** Fetches table schema columns when tableId changes */
useEffect(() => {
const fetchColumns = async () => {
if (!tableIdValue || tableIdValue === fetchedTableIdRef.current) return
try {
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return
const response = await fetch(`/api/table/${tableIdValue}?workspaceId=${workspaceId}`)
if (!response.ok) return
const result = await response.json()
const cols = result.data?.table?.schema?.columns || result.table?.schema?.columns || []
setDynamicColumns(
cols.map((col: { name: string }) => ({ value: col.name, label: col.name }))
)
fetchedTableIdRef.current = tableIdValue
} catch {
// Silently fail - columns will be empty
}
}
fetchColumns()
}, [tableIdValue])
const dynamicColumns = useTableColumns({ tableId: tableIdValue })
const columns = useMemo(() => {
if (propColumns && propColumns.length > 0) return propColumns
return dynamicColumns
@@ -136,7 +58,18 @@ export function FilterFormat({
const conditions: FilterCondition[] = Array.isArray(value) && value.length > 0 ? value : []
const isReadOnly = isPreview || disabled
// Use the shared filter builder hook for condition management
useBuilderJsonSync({
modeValue,
jsonValue,
setJsonValue,
isPreview,
conditions,
setConditions: setStoreValue,
jsonToConditions: jsonStringToConditions,
conditionsToJson: conditionsToJsonString,
enabled: Boolean(modeSubBlockId && jsonSubBlockId),
})
const { comparisonOptions, logicalOptions, addCondition, removeCondition, updateCondition } =
useFilterBuilder({
columns,
@@ -148,122 +81,26 @@ export function FilterFormat({
return (
<div className='flex flex-col gap-[8px]'>
{conditions.length === 0 ? (
<div className='flex items-center justify-center rounded-[4px] border border-[var(--border-1)] border-dashed py-[16px]'>
<Button variant='ghost' size='sm' onClick={addCondition} disabled={isReadOnly}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add filter condition
</Button>
</div>
<EmptyState onAdd={addCondition} disabled={isReadOnly} label='Add filter condition' />
) : (
<>
{conditions.map((condition, index) => (
<div key={condition.id} className='flex items-center gap-[6px]'>
{/* Remove Button */}
<Button
variant='ghost'
size='sm'
onClick={() => removeCondition(condition.id)}
disabled={isReadOnly}
className='h-[24px] w-[24px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
{/* Logical Operator */}
<div className='w-[80px] shrink-0'>
{index === 0 ? (
<Combobox
size='sm'
options={[{ value: 'where', label: 'where' }]}
value='where'
disabled
/>
) : (
<Combobox
size='sm'
options={logicalOptions}
value={condition.logicalOperator}
onChange={(v) =>
updateCondition(condition.id, 'logicalOperator', v as 'and' | 'or')
}
disabled={isReadOnly}
/>
)}
</div>
{/* Column Selector */}
<div className='w-[100px] shrink-0'>
<Combobox
size='sm'
options={columns}
value={condition.column}
onChange={(v) => updateCondition(condition.id, 'column', v)}
placeholder='Column'
disabled={isReadOnly}
/>
</div>
{/* Comparison Operator */}
<div className='w-[110px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={condition.operator}
onChange={(v) => updateCondition(condition.id, 'operator', v)}
disabled={isReadOnly}
/>
</div>
{/* Value Input with Tag Dropdown */}
<div className='relative min-w-[80px] flex-1'>
<SubBlockInputController
blockId={blockId}
subBlockId={`${subBlockId}_filter_${condition.id}`}
config={{ id: `filter_value_${condition.id}`, type: 'short-input' }}
value={condition.value}
onChange={(newValue) => updateCondition(condition.id, 'value', newValue)}
isPreview={isPreview}
disabled={disabled}
>
{({ ref, value: ctrlValue, onChange, onKeyDown, onDrop, onDragOver }) => {
const formattedText = formatDisplayText(ctrlValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})
return (
<div className='relative'>
<Input
ref={ref as React.RefObject<HTMLInputElement>}
className='h-[28px] w-full overflow-auto text-[12px] text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
value={ctrlValue}
onChange={onChange as (e: React.ChangeEvent<HTMLInputElement>) => void}
onKeyDown={
onKeyDown as (e: React.KeyboardEvent<HTMLInputElement>) => void
}
onDrop={onDrop as (e: React.DragEvent<HTMLInputElement>) => void}
onDragOver={onDragOver as (e: React.DragEvent<HTMLInputElement>) => void}
placeholder='Value'
disabled={isReadOnly}
autoComplete='off'
/>
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-[12px] text-foreground [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
(isPreview || disabled) && 'opacity-50'
)}
>
<div className='min-w-fit whitespace-pre'>{formattedText}</div>
</div>
</div>
)
}}
</SubBlockInputController>
</div>
</div>
<FilterConditionRow
key={condition.id}
blockId={blockId}
subBlockId={subBlockId}
condition={condition}
index={index}
columns={columns}
comparisonOptions={comparisonOptions}
logicalOptions={logicalOptions}
isReadOnly={isReadOnly}
isPreview={isPreview}
disabled={disabled}
onRemove={removeCondition}
onUpdate={updateCondition}
/>
))}
{/* Add Button */}
<Button
variant='ghost'
size='sm'

View File

@@ -0,0 +1,19 @@
import { Plus } from 'lucide-react'
import { Button } from '@/components/emcn'
interface EmptyStateProps {
onAdd: () => void
disabled: boolean
label: string
}
export function EmptyState({ onAdd, disabled, label }: EmptyStateProps) {
return (
<div className='flex items-center justify-center rounded-[4px] border border-[var(--border-1)] border-dashed py-[16px]'>
<Button variant='ghost' size='sm' onClick={onAdd} disabled={disabled}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
{label}
</Button>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption } from '@/components/emcn'
import type { SortCondition } from '@/lib/table/filters/constants'
interface SortConditionRowProps {
condition: SortCondition
index: number
columns: ComboboxOption[]
directionOptions: ComboboxOption[]
isReadOnly: boolean
onRemove: (id: string) => void
onUpdate: (id: string, field: keyof SortCondition, value: string) => void
}
export function SortConditionRow({
condition,
index,
columns,
directionOptions,
isReadOnly,
onRemove,
onUpdate,
}: SortConditionRowProps) {
return (
<div className='flex items-center gap-[6px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(condition.id)}
disabled={isReadOnly}
className='h-[24px] w-[24px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[90px] shrink-0'>
<Combobox
size='sm'
options={[{ value: String(index + 1), label: index === 0 ? 'order by' : 'then by' }]}
value={String(index + 1)}
disabled
/>
</div>
<div className='min-w-[120px] flex-1'>
<Combobox
size='sm'
options={columns}
value={condition.column}
onChange={(v) => onUpdate(condition.id, 'column', v)}
placeholder='Column'
disabled={isReadOnly}
/>
</div>
<div className='w-[110px] shrink-0'>
<Combobox
size='sm'
options={directionOptions}
value={condition.direction}
onChange={(v) => onUpdate(condition.id, 'direction', v as 'asc' | 'desc')}
disabled={isReadOnly}
/>
</div>
</div>
)
}

View File

@@ -1,15 +1,18 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react'
import { nanoid } from 'nanoid'
import { Button, Combobox, type ComboboxOption } from '@/components/emcn'
import { Button, type ComboboxOption } from '@/components/emcn'
import {
jsonStringToSortConditions,
sortConditionsToJsonString,
} from '@/lib/table/filters/builder-utils'
import { SORT_DIRECTIONS, type SortCondition } from '@/lib/table/filters/constants'
import { useBuilderJsonSync, useTableColumns } from '@/lib/table/hooks'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { EmptyState } from './components/empty-state'
import { SortConditionRow } from './components/sort-condition-row'
interface SortFormatProps {
blockId: string
@@ -19,15 +22,10 @@ interface SortFormatProps {
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
/** SubBlock ID for the mode dropdown (e.g., 'sortMode') - enables builder ↔ JSON sync */
modeSubBlockId?: string
/** SubBlock ID for the JSON sort field (e.g., 'sort') - target for JSON output */
jsonSubBlockId?: string
}
/**
* Creates a new sort condition with default values
*/
const createDefaultCondition = (columns: ComboboxOption[]): SortCondition => ({
id: nanoid(),
column: columns[0]?.value || '',
@@ -37,10 +35,8 @@ const createDefaultCondition = (columns: ComboboxOption[]): SortCondition => ({
/**
* Visual builder for sort conditions with optional JSON sync.
*
* When `modeSubBlockId` and `jsonSubBlockId` are provided, this component handles
* bidirectional conversion between builder conditions and JSON format:
* - Builder → JSON: Conditions sync to JSON when modified in builder mode
* - JSON → Builder: JSON parses to conditions when switching to builder mode
* When `modeSubBlockId` and `jsonSubBlockId` are provided, handles bidirectional
* conversion between builder conditions and JSON format.
*/
export function SortFormat({
blockId,
@@ -55,90 +51,13 @@ export function SortFormat({
}: SortFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<SortCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [dynamicColumns, setDynamicColumns] = useState<ComboboxOption[]>([])
const fetchedTableIdRef = useRef<string | null>(null)
// Mode sync state - only used when modeSubBlockId and jsonSubBlockId are provided
const [modeValue] = useSubBlockValue<string>(blockId, modeSubBlockId || '_unused_mode')
const [jsonValue, setJsonValue] = useSubBlockValue<string>(
blockId,
jsonSubBlockId || '_unused_json'
)
const prevModeRef = useRef<string | null>(null)
const isSyncingRef = useRef(false)
/**
* Syncs JSON → Builder when mode switches to 'builder'.
* Uses refs to prevent sync loops and only triggers on actual mode transitions.
*/
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview) return
const switchingToBuilder =
prevModeRef.current !== null && prevModeRef.current !== 'builder' && modeValue === 'builder'
if (switchingToBuilder && jsonValue?.trim()) {
isSyncingRef.current = true
const conditions = jsonStringToSortConditions(jsonValue)
if (conditions.length > 0) {
setStoreValue(conditions)
}
isSyncingRef.current = false
}
prevModeRef.current = modeValue
}, [modeValue, jsonValue, modeSubBlockId, jsonSubBlockId, setStoreValue, isPreview])
/**
* Syncs Builder → JSON when conditions change while in builder mode.
* Skips sync when isSyncingRef is true to prevent loops.
*/
useEffect(() => {
if (!modeSubBlockId || !jsonSubBlockId || isPreview || isSyncingRef.current) return
if (modeValue !== 'builder') return
const conditions = Array.isArray(storeValue) ? storeValue : []
if (conditions.length > 0) {
const newJson = sortConditionsToJsonString(conditions)
if (newJson !== jsonValue) {
setJsonValue(newJson)
}
}
}, [storeValue, modeValue, modeSubBlockId, jsonSubBlockId, jsonValue, setJsonValue, isPreview])
/** Fetches table schema columns when tableId changes */
useEffect(() => {
const fetchColumns = async () => {
if (!tableIdValue || tableIdValue === fetchedTableIdRef.current) return
try {
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return
const response = await fetch(`/api/table/${tableIdValue}?workspaceId=${workspaceId}`)
if (!response.ok) return
const result = await response.json()
const cols = result.data?.table?.schema?.columns || result.table?.schema?.columns || []
const builtInCols = [
{ value: 'createdAt', label: 'createdAt' },
{ value: 'updatedAt', label: 'updatedAt' },
]
const schemaCols = cols.map((col: { name: string }) => ({
value: col.name,
label: col.name,
}))
setDynamicColumns([...schemaCols, ...builtInCols])
fetchedTableIdRef.current = tableIdValue
} catch {
// Silently fail - columns will be empty
}
}
fetchColumns()
}, [tableIdValue])
const dynamicColumns = useTableColumns({ tableId: tableIdValue, includeBuiltIn: true })
const columns = useMemo(() => {
if (propColumns && propColumns.length > 0) return propColumns
return dynamicColumns
@@ -153,6 +72,18 @@ export function SortFormat({
const conditions: SortCondition[] = Array.isArray(value) && value.length > 0 ? value : []
const isReadOnly = isPreview || disabled
useBuilderJsonSync({
modeValue,
jsonValue,
setJsonValue,
isPreview,
conditions,
setConditions: setStoreValue,
jsonToConditions: jsonStringToSortConditions,
conditionsToJson: sortConditionsToJsonString,
enabled: Boolean(modeSubBlockId && jsonSubBlockId),
})
const addCondition = useCallback(() => {
if (isReadOnly) return
setStoreValue([...conditions, createDefaultCondition(columns)])
@@ -177,65 +108,21 @@ export function SortFormat({
return (
<div className='flex flex-col gap-[8px]'>
{conditions.length === 0 ? (
<div className='flex items-center justify-center rounded-[4px] border border-[var(--border-1)] border-dashed py-[16px]'>
<Button variant='ghost' size='sm' onClick={addCondition} disabled={isReadOnly}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add sort condition
</Button>
</div>
<EmptyState onAdd={addCondition} disabled={isReadOnly} label='Add sort condition' />
) : (
<>
{conditions.map((condition, index) => (
<div key={condition.id} className='flex items-center gap-[6px]'>
{/* Remove Button */}
<Button
variant='ghost'
size='sm'
onClick={() => removeCondition(condition.id)}
disabled={isReadOnly}
className='h-[24px] w-[24px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
{/* Order indicator */}
<div className='w-[90px] shrink-0'>
<Combobox
size='sm'
options={[
{ value: String(index + 1), label: index === 0 ? 'order by' : `then by` },
]}
value={String(index + 1)}
disabled
/>
</div>
{/* Column Selector */}
<div className='min-w-[120px] flex-1'>
<Combobox
size='sm'
options={columns}
value={condition.column}
onChange={(v) => updateCondition(condition.id, 'column', v)}
placeholder='Column'
disabled={isReadOnly}
/>
</div>
{/* Direction Selector */}
<div className='w-[110px] shrink-0'>
<Combobox
size='sm'
options={directionOptions}
value={condition.direction}
onChange={(v) => updateCondition(condition.id, 'direction', v as 'asc' | 'desc')}
disabled={isReadOnly}
/>
</div>
</div>
<SortConditionRow
key={condition.id}
condition={condition}
index={index}
columns={columns}
directionOptions={directionOptions}
isReadOnly={isReadOnly}
onRemove={removeCondition}
onUpdate={updateCondition}
/>
))}
{/* Add Button */}
<Button
variant='ghost'
size='sm'

View File

@@ -0,0 +1,2 @@
export { useBuilderJsonSync } from './use-builder-json-sync'
export { useTableColumns } from './use-table-columns'

View File

@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react'
interface UseBuilderJsonSyncOptions<T> {
modeValue: string | null
jsonValue: string | null
setJsonValue: (value: string) => void
isPreview: boolean
conditions: T[]
setConditions: (conditions: T[]) => void
jsonToConditions: (json: string) => T[]
conditionsToJson: (conditions: T[]) => string
enabled?: boolean
}
/**
* Handles bidirectional sync between builder conditions and JSON format.
*
* - JSON → Builder: When mode switches to 'builder', parses JSON into conditions
* - Builder → JSON: When conditions change in builder mode, converts to JSON
*/
export function useBuilderJsonSync<T>({
modeValue,
jsonValue,
setJsonValue,
isPreview,
conditions,
setConditions,
jsonToConditions,
conditionsToJson,
enabled = true,
}: UseBuilderJsonSyncOptions<T>) {
const prevModeRef = useRef<string | null>(null)
const isSyncingRef = useRef(false)
// Sync JSON → Builder when switching to builder mode
useEffect(() => {
if (!enabled || isPreview) return
const switchingToBuilder =
prevModeRef.current !== null && prevModeRef.current !== 'builder' && modeValue === 'builder'
if (switchingToBuilder && jsonValue?.trim()) {
isSyncingRef.current = true
const parsedConditions = jsonToConditions(jsonValue)
if (parsedConditions.length > 0) {
setConditions(parsedConditions)
}
isSyncingRef.current = false
}
prevModeRef.current = modeValue
}, [modeValue, jsonValue, setConditions, isPreview, jsonToConditions, enabled])
// Sync Builder → JSON when conditions change in builder mode
useEffect(() => {
if (!enabled || isPreview || isSyncingRef.current) return
if (modeValue !== 'builder') return
if (conditions.length > 0) {
const newJson = conditionsToJson(conditions)
if (newJson !== jsonValue) {
setJsonValue(newJson)
}
}
}, [conditions, modeValue, jsonValue, setJsonValue, isPreview, conditionsToJson, enabled])
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from 'react'
interface ColumnOption {
value: string
label: string
}
interface UseTableColumnsOptions {
tableId: string | null | undefined
includeBuiltIn?: boolean
}
/**
* Fetches table schema columns from the API.
* Returns columns as options for use in dropdowns.
*/
export function useTableColumns({ tableId, includeBuiltIn = false }: UseTableColumnsOptions) {
const [columns, setColumns] = useState<ColumnOption[]>([])
const fetchedTableIdRef = useRef<string | null>(null)
useEffect(() => {
const fetchColumns = async () => {
if (!tableId || tableId === fetchedTableIdRef.current) return
try {
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return
const response = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`)
if (!response.ok) return
const result = await response.json()
const cols = result.data?.table?.schema?.columns || result.table?.schema?.columns || []
const schemaCols = cols.map((col: { name: string }) => ({
value: col.name,
label: col.name,
}))
if (includeBuiltIn) {
const builtInCols = [
{ value: 'createdAt', label: 'createdAt' },
{ value: 'updatedAt', label: 'updatedAt' },
]
setColumns([...schemaCols, ...builtInCols])
} else {
setColumns(schemaCols)
}
fetchedTableIdRef.current = tableId
} catch {
// Silently fail - columns will be empty
}
}
fetchColumns()
}, [tableId, includeBuiltIn])
return columns
}

View File

@@ -8,5 +8,6 @@
export * from './constants'
export * from './filters'
export * from './hooks'
export * from './query-builder'
export * from './validation'