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 b5abcaf47a4..7023aa4f226 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 @@ -4,12 +4,13 @@ import { useMemo, useRef } from 'react' import { Badge, Button, + CollapsibleCard, Combobox, type ComboboxOption, cn, - handleKeyboardActivation, Input, Label, + OverflowText, Trash, } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' @@ -235,55 +236,46 @@ export function DocumentTagEntry({ } /** - * Renders the tag header with name, badge, and action buttons + * Renders the tag summary with its type badge * Shows tag name only when collapsed (as summary), generic label when expanded */ - const renderTagHeader = (tag: DocumentTag, index: number) => ( -
toggleCollapse(tag.id)} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return - handleKeyboardActivation(event, () => toggleCollapse(tag.id)) - }} - > -
- - {tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`} - - {tag.collapsed && tag.tagName && ( - - {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'} - - )} -
-
e.stopPropagation()} + const renderTitle = (tag: DocumentTag, index: number) => ( + + - - -
-
+ {tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`} + + {tag.collapsed && tag.tagName && ( + + {FIELD_TYPE_LABELS[tag.fieldType] || 'Text'} + + )} + + ) + + const renderActions = (tag: DocumentTag) => ( + <> + + + ) /** @@ -385,7 +377,7 @@ export function DocumentTagEntry({ })) return ( -
+ <>
Value {renderValueInput(tag)}
-
+ ) } return (
{tags.map((tag, index) => ( -
toggleCollapse(tag.id)} > - {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/filter-builder/components/filter-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx index d41572a72d6..7e0e4375b3b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/components/filter-rule-row.tsx @@ -2,12 +2,13 @@ import { useRef } from 'react' import { Badge, Button, + CollapsibleCard, Combobox, type ComboboxOption, cn, - handleKeyboardActivation, Input, Label, + OverflowText, Trash, } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' @@ -102,56 +103,46 @@ export function FilterRuleRow({ label, }) - const renderHeader = () => ( -
onToggleCollapse(rule.id)} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return - handleKeyboardActivation(event, () => onToggleCollapse(rule.id)) - }} - > -
- - {rule.collapsed && rule.column - ? formatDisplayText(getColumnLabel(rule.column), { - workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)), - }) - : `Condition ${index + 1}`} - - {rule.collapsed && rule.column && ( - - {formatDisplayText(getOperatorLabel(rule.operator), { - workflowSearchHighlight: getLabelHighlight( - 'operator', - getOperatorLabel(rule.operator) - ), - })} - - )} -
-
e.stopPropagation()} + const renderTitle = () => ( + + - - -
-
+ {rule.collapsed && rule.column + ? formatDisplayText(getColumnLabel(rule.column), { + workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)), + }) + : `Condition ${index + 1}`} + + {rule.collapsed && rule.column && ( + + {formatDisplayText(getOperatorLabel(rule.operator), { + workflowSearchHighlight: getLabelHighlight('operator', getOperatorLabel(rule.operator)), + })} + + )} + + ) + + const renderActions = () => ( + <> + + + ) const renderValueInput = () => ( @@ -209,7 +200,7 @@ export function FilterRuleRow({ ) const renderContent = () => ( -
+ <> {index > 0 && (
@@ -281,19 +272,20 @@ export function FilterRuleRow({ {renderValueInput()}
-
+ ) return ( -
onToggleCollapse(rule.id)} > - {renderHeader()} {!rule.collapsed && renderContent()} -
+ ) } 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 b5ca52dd694..dc4e6b62c34 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 @@ -4,12 +4,13 @@ import { useRef } from 'react' import { Badge, Button, + CollapsibleCard, Combobox, type ComboboxOption, cn, - handleKeyboardActivation, Input, Label, + OverflowText, Trash, } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' @@ -228,57 +229,52 @@ export function KnowledgeTagFilters({ } /** - * Renders the filter header with name, badge, and action buttons + * Renders the filter summary with its type badge * Shows tag name only when collapsed (as summary), generic label when expanded */ - const renderFilterHeader = (filter: TagFilter, index: number) => ( -
toggleCollapse(filter.id)} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return - handleKeyboardActivation(event, () => toggleCollapse(filter.id)) - }} - > -
- - {filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`} - - {filter.collapsed && filter.tagName && ( - - {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'} - - )} -
-
- - -
-
+ const renderTitle = (filter: TagFilter, index: number) => ( + + + {filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`} + + {filter.collapsed && filter.tagName && ( + + {FIELD_TYPE_LABELS[filter.fieldType] || 'Text'} + + )} + + ) + + const renderActions = (filter: TagFilter) => ( + <> + + + ) /** @@ -383,7 +379,7 @@ export function KnowledgeTagFilters({ const isBetween = filter.operator === 'between' return ( -
+ <>
-
+ ) } return (
{filters.map((filter, index) => ( -
toggleCollapse(filter.id)} > - {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/sort-builder/components/sort-rule-row.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx index 329adbef3c6..d831a90a785 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/components/sort-rule-row.tsx @@ -1,11 +1,11 @@ import { Badge, Button, + CollapsibleCard, Combobox, type ComboboxOption, - cn, - handleKeyboardActivation, Label, + OverflowText, Trash, } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' @@ -61,60 +61,51 @@ export function SortRuleRow({ label, }) - const renderHeader = () => ( -
onToggleCollapse(rule.id)} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return - handleKeyboardActivation(event, () => onToggleCollapse(rule.id)) - }} - > -
- - {rule.collapsed && rule.column - ? formatDisplayText(getColumnLabel(rule.column), { - workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)), - }) - : `Sort ${index + 1}`} - - {rule.collapsed && rule.column && ( - - {formatDisplayText(getDirectionLabel(rule.direction), { - workflowSearchHighlight: getLabelHighlight( - 'direction', - getDirectionLabel(rule.direction) - ), - })} - - )} -
-
e.stopPropagation()} + const renderTitle = () => ( + + - - -
-
+ {rule.collapsed && rule.column + ? formatDisplayText(getColumnLabel(rule.column), { + workflowSearchHighlight: getLabelHighlight('column', getColumnLabel(rule.column)), + }) + : `Sort ${index + 1}`} + + {rule.collapsed && rule.column && ( + + {formatDisplayText(getDirectionLabel(rule.direction), { + workflowSearchHighlight: getLabelHighlight( + 'direction', + getDirectionLabel(rule.direction) + ), + })} + + )} + + ) + + const renderActions = () => ( + <> + + + ) const renderContent = () => ( -
+ <>
-
+ ) return ( -
onToggleCollapse(rule.id)} > - {renderHeader()} {!rule.collapsed && renderContent()} -
+ ) } diff --git a/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx b/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx new file mode 100644 index 00000000000..48c2002f869 --- /dev/null +++ b/packages/emcn/src/components/collapsible-card/collapsible-card.test.tsx @@ -0,0 +1,94 @@ +/** @vitest-environment jsdom */ +import { act, type ReactNode, useState } from 'react' +import { CollapsibleCard } from '@sim/emcn' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +let container: HTMLDivElement | null = null +let root: Root | null = null + +function mount(children: ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(children)) +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('CollapsibleCard', () => { + it('toggles controlled content by click, Enter and Space and forwards root attributes', () => { + function Example() { + const [collapsed, setCollapsed] = useState(true) + return ( + setCollapsed((value) => !value)} + > + + + ) + } + mount() + const card = container!.querySelector('[data-filter-id="condition-1"]')! + const trigger = card.querySelector('[role="button"]')! + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(card.querySelector('input')).toBeNull() + act(() => trigger.click()) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(card.querySelector('input')?.value).toBe('Example') + act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + expect(card.querySelector('input')).toBeNull() + act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))) + expect(card.querySelector('input')).not.toBeNull() + }) + + it('keeps enabled and disabled actions outside the collapse target', () => { + const toggle = vi.fn() + const add = vi.fn() + const remove = vi.fn() + const parentClick = vi.fn() + mount( + Long condition name} + badge={Text} + collapsed + onToggleCollapse={toggle} + onClick={parentClick} + actions={ + <> + + + + } + > + Content + + ) + const trigger = container!.querySelector('[role="button"]')! + const [addButton, deleteButton] = container!.querySelectorAll('button') + expect(trigger.contains(addButton)).toBe(false) + act(() => { + addButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + addButton.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })) + addButton.click() + deleteButton.click() + }) + expect(add).toHaveBeenCalledTimes(1) + expect(remove).not.toHaveBeenCalled() + expect(toggle).not.toHaveBeenCalled() + expect(parentClick).not.toHaveBeenCalled() + }) +}) diff --git a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx index 661bab113c9..7dbc6a76b8a 100644 --- a/packages/emcn/src/components/collapsible-card/collapsible-card.tsx +++ b/packages/emcn/src/components/collapsible-card/collapsible-card.tsx @@ -5,47 +5,65 @@ import { cn } from '../../lib/cn' import { handleKeyboardActivation } from '../../lib/keyboard' import { OverflowText, overflowTextClipClass } from '../overflow-text/overflow-text' -export interface CollapsibleCardProps { +export interface CollapsibleCardProps + extends Omit, 'title' | 'children'> { /** Header label rendered with the standard fade-only overflow treatment. */ title: React.ReactNode /** Optional trailing header content, e.g. a type `Badge`. */ badge?: React.ReactNode + /** Header actions, outside the collapse target and arranged with standard spacing. */ + actions?: React.ReactNode collapsed: boolean onToggleCollapse: () => void /** Body content, shown when expanded. */ children: React.ReactNode - className?: string } /** * A collapsible field card: a `--surface-4` header (click / keyboard to toggle) * with a fade-clipped title + optional badge, over a `--surface-2` body. Shared by * the workflow input-mapping rows and the enrichment output-column config. + * + * @example + * Add condition}> + * {fields} + * */ export function CollapsibleCard({ title, badge, + actions, collapsed, onToggleCollapse, children, className, + ...props }: CollapsibleCardProps) { return (
-
handleKeyboardActivation(event, onToggleCollapse)} - > -
+
+
{ + if (event.target !== event.currentTarget) return + handleKeyboardActivation(event, onToggleCollapse) + }} + > {typeof title === 'string' || typeof title === 'number' ? ( + {actions && ( +
event.stopPropagation()} + > + {actions} +
+ )}
{!collapsed && (