Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ChipModalTabs,
Code,
cn,
DetailsPanel,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
Expand Down Expand Up @@ -804,79 +805,64 @@ export const LogDetails = memo(function LogDetails({
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])

return (
<>
{/* Resize Handle - positioned outside the panel */}
{isOpen && (
<div
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
style={{ right: `calc(${effectiveWidth} - 4px)` }}
onMouseDown={handleMouseDown}
role='separator'
aria-label='Resize log details panel'
aria-orientation='vertical'
/>
)}

<div
className={cn(
'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',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
style={{ width: effectiveWidth }}
aria-label='Log details sidebar'
>
{log && (
<div className='flex h-full flex-col px-3.5 pt-3'>
{/* Header */}
<div className='flex items-center justify-between'>
<h2 className='text-[var(--text-primary)] text-sm'>Log Details</h2>
<div className='flex items-center gap-[1px]'>
{log.status === 'failed' &&
(log.workflow?.id || log.workflowId) &&
log.trigger !== 'mothership' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
iconPadding='sm'
onClick={() => onRetryExecution?.()}
disabled={isRetryPending}
aria-label='Retry execution'
>
<Redo className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
</Tooltip.Root>
)}
<Button
variant='ghost'
iconPadding='sm'
onClick={() => hasPrev && onNavigatePrev?.()}
disabled={!hasPrev}
aria-label='Previous log'
>
<ChevronUp className='size-[14px]' />
</Button>
<Button
variant='ghost'
iconPadding='sm'
onClick={() => hasNext && onNavigateNext?.()}
disabled={!hasNext}
aria-label='Next log'
>
<ChevronUp className='size-[14px] rotate-180' />
</Button>
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
<X className='size-[14px]' />
</Button>
</div>
<DetailsPanel
open={isOpen}
width={effectiveWidth}
onResizeStart={handleMouseDown}
resizeLabel='Resize log details panel'
aria-label='Log details sidebar'
>
{log && (
<div className='flex h-full flex-col px-3.5 pt-3'>
{/* Header */}
<div className='flex items-center justify-between'>
<h2 className='text-[var(--text-primary)] text-sm'>Log Details</h2>
<div className='flex items-center gap-[1px]'>
{log.status === 'failed' &&
(log.workflow?.id || log.workflowId) &&
log.trigger !== 'mothership' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
iconPadding='sm'
onClick={() => onRetryExecution?.()}
disabled={isRetryPending}
aria-label='Retry execution'
>
<Redo className='size-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
</Tooltip.Root>
)}
<Button
variant='ghost'
iconPadding='sm'
onClick={() => hasPrev && onNavigatePrev?.()}
disabled={!hasPrev}
aria-label='Previous log'
>
<ChevronUp className='size-[14px]' />
</Button>
<Button
variant='ghost'
iconPadding='sm'
onClick={() => hasNext && onNavigateNext?.()}
disabled={!hasNext}
aria-label='Next log'
>
<ChevronUp className='size-[14px] rotate-180' />
</Button>
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
<X className='size-[14px]' />
</Button>
</div>

<LogDetailsContent log={log} onActiveTabChange={handleActiveTabChange} />
</div>
)}
</div>
</>

<LogDetailsContent log={log} onActiveTabChange={handleActiveTabChange} />
</div>
)}
</DetailsPanel>
)
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

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

return (
<>
{isOpen && (
<div
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
style={{ right: `calc(${effectiveWidth} - 4px)` }}
onMouseDown={handleMouseDown}
role='separator'
aria-label='Resize enrichment details panel'
aria-orientation='vertical'
/>
)}

<div
className={cn(
'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',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
style={{ width: effectiveWidth }}
aria-label='Enrichment details sidebar'
>
{rowId && groupId && (
<div className='flex h-full flex-col px-3.5 pt-3'>
<div className='flex items-center justify-between'>
<h2 className='text-[var(--text-primary)] text-sm'>Enrichment Details</h2>
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
<X className='size-[14px]' />
</Button>
</div>

<EnrichmentDetailsContent
tableId={tableId}
rowId={rowId}
groupId={groupId}
groupName={groupName}
isOpen={isOpen}
/>
<DetailsPanel
open={isOpen}
width={effectiveWidth}
onResizeStart={handleMouseDown}
resizeLabel='Resize enrichment details panel'
aria-label='Enrichment details sidebar'
>
{rowId && groupId && (
<div className='flex h-full flex-col px-3.5 pt-3'>
<div className='flex items-center justify-between'>
<h2 className='text-[var(--text-primary)] text-sm'>Enrichment Details</h2>
<Button variant='ghost' iconPadding='sm' onClick={onClose} aria-label='Close'>
<X className='size-[14px]' />
</Button>
</div>
)}
</div>
</>

<EnrichmentDetailsContent
tableId={tableId}
rowId={rowId}
groupId={groupId}
groupName={groupName}
isOpen={isOpen}
/>
</div>
)}
</DetailsPanel>
)
}
60 changes: 60 additions & 0 deletions packages/emcn/src/components/details-panel/details-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/** @vitest-environment jsdom */
import { act, createRef } from 'react'
import { DetailsPanel, type DetailsPanelProps } from '@sim/emcn'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'

it('forwards resize events and retains content and refs while closed', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const ref = createRef<HTMLDivElement>()
const resize = vi.fn()
const render = (open: boolean, width: DetailsPanelProps['width'] = 520) => (
<DetailsPanel
ref={ref}
open={open}
width={width}
style={{ width: 1, opacity: 0.9 }}
onResizeStart={resize}
resizeLabel='Resize details'
aria-label='Details'
>
<input aria-label='Search details' defaultValue='Retained query' />
</DetailsPanel>
)

try {
act(() => root.render(render(true)))
const panel = ref.current!
expect(panel.style.getPropertyValue('--details-panel-width')).toBe('520px')
expect(panel.style.width).toBe('')
expect(panel.style.opacity).toBe('0.9')
expect(panel.hasAttribute('inert')).toBe(false)
const input = panel.querySelector('input')!
const handle = container.querySelector<HTMLDivElement>('[role="separator"]')!
expect(handle.style.getPropertyValue('--details-panel-width')).toBe('520px')
expect(handle.getAttribute('aria-label')).toBe('Resize details')
expect(panel.contains(handle)).toBe(false)
act(() => handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 240 })))
expect(resize).toHaveBeenCalledTimes(1)
expect(resize.mock.calls[0][0].clientX).toBe(240)

act(() => root.render(render(false)))
expect(panel.hasAttribute('inert')).toBe(true)
expect(ref.current).toBe(panel)
expect(panel.querySelector('input')).toBe(input)
expect(input.value).toBe('Retained query')
expect(container.querySelector('[role="separator"]')).toBeNull()
const responsiveWidth = 'clamp(min(320px, 60vw), 520px, 60vw)'
act(() => root.render(render(true, responsiveWidth)))
expect(panel.style.getPropertyValue('--details-panel-width')).toBe(responsiveWidth)
expect(panel.hasAttribute('inert')).toBe(false)
expect(panel.querySelector('input')).toBe(input)
expect(container.querySelector('[role="separator"]')).not.toBeNull()
} finally {
act(() => root.unmount())
container.remove()
}
})
62 changes: 62 additions & 0 deletions packages/emcn/src/components/details-panel/details-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { type CSSProperties, forwardRef, type HTMLAttributes, type MouseEventHandler } from 'react'
import { cn } from '../../lib/cn'

