Params/Metrics/Alerts/Dashboards each hand-assembled the same collapsible- section header wiring (role/tabIndex/aria-expanded/onClick/onKeyDown around CollapseToggle+HelpIcon+PopOutButton), and Dashboards duplicated it a second time for its no-active-profile branch. Params/Metrics/Alerts also duplicated the declare/edit form footer (editing banner, submit label toggle, Cancel button). Added SectionHeader and FormFooter to CollapsibleSection.tsx and adopted them at all five call sites; pure extraction, no behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
233 lines
9.1 KiB
TypeScript
233 lines
9.1 KiB
TypeScript
import { useState } from 'react'
|
|
import type { TimeRange, TimeRangePresetId } from '../lib/timeRange'
|
|
import { HelpIcon } from './HelpIcon'
|
|
|
|
/// Shared look for a primary section's title bar: shaded so it reads as a
|
|
/// distinct band rather than blending into the section body below it.
|
|
/// The bar itself is the collapse/expand click target (see `sectionHeaderInteractiveClassName`) —
|
|
/// this className alone (no click handling) is for the rare header that stays static.
|
|
export const sectionHeaderClassName =
|
|
'flex items-center justify-between gap-2 border-b border-border-soft bg-surface-1 px-3 py-1.5'
|
|
|
|
/// `sectionHeaderClassName` plus the affordance for a header row that toggles collapse on click:
|
|
/// pointer cursor and a hover tint so the whole bar reads as clickable, not just the chevron.
|
|
export const sectionHeaderInteractiveClassName = `${sectionHeaderClassName} cursor-pointer select-none hover:bg-surface-2`
|
|
|
|
/// Left-hand cluster of a section header: title text, help icon, collapse/pop-out controls.
|
|
export const sectionHeaderLeftClassName = 'flex items-center gap-1.5 text-sm font-medium text-text-secondary'
|
|
|
|
/// localStorage key holding a record of section id -> collapsed, mirroring LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern.
|
|
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
|
|
|
|
function loadCollapsedSections(): Record<string, boolean> {
|
|
try {
|
|
const raw = localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY)
|
|
if (!raw) return {}
|
|
const parsed = JSON.parse(raw)
|
|
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function saveCollapsedSections(sections: Record<string, boolean>) {
|
|
try {
|
|
localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify(sections))
|
|
} catch {
|
|
// best-effort — a private browsing mode or full storage just means the
|
|
// collapsed state won't persist across reloads.
|
|
}
|
|
}
|
|
|
|
/// Returns only the boolean + setter, not a conditional-render helper — callers keep rendering their subtree and hide it with CSS (`hidden`), so collapsing never unmounts in-progress form state.
|
|
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
|
|
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false)
|
|
|
|
function setCollapsed(next: boolean) {
|
|
setCollapsedState(next)
|
|
const all = loadCollapsedSections()
|
|
all[sectionId] = next
|
|
saveCollapsedSections(all)
|
|
}
|
|
|
|
return [collapsed, setCollapsed]
|
|
}
|
|
|
|
/// Chevron indicator for a section's collapsed/expanded state. For the title-bar sections
|
|
/// (Alerts/Metrics/Params/Dashboards), the whole header row is the click target (see
|
|
/// `sectionHeaderInteractiveClassName`) and `onToggle` is omitted — this renders as a
|
|
/// non-interactive, decorative `span` rather than a nested `<button>`, with `aria-expanded`
|
|
/// living on the header row itself. `onToggle` stays available for the log viewer's toggle
|
|
/// (`App.tsx`), which sits in a filter toolbar rather than a plain title bar — there the
|
|
/// chevron itself remains a dedicated button.
|
|
export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; onToggle?: () => void }) {
|
|
if (!onToggle) {
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted"
|
|
>
|
|
{collapsed ? '▸' : '▾'}
|
|
</span>
|
|
)
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
title={collapsed ? 'Expand section' : 'Collapse section'}
|
|
aria-label={collapsed ? 'Expand section' : 'Collapse section'}
|
|
aria-expanded={!collapsed}
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted hover:border-border-strong hover:text-text-secondary"
|
|
>
|
|
{collapsed ? '▸' : '▾'}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
/// Keyboard handler for a header row using `sectionHeaderInteractiveClassName`: Enter/Space
|
|
/// activate it like a button, matching the `role="button"` semantics on the row.
|
|
export function handleSectionHeaderKeyDown(onToggle: () => void) {
|
|
return (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
onToggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The header row every settings panel (Params/Metrics/Alerts/Dashboards) opens with: a click/keyboard-toggleable
|
|
/// title bar pairing a title + `HelpIcon` on the left with `CollapseToggle` (and, for panels with a standalone
|
|
/// popped-out view, a `PopOutButton`) on the right. Assembles `sectionHeaderInteractiveClassName` /
|
|
/// `handleSectionHeaderKeyDown` / `sectionHeaderLeftClassName` so callers don't each re-wire the same
|
|
/// `role="button"`/`tabIndex`/`aria-expanded` boilerplate by hand.
|
|
export function SectionHeader({
|
|
title,
|
|
helpSectionId,
|
|
helpLabel,
|
|
collapsed,
|
|
onToggle,
|
|
popOut,
|
|
}: {
|
|
title: string
|
|
helpSectionId: string
|
|
helpLabel?: string
|
|
collapsed: boolean
|
|
onToggle: () => void
|
|
popOut?: React.ReactNode
|
|
}) {
|
|
return (
|
|
<div
|
|
className={sectionHeaderInteractiveClassName}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-expanded={!collapsed}
|
|
onClick={onToggle}
|
|
onKeyDown={handleSectionHeaderKeyDown(onToggle)}
|
|
>
|
|
<div className={sectionHeaderLeftClassName}>
|
|
{title}
|
|
<HelpIcon sectionId={helpSectionId} label={helpLabel ?? `Help: ${title}`} />
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<CollapseToggle collapsed={collapsed} />
|
|
{popOut}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/// The declare/edit form footer shared by `Params.tsx`/`Metrics.tsx`/`Alerts.tsx`: a submit button whose label
|
|
/// swaps between create/edit and idle/in-flight variants, plus a Cancel button that only appears while editing.
|
|
/// Doesn't render the "Editing X" banner some callers show above this — that banner's placement relative to
|
|
/// `submitError` differs per caller, so it stays theirs to render; this covers only the part that's identical
|
|
/// everywhere. Label text stays a per-caller prop (e.g. "Declare param" vs "Declare metric") rather than
|
|
/// hardcoded here, since that's the one part of this footer each panel's copy actually differs on.
|
|
export function FormFooter({
|
|
editing,
|
|
submitting,
|
|
idleLabel,
|
|
editingLabel,
|
|
submittingLabel = 'Saving…',
|
|
editingSubmittingLabel,
|
|
onCancel,
|
|
}: {
|
|
editing: boolean
|
|
submitting: boolean
|
|
idleLabel: string
|
|
editingLabel: string
|
|
submittingLabel?: string
|
|
editingSubmittingLabel?: string
|
|
onCancel: () => void
|
|
}) {
|
|
const label = submitting
|
|
? editing
|
|
? (editingSubmittingLabel ?? submittingLabel)
|
|
: submittingLabel
|
|
: editing
|
|
? editingLabel
|
|
: idleLabel
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
|
>
|
|
{label}
|
|
</button>
|
|
{editing && (
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={submitting}
|
|
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
|
|
export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards'
|
|
|
|
/// A rolling preset (anything but a manually dragged/fixed range) is carried over by id so the popped-out tab can resolve and keep re-resolving its own independent window of the same size, instead of freezing whatever absolute bounds happened to be active at click time. A fixed/dragged range has no such id, so it's carried over as absolute bounds, same as before.
|
|
function popOutUrl(section: PopOutSectionId, options?: { range?: TimeRange | null; presetId?: TimeRangePresetId | null }): string {
|
|
const query = new URLSearchParams({ section })
|
|
if (options?.presetId != null) {
|
|
query.set('preset', options.presetId)
|
|
} else if (options?.range) {
|
|
query.set('startMs', String(options.range.startMs))
|
|
query.set('endMs', String(options.range.endMs))
|
|
}
|
|
return `?${query.toString()}`
|
|
}
|
|
|
|
/// `range`/`activePreset` are only needed for sections without their own LogTimeline — the popped-out page can't pick a range itself until it mounts, so the current one is carried over via query params.
|
|
export function PopOutButton({
|
|
section,
|
|
range,
|
|
activePreset,
|
|
}: {
|
|
section: PopOutSectionId
|
|
range?: TimeRange | null
|
|
activePreset?: TimeRangePresetId | null
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
window.open(popOutUrl(section, { range, presetId: activePreset }), '_blank')
|
|
}}
|
|
title="Open in new tab"
|
|
aria-label="Open in new tab"
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted hover:border-border-strong hover:text-text-secondary"
|
|
>
|
|
↗
|
|
</button>
|
|
)
|
|
}
|