From 0337ef203319684a1c323578a74c09541cb5ac32 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Mon, 29 Dec 2025 16:29:25 -0800 Subject: [PATCH 1/3] improvement: knowledge tags subblock, start subblock --- .../document-tag-entry/document-tag-entry.tsx | 511 ++++++++------- .../knowledge-tag-filters.tsx | 618 ++++++++---------- .../components/starter/input-format.tsx | 8 +- 3 files changed, 528 insertions(+), 609 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx index 4cad6ba7a2..99eac59faf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx @@ -2,8 +2,16 @@ import { useMemo, useRef } from 'react' import { Plus } from 'lucide-react' -import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn' -import { Input } from '@/components/ui/input' +import { + Badge, + Button, + Combobox, + type ComboboxOption, + Input, + Label, + Trash, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' @@ -14,14 +22,13 @@ import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions' import { useTagSelection } from '@/hooks/use-tag-selection' -interface DocumentTagRow { +interface DocumentTag { id: string - cells: { - tagName: string - tagSlot?: string - fieldType: string - value: string - } + tagName: string + tagSlot?: string + fieldType: string + value: string + collapsed?: boolean } interface DocumentTagEntryProps { @@ -32,6 +39,17 @@ interface DocumentTagEntryProps { previewValue?: any } +/** + * Creates a new document tag with default values + */ +const createDefaultTag = (): DocumentTag => ({ + id: crypto.randomUUID(), + tagName: '', + fieldType: 'text', + value: '', + collapsed: false, +}) + export function DocumentTagEntry({ blockId, subBlock, @@ -41,9 +59,9 @@ export function DocumentTagEntry({ }: DocumentTagEntryProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) - const valueInputRefs = useRef>({}) + const valueInputRefs = useRef>({}) + const overlayRefs = useRef>({}) - // Use the extended hook for field-level management const inputController = useSubBlockInput({ blockId, subBlockId: subBlock.id, @@ -56,155 +74,127 @@ export function DocumentTagEntry({ disabled, }) - // Get the knowledge base ID from other sub-blocks const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId') const knowledgeBaseId = knowledgeBaseIdValue || null - // Use KB tag definitions hook to get available tags const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId) - const emitTagSelection = useTagSelection(blockId, subBlock.id) - // Use preview value when in preview mode, otherwise use store value const currentValue = isPreview ? previewValue : storeValue - // Transform stored JSON string to table format for display - const rows = useMemo(() => { - if (currentValue) { - try { - const tagData = JSON.parse(currentValue) - if (Array.isArray(tagData) && tagData.length > 0) { - return tagData.map((tag: any, index: number) => ({ - id: tag.id || `tag-${index}`, - cells: { - tagName: tag.tagName || '', - tagSlot: tag.tagSlot, - fieldType: tag.fieldType || 'text', - value: tag.value || '', - }, - })) - } - } catch { - // If parsing fails, fall through to default - } + const parseTags = (tagValue: string | null): DocumentTag[] => { + if (!tagValue) return [] + try { + const parsed = JSON.parse(tagValue) + if (!Array.isArray(parsed)) return [] + return parsed.map((t: DocumentTag) => ({ + ...t, + fieldType: t.fieldType || 'text', + collapsed: t.collapsed ?? false, + })) + } catch { + return [] } + } - // Default: just one empty row - return [ - { - id: 'empty-row-0', - cells: { tagName: '', tagSlot: undefined, fieldType: 'text', value: '' }, - }, - ] - }, [currentValue]) + const parsedTags = parseTags(currentValue || null) + const tags: DocumentTag[] = parsedTags.length > 0 ? parsedTags : [createDefaultTag()] + const isReadOnly = isPreview || disabled - // Get tag names already used in rows (case-insensitive) + // Get tag names already used (case-insensitive) const usedTagNames = useMemo(() => { - return new Set( - rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim()) - ) - }, [rows]) + return new Set(tags.map((t) => t.tagName?.toLowerCase()).filter((name) => name?.trim())) + }, [tags]) // Filter available tags (exclude already used ones) const availableTagDefinitions = useMemo(() => { return tagDefinitions.filter((def) => !usedTagNames.has(def.displayName.toLowerCase())) }, [tagDefinitions, usedTagNames]) - // Can add more tags if there are available tag definitions const canAddMoreTags = availableTagDefinitions.length > 0 - // Shared helper function for updating rows and generating JSON - const updateRowsAndGenerateJson = ( - rowIndex: number, - column: string, - value: string, - tagDef?: { tagSlot: string; fieldType: string } - ) => { - const updatedRows = [...rows].map((row, idx) => { - if (idx === rowIndex) { - const newCells = { ...row.cells, [column]: value } - - // When selecting a tag, also set the tagSlot and fieldType - if (column === 'tagName' && tagDef) { - newCells.tagSlot = tagDef.tagSlot - newCells.fieldType = tagDef.fieldType - // Clear value when tag changes - if (row.cells.tagName !== value) { - newCells.value = '' - } - } - - return { ...row, cells: newCells } - } - return row - }) - - const dataToStore = updatedRows.map((row) => ({ - id: row.id, - tagName: row.cells.tagName || '', - tagSlot: row.cells.tagSlot, - fieldType: row.cells.fieldType || 'text', - value: row.cells.value || '', - })) - - return dataToStore.length > 0 ? JSON.stringify(dataToStore) : '' + /** + * Updates the store with new tags + */ + const updateTags = (newTags: DocumentTag[]) => { + if (isReadOnly) return + const value = newTags.length > 0 ? JSON.stringify(newTags) : '' + setStoreValue(value) } - const handleTagSelection = (rowIndex: number, tagName: string) => { - if (isPreview || disabled) return - - const tagDef = tagDefinitions.find((def) => def.displayName === tagName) - const jsonString = updateRowsAndGenerateJson(rowIndex, 'tagName', tagName, tagDef) - setStoreValue(jsonString) + /** + * Adds a new tag + */ + const addTag = () => { + if (isReadOnly || !canAddMoreTags) return + updateTags([...tags, createDefaultTag()]) } - const handleValueChange = (rowIndex: number, value: string) => { - if (isPreview || disabled) return - - const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value) - setStoreValue(jsonString) + /** + * Removes a tag by ID (prevents removing the last tag) + */ + const removeTag = (id: string) => { + if (isReadOnly || tags.length === 1) return + updateTags(tags.filter((t) => t.id !== id)) } - const handleTagDropdownSelection = (rowIndex: number, value: string) => { - if (isPreview || disabled) return - - const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value) - emitTagSelection(jsonString) - } + /** + * Updates a specific tag property + */ + const updateTag = (id: string, field: keyof DocumentTag, value: any) => { + if (isReadOnly) return + + const updatedTags = tags.map((t) => { + if (t.id === id) { + const updated = { ...t, [field]: value } + + // When tag name changes, update tagSlot and fieldType, clear value + if (field === 'tagName') { + const tagDef = tagDefinitions.find((def) => def.displayName === value) + updated.tagSlot = tagDef?.tagSlot + updated.fieldType = tagDef?.fieldType || 'text' + if (t.tagName !== value) { + updated.value = '' + } + } - const handleAddRow = () => { - if (isPreview || disabled || !canAddMoreTags) return + return updated + } + return t + }) - const currentData = currentValue ? JSON.parse(currentValue) : [] - const newRowId = `tag-${currentData.length}-${Math.random().toString(36).slice(2, 11)}` - const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }] - setStoreValue(JSON.stringify(newData)) + updateTags(updatedTags) } - const handleDeleteRow = (rowIndex: number) => { - if (isPreview || disabled) return + /** + * Handles tag dropdown selection for value field + */ + const handleTagDropdownSelection = (id: string, value: string) => { + if (isReadOnly) return - if (rows.length <= 1) { - // Clear the single row instead of deleting - setStoreValue('') - return - } + const updatedTags = tags.map((t) => (t.id === id ? { ...t, value } : t)) + const jsonValue = updatedTags.length > 0 ? JSON.stringify(updatedTags) : '' + emitTagSelection(jsonValue) + } - const updatedRows = rows.filter((_, idx) => idx !== rowIndex) - const tableDataForStorage = updatedRows.map((row) => ({ - id: row.id, - tagName: row.cells.tagName || '', - tagSlot: row.cells.tagSlot, - fieldType: row.cells.fieldType || 'text', - value: row.cells.value || '', - })) + /** + * Toggles the collapsed state of a tag + */ + const toggleCollapse = (id: string) => { + if (isReadOnly) return + updateTags(tags.map((t) => (t.id === id ? { ...t, collapsed: !t.collapsed } : t))) + } - const jsonString = tableDataForStorage.length > 0 ? JSON.stringify(tableDataForStorage) : '' - setStoreValue(jsonString) + /** + * Syncs scroll position between input and overlay + */ + const syncOverlayScroll = (tagId: string, scrollLeft: number) => { + const overlay = overlayRefs.current[tagId] + if (overlay) overlay.scrollLeft = scrollLeft } if (isPreview) { - const tagCount = rows.filter((r) => r.cells.tagName?.trim()).length + const tagCount = tags.filter((t) => t.tagName?.trim()).length return (
@@ -215,13 +205,9 @@ export function DocumentTagEntry({ ) } - if (isLoading) { - return
Loading tag definitions...
- } - - if (tagDefinitions.length === 0) { + if (!isLoading && tagDefinitions.length === 0 && knowledgeBaseId) { return ( -
+

No tags defined for this knowledge base @@ -234,156 +220,169 @@ export function DocumentTagEntry({ ) } - const renderHeader = () => ( - - - - Tag - - - Value - - - + /** + * Renders the tag header with name, badge, and action buttons + */ + const renderTagHeader = (tag: DocumentTag, index: number) => ( +

toggleCollapse(tag.id)} + > +
+ + {tag.tagName || `Tag ${index + 1}`} + + {tag.tagName && ( + + {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'} + + )} +
+
e.stopPropagation()}> + + +
+
) - const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => { - const cellValue = row.cells.tagName || '' - - // Show tags that are either available OR currently selected for this row - const selectableTags = tagDefinitions.filter( - (def) => def.displayName === cellValue || !usedTagNames.has(def.displayName.toLowerCase()) - ) - - const tagOptions: ComboboxOption[] = selectableTags.map((tag) => ({ - value: tag.displayName, - label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`, - })) - - return ( - - handleTagSelection(rowIndex, value)} - disabled={disabled || isLoading} - placeholder='Select tag' - className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate' - /> - - ) - } - - const renderValueCell = (row: DocumentTagRow, rowIndex: number) => { - const cellValue = row.cells.value || '' - const fieldType = row.cells.fieldType || 'text' - const cellKey = `value-${rowIndex}` - const placeholder = getPlaceholderForFieldType(fieldType) - const isTagSelected = !!row.cells.tagName?.trim() + /** + * Renders the value input with tag dropdown support + */ + const renderValueInput = (tag: DocumentTag) => { + const fieldValue = tag.value || '' + const cellKey = `${tag.id}-value` + const placeholder = getPlaceholderForFieldType(tag.fieldType) const fieldState = inputController.fieldHelpers.getFieldState(cellKey) const handlers = inputController.fieldHelpers.createFieldHandlers( cellKey, - cellValue, - (newValue) => handleValueChange(rowIndex, newValue) + fieldValue, + (newValue) => updateTag(tag.id, 'value', newValue) ) const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler( cellKey, - cellValue, - (newValue) => handleTagDropdownSelection(rowIndex, newValue) + fieldValue, + (newValue) => handleTagDropdownSelection(tag.id, newValue) ) return ( - -
- { - if (el) valueInputRefs.current[rowIndex] = el - }} - value={cellValue} - onChange={handlers.onChange} - onKeyDown={handlers.onKeyDown} - onDrop={handlers.onDrop} - onDragOver={handlers.onDragOver} - disabled={disabled || !isTagSelected} - autoComplete='off' - placeholder={isTagSelected ? placeholder : 'Select a tag first'} - className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0' - /> -
-
- {formatDisplayText(cellValue, { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} -
+
+ { + if (el) valueInputRefs.current[cellKey] = el + }} + value={fieldValue} + onChange={handlers.onChange} + onKeyDown={handlers.onKeyDown} + onDrop={handlers.onDrop} + onDragOver={handlers.onDragOver} + onScroll={(e) => syncOverlayScroll(cellKey, e.currentTarget.scrollLeft)} + onPaste={() => + setTimeout(() => { + const input = valueInputRefs.current[cellKey] + input && syncOverlayScroll(cellKey, input.scrollLeft) + }, 0) + } + disabled={isReadOnly} + autoComplete='off' + placeholder={placeholder} + className='allow-scroll w-full overflow-auto text-transparent caret-foreground' + /> +
{ + if (el) overlayRefs.current[cellKey] = el + }} + className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm' + > +
+ {formatDisplayText( + fieldValue, + accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true } + )}
- {fieldState.showTags && ( - inputController.fieldHelpers.hideFieldDropdowns(cellKey)} - inputRef={ - { - current: valueInputRefs.current[rowIndex] || null, - } as React.RefObject - } - /> - )}
- + {fieldState.showTags && ( + inputController.fieldHelpers.hideFieldDropdowns(cellKey)} + inputRef={{ current: valueInputRefs.current[cellKey] || null }} + /> + )} +
) } - const renderDeleteButton = (rowIndex: number) => { - if (isPreview || disabled) return null + /** + * Renders the tag content (tag selector and value input) + */ + const renderTagContent = (tag: DocumentTag) => { + // Show tags that are either available OR currently selected for this tag + const selectableTags = tagDefinitions.filter( + (def) => def.displayName === tag.tagName || !usedTagNames.has(def.displayName.toLowerCase()) + ) + + const tagOptions: ComboboxOption[] = selectableTags.map((t) => ({ + value: t.displayName, + label: `${t.displayName} (${FIELD_TYPE_LABELS[t.fieldType] || 'Text'})`, + })) return ( - - - +
+
+ + updateTag(tag.id, 'tagName', value)} + disabled={isReadOnly || isLoading} + placeholder={isLoading ? 'Loading tags...' : 'Select tag'} + /> +
+ +
+ + {renderValueInput(tag)} +
+
) } return ( -
-
- - {renderHeader()} - - {rows.map((row, rowIndex) => ( - - {renderTagNameCell(row, rowIndex)} - {renderValueCell(row, rowIndex)} - {renderDeleteButton(rowIndex)} - - ))} - -
-
- - {/* Add Tag Button */} - {!isPreview && !disabled && ( -
- +
+ {tags.map((tag, index) => ( +
+ {renderTagHeader(tag, index)} + {!tag.collapsed && renderTagContent(tag)}
- )} + ))}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index 38bfef146e..a6faee4deb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -1,16 +1,22 @@ 'use client' -import { useState } from 'react' +import { useRef } from 'react' import { Plus } from 'lucide-react' -import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn' -import { Input } from '@/components/ui/input' +import { + Badge, + Button, + Combobox, + type ComboboxOption, + Input, + Label, + Trash, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' -import { - checkTagTrigger, - TagDropdown, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions' @@ -24,19 +30,8 @@ interface TagFilter { fieldType: FilterFieldType operator: string tagValue: string - valueTo?: string // For 'between' operator -} - -interface TagFilterRow { - id: string - cells: { - tagName: string - tagSlot?: string - fieldType: FilterFieldType - operator: string - value: string - valueTo?: string - } + valueTo?: string + collapsed?: boolean } interface KnowledgeTagFiltersProps { @@ -47,6 +42,18 @@ interface KnowledgeTagFiltersProps { previewValue?: string | null } +/** + * Creates a new filter with default values + */ +const createDefaultFilter = (): TagFilter => ({ + id: crypto.randomUUID(), + tagName: '', + fieldType: 'text', + operator: 'eq', + tagValue: '', + collapsed: false, +}) + export function KnowledgeTagFilters({ blockId, subBlock, @@ -56,30 +63,37 @@ export function KnowledgeTagFilters({ }: KnowledgeTagFiltersProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) const emitTagSelection = useTagSelection(blockId, subBlock.id) + const valueInputRefs = useRef>({}) + const overlayRefs = useRef>({}) const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId') const knowledgeBaseId = knowledgeBaseIdValue || null const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId) - const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) - const [activeTagDropdown, setActiveTagDropdown] = useState<{ - rowIndex: number - showTags: boolean - cursorPosition: number - activeSourceBlockId: string | null - element?: HTMLElement | null - } | null>(null) + const inputController = useSubBlockInput({ + blockId, + subBlockId: subBlock.id, + config: { + id: subBlock.id, + type: 'knowledge-tag-filters', + connectionDroppable: true, + }, + isPreview, + disabled, + }) const parseFilters = (filterValue: string | null): TagFilter[] => { if (!filterValue) return [] try { const parsed = JSON.parse(filterValue) + if (!Array.isArray(parsed)) return [] return parsed.map((f: TagFilter) => ({ ...f, fieldType: f.fieldType || 'text', operator: f.operator || 'eq', + collapsed: f.collapsed ?? false, })) } catch { return [] @@ -87,145 +101,103 @@ export function KnowledgeTagFilters({ } const currentValue = isPreview ? previewValue : storeValue - const filters = parseFilters(currentValue || null) - - const rows: TagFilterRow[] = - filters.length > 0 - ? filters.map((filter) => ({ - id: filter.id, - cells: { - tagName: filter.tagName || '', - tagSlot: filter.tagSlot, - fieldType: filter.fieldType || 'text', - operator: filter.operator || 'eq', - value: filter.tagValue || '', - valueTo: filter.valueTo, - }, - })) - : [ - { - id: 'empty-row-0', - cells: { tagName: '', fieldType: 'text', operator: '', value: '' }, - }, - ] + const parsedFilters = parseFilters(currentValue || null) + const filters: TagFilter[] = parsedFilters.length > 0 ? parsedFilters : [createDefaultFilter()] + const isReadOnly = isPreview || disabled + /** + * Updates the store with new filters + */ const updateFilters = (newFilters: TagFilter[]) => { - if (isPreview) return + if (isReadOnly) return const value = newFilters.length > 0 ? JSON.stringify(newFilters) : null setStoreValue(value) } - const rowsToFilters = (rowsToConvert: TagFilterRow[]): TagFilter[] => { - return rowsToConvert - .filter((row) => row.cells.tagName?.trim()) - .map((row) => ({ - id: row.id, - tagName: row.cells.tagName || '', - tagSlot: row.cells.tagSlot, - fieldType: row.cells.fieldType || 'text', - operator: row.cells.operator || 'eq', - tagValue: row.cells.value || '', - valueTo: row.cells.valueTo, - })) + /** + * Adds a new filter + */ + const addFilter = () => { + if (isReadOnly) return + updateFilters([...filters, createDefaultFilter()]) } - const handleCellChange = (rowIndex: number, column: string, value: string | FilterFieldType) => { - if (isPreview || disabled) return + /** + * Removes a filter by ID (prevents removing the last filter) + */ + const removeFilter = (id: string) => { + if (isReadOnly || filters.length === 1) return + updateFilters(filters.filter((f) => f.id !== id)) + } - const updatedRows = [...rows].map((row, idx) => { - if (idx === rowIndex) { - const newCells = { ...row.cells, [column]: value } + /** + * Updates a specific filter property + */ + const updateFilter = (id: string, field: keyof TagFilter, value: any) => { + if (isReadOnly) return + + const updatedFilters = filters.map((f) => { + if (f.id === id) { + const updated = { ...f, [field]: value } + + // When tag changes, reset operator and value based on new field type + if (field === 'tagName') { + const tagDef = tagDefinitions.find((t) => t.displayName === value) + const fieldType = (tagDef?.fieldType || 'text') as FilterFieldType + const operators = getOperatorsForFieldType(fieldType) + updated.tagSlot = tagDef?.tagSlot + updated.fieldType = fieldType + updated.operator = operators[0]?.value || 'eq' + updated.tagValue = '' + updated.valueTo = undefined + } - if (column === 'fieldType') { + // When field type changes, reset operator and value + if (field === 'fieldType') { const operators = getOperatorsForFieldType(value as FilterFieldType) - newCells.operator = operators[0]?.value || 'eq' - newCells.value = '' - newCells.valueTo = undefined + updated.operator = operators[0]?.value || 'eq' + updated.tagValue = '' + updated.valueTo = undefined } - if (column === 'operator' && value !== 'between') { - newCells.valueTo = undefined + // When operator changes from 'between', clear valueTo + if (field === 'operator' && value !== 'between') { + updated.valueTo = undefined } - return { ...row, cells: newCells } - } - return row - }) - - updateFilters(rowsToFilters(updatedRows)) - } - - const handleTagNameSelection = (rowIndex: number, tagName: string) => { - if (isPreview || disabled) return - - const tagDef = tagDefinitions.find((t) => t.displayName === tagName) - const fieldType = (tagDef?.fieldType || 'text') as FilterFieldType - const operators = getOperatorsForFieldType(fieldType) - - const updatedRows = [...rows].map((row, idx) => { - if (idx === rowIndex) { - return { - ...row, - cells: { - ...row.cells, - tagName, - tagSlot: tagDef?.tagSlot, - fieldType, - operator: operators[0]?.value || 'eq', - value: '', - valueTo: undefined, - }, - } + return updated } - return row + return f }) - updateFilters(rowsToFilters(updatedRows)) + updateFilters(updatedFilters) } - const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => { - if (isPreview || disabled) return - - const updatedRows = [...rows].map((row, idx) => { - if (idx === rowIndex) { - return { - ...row, - cells: { ...row.cells, [column]: value }, - } - } - return row - }) + /** + * Handles tag dropdown selection for value field + */ + const handleTagDropdownSelection = (id: string, field: 'tagValue' | 'valueTo', value: string) => { + if (isReadOnly) return - const jsonValue = - rowsToFilters(updatedRows).length > 0 ? JSON.stringify(rowsToFilters(updatedRows)) : null + const updatedFilters = filters.map((f) => (f.id === id ? { ...f, [field]: value } : f)) + const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : null emitTagSelection(jsonValue) } - const handleAddRow = () => { - if (isPreview || disabled) return - - const newRowId = `filter-${filters.length}-${Math.random().toString(36).slice(2, 11)}` - const newFilter: TagFilter = { - id: newRowId, - tagName: '', - fieldType: 'text', - operator: 'eq', - tagValue: '', - } - updateFilters([...filters, newFilter]) + /** + * Toggles the collapsed state of a filter + */ + const toggleCollapse = (id: string) => { + if (isReadOnly) return + updateFilters(filters.map((f) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f))) } - const handleDeleteRow = (rowIndex: number) => { - if (isPreview || disabled) return - - if (rows.length <= 1) { - // Clear the single row instead of deleting - setStoreValue(null) - return - } - - const updatedRows = rows.filter((_, idx) => idx !== rowIndex) - updateFilters(rowsToFilters(updatedRows)) + /** + * Syncs scroll position between input and overlay + */ + const syncOverlayScroll = (filterId: string, scrollLeft: number) => { + const overlay = overlayRefs.current[filterId] + if (overlay) overlay.scrollLeft = scrollLeft } if (isPreview) { @@ -241,238 +213,186 @@ export function KnowledgeTagFilters({ ) } - const renderHeader = () => ( - - - - Tag - - - Operator - - - Value - - - + /** + * Renders the filter header with name, badge, and action buttons + */ + const renderFilterHeader = (filter: TagFilter, index: number) => ( +
toggleCollapse(filter.id)} + > +
+ + {filter.tagName || `Filter ${index + 1}`} + + {filter.tagName && ( + + {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'} + + )} +
+
e.stopPropagation()}> + + +
+
) - const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => { - const cellValue = row.cells.tagName || '' - - const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({ - value: tag.displayName, - label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`, - })) - - return ( - - handleTagNameSelection(rowIndex, value)} - disabled={disabled || isLoading} - placeholder='Select tag' - className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate' - /> - + /** + * Renders the value input with tag dropdown support + */ + const renderValueInput = (filter: TagFilter, field: 'tagValue' | 'valueTo') => { + const fieldValue = field === 'tagValue' ? filter.tagValue : filter.valueTo || '' + const cellKey = `${filter.id}-${field}` + const placeholder = getPlaceholderForFieldType(filter.fieldType) + + const fieldState = inputController.fieldHelpers.getFieldState(cellKey) + const handlers = inputController.fieldHelpers.createFieldHandlers( + cellKey, + fieldValue, + (newValue) => updateFilter(filter.id, field, newValue) ) - } - - const renderOperatorCell = (row: TagFilterRow, rowIndex: number) => { - const fieldType = row.cells.fieldType || 'text' - const operator = row.cells.operator || '' - const operators = getOperatorsForFieldType(fieldType) - const isOperatorDisabled = disabled || !row.cells.tagName - - const operatorOptions: ComboboxOption[] = operators.map((op) => ({ - value: op.value, - label: op.label, - })) - - return ( - - handleCellChange(rowIndex, 'operator', value)} - disabled={isOperatorDisabled} - placeholder='Select operator' - className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate' - /> - + const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler( + cellKey, + fieldValue, + (newValue) => handleTagDropdownSelection(filter.id, field, newValue) ) - } - const renderValueCell = (row: TagFilterRow, rowIndex: number) => { - const cellValue = row.cells.value || '' - const fieldType = row.cells.fieldType || 'text' - const operator = row.cells.operator || 'eq' - const isBetween = operator === 'between' - const valueTo = row.cells.valueTo || '' - const isDisabled = disabled || !row.cells.tagName - const placeholder = getPlaceholderForFieldType(fieldType) - - const renderInput = (value: string, column: 'value' | 'valueTo') => ( -
+ return ( +
{ - const newValue = e.target.value - const cursorPosition = e.target.selectionStart ?? 0 - - handleCellChange(rowIndex, column, newValue) - - if (column === 'value') { - const tagTrigger = checkTagTrigger(newValue, cursorPosition) - - setActiveTagDropdown({ - rowIndex, - showTags: tagTrigger.show, - cursorPosition, - activeSourceBlockId: null, - element: e.target, - }) - } - }} - onFocus={(e) => { - if (!isDisabled && column === 'value') { - setActiveTagDropdown({ - rowIndex, - showTags: false, - cursorPosition: 0, - activeSourceBlockId: null, - element: e.target, - }) - } + ref={(el) => { + if (el) valueInputRefs.current[cellKey] = el }} - onBlur={() => { - if (column === 'value') { - setTimeout(() => setActiveTagDropdown(null), 200) - } - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setActiveTagDropdown(null) - } - }} - disabled={isDisabled} + value={fieldValue} + onChange={handlers.onChange} + onKeyDown={handlers.onKeyDown} + onDrop={handlers.onDrop} + onDragOver={handlers.onDragOver} + onScroll={(e) => syncOverlayScroll(cellKey, e.currentTarget.scrollLeft)} + onPaste={() => + setTimeout(() => { + const input = valueInputRefs.current[cellKey] + input && syncOverlayScroll(cellKey, input.scrollLeft) + }, 0) + } + disabled={isReadOnly} autoComplete='off' placeholder={placeholder} - className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0' + className='allow-scroll w-full overflow-auto text-transparent caret-foreground' /> -
-
- {formatDisplayText(value || '', { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} +
{ + if (el) overlayRefs.current[cellKey] = el + }} + className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm' + > +
+ {formatDisplayText( + fieldValue, + accessiblePrefixes ? { accessiblePrefixes } : { highlightAll: true } + )}
+ {fieldState.showTags && ( + inputController.fieldHelpers.hideFieldDropdowns(cellKey)} + inputRef={{ current: valueInputRefs.current[cellKey] || null }} + /> + )}
) + } - if (isBetween) { - return ( - -
- {renderInput(cellValue, 'value')} - to - {renderInput(valueTo, 'valueTo')} -
- - ) - } + /** + * Renders the filter content (tag, operator, value inputs) + */ + const renderFilterContent = (filter: TagFilter) => { + const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({ + value: tag.displayName, + label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`, + })) - return ( - - {renderInput(cellValue, 'value')} - - ) - } + const operators = getOperatorsForFieldType(filter.fieldType) + const operatorOptions: ComboboxOption[] = operators.map((op) => ({ + value: op.value, + label: op.label, + })) - const renderDeleteButton = (rowIndex: number) => { - if (isPreview || disabled) return null + const isBetween = filter.operator === 'between' return ( - - - - ) - } +
+
+ + updateFilter(filter.id, 'tagName', value)} + disabled={isReadOnly || isLoading} + placeholder={isLoading ? 'Loading tags...' : 'Select tag'} + /> +
- if (isLoading) { - return
Loading tag definitions...
- } +
+ + updateFilter(filter.id, 'operator', value)} + disabled={isReadOnly} + placeholder='Select operator' + /> +
- return ( -
-
- - {renderHeader()} - - {rows.map((row, rowIndex) => ( - - {renderTagNameCell(row, rowIndex)} - {renderOperatorCell(row, rowIndex)} - {renderValueCell(row, rowIndex)} - {renderDeleteButton(rowIndex)} - - ))} - -
+
+ + {isBetween ? ( +
+
{renderValueInput(filter, 'tagValue')}
+ to +
{renderValueInput(filter, 'valueTo')}
+
+ ) : ( + renderValueInput(filter, 'tagValue') + )} +
+ ) + } - {/* Tag Dropdown */} - {activeTagDropdown?.element && ( - { - // Use immediate emission for tag dropdown selections - handleTagDropdownSelection(activeTagDropdown.rowIndex, 'value', newValue) - setActiveTagDropdown(null) - }} - blockId={blockId} - activeSourceBlockId={activeTagDropdown.activeSourceBlockId} - inputValue={rows[activeTagDropdown.rowIndex]?.cells.value || ''} - cursorPosition={activeTagDropdown.cursorPosition} - onClose={() => { - setActiveTagDropdown((prev) => (prev ? { ...prev, showTags: false } : null)) - }} - className='absolute z-[9999] mt-0' - /> - )} - - {/* Add Filter Button */} - {!isPreview && !disabled && ( -
- - - {/* Filter count indicator */} - {(() => { - const appliedFilters = filters.filter( - (f) => f.tagName.trim() && f.tagValue.trim() - ).length - return ( -
- {appliedFilters} filter{appliedFilters !== 1 ? 's' : ''} applied -
- ) - })()} + return ( +
+ {filters.map((filter, index) => ( +
+ {renderFilterHeader(filter, index)} + {!filter.collapsed && renderFilterContent(filter)}
- )} + ))}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index a8207aa313..0fc7c72a4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -414,8 +414,8 @@ export function FieldFormat({ {renderFieldHeader(field, index)} {!field.collapsed && ( -
-
+
+
{showType && ( -
+
+
{renderValueInput(field)}
From ddf783d7ac4a7c02ba17a2f65a576289216b101c Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Mon, 29 Dec 2025 17:01:27 -0800 Subject: [PATCH 2/3] improvement: terminal height, subblocks --- apps/sim/app/_styles/globals.css | 2 +- .../document-tag-entry/document-tag-entry.tsx | 12 ++++-------- .../components/eval-input/eval-input.tsx | 16 ++++++++-------- .../components/input-mapping/input-mapping.tsx | 10 +++++----- .../knowledge-tag-filters.tsx | 12 ++++-------- .../components/starter/input-format.tsx | 6 +++--- .../variables-input/variables-input.tsx | 14 ++++++-------- .../components/terminal/terminal.tsx | 2 +- apps/sim/stores/terminal/store.ts | 2 +- 9 files changed, 33 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index dccad450f1..1ece0a9660 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -11,7 +11,7 @@ --panel-width: 260px; --toolbar-triggers-height: 300px; --editor-connections-height: 200px; - --terminal-height: 196px; + --terminal-height: 155px; } .sidebar-container { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx index 99eac59faf..31a4423f82 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx @@ -225,18 +225,14 @@ export function DocumentTagEntry({ */ const renderTagHeader = (tag: DocumentTag, index: number) => (
toggleCollapse(tag.id)} >
{tag.tagName || `Tag ${index + 1}`} - {tag.tagName && ( - - {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'} - - )} + {tag.tagName && {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}}
e.stopPropagation()}>
@@ -375,7 +371,7 @@ export function DocumentTagEntry({ key={tag.id} data-tag-id={tag.id} className={cn( - 'rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-3)] dark:bg-[#1F1F1F]', + 'rounded-[4px] border border-[var(--border-1)]', tag.collapsed ? 'overflow-hidden' : 'overflow-visible' )} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx index 2757dea663..6403600c43 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/eval-input/eval-input.tsx @@ -127,7 +127,7 @@ export function EvalInput({ } const renderMetricHeader = (metric: EvalMetric, index: number) => ( -
+
Metric {index + 1} @@ -171,12 +171,12 @@ export function EvalInput({
{renderMetricHeader(metric, index)} -
-
+
+
-
+
{(() => { @@ -254,8 +254,8 @@ export function EvalInput({
-
-
+
+
-
+
{fieldName} - {fieldType && {fieldType}} + {fieldType && {fieldType}}
{!collapsed && ( -
-
+
+
(
toggleCollapse(filter.id)} >
{filter.tagName || `Filter ${index + 1}`} - {filter.tagName && ( - - {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'} - - )} + {filter.tagName && {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}}
e.stopPropagation()}>
@@ -385,7 +381,7 @@ export function KnowledgeTagFilters({ key={filter.id} data-filter-id={filter.id} className={cn( - 'rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-3)] dark:bg-[#1F1F1F]', + 'rounded-[4px] border border-[var(--border-1)]', filter.collapsed ? 'overflow-hidden' : 'overflow-visible' )} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 0fc7c72a4b..a6874adacc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -163,14 +163,14 @@ export function FieldFormat({ */ const renderFieldHeader = (field: Field, index: number) => (
toggleCollapse(field.id)} >
{field.name || `${title} ${index + 1}`} - {field.name && showType && {field.type}} + {field.name && showType && {field.type}}
e.stopPropagation()}> ) @@ -996,41 +941,37 @@ export default function ResumeExecutionPage({ if (!executionDetail) { return ( -
-
+