Skip to content

Commit a4730ea

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
refactor(ui): share log and enrichment details panels (#8026)
* refactor(ui): share log and enrichment details panels * fix(ui): prevent focus in closed details panels * refactor(ui): use CSS-variable sizing for details panels * style(ui): make resize calculation spacing explicit --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 4dd929a commit a4730ea

5 files changed

Lines changed: 207 additions & 112 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx‎

Lines changed: 58 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ChipModalTabs,
2020
Code,
2121
cn,
22+
DetailsPanel,
2223
DropdownMenu,
2324
DropdownMenuContent,
2425
DropdownMenuItem,
@@ -804,79 +805,64 @@ export const LogDetails = memo(function LogDetails({
804805
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
805806

806807
return (
807-
<>
808-
{/* Resize Handle - positioned outside the panel */}
809-
{isOpen && (
810-
<div
811-
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
812-
style={{ right: `calc(${effectiveWidth} - 4px)` }}
813-
onMouseDown={handleMouseDown}
814-
role='separator'
815-
aria-label='Resize log details panel'
816-
aria-orientation='vertical'
817-
/>
818-
)}
819-
820-
<div
821-
className={cn(
822-
'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
823-
isOpen ? 'translate-x-0' : 'translate-x-full'
824-
)}
825-
style={{ width: effectiveWidth }}
826-
aria-label='Log details sidebar'
827-
>
828-
{log && (
829-
<div className='flex h-full flex-col px-3.5 pt-3'>
830-
{/* Header */}
831-
<div className='flex items-center justify-between'>
832-
<h2 className='text-[var(--text-primary)] text-sm'>Log Details</h2>
833-
<div className='flex items-center gap-[1px]'>
834-
{log.status === 'failed' &&
835-
(log.workflow?.id || log.workflowId) &&
836-
log.trigger !== 'mothership' && (
837-
<Tooltip.Root>
838-
<Tooltip.Trigger asChild>
839-
<Button
840-
variant='ghost'
841-
iconPadding='sm'
842-
onClick={() => onRetryExecution?.()}
843-
disabled={isRetryPending}
844-
aria-label='Retry execution'
845-
>
846-
<Redo className='size-[14px]' />
847-
</Button>
848-
</Tooltip.Trigger>
849-
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
850-
</Tooltip.Root>
851-
)}
852-
<Button
853-
variant='ghost'
854-
iconPadding='sm'
855-
onClick={() => hasPrev && onNavigatePrev?.()}
856-
disabled={!hasPrev}
857-
aria-label='Previous log'
858-
>
859-
<ChevronUp className='size-[14px]' />
860-
</Button>
861-
<Button
862-
variant='ghost'
863-
iconPadding='sm'
864-
onClick={() => hasNext && onNavigateNext?.()}
865-
disabled={!hasNext}
866-
aria-label='Next log'
867-
>
868-
<ChevronUp className='size-[14px] rotate-180' />
869-
</Button>
870-
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
871-
<X className='size-[14px]' />
872-
</Button>
873-
</div>
808+
<DetailsPanel
809+
open={isOpen}
810+
width={effectiveWidth}
811+
onResizeStart={handleMouseDown}
812+
resizeLabel='Resize log details panel'
813+
aria-label='Log details sidebar'
814+
>
815+
{log && (
816+
<div className='flex h-full flex-col px-3.5 pt-3'>
817+
{/* Header */}
818+
<div className='flex items-center justify-between'>
819+
<h2 className='text-[var(--text-primary)] text-sm'>Log Details</h2>
820+
<div className='flex items-center gap-[1px]'>
821+
{log.status === 'failed' &&
822+
(log.workflow?.id || log.workflowId) &&
823+
log.trigger !== 'mothership' && (
824+
<Tooltip.Root>
825+
<Tooltip.Trigger asChild>
826+
<Button
827+
variant='ghost'
828+
iconPadding='sm'
829+
onClick={() => onRetryExecution?.()}
830+
disabled={isRetryPending}
831+
aria-label='Retry execution'
832+
>
833+
<Redo className='size-[14px]' />
834+
</Button>
835+
</Tooltip.Trigger>
836+
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
837+
</Tooltip.Root>
838+
)}
839+
<Button
840+
variant='ghost'
841+
iconPadding='sm'
842+
onClick={() => hasPrev && onNavigatePrev?.()}
843+
disabled={!hasPrev}
844+
aria-label='Previous log'
845+
>
846+
<ChevronUp className='size-[14px]' />
847+
</Button>
848+
<Button
849+
variant='ghost'
850+
iconPadding='sm'
851+
onClick={() => hasNext && onNavigateNext?.()}
852+
disabled={!hasNext}
853+
aria-label='Next log'
854+
>
855+
<ChevronUp className='size-[14px] rotate-180' />
856+
</Button>
857+
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
858+
<X className='size-[14px]' />
859+
</Button>
874860
</div>
875-
876-
<LogDetailsContent log={log} onActiveTabChange={handleActiveTabChange} />
877861
</div>
878-
)}
879-
</div>
880-
</>
862+
863+
<LogDetailsContent log={log} onActiveTabChange={handleActiveTabChange} />
864+
</div>
865+
)}
866+
</DetailsPanel>
881867
)
882868
})

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichment-details/enrichment-details.tsx‎

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useEffect, useState } from 'react'
4-
import { Badge, Button, ChipModalTabs, cn, X } from '@sim/emcn'
4+
import { Badge, Button, ChipModalTabs, cn, DetailsPanel, X } from '@sim/emcn'
55
import { formatDuration } from '@sim/utils/formatting'
66
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table'
77
import {
@@ -340,45 +340,31 @@ export function EnrichmentDetails({
340340
}, [isOpen, onClose])
341341

342342
return (
343-
<>
344-
{isOpen && (
345-
<div
346-
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
347-
style={{ right: `calc(${effectiveWidth} - 4px)` }}
348-
onMouseDown={handleMouseDown}
349-
role='separator'
350-
aria-label='Resize enrichment details panel'
351-
aria-orientation='vertical'
352-
/>
353-
)}
354-
355-
<div
356-
className={cn(
357-
'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
358-
isOpen ? 'translate-x-0' : 'translate-x-full'
359-
)}
360-
style={{ width: effectiveWidth }}
361-
aria-label='Enrichment details sidebar'
362-
>
363-
{rowId && groupId && (
364-
<div className='flex h-full flex-col px-3.5 pt-3'>
365-
<div className='flex items-center justify-between'>
366-
<h2 className='text-[var(--text-primary)] text-sm'>Enrichment Details</h2>
367-
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
368-
<X className='size-[14px]' />
369-
</Button>
370-
</div>
371-
372-
<EnrichmentDetailsContent
373-
tableId={tableId}
374-
rowId={rowId}
375-
groupId={groupId}
376-
groupName={groupName}
377-
isOpen={isOpen}
378-
/>
343+
<DetailsPanel
344+
open={isOpen}
345+
width={effectiveWidth}
346+
onResizeStart={handleMouseDown}
347+
resizeLabel='Resize enrichment details panel'
348+
aria-label='Enrichment details sidebar'
349+
>
350+
{rowId && groupId && (
351+
<div className='flex h-full flex-col px-3.5 pt-3'>
352+
<div className='flex items-center justify-between'>
353+
<h2 className='text-[var(--text-primary)] text-sm'>Enrichment Details</h2>
354+
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
355+
<X className='size-[14px]' />
356+
</Button>
379357
</div>
380-
)}
381-
</div>
382-
</>
358+
359+
<EnrichmentDetailsContent
360+
tableId={tableId}
361+
rowId={rowId}
362+
groupId={groupId}
363+
groupName={groupName}
364+
isOpen={isOpen}
365+
/>
366+
</div>
367+
)}
368+
</DetailsPanel>
383369
)
384370
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/** @vitest-environment jsdom */
2+
import { act, createRef } from 'react'
3+
import { DetailsPanel, type DetailsPanelProps } from '@sim/emcn'
4+
import { createRoot } from 'react-dom/client'
5+
import { expect, it, vi } from 'vitest'
6+
7+
it('forwards resize events and retains content and refs while closed', () => {
8+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
9+
const container = document.createElement('div')
10+
document.body.appendChild(container)
11+
const root = createRoot(container)
12+
const ref = createRef<HTMLDivElement>()
13+
const resize = vi.fn()
14+
const render = (open: boolean, width: DetailsPanelProps['width'] = 520) => (
15+
<DetailsPanel
16+
ref={ref}
17+
open={open}
18+
width={width}
19+
style={{ width: 1, opacity: 0.9 }}
20+
onResizeStart={resize}
21+
resizeLabel='Resize details'
22+
aria-label='Details'
23+
>
24+
<input aria-label='Search details' defaultValue='Retained query' />
25+
</DetailsPanel>
26+
)
27+
28+
try {
29+
act(() => root.render(render(true)))
30+
const panel = ref.current!
31+
expect(panel.style.getPropertyValue('--details-panel-width')).toBe('520px')
32+
expect(panel.style.width).toBe('')
33+
expect(panel.style.opacity).toBe('0.9')
34+
expect(panel.hasAttribute('inert')).toBe(false)
35+
const input = panel.querySelector('input')!
36+
const handle = container.querySelector<HTMLDivElement>('[role="separator"]')!
37+
expect(handle.style.getPropertyValue('--details-panel-width')).toBe('520px')
38+
expect(handle.getAttribute('aria-label')).toBe('Resize details')
39+
expect(panel.contains(handle)).toBe(false)
40+
act(() => handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 240 })))
41+
expect(resize).toHaveBeenCalledTimes(1)
42+
expect(resize.mock.calls[0][0].clientX).toBe(240)
43+
44+
act(() => root.render(render(false)))
45+
expect(panel.hasAttribute('inert')).toBe(true)
46+
expect(ref.current).toBe(panel)
47+
expect(panel.querySelector('input')).toBe(input)
48+
expect(input.value).toBe('Retained query')
49+
expect(container.querySelector('[role="separator"]')).toBeNull()
50+
const responsiveWidth = 'clamp(min(320px, 60vw), 520px, 60vw)'
51+
act(() => root.render(render(true, responsiveWidth)))
52+
expect(panel.style.getPropertyValue('--details-panel-width')).toBe(responsiveWidth)
53+
expect(panel.hasAttribute('inert')).toBe(false)
54+
expect(panel.querySelector('input')).toBe(input)
55+
expect(container.querySelector('[role="separator"]')).not.toBeNull()
56+
} finally {
57+
act(() => root.unmount())
58+
container.remove()
59+
}
60+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { type CSSProperties, forwardRef, type HTMLAttributes, type MouseEventHandler } from 'react'
2+
import { cn } from '../../lib/cn'
3+
4+
export interface DetailsPanelProps extends HTMLAttributes<HTMLDivElement> {
5+
/** Slide the panel into view; closed content stays mounted but inert. */
6+
open: boolean
7+
/** Controlled width, including responsive CSS expressions such as clamp(). */
8+
width: NonNullable<CSSProperties['width']>
9+
/** Existing product resize handler; width state stays with the caller. */
10+
onResizeStart: MouseEventHandler<HTMLDivElement>
11+
/** Accessible name for the external resize handle. */
12+
resizeLabel: string
13+
}
14+
15+
/**
16+
* Non-modal details sidebar with a right-edge slide and an external resize handle.
17+
* Its parent supplies the positioned containing block. Content, keyboard commands,
18+
* and persisted width remain owned by the caller.
19+
*
20+
* @example
21+
* <DetailsPanel open={open} width={width} onResizeStart={handleMouseDown}
22+
* resizeLabel='Resize log details panel' aria-label='Log details sidebar'>
23+
* {content}
24+
* </DetailsPanel>
25+
*/
26+
export const DetailsPanel = forwardRef<HTMLDivElement, DetailsPanelProps>(
27+
({ open, width, onResizeStart, resizeLabel, className, style, children, ...props }, ref) => {
28+
const cssWidth = typeof width === 'number' ? `${width}px` : width
29+
const widthStyle = { '--details-panel-width': cssWidth } as CSSProperties
30+
const { width: _styleWidth, ...panelStyle } = style ?? {}
31+
32+
return (
33+
<>
34+
{open && (
35+
<div
36+
className='absolute top-0 right-[calc(var(--details-panel-width)_-_4px)] bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
37+
style={widthStyle}
38+
onMouseDown={onResizeStart}
39+
role='separator'
40+
aria-label={resizeLabel}
41+
aria-orientation='vertical'
42+
/>
43+
)}
44+
<div
45+
{...props}
46+
ref={ref}
47+
inert={!open || props.inert}
48+
className={cn(
49+
'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] w-[var(--details-panel-width)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
50+
open ? 'translate-x-0' : 'translate-x-full',
51+
className
52+
)}
53+
style={{ ...panelStyle, ...widthStyle }}
54+
>
55+
{children}
56+
</div>
57+
</>
58+
)
59+
}
60+
)
61+
62+
DetailsPanel.displayName = 'DetailsPanel'

‎packages/emcn/src/components/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export {
130130
type ComposerActionButtonProps,
131131
composerActionButtonVariants,
132132
} from './composer-action-button/composer-action-button'
133+
export { DetailsPanel, type DetailsPanelProps } from './details-panel/details-panel'
133134
export {
134135
DropdownMenu,
135136
DropdownMenuCheckboxItem,

0 commit comments

Comments
 (0)