export interface DetailsPanelProps extends HTMLAttributes<HTMLDivElement> {
/** Slide the panel into view; closed content stays mounted but inert. */
open: boolean
/** Controlled width, including responsive CSS expressions such as clamp(). */
width: NonNullable<CSSProperties['width']>
/** Existing product resize handler; width state stays with the caller. */
onResizeStart: MouseEventHandler<HTMLDivElement>
/** Accessible name for the external resize handle. */
resizeLabel: string
}

/**
* Non-modal details sidebar with a right-edge slide and an external resize handle.
* Its parent supplies the positioned containing block. Content, keyboard commands,
* and persisted width remain owned by the caller.
*
* @example
* <DetailsPanel open={open} width={width} onResizeStart={handleMouseDown}
* resizeLabel='Resize log details panel' aria-label='Log details sidebar'>
* {content}
* </DetailsPanel>
*/
export const DetailsPanel = forwardRef<HTMLDivElement, DetailsPanelProps>(
({ open, width, onResizeStart, resizeLabel, className, style, children, ...props }, ref) => {
const cssWidth = typeof width === 'number' ? `${width}px` : width
const widthStyle = { '--details-panel-width': cssWidth } as CSSProperties
const { width: _styleWidth, ...panelStyle } = style ?? {}

return (
<>
{open && (
<div
className='absolute top-0 right-[calc(var(--details-panel-width)_-_4px)] bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
style={widthStyle}
onMouseDown={onResizeStart}
role='separator'
aria-label={resizeLabel}
aria-orientation='vertical'
/>
)}
<div
{...props}
ref={ref}
inert={!open || props.inert}
className={cn(
'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',
open ? 'translate-x-0' : 'translate-x-full',
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
className
)}
style={{ ...panelStyle, ...widthStyle }}
>
{children}
</div>
</>
)
}
)

DetailsPanel.displayName = 'DetailsPanel'
1 change: 1 addition & 0 deletions packages/emcn/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export {
type ComposerActionButtonProps,
composerActionButtonVariants,
} from './composer-action-button/composer-action-button'
export { DetailsPanel, type DetailsPanelProps } from './details-panel/details-panel'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
Expand Down
Loading