mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
updates
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
2
apps/sim/lib/table/hooks/index.ts
Normal file
2
apps/sim/lib/table/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useBuilderJsonSync } from './use-builder-json-sync'
|
||||
export { useTableColumns } from './use-table-columns'
|
||||
66
apps/sim/lib/table/hooks/use-builder-json-sync.ts
Normal file
66
apps/sim/lib/table/hooks/use-builder-json-sync.ts
Normal 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])
|
||||
}
|
||||
60
apps/sim/lib/table/hooks/use-table-columns.ts
Normal file
60
apps/sim/lib/table/hooks/use-table-columns.ts
Normal 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
|
||||
}
|
||||
@@ -8,5 +8,6 @@
|
||||
|
||||
export * from './constants'
|
||||
export * from './filters'
|
||||
export * from './hooks'
|
||||
export * from './query-builder'
|
||||
export * from './validation'
|
||||
|
||||
Reference in New Issue
Block a user