mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(automations): restore column sorting on the list (#18885)
The flat-table redesign in #16532 dropped the sort UI, orphaning AutomationListSortHeader, nextAutomationListSort and the whole AutomationListViewItem layer. Wire them back to the rendered list. Name and Last run become interactive header cells again; the other six columns stay plain text. Sorting now spans local and external rows as one list, so the panel renders per-row components from a single sorted collection instead of two independent sections. Two model fixes fall out of that: - View items key on the host-qualified row key, not the bare automation ID. The old builder predated automation-list-row-identity, so under All hosts two authorities returning the same ID collapsed in the sort tie-break. - sortAutomationListViewItems takes the locale as a parameter instead of reading getIntlLocale(). A hidden global read is invisible to a dependency array, and the list result is memoized. Keyboard traversal and focus recovery now read the sorted order, so arrow navigation matches what is on screen. The dead unified filter is removed in favor of the live row/entry filters the page already used.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import React from 'react'
|
||||
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
ExternalAutomationAction,
|
||||
ExternalAutomationJob,
|
||||
ExternalAutomationManager
|
||||
} from '../../../../shared/automations-types'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
import type { ExternalAutomationScope } from './external-automation-scope-client'
|
||||
import {
|
||||
formatExternalDate,
|
||||
getExternalProviderLabel,
|
||||
getExternalTargetKindLabel
|
||||
} from './external-automation-display'
|
||||
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
|
||||
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
|
||||
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
|
||||
import {
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
LIST_TABLE_ROW_SELECTED_CLASS,
|
||||
LIST_TABLE_STICKY_ROW_CELL_CLASS
|
||||
} from '@/lib/list-table-layout'
|
||||
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
|
||||
import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run'
|
||||
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
|
||||
import { AutomationListStatusCell } from './AutomationListStatusCell'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type AutomationListExternalRowProps = {
|
||||
entry: ExternalAutomationListEntry
|
||||
selectedExternalKey: string | null | undefined
|
||||
relativeNow: number
|
||||
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
|
||||
externalActionKey: string | null
|
||||
onSelect: (entryKey: string) => void
|
||||
onRequestAction: (
|
||||
manager: ExternalAutomationManager,
|
||||
job: ExternalAutomationJob,
|
||||
action: ExternalAutomationAction,
|
||||
scope: ExternalAutomationScope
|
||||
) => void
|
||||
onEdit: (
|
||||
manager: ExternalAutomationManager,
|
||||
job: ExternalAutomationJob,
|
||||
scope: ExternalAutomationScope
|
||||
) => void
|
||||
}
|
||||
|
||||
export function AutomationListExternalRow({
|
||||
entry,
|
||||
selectedExternalKey,
|
||||
relativeNow,
|
||||
sshConnectionStates,
|
||||
externalActionKey,
|
||||
onSelect,
|
||||
onRequestAction,
|
||||
onEdit
|
||||
}: AutomationListExternalRowProps): React.JSX.Element {
|
||||
const providerLabel = getExternalProviderLabel(entry.manager)
|
||||
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
|
||||
const isSelected = selectedExternalKey === entry.key
|
||||
const sshStatus =
|
||||
entry.manager.target.type === 'ssh'
|
||||
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
|
||||
: undefined
|
||||
const disabledMessage = getExternalAutomationActionDisabledMessage({
|
||||
manager: entry.manager,
|
||||
providerLabel,
|
||||
targetKindLabel,
|
||||
sshStatus,
|
||||
actionInProgress: externalActionKey !== null
|
||||
})
|
||||
const actionDisabled = disabledMessage !== null
|
||||
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
|
||||
const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local'
|
||||
const projectLabel = entry.job.workdir ?? providerLabel
|
||||
const nextRunLabel = entry.job.enabled
|
||||
? formatExternalDate(entry.job.nextRunAt, relativeNow)
|
||||
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
|
||||
const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-current={isSelected ? 'true' : undefined}
|
||||
onClick={(event) => {
|
||||
// Why: Radix portals menus out of the row DOM, but React still
|
||||
// bubbles those clicks here — ignore so menu actions don't open detail.
|
||||
if (isPortaledRowMenuClick(event)) {
|
||||
return
|
||||
}
|
||||
onSelect(entry.key)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isRowActivationKey(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onSelect(entry.key)
|
||||
}}
|
||||
className={cn(
|
||||
AUTOMATIONS_TABLE_GRID_CLASS,
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
|
||||
)}
|
||||
>
|
||||
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
|
||||
<span className="min-w-0 truncate font-medium">{entry.job.name}</span>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
|
||||
{scheduleLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
|
||||
{nextRunLabel}
|
||||
</span>
|
||||
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
|
||||
<AutomationListStatusCell enabled={entry.job.enabled} />
|
||||
<span className="truncate text-center text-xs text-muted-foreground">
|
||||
{providerLabel}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.automations.AutomationsPage.rowActions',
|
||||
'Automation actions'
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">
|
||||
{disabledMessage ??
|
||||
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{entry.manager.provider === 'hermes' ? (
|
||||
<DropdownMenuItem
|
||||
disabled={!entry.manager.canManage || externalActionKey !== null}
|
||||
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() =>
|
||||
onRequestAction(
|
||||
entry.manager,
|
||||
entry.job,
|
||||
entry.job.enabled ? 'pause' : 'resume',
|
||||
entry.scope
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.job.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
|
||||
{entry.job.enabled
|
||||
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
|
||||
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={actionDisabled}
|
||||
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">
|
||||
{disabledMessage ??
|
||||
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
{entry.manager.provider === 'hermes' ? (
|
||||
<ContextMenuItem
|
||||
disabled={!entry.manager.canManage || externalActionKey !== null}
|
||||
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() =>
|
||||
onRequestAction(
|
||||
entry.manager,
|
||||
entry.job,
|
||||
entry.job.enabled ? 'pause' : 'resume',
|
||||
entry.scope
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.job.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
|
||||
{entry.job.enabled
|
||||
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
|
||||
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={actionDisabled}
|
||||
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,285 +1,23 @@
|
||||
import React from 'react'
|
||||
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
ExternalAutomationAction,
|
||||
ExternalAutomationJob,
|
||||
ExternalAutomationManager
|
||||
} from '../../../../shared/automations-types'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
import type { ExternalAutomationScope } from './external-automation-scope-client'
|
||||
import {
|
||||
formatExternalDate,
|
||||
getExternalProviderLabel,
|
||||
getExternalTargetKindLabel
|
||||
} from './external-automation-display'
|
||||
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
|
||||
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
|
||||
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
|
||||
import {
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
LIST_TABLE_ROW_SELECTED_CLASS,
|
||||
LIST_TABLE_STICKY_ROW_CELL_CLASS
|
||||
} from '@/lib/list-table-layout'
|
||||
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
|
||||
import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run'
|
||||
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
|
||||
import { AutomationListStatusCell } from './AutomationListStatusCell'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
AutomationListExternalRow,
|
||||
type AutomationListExternalRowProps
|
||||
} from './AutomationListExternalRow'
|
||||
|
||||
export type AutomationListExternalRowsProps = Omit<AutomationListExternalRowProps, 'entry'> & {
|
||||
entries: readonly ExternalAutomationListEntry[]
|
||||
}
|
||||
|
||||
export function AutomationListExternalRows({
|
||||
entries,
|
||||
selectedExternalKey,
|
||||
relativeNow,
|
||||
sshConnectionStates,
|
||||
externalActionKey,
|
||||
onSelect,
|
||||
onRequestAction,
|
||||
onEdit
|
||||
}: {
|
||||
entries: readonly ExternalAutomationListEntry[]
|
||||
selectedExternalKey: string | null | undefined
|
||||
relativeNow: number
|
||||
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
|
||||
externalActionKey: string | null
|
||||
onSelect: (entryKey: string) => void
|
||||
onRequestAction: (
|
||||
manager: ExternalAutomationManager,
|
||||
job: ExternalAutomationJob,
|
||||
action: ExternalAutomationAction,
|
||||
scope: ExternalAutomationScope
|
||||
) => void
|
||||
onEdit: (
|
||||
manager: ExternalAutomationManager,
|
||||
job: ExternalAutomationJob,
|
||||
scope: ExternalAutomationScope
|
||||
) => void
|
||||
}): React.JSX.Element {
|
||||
...rowProps
|
||||
}: AutomationListExternalRowsProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => {
|
||||
const providerLabel = getExternalProviderLabel(entry.manager)
|
||||
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
|
||||
const isSelected = selectedExternalKey === entry.key
|
||||
const sshStatus =
|
||||
entry.manager.target.type === 'ssh'
|
||||
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
|
||||
: undefined
|
||||
const disabledMessage = getExternalAutomationActionDisabledMessage({
|
||||
manager: entry.manager,
|
||||
providerLabel,
|
||||
targetKindLabel,
|
||||
sshStatus,
|
||||
actionInProgress: externalActionKey !== null
|
||||
})
|
||||
const actionDisabled = disabledMessage !== null
|
||||
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
|
||||
const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local'
|
||||
const projectLabel = entry.job.workdir ?? providerLabel
|
||||
const nextRunLabel = entry.job.enabled
|
||||
? formatExternalDate(entry.job.nextRunAt, relativeNow)
|
||||
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
|
||||
const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job)
|
||||
|
||||
return (
|
||||
<ContextMenu key={entry.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-current={isSelected ? 'true' : undefined}
|
||||
onClick={(event) => {
|
||||
// Why: Radix portals menus out of the row DOM, but React still
|
||||
// bubbles those clicks here — ignore so menu actions don't open detail.
|
||||
if (isPortaledRowMenuClick(event)) {
|
||||
return
|
||||
}
|
||||
onSelect(entry.key)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isRowActivationKey(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onSelect(entry.key)
|
||||
}}
|
||||
className={cn(
|
||||
AUTOMATIONS_TABLE_GRID_CLASS,
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
|
||||
)}
|
||||
>
|
||||
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
|
||||
<span className="min-w-0 truncate font-medium">{entry.job.name}</span>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
|
||||
{scheduleLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
|
||||
{nextRunLabel}
|
||||
</span>
|
||||
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
|
||||
<AutomationListStatusCell enabled={entry.job.enabled} />
|
||||
<span className="truncate text-center text-xs text-muted-foreground">
|
||||
{providerLabel}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.automations.AutomationsPage.rowActions',
|
||||
'Automation actions'
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">
|
||||
{disabledMessage ??
|
||||
translate(
|
||||
'auto.components.automations.AutomationsPage.2faecab10b',
|
||||
'Run Now'
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{entry.manager.provider === 'hermes' ? (
|
||||
<DropdownMenuItem
|
||||
disabled={!entry.manager.canManage || externalActionKey !== null}
|
||||
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.f4612e3f78',
|
||||
'Edit'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() =>
|
||||
onRequestAction(
|
||||
entry.manager,
|
||||
entry.job,
|
||||
entry.job.enabled ? 'pause' : 'resume',
|
||||
entry.scope
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.job.enabled ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
{entry.job.enabled
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.b457436d6a',
|
||||
'Pause'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationsPage.376631ef2b',
|
||||
'Resume'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={actionDisabled}
|
||||
onSelect={() =>
|
||||
onRequestAction(entry.manager, entry.job, 'delete', entry.scope)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.15e0bfb13b',
|
||||
'Delete'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">
|
||||
{disabledMessage ??
|
||||
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
{entry.manager.provider === 'hermes' ? (
|
||||
<ContextMenuItem
|
||||
disabled={!entry.manager.canManage || externalActionKey !== null}
|
||||
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem
|
||||
disabled={actionDisabled}
|
||||
onSelect={() =>
|
||||
onRequestAction(
|
||||
entry.manager,
|
||||
entry.job,
|
||||
entry.job.enabled ? 'pause' : 'resume',
|
||||
entry.scope
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.job.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
|
||||
{entry.job.enabled
|
||||
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
|
||||
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={actionDisabled}
|
||||
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
{entries.map((entry) => (
|
||||
<AutomationListExternalRow key={entry.key} entry={entry} {...rowProps} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React from 'react'
|
||||
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
|
||||
import { formatUiAutomationSchedule } from './automation-schedule-label'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getLocalExecutionHostLabel,
|
||||
getRepoExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
|
||||
import type { AutomationRowAction } from './automation-captured-owner'
|
||||
import type { AutomationHostTarget } from './automation-host-client'
|
||||
import {
|
||||
getAutomationRowLastRunSnapshot,
|
||||
getLocalAutomationLastRunSnapshot
|
||||
} from './automation-list-last-run'
|
||||
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
|
||||
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
|
||||
import { getAutomationTargetAvailability } from './automation-target-availability'
|
||||
import { getAgentLabel } from './automation-draft-model'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import {
|
||||
formatAutomationCost,
|
||||
formatAutomationTokens,
|
||||
type AutomationUsageSummary
|
||||
} from './automation-usage-model'
|
||||
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
|
||||
import {
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
LIST_TABLE_ROW_SELECTED_CLASS,
|
||||
LIST_TABLE_STICKY_ROW_CELL_CLASS
|
||||
} from '@/lib/list-table-layout'
|
||||
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
|
||||
import { AutomationListStatusCell } from './AutomationListStatusCell'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type AutomationListLocalRowProps = {
|
||||
row: AutomationListRow
|
||||
selectedRowKey: string | null | undefined
|
||||
isSelectedLocal: boolean
|
||||
lastRunByAutomationId: ReadonlyMap<string, AutomationRun>
|
||||
relativeNow: number
|
||||
repoMap: ReadonlyMap<string, Repo>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
repoForRow?: (row: AutomationListRow) => Repo | undefined
|
||||
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
|
||||
runtimeStatusByEnvironmentId: ReadonlyMap<
|
||||
string,
|
||||
{ status: RuntimeStatus | null; checkedAt: number }
|
||||
>
|
||||
hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null
|
||||
automationSourceHostAvailabilityByRowKey: ReadonlyMap<string, TaskSourceHostAvailability[]>
|
||||
hostLabelById?: ReadonlyMap<string, string>
|
||||
isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean
|
||||
onSelect: (rowKey: string) => void
|
||||
onRunNow: (row: AutomationListRow) => void
|
||||
onEdit: (row: AutomationListRow) => void
|
||||
onToggle: (row: AutomationListRow) => void
|
||||
onDelete: (row: AutomationListRow) => void
|
||||
}
|
||||
|
||||
const EMPTY_HOST_LABELS: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
function automationUsageText(summary: AutomationUsageSummary | undefined): string {
|
||||
if (!summary || summary.unavailableRuns > 0) {
|
||||
return summary?.knownRuns
|
||||
? usageAmountText(summary)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationsPage.usageUnavailable',
|
||||
'Usage unavailable'
|
||||
)
|
||||
}
|
||||
return summary.knownRuns > 0
|
||||
? usageAmountText(summary)
|
||||
: translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet')
|
||||
}
|
||||
|
||||
function usageAmountText(summary: AutomationUsageSummary): string {
|
||||
return translate(
|
||||
'auto.components.automations.AutomationsPage.runUsageSummary',
|
||||
'{{cost}} est. · {{tokens}} tokens',
|
||||
{
|
||||
cost: formatAutomationCost(summary.estimatedCostUsd),
|
||||
tokens: formatAutomationTokens(summary.totalTokens)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomationListLocalRow({
|
||||
row,
|
||||
selectedRowKey,
|
||||
isSelectedLocal,
|
||||
lastRunByAutomationId,
|
||||
relativeNow,
|
||||
repoMap,
|
||||
worktreeMap,
|
||||
repoForRow,
|
||||
worktreeForRow,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
hostTargetFor,
|
||||
automationSourceHostAvailabilityByRowKey,
|
||||
hostLabelById = EMPTY_HOST_LABELS,
|
||||
isActionEnabled,
|
||||
onSelect,
|
||||
onRunNow,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete
|
||||
}: AutomationListLocalRowProps): React.JSX.Element {
|
||||
const allows = (row: AutomationListRow, action: AutomationRowAction): boolean =>
|
||||
isActionEnabled?.(row, action) ?? true
|
||||
const { automation } = row
|
||||
const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation))
|
||||
const automationWorktree = automation.workspaceId
|
||||
? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId))
|
||||
: null
|
||||
const automationRunAvailability = getAutomationTargetAvailability({
|
||||
automation,
|
||||
repo: automationRepo,
|
||||
workspace: automationWorktree,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
automationHostTarget: hostTargetFor(row),
|
||||
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key)
|
||||
})
|
||||
const projectLabel =
|
||||
automationRepo?.displayName ??
|
||||
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
|
||||
const scheduleLabel = formatUiAutomationSchedule(automation.rrule)
|
||||
const nextRunLabel = automation.enabled
|
||||
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
|
||||
: translate('auto.components.automations.enablement.paused', 'Paused')
|
||||
const isSelected = isSelectedLocal && selectedRowKey === row.key
|
||||
const agentLabel = getAgentLabel(automation.agentId)
|
||||
const hostId =
|
||||
automation.runContext?.hostId ??
|
||||
(automationRepo ? getRepoExecutionHostId(automationRepo) : null)
|
||||
const hostLabel =
|
||||
row.hostLabel ||
|
||||
(hostId
|
||||
? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId))
|
||||
: getLocalExecutionHostLabel())
|
||||
const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}`
|
||||
const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run')
|
||||
const lastRun = lastRunByAutomationId.get(automation.id)
|
||||
// Without a fetched run, the row's projected summary carries the newest
|
||||
// retained run's status — the list never downloads run history for this.
|
||||
const lastRunSnapshot = lastRun
|
||||
? getLocalAutomationLastRunSnapshot(automation, lastRun)
|
||||
: getAutomationRowLastRunSnapshot(row)
|
||||
|
||||
const actionItems = (
|
||||
<>
|
||||
<MenuRunItem
|
||||
disabled={!canRunNow}
|
||||
label={
|
||||
automationRunAvailability.canRunNow
|
||||
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
|
||||
: automationRunAvailability.message
|
||||
}
|
||||
onSelect={() => onRunNow(row)}
|
||||
/>
|
||||
<MenuItem
|
||||
disabled={!allows(row, 'edit')}
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
|
||||
onSelect={() => onEdit(row)}
|
||||
/>
|
||||
<MenuItem
|
||||
disabled={!allows(row, 'toggle')}
|
||||
icon={automation.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
|
||||
label={
|
||||
automation.enabled
|
||||
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
|
||||
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')
|
||||
}
|
||||
onSelect={() => onToggle(row)}
|
||||
/>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
disabled={!allows(row, 'delete')}
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(row)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-automation-row-id={row.key}
|
||||
data-current={isSelected ? 'true' : undefined}
|
||||
onClick={(event) => {
|
||||
// Why: Radix portals menus out of the row DOM, but React still
|
||||
// bubbles those clicks here — ignore so menu actions don't open detail.
|
||||
if (isPortaledRowMenuClick(event)) {
|
||||
return
|
||||
}
|
||||
onSelect(row.key)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isRowActivationKey(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onSelect(row.key)
|
||||
}}
|
||||
className={cn(
|
||||
AUTOMATIONS_TABLE_GRID_CLASS,
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
|
||||
)}
|
||||
>
|
||||
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
|
||||
<span className="min-w-0 truncate font-medium">{automation.name}</span>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
|
||||
{scheduleLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
|
||||
{nextRunLabel}
|
||||
</span>
|
||||
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
|
||||
<AutomationListStatusCell enabled={automation.enabled} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
aria-label={agentTooltipLabel}
|
||||
>
|
||||
<AgentIcon agent={automation.agentId} size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{agentTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.automations.AutomationListLocalRows.c92c9463c6',
|
||||
'Automation actions'
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={!canRunNow}
|
||||
onSelect={() => {
|
||||
if (canRunNow) {
|
||||
onRunNow(row)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">
|
||||
{automationRunAvailability.canRunNow
|
||||
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
|
||||
: automationRunAvailability.message}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!allows(row, 'edit')} onSelect={() => onEdit(row)}>
|
||||
<Pencil className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!allows(row, 'toggle')} onSelect={() => onToggle(row)}>
|
||||
{automation.enabled ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
{automation.enabled
|
||||
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
|
||||
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={!allows(row, 'delete')}
|
||||
onSelect={() => onDelete(row)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">{actionItems}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuRunItem({
|
||||
disabled,
|
||||
label,
|
||||
onSelect
|
||||
}: {
|
||||
disabled: boolean
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onSelect={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onSelect()
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
disabled,
|
||||
icon,
|
||||
label,
|
||||
onSelect,
|
||||
variant
|
||||
}: {
|
||||
disabled?: boolean
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onSelect: () => void
|
||||
variant?: 'destructive'
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} variant={variant} onSelect={onSelect}>
|
||||
{icon}
|
||||
{label}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuSeparator(): React.JSX.Element {
|
||||
return <ContextMenuSeparator />
|
||||
}
|
||||
@@ -1,414 +1,20 @@
|
||||
import React from 'react'
|
||||
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
|
||||
import { formatUiAutomationSchedule } from './automation-schedule-label'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getLocalExecutionHostLabel,
|
||||
getRepoExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
|
||||
import type { AutomationRowAction } from './automation-captured-owner'
|
||||
import type { AutomationHostTarget } from './automation-host-client'
|
||||
import {
|
||||
getAutomationRowLastRunSnapshot,
|
||||
getLocalAutomationLastRunSnapshot
|
||||
} from './automation-list-last-run'
|
||||
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
|
||||
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
|
||||
import { getAutomationTargetAvailability } from './automation-target-availability'
|
||||
import { getAgentLabel } from './automation-draft-model'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import {
|
||||
formatAutomationCost,
|
||||
formatAutomationTokens,
|
||||
type AutomationUsageSummary
|
||||
} from './automation-usage-model'
|
||||
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
|
||||
import {
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
LIST_TABLE_ROW_SELECTED_CLASS,
|
||||
LIST_TABLE_STICKY_ROW_CELL_CLASS
|
||||
} from '@/lib/list-table-layout'
|
||||
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
|
||||
import { AutomationListStatusCell } from './AutomationListStatusCell'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { AutomationListLocalRow, type AutomationListLocalRowProps } from './AutomationListLocalRow'
|
||||
|
||||
export type AutomationListLocalRowsProps = {
|
||||
export type AutomationListLocalRowsProps = Omit<AutomationListLocalRowProps, 'row'> & {
|
||||
rows: readonly AutomationListRow[]
|
||||
selectedRowKey: string | null | undefined
|
||||
isSelectedLocal: boolean
|
||||
lastRunByAutomationId: ReadonlyMap<string, AutomationRun>
|
||||
relativeNow: number
|
||||
repoMap: ReadonlyMap<string, Repo>
|
||||
worktreeMap: ReadonlyMap<string, Worktree>
|
||||
repoForRow?: (row: AutomationListRow) => Repo | undefined
|
||||
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
|
||||
runtimeStatusByEnvironmentId: ReadonlyMap<
|
||||
string,
|
||||
{ status: RuntimeStatus | null; checkedAt: number }
|
||||
>
|
||||
hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null
|
||||
automationSourceHostAvailabilityByRowKey: ReadonlyMap<string, TaskSourceHostAvailability[]>
|
||||
hostLabelById?: ReadonlyMap<string, string>
|
||||
isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean
|
||||
onSelect: (rowKey: string) => void
|
||||
onRunNow: (row: AutomationListRow) => void
|
||||
onEdit: (row: AutomationListRow) => void
|
||||
onToggle: (row: AutomationListRow) => void
|
||||
onDelete: (row: AutomationListRow) => void
|
||||
}
|
||||
|
||||
const EMPTY_HOST_LABELS: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
function automationUsageText(summary: AutomationUsageSummary | undefined): string {
|
||||
if (!summary || summary.unavailableRuns > 0) {
|
||||
return summary?.knownRuns
|
||||
? usageAmountText(summary)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationsPage.usageUnavailable',
|
||||
'Usage unavailable'
|
||||
)
|
||||
}
|
||||
return summary.knownRuns > 0
|
||||
? usageAmountText(summary)
|
||||
: translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet')
|
||||
}
|
||||
|
||||
function usageAmountText(summary: AutomationUsageSummary): string {
|
||||
return translate(
|
||||
'auto.components.automations.AutomationsPage.runUsageSummary',
|
||||
'{{cost}} est. · {{tokens}} tokens',
|
||||
{
|
||||
cost: formatAutomationCost(summary.estimatedCostUsd),
|
||||
tokens: formatAutomationTokens(summary.totalTokens)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomationListLocalRows({
|
||||
rows,
|
||||
selectedRowKey,
|
||||
isSelectedLocal,
|
||||
lastRunByAutomationId,
|
||||
relativeNow,
|
||||
repoMap,
|
||||
worktreeMap,
|
||||
repoForRow,
|
||||
worktreeForRow,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
hostTargetFor,
|
||||
automationSourceHostAvailabilityByRowKey,
|
||||
hostLabelById = EMPTY_HOST_LABELS,
|
||||
isActionEnabled,
|
||||
onSelect,
|
||||
onRunNow,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete
|
||||
...rowProps
|
||||
}: AutomationListLocalRowsProps): React.JSX.Element {
|
||||
const allows = (row: AutomationListRow, action: AutomationRowAction): boolean =>
|
||||
isActionEnabled?.(row, action) ?? true
|
||||
return (
|
||||
<>
|
||||
{rows.map((row) => {
|
||||
const { automation } = row
|
||||
const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation))
|
||||
const automationWorktree = automation.workspaceId
|
||||
? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId))
|
||||
: null
|
||||
const automationRunAvailability = getAutomationTargetAvailability({
|
||||
automation,
|
||||
repo: automationRepo,
|
||||
workspace: automationWorktree,
|
||||
projectHostSetups,
|
||||
sshConnectionStates,
|
||||
runtimeStatusByEnvironmentId,
|
||||
automationHostTarget: hostTargetFor(row),
|
||||
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key)
|
||||
})
|
||||
const projectLabel =
|
||||
automationRepo?.displayName ??
|
||||
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
|
||||
const scheduleLabel = formatUiAutomationSchedule(automation.rrule)
|
||||
const nextRunLabel = automation.enabled
|
||||
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
|
||||
: translate('auto.components.automations.enablement.paused', 'Paused')
|
||||
const isSelected = isSelectedLocal && selectedRowKey === row.key
|
||||
const agentLabel = getAgentLabel(automation.agentId)
|
||||
const hostId =
|
||||
automation.runContext?.hostId ??
|
||||
(automationRepo ? getRepoExecutionHostId(automationRepo) : null)
|
||||
const hostLabel =
|
||||
row.hostLabel ||
|
||||
(hostId
|
||||
? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId))
|
||||
: getLocalExecutionHostLabel())
|
||||
const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}`
|
||||
const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run')
|
||||
const lastRun = lastRunByAutomationId.get(automation.id)
|
||||
// Without a fetched run, the row's projected summary carries the newest
|
||||
// retained run's status — the list never downloads run history for this.
|
||||
const lastRunSnapshot = lastRun
|
||||
? getLocalAutomationLastRunSnapshot(automation, lastRun)
|
||||
: getAutomationRowLastRunSnapshot(row)
|
||||
|
||||
const actionItems = (
|
||||
<>
|
||||
<MenuRunItem
|
||||
disabled={!canRunNow}
|
||||
label={
|
||||
automationRunAvailability.canRunNow
|
||||
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
|
||||
: automationRunAvailability.message
|
||||
}
|
||||
onSelect={() => onRunNow(row)}
|
||||
/>
|
||||
<MenuItem
|
||||
disabled={!allows(row, 'edit')}
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
|
||||
onSelect={() => onEdit(row)}
|
||||
/>
|
||||
<MenuItem
|
||||
disabled={!allows(row, 'toggle')}
|
||||
icon={
|
||||
automation.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />
|
||||
}
|
||||
label={
|
||||
automation.enabled
|
||||
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
|
||||
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')
|
||||
}
|
||||
onSelect={() => onToggle(row)}
|
||||
/>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
disabled={!allows(row, 'delete')}
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(row)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<ContextMenu key={row.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-automation-row-id={row.key}
|
||||
data-current={isSelected ? 'true' : undefined}
|
||||
onClick={(event) => {
|
||||
// Why: Radix portals menus out of the row DOM, but React still
|
||||
// bubbles those clicks here — ignore so menu actions don't open detail.
|
||||
if (isPortaledRowMenuClick(event)) {
|
||||
return
|
||||
}
|
||||
onSelect(row.key)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isRowActivationKey(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onSelect(row.key)
|
||||
}}
|
||||
className={cn(
|
||||
AUTOMATIONS_TABLE_GRID_CLASS,
|
||||
LIST_TABLE_ROW_CLASS,
|
||||
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
|
||||
)}
|
||||
>
|
||||
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
|
||||
<span className="min-w-0 truncate font-medium">{automation.name}</span>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
|
||||
{scheduleLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
|
||||
{nextRunLabel}
|
||||
</span>
|
||||
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
|
||||
<AutomationListStatusCell enabled={automation.enabled} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
aria-label={agentTooltipLabel}
|
||||
>
|
||||
<AgentIcon agent={automation.agentId} size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{agentTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-label={translate(
|
||||
'auto.components.automations.AutomationListLocalRows.c92c9463c6',
|
||||
'Automation actions'
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={!canRunNow}
|
||||
onSelect={() => {
|
||||
if (canRunNow) {
|
||||
onRunNow(row)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">
|
||||
{automationRunAvailability.canRunNow
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.2faecab10b',
|
||||
'Run Now'
|
||||
)
|
||||
: automationRunAvailability.message}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!allows(row, 'edit')} onSelect={() => onEdit(row)}>
|
||||
<Pencil className="size-3.5" />
|
||||
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!allows(row, 'toggle')}
|
||||
onSelect={() => onToggle(row)}
|
||||
>
|
||||
{automation.enabled ? (
|
||||
<Pause className="size-3.5" />
|
||||
) : (
|
||||
<Play className="size-3.5" />
|
||||
)}
|
||||
{automation.enabled
|
||||
? translate(
|
||||
'auto.components.automations.AutomationsPage.b457436d6a',
|
||||
'Pause'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationsPage.376631ef2b',
|
||||
'Resume'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={!allows(row, 'delete')}
|
||||
onSelect={() => onDelete(row)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.automations.AutomationsPage.15e0bfb13b',
|
||||
'Delete'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">{actionItems}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
{rows.map((row) => (
|
||||
<AutomationListLocalRow key={row.key} row={row} {...rowProps} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuRunItem({
|
||||
disabled,
|
||||
label,
|
||||
onSelect
|
||||
}: {
|
||||
disabled: boolean
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onSelect={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onSelect()
|
||||
}}
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
disabled,
|
||||
icon,
|
||||
label,
|
||||
onSelect,
|
||||
variant
|
||||
}: {
|
||||
disabled?: boolean
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onSelect: () => void
|
||||
variant?: 'destructive'
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} variant={variant} onSelect={onSelect}>
|
||||
{icon}
|
||||
{label}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuSeparator(): React.JSX.Element {
|
||||
return <ContextMenuSeparator />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AutomationListSort, AutomationListSortField } from './automation-list-view'
|
||||
|
||||
export function AutomationListSortHeader({
|
||||
field,
|
||||
label,
|
||||
sort,
|
||||
onSort
|
||||
}: {
|
||||
field: AutomationListSortField
|
||||
label: string
|
||||
sort: AutomationListSort | null
|
||||
onSort: (field: AutomationListSortField) => void
|
||||
}): React.JSX.Element {
|
||||
const active = sort?.field === field
|
||||
const direction = active ? sort.direction : null
|
||||
// Why: one interpolated key per direction — word order and punctuation around
|
||||
// the column name differ per language.
|
||||
const sortedLabel =
|
||||
direction === 'asc'
|
||||
? translate(
|
||||
'auto.components.automations.AutomationListSortHeader.sortedAscending',
|
||||
'{{value0}}, sorted ascending',
|
||||
{ value0: label }
|
||||
)
|
||||
: direction === 'desc'
|
||||
? translate(
|
||||
'auto.components.automations.AutomationListSortHeader.sortedDescending',
|
||||
'{{value0}}, sorted descending',
|
||||
{ value0: label }
|
||||
)
|
||||
: null
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(field)}
|
||||
aria-label={sortedLabel ?? label}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 rounded-sm text-left text-[11px] font-medium tracking-[0.08em] uppercase select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
|
||||
active && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
{direction === 'asc' ? <ArrowUp aria-hidden="true" className="size-3 shrink-0" /> : null}
|
||||
{direction === 'desc' ? <ArrowDown aria-hidden="true" className="size-3 shrink-0" /> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AutomationListTableHeader } from './AutomationListTableHeader'
|
||||
import {
|
||||
LIST_TABLE_HEADER_CLASS,
|
||||
@@ -43,3 +44,45 @@ describe('AutomationListTableHeader', () => {
|
||||
expect(nameCell.className).toBe(LIST_TABLE_STICKY_HEADER_CELL_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutomationListTableHeader sorting', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('exposes only the orderable columns as buttons', () => {
|
||||
render(<AutomationListTableHeader sort={null} onSort={() => {}} />)
|
||||
|
||||
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
|
||||
'Name',
|
||||
'Last run'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports the sorted column and direction in the accessible name', () => {
|
||||
const { rerender } = render(
|
||||
<AutomationListTableHeader sort={{ field: 'name', direction: 'asc' }} onSort={() => {}} />
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Name, sorted ascending' })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Last run' })).toBeDefined()
|
||||
|
||||
rerender(
|
||||
<AutomationListTableHeader sort={{ field: 'lastRun', direction: 'desc' }} onSort={() => {}} />
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Last run, sorted descending' })).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: 'Name' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('requests a sort for the clicked column', async () => {
|
||||
const onSort = vi.fn()
|
||||
render(<AutomationListTableHeader sort={null} onSort={onSort} />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Last run' }))
|
||||
|
||||
expect(onSort.mock.calls).toEqual([['lastRun']])
|
||||
})
|
||||
|
||||
it('stays non-interactive when the list cannot be sorted', () => {
|
||||
render(<AutomationListTableHeader />)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,34 +5,85 @@ import {
|
||||
LIST_TABLE_HEADER_CLASS,
|
||||
LIST_TABLE_STICKY_HEADER_CELL_CLASS
|
||||
} from '@/lib/list-table-layout'
|
||||
import { AutomationListSortHeader } from './AutomationListSortHeader'
|
||||
import type { AutomationListSort, AutomationListSortField } from './automation-list-view'
|
||||
|
||||
export function AutomationListTableHeader(): React.JSX.Element {
|
||||
const labels = [
|
||||
['auto.components.automations.AutomationsPage.tableName', 'Name'],
|
||||
['auto.components.automations.AutomationDetail.18763ded26', 'Schedule'],
|
||||
['auto.components.automations.AutomationsPage.tableProject', 'Project'],
|
||||
['auto.components.automations.AutomationsPage.tableHost', 'Host'],
|
||||
['auto.components.automations.AutomationDetail.578ff46987', 'Next run'],
|
||||
['auto.components.automations.AutomationsPage.tableLastRun', 'Last run'],
|
||||
['auto.components.automations.AutomationsPage.tableStatus', 'Status'],
|
||||
['auto.components.automations.AutomationDetail.2df8970cd5', 'Agent']
|
||||
] as const
|
||||
type HeaderColumn = {
|
||||
key: string
|
||||
fallback: string
|
||||
/** Absent for columns the list cannot order by. */
|
||||
sortField?: AutomationListSortField
|
||||
}
|
||||
|
||||
const COLUMNS: readonly HeaderColumn[] = [
|
||||
{
|
||||
key: 'auto.components.automations.AutomationsPage.tableName',
|
||||
fallback: 'Name',
|
||||
sortField: 'name'
|
||||
},
|
||||
{
|
||||
key: 'auto.components.automations.AutomationDetail.18763ded26',
|
||||
fallback: 'Schedule'
|
||||
},
|
||||
{
|
||||
key: 'auto.components.automations.AutomationsPage.tableProject',
|
||||
fallback: 'Project'
|
||||
},
|
||||
{
|
||||
key: 'auto.components.automations.AutomationsPage.tableHost',
|
||||
fallback: 'Host'
|
||||
},
|
||||
{
|
||||
key: 'auto.components.automations.AutomationDetail.578ff46987',
|
||||
fallback: 'Next run'
|
||||
},
|
||||
{
|
||||
key: 'auto.components.automations.AutomationsPage.tableLastRun',
|
||||
fallback: 'Last run',
|
||||
sortField: 'lastRun'
|
||||
},
|
||||
{
|
||||
key: 'auto.components.automations.AutomationsPage.tableStatus',
|
||||
fallback: 'Status'
|
||||
},
|
||||
{
|
||||
key: 'auto.components.automations.AutomationDetail.2df8970cd5',
|
||||
fallback: 'Agent'
|
||||
}
|
||||
]
|
||||
|
||||
export function AutomationListTableHeader({
|
||||
sort = null,
|
||||
onSort
|
||||
}: {
|
||||
sort?: AutomationListSort | null
|
||||
onSort?: (field: AutomationListSortField) => void
|
||||
} = {}): React.JSX.Element {
|
||||
return (
|
||||
<div className={`${AUTOMATIONS_TABLE_GRID_CLASS} ${LIST_TABLE_HEADER_CLASS}`}>
|
||||
{labels.map(([key, fallback], index) => (
|
||||
<span
|
||||
key={key}
|
||||
className={
|
||||
index === 0
|
||||
? LIST_TABLE_STICKY_HEADER_CELL_CLASS
|
||||
: index === labels.length - 1
|
||||
? 'text-center'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{translate(key, fallback)}
|
||||
</span>
|
||||
))}
|
||||
{COLUMNS.map((column, index) => {
|
||||
const label = translate(column.key, column.fallback)
|
||||
const className =
|
||||
index === 0
|
||||
? LIST_TABLE_STICKY_HEADER_CELL_CLASS
|
||||
: index === COLUMNS.length - 1
|
||||
? 'text-center'
|
||||
: undefined
|
||||
return (
|
||||
<span key={column.key} className={className}>
|
||||
{column.sortField && onSort ? (
|
||||
<AutomationListSortHeader
|
||||
field={column.sortField}
|
||||
label={label}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
/>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<span className="sr-only">
|
||||
{translate('auto.components.automations.AutomationsPage.tableActions', 'Actions')}
|
||||
</span>
|
||||
|
||||
@@ -11,7 +11,12 @@ import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AutomationsListPanel } from './AutomationsListPanel'
|
||||
import { EMPTY_AUTOMATION_LIST_FILTER } from './automation-list-view'
|
||||
import {
|
||||
buildAutomationListViewItems,
|
||||
EMPTY_AUTOMATION_LIST_FILTER,
|
||||
type AutomationListSort,
|
||||
type AutomationListSortField
|
||||
} from './automation-list-view'
|
||||
import type { AutomationHostCatalogView } from './use-automation-host-catalog'
|
||||
import {
|
||||
makeAutomation,
|
||||
@@ -49,7 +54,13 @@ const HOST_CATALOG = {
|
||||
status: 'all',
|
||||
announceFallback: false
|
||||
},
|
||||
rows: { rows: [], automations: [], capturedOwners: new Map(), groups: [], answered: true },
|
||||
rows: {
|
||||
rows: [],
|
||||
automations: [],
|
||||
capturedOwners: new Map(),
|
||||
groups: [],
|
||||
answered: true
|
||||
},
|
||||
loadCounts: { failedHostCount: 0, totalHostCount: 1 },
|
||||
selectHost: () => undefined,
|
||||
recover: () => undefined,
|
||||
@@ -70,6 +81,8 @@ function renderPanel(
|
||||
selectExternalKey?: (key: string | null) => void
|
||||
externalEntries?: readonly ExternalAutomationListEntry[]
|
||||
setActivePaneTab?: (tab: AutomationPaneTab) => void
|
||||
listSort?: AutomationListSort | null
|
||||
onListSortChange?: (field: AutomationListSortField) => void
|
||||
} = {}
|
||||
): void {
|
||||
const externalEntries = options.externalEntries ?? []
|
||||
@@ -95,8 +108,12 @@ function renderPanel(
|
||||
externalManagersUncheckedNotice={uncheckedNotice}
|
||||
onSelectHost={() => undefined}
|
||||
onRecoverHost={() => undefined}
|
||||
filteredRows={rows}
|
||||
filteredExternalAutomationEntries={externalEntries}
|
||||
sortedListItems={buildAutomationListViewItems({
|
||||
rows,
|
||||
externalEntries
|
||||
})}
|
||||
listSort={options.listSort ?? null}
|
||||
onListSortChange={options.onListSortChange ?? (() => undefined)}
|
||||
selectedRowKey={options.selectedRowKey ?? null}
|
||||
selectedExternalKey={options.selectedExternalKey ?? null}
|
||||
relativeNow={0}
|
||||
@@ -221,7 +238,11 @@ describe('AutomationsListPanel enter key navigation', () => {
|
||||
const input = searchField()
|
||||
expect(input).not.toBeNull()
|
||||
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||
const enter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
input?.dispatchEvent(enter)
|
||||
|
||||
expect(enter.defaultPrevented).toBe(true)
|
||||
@@ -252,7 +273,11 @@ describe('AutomationsListPanel enter key navigation', () => {
|
||||
const input = searchField()
|
||||
expect(input).not.toBeNull()
|
||||
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||
const enter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
input?.dispatchEvent(enter)
|
||||
|
||||
expect(enter.defaultPrevented).toBe(true)
|
||||
@@ -272,7 +297,11 @@ describe('AutomationsListPanel enter key navigation', () => {
|
||||
const input = searchField()
|
||||
expect(input).not.toBeNull()
|
||||
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
||||
const enter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
input?.dispatchEvent(enter)
|
||||
|
||||
expect(detailOpened).toBe(false)
|
||||
|
||||
@@ -23,13 +23,19 @@ import {
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import type { AutomationPaneTab } from './automation-page-state'
|
||||
import { AutomationListFilterPills } from './AutomationListFilterMenu'
|
||||
import { isAutomationListFilterActive, type AutomationListFilter } from './automation-list-view'
|
||||
import {
|
||||
isAutomationListFilterActive,
|
||||
type AutomationListFilter,
|
||||
type AutomationListSort,
|
||||
type AutomationListSortField,
|
||||
type AutomationListViewItem
|
||||
} from './automation-list-view'
|
||||
import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter'
|
||||
import type { AutomationTemplate } from './automation-templates'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
import type { ExternalAutomationScope } from './external-automation-scope-client'
|
||||
import { AutomationListLocalRows } from './AutomationListLocalRows'
|
||||
import { AutomationListExternalRows } from './AutomationListExternalRows'
|
||||
import { AutomationListLocalRow } from './AutomationListLocalRow'
|
||||
import { AutomationListExternalRow } from './AutomationListExternalRow'
|
||||
import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice'
|
||||
import { AutomationListEmptyView } from './AutomationListEmptyView'
|
||||
import { resolveAutomationListEmptyState } from './automation-list-empty-state'
|
||||
@@ -63,8 +69,10 @@ type AutomationsListPanelProps = {
|
||||
action: AutomationHostRecoveryAction,
|
||||
entry?: AutomationHostCatalogEntry | null
|
||||
) => void
|
||||
filteredRows: readonly AutomationListRow[]
|
||||
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
|
||||
/** Both collections as one list in render order; the sort spans local and external rows. */
|
||||
sortedListItems: readonly AutomationListViewItem[]
|
||||
listSort: AutomationListSort | null
|
||||
onListSortChange: (field: AutomationListSortField) => void
|
||||
selectedRowKey: string | null
|
||||
selectedExternalKey: string | null
|
||||
selectedExternal?: ExternalAutomationListEntry | null
|
||||
@@ -124,8 +132,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
externalManagersUncheckedNotice,
|
||||
onSelectHost,
|
||||
onRecoverHost,
|
||||
filteredRows,
|
||||
filteredExternalAutomationEntries,
|
||||
sortedListItems,
|
||||
listSort,
|
||||
onListSortChange,
|
||||
selectedRowKey,
|
||||
selectedExternalKey,
|
||||
relativeNow,
|
||||
@@ -161,18 +170,20 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
// Hosts moved into the Filters menu, so its toolbar row is the focus fallback now.
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const pendingKeyboardScrollRef = useRef(false)
|
||||
const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows])
|
||||
const visibleItems = React.useMemo(
|
||||
() => [
|
||||
...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })),
|
||||
...filteredExternalAutomationEntries.map((entry) => ({
|
||||
kind: 'external' as const,
|
||||
id: entry.key
|
||||
}))
|
||||
],
|
||||
[filteredExternalAutomationEntries, filteredRows]
|
||||
// Why: keyboard traversal and focus recovery read render order, which the sort owns.
|
||||
const rowKeys = React.useMemo(
|
||||
() => sortedListItems.filter((item) => item.kind === 'local').map((item) => item.id),
|
||||
[sortedListItems]
|
||||
)
|
||||
useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: toolbarRef })
|
||||
const visibleItems = React.useMemo(
|
||||
() => sortedListItems.map((item) => ({ kind: item.kind, id: item.id })),
|
||||
[sortedListItems]
|
||||
)
|
||||
useAutomationListFocusRecovery({
|
||||
rowKeys,
|
||||
containerRef: listRef,
|
||||
fallbackRef: toolbarRef
|
||||
})
|
||||
const handleSearchArrowNavigate = React.useCallback(
|
||||
(key: AutomationListArrowKey) => {
|
||||
const next = getAutomationListArrowNavigationTarget({
|
||||
@@ -331,24 +342,30 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
|
||||
>
|
||||
{hasFilteredListItems ? (
|
||||
<div className="min-w-full w-fit">
|
||||
<AutomationListTableHeader />
|
||||
<AutomationListTableHeader sort={listSort} onSort={onListSortChange} />
|
||||
<div className="divide-y divide-border/50">
|
||||
<AutomationListLocalRows {...rowProps} rows={filteredRows} />
|
||||
<AutomationListExternalRows
|
||||
entries={filteredExternalAutomationEntries}
|
||||
selectedExternalKey={selectedExternalKey}
|
||||
relativeNow={relativeNow}
|
||||
sshConnectionStates={sshConnectionStates}
|
||||
externalActionKey={externalActionKey}
|
||||
onSelect={(entryKey) => {
|
||||
selectAutomationRow(null)
|
||||
selectExternalKey(entryKey)
|
||||
setActivePaneTab('overview')
|
||||
onOpenDetail()
|
||||
}}
|
||||
onRequestAction={requestExternalAction}
|
||||
onEdit={openEditExternalDialog}
|
||||
/>
|
||||
{sortedListItems.map((item) =>
|
||||
item.kind === 'local' ? (
|
||||
<AutomationListLocalRow key={item.id} {...rowProps} row={item.row} />
|
||||
) : (
|
||||
<AutomationListExternalRow
|
||||
key={item.id}
|
||||
entry={item.entry}
|
||||
selectedExternalKey={selectedExternalKey}
|
||||
relativeNow={relativeNow}
|
||||
sshConnectionStates={sshConnectionStates}
|
||||
externalActionKey={externalActionKey}
|
||||
onSelect={(entryKey) => {
|
||||
selectAutomationRow(null)
|
||||
selectExternalKey(entryKey)
|
||||
setActivePaneTab('overview')
|
||||
onOpenDetail()
|
||||
}}
|
||||
onRequestAction={requestExternalAction}
|
||||
onEdit={openEditExternalDialog}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
addRuntimeProject,
|
||||
api,
|
||||
installAutomationsPageHarness,
|
||||
listedRow,
|
||||
mocks,
|
||||
renderPage,
|
||||
runtimeHost,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
scopedList,
|
||||
settleHostQueries
|
||||
} from './automations-page-test-harness'
|
||||
import { listedRow } from './automations-page-listed-items'
|
||||
import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
|
||||
+3
-6
@@ -22,6 +22,7 @@ import {
|
||||
SELF_PRECONDITION,
|
||||
settleHostQueries
|
||||
} from './automations-page-test-harness'
|
||||
import { listedRows } from './automations-page-listed-items'
|
||||
import { makeAutomation } from './automations-page-fixtures'
|
||||
|
||||
installAutomationsPageHarness()
|
||||
@@ -36,9 +37,7 @@ async function collidingHosts(): Promise<void> {
|
||||
}
|
||||
|
||||
function selectDesktopRow(): string {
|
||||
const row = mocks.listPanel?.filteredRows.find(
|
||||
(candidate) => candidate.automation.name === 'Desktop nightly'
|
||||
)
|
||||
const row = listedRows().find((candidate) => candidate.automation.name === 'Desktop nightly')
|
||||
expect(row).toBeDefined()
|
||||
return row?.key ?? ''
|
||||
}
|
||||
@@ -58,9 +57,7 @@ describe('AutomationsPage row actions under a colliding automation id', () => {
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
|
||||
const remote = mocks.listPanel?.filteredRows.find(
|
||||
(candidate) => candidate.automation.name === 'Remote nightly'
|
||||
)
|
||||
const remote = listedRows().find((candidate) => candidate.automation.name === 'Remote nightly')
|
||||
await act(async () => {
|
||||
mocks.listPanel?.selectAutomationRow(remote?.key ?? '')
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
RUNTIME_SELF_FILTER,
|
||||
settleHostQueries
|
||||
} from './automations-page-test-harness'
|
||||
import { listedExternalEntries } from './automations-page-listed-items'
|
||||
import { makeExternalManager } from './automations-page-fixtures'
|
||||
|
||||
installAutomationsPageHarness()
|
||||
@@ -117,7 +118,7 @@ describe('AutomationsPage external manager probes', () => {
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
|
||||
expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([])
|
||||
expect(listedExternalEntries()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the previous host rows when the selection moves, not when the new probe lands', async () => {
|
||||
@@ -127,7 +128,7 @@ describe('AutomationsPage external manager probes', () => {
|
||||
|
||||
const { rerender } = await renderPage()
|
||||
await settleHostQueries()
|
||||
expect(mocks.listPanel?.filteredExternalAutomationEntries.length).toBeGreaterThan(0)
|
||||
expect(listedExternalEntries().length).toBeGreaterThan(0)
|
||||
|
||||
// The new host never answers, so anything still listed belongs to the old one.
|
||||
api.automations.listExternalManagerForOwner.mockImplementation(
|
||||
@@ -137,7 +138,7 @@ describe('AutomationsPage external manager probes', () => {
|
||||
await rerender()
|
||||
await settleHostQueries()
|
||||
|
||||
expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([])
|
||||
expect(listedExternalEntries()).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a host it could not check rather than showing it as clean', async () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
addRuntimeProject,
|
||||
api,
|
||||
installAutomationsPageHarness,
|
||||
listedRow,
|
||||
mocks,
|
||||
renderPage,
|
||||
runtimeHost,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
scopedList,
|
||||
settleHostQueries
|
||||
} from './automations-page-test-harness'
|
||||
import { listedRow } from './automations-page-listed-items'
|
||||
import { makeAutomation } from './automations-page-fixtures'
|
||||
|
||||
installAutomationsPageHarness()
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SELF_PRECONDITION,
|
||||
settleHostQueries
|
||||
} from './automations-page-test-harness'
|
||||
import { listedRows } from './automations-page-listed-items'
|
||||
import { makeAutomation, makeRun } from './automations-page-fixtures'
|
||||
|
||||
installAutomationsPageHarness()
|
||||
@@ -69,7 +70,7 @@ describe('AutomationsPage refresh', () => {
|
||||
|
||||
await renderPage()
|
||||
|
||||
expect(mocks.listPanel?.filteredRows[0]?.usageSummary).toEqual(usageSummary)
|
||||
expect(listedRows()[0]?.usageSummary).toEqual(usageSummary)
|
||||
})
|
||||
|
||||
it('does not re-list through the active runtime just because one is selected', async () => {
|
||||
@@ -231,9 +232,7 @@ describe('AutomationsPage multi-host selection', () => {
|
||||
)
|
||||
).toEqual(['Desktop nightly', 'Remote nightly'])
|
||||
|
||||
const remote = mocks.listPanel?.filteredRows.find(
|
||||
(row) => row.automation.name === 'Remote nightly'
|
||||
)
|
||||
const remote = listedRows().find((row) => row.automation.name === 'Remote nightly')
|
||||
await act(async () => {
|
||||
mocks.listPanel?.selectAutomationRow(remote?.key ?? '')
|
||||
})
|
||||
|
||||
@@ -16,12 +16,12 @@ import type { Automation } from '../../../../shared/automations-types'
|
||||
import {
|
||||
api,
|
||||
installAutomationsPageHarness,
|
||||
listedRow,
|
||||
mocks,
|
||||
renderPage,
|
||||
scopedList,
|
||||
settleHostQueries
|
||||
} from './automations-page-test-harness'
|
||||
import { listedRow, listedRows } from './automations-page-listed-items'
|
||||
import { makeAutomation } from './automations-page-fixtures'
|
||||
|
||||
installAutomationsPageHarness()
|
||||
@@ -42,7 +42,7 @@ function desktopStoreHolds(automations: Automation[]): void {
|
||||
|
||||
/** The next-run column reads this; the mocked list panel renders only names. */
|
||||
function listedNextRunAt(): number | null | undefined {
|
||||
return mocks.listPanel?.filteredRows[0]?.automation.nextRunAt
|
||||
return listedRows()[0]?.automation.nextRunAt
|
||||
}
|
||||
|
||||
describe('AutomationsPage run visibility', () => {
|
||||
|
||||
@@ -24,13 +24,13 @@ import {
|
||||
api,
|
||||
DESKTOP_SELF_OWNER,
|
||||
installAutomationsPageHarness,
|
||||
listedRow,
|
||||
mocks,
|
||||
renderPage,
|
||||
rows,
|
||||
scopedList,
|
||||
SELF_PRECONDITION
|
||||
} from './automations-page-test-harness'
|
||||
import { listedRow, listedExternalEntries } from './automations-page-listed-items'
|
||||
import {
|
||||
makeAutomation,
|
||||
makeExternalManager,
|
||||
@@ -147,7 +147,7 @@ describe('AutomationsPage list rendering', () => {
|
||||
api.automations.updateExternalForOwner.mockResolvedValue(undefined)
|
||||
|
||||
await renderPage()
|
||||
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
|
||||
const entry = listedExternalEntries()[0]
|
||||
if (!entry) {
|
||||
throw new Error('no external entry to edit')
|
||||
}
|
||||
@@ -177,7 +177,7 @@ describe('AutomationsPage list rendering', () => {
|
||||
api.automations.runExternalActionForOwner.mockResolvedValue(undefined)
|
||||
|
||||
await renderPage()
|
||||
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
|
||||
const entry = listedExternalEntries()[0]
|
||||
if (!entry) {
|
||||
throw new Error('no external entry to act on')
|
||||
}
|
||||
@@ -217,7 +217,7 @@ describe('AutomationsPage list rendering', () => {
|
||||
api.automations.listExternalRunsForOwner.mockResolvedValue({ runs: [], total: 0 })
|
||||
|
||||
const { container } = await renderPage()
|
||||
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
|
||||
const entry = listedExternalEntries()[0]
|
||||
if (!entry) {
|
||||
throw new Error('no external entry to read runs for')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import type { AutomationsPageController } from './use-automations-page-controller'
|
||||
import { AutomationsListPanel } from './AutomationsListPanel'
|
||||
import { nextAutomationListSort } from './automation-list-view'
|
||||
|
||||
export function AutomationsPageListPanel({
|
||||
controller,
|
||||
@@ -45,8 +46,6 @@ export function AutomationsPageListPanel({
|
||||
hasListItems,
|
||||
hasFilteredListItems,
|
||||
isListSearchQueryTooLarge,
|
||||
filteredRows,
|
||||
filteredExternalAutomationEntries,
|
||||
selectedRow,
|
||||
selectedExternal,
|
||||
searchCounts
|
||||
@@ -79,8 +78,9 @@ export function AutomationsPageListPanel({
|
||||
void pageRefresh.refresh()
|
||||
}
|
||||
}}
|
||||
filteredRows={filteredRows}
|
||||
filteredExternalAutomationEntries={filteredExternalAutomationEntries}
|
||||
sortedListItems={list.sortedListItems}
|
||||
listSort={local.listSort}
|
||||
onListSortChange={(field) => local.setListSort(nextAutomationListSort(local.listSort, field))}
|
||||
selectedRowKey={selectedRow?.key ?? null}
|
||||
selectedExternalKey={local.selectedExternalKey}
|
||||
selectedExternal={selectedExternal}
|
||||
|
||||
@@ -5,32 +5,34 @@ import {
|
||||
type AutomationListSort,
|
||||
type AutomationListViewItem
|
||||
} from './automation-list-view'
|
||||
import { unscopedAutomationListRows } from './automation-list-row-identity'
|
||||
import { makeAutomation } from './automations-page-fixtures'
|
||||
|
||||
const locale = vi.hoisted(() => ({ value: 'en' }))
|
||||
vi.mock('@/i18n/i18n', () => ({ getIntlLocale: () => locale.value }))
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
locale.value = 'en'
|
||||
})
|
||||
|
||||
function rows(count = 512): AutomationListViewItem[] {
|
||||
function items(count = 512): AutomationListViewItem[] {
|
||||
const names = ['Alpha', 'álpha', 'Ångström', 'Zebra', 'Örebro', 'I', 'ı', 'İ', 'job 10', 'job 2']
|
||||
return buildAutomationListViewItems({
|
||||
automations: Array.from({ length: count }, (_, index) =>
|
||||
makeAutomation({ id: `job-${index}`, name: names[(index * 7) % names.length] })
|
||||
rows: unscopedAutomationListRows(
|
||||
Array.from({ length: count }, (_, index) =>
|
||||
makeAutomation({
|
||||
id: `job-${index}`,
|
||||
name: names[(index * 7) % names.length]
|
||||
})
|
||||
)
|
||||
),
|
||||
externalEntries: [],
|
||||
runs: []
|
||||
externalEntries: []
|
||||
})
|
||||
}
|
||||
|
||||
function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort) {
|
||||
/** The pre-collator comparator, resolving options on every comparison. */
|
||||
function previousOrder(list: AutomationListViewItem[], sort: AutomationListSort, locale: string) {
|
||||
function compare(left: AutomationListViewItem, right: AutomationListViewItem) {
|
||||
const value =
|
||||
sort.field === 'name'
|
||||
? left.name.localeCompare(right.name, locale.value, { sensitivity: 'base' })
|
||||
? left.name.localeCompare(right.name, locale, { sensitivity: 'base' })
|
||||
: (left.lastRunAt ?? 0) - (right.lastRunAt ?? 0)
|
||||
return value !== 0
|
||||
? sort.direction === 'asc'
|
||||
@@ -38,37 +40,35 @@ function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort
|
||||
: -value
|
||||
: left.id.localeCompare(right.id)
|
||||
}
|
||||
return [...items].sort(compare)
|
||||
return [...list].sort(compare)
|
||||
}
|
||||
|
||||
describe('automation list collation', () => {
|
||||
it.each(['en', 'sv', 'tr', 'ja'])(
|
||||
'preserves %s ordering, tie-breaks and input identity',
|
||||
(language) => {
|
||||
locale.value = language
|
||||
const items = rows()
|
||||
const original = [...items]
|
||||
(locale) => {
|
||||
const list = items()
|
||||
const original = [...list]
|
||||
for (const direction of ['asc', 'desc'] as const) {
|
||||
const sort = { field: 'name', direction } as const
|
||||
const expected = previousOrder(items, sort)
|
||||
const result = sortAutomationListViewItems(items, sort)
|
||||
const expected = previousOrder(list, sort, locale)
|
||||
const result = sortAutomationListViewItems(list, sort, locale)
|
||||
expect(result).toEqual(expected)
|
||||
expect(result.every((row, index) => row === expected[index])).toBe(true)
|
||||
}
|
||||
expect(items).toEqual(original)
|
||||
expect(list).toEqual(original)
|
||||
}
|
||||
)
|
||||
|
||||
it('resolves collation once per name sort and responds to locale changes', () => {
|
||||
const items = rows()
|
||||
it('resolves collation once per name sort and follows the locale it is given', () => {
|
||||
const list = items()
|
||||
const OriginalCollator = Intl.Collator
|
||||
const construct = vi.spyOn(Intl, 'Collator').mockImplementation(function (locales, options) {
|
||||
return new OriginalCollator(locales, options)
|
||||
})
|
||||
const compare = vi.spyOn(String.prototype, 'localeCompare')
|
||||
sortAutomationListViewItems(items, { field: 'name', direction: 'asc' })
|
||||
locale.value = 'sv'
|
||||
sortAutomationListViewItems(items, { field: 'name', direction: 'desc' })
|
||||
sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en')
|
||||
sortAutomationListViewItems(list, { field: 'name', direction: 'desc' }, 'sv')
|
||||
expect(construct.mock.calls).toEqual([
|
||||
['en', { sensitivity: 'base' }],
|
||||
['sv', { sensitivity: 'base' }]
|
||||
@@ -76,16 +76,39 @@ describe('automation list collation', () => {
|
||||
expect(compare.mock.calls.filter((args) => args.length >= 3)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('orders by row key, not the bare automation ID, so hosts cannot collapse', () => {
|
||||
const duplicate = makeAutomation({ id: 'shared', name: 'Same' })
|
||||
const list = buildAutomationListViewItems({
|
||||
rows: [
|
||||
{
|
||||
key: 'row|host-b|shared',
|
||||
automation: duplicate,
|
||||
hostLabel: 'b',
|
||||
usageSummary: null
|
||||
},
|
||||
{
|
||||
key: 'row|host-a|shared',
|
||||
automation: duplicate,
|
||||
hostLabel: 'a',
|
||||
usageSummary: null
|
||||
}
|
||||
],
|
||||
externalEntries: []
|
||||
})
|
||||
const sorted = sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en')
|
||||
expect(sorted.map((item) => item.id)).toEqual(['row|host-a|shared', 'row|host-b|shared'])
|
||||
})
|
||||
|
||||
it('does not construct collation for unsorted, time-sorted or trivial lists', () => {
|
||||
const items = rows()
|
||||
const list = items()
|
||||
const construct = vi.spyOn(Intl, 'Collator')
|
||||
expect(sortAutomationListViewItems(items, null)).toEqual(items)
|
||||
expect(sortAutomationListViewItems(list, null, 'en')).toEqual(list)
|
||||
const sort = { field: 'lastRun', direction: 'desc' } as const
|
||||
expect(sortAutomationListViewItems(items, sort)).toEqual(previousOrder(items, sort))
|
||||
expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' })).toEqual([])
|
||||
expect(sortAutomationListViewItems(list, sort, 'en')).toEqual(previousOrder(list, sort, 'en'))
|
||||
expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' }, 'en')).toEqual([])
|
||||
expect(
|
||||
sortAutomationListViewItems(items.slice(0, 1), { field: 'name', direction: 'asc' })
|
||||
).toEqual(items.slice(0, 1))
|
||||
sortAutomationListViewItems(list.slice(0, 1), { field: 'name', direction: 'asc' }, 'en')
|
||||
).toEqual(list.slice(0, 1))
|
||||
expect(construct).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
Automation,
|
||||
AutomationRun,
|
||||
AutomationRunStatus,
|
||||
ExternalAutomationJob,
|
||||
ExternalAutomationManager
|
||||
@@ -48,31 +47,6 @@ function makeAutomation(overrides: Partial<Automation> = {}): Automation {
|
||||
}
|
||||
}
|
||||
|
||||
function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
||||
return {
|
||||
id: 'run-1',
|
||||
automationId: 'automation-1',
|
||||
title: 'Zebra job',
|
||||
scheduledFor: 10,
|
||||
status: 'completed',
|
||||
trigger: 'scheduled',
|
||||
workspaceId: 'worktree-1',
|
||||
sessionKind: 'terminal',
|
||||
chatSessionId: null,
|
||||
terminalSessionId: null,
|
||||
terminalPaneKey: null,
|
||||
terminalPtyId: null,
|
||||
outputSnapshot: null,
|
||||
precheckResult: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
startedAt: 20,
|
||||
dispatchedAt: 30,
|
||||
createdAt: 10,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeExternalEntry(
|
||||
overrides: Partial<ExternalAutomationJob> = {}
|
||||
): ExternalAutomationListEntry {
|
||||
@@ -118,22 +92,69 @@ function makeExternalEntry(
|
||||
}
|
||||
}
|
||||
|
||||
/** A catalog row with an optional projected last-run status, keyed like a real host row. */
|
||||
function makeCatalogRow(
|
||||
id: string,
|
||||
overrides: Partial<Automation> = {},
|
||||
lastRunStatus?: AutomationRunStatus
|
||||
): AutomationListRow {
|
||||
return {
|
||||
key: `row|host|${id}`,
|
||||
automation: makeAutomation({ id, ...overrides }),
|
||||
hostLabel: 'This computer',
|
||||
usageSummary: lastRunStatus
|
||||
? {
|
||||
knownRuns: 1,
|
||||
unavailableRuns: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null,
|
||||
lastRunStatus,
|
||||
lastRunAt: 111
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
const rowKey = (id: string): string => `row|host|${id}`
|
||||
|
||||
describe('automation-list-view', () => {
|
||||
it('counts and detects active filters', () => {
|
||||
expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
|
||||
false
|
||||
)
|
||||
expect(isAutomationListFilterActive({ status: 'paused', lastRun: 'all', agentIds: [] })).toBe(
|
||||
true
|
||||
)
|
||||
expect(countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: [] })).toBe(
|
||||
2
|
||||
)
|
||||
expect(
|
||||
isAutomationListFilterActive({
|
||||
status: 'all',
|
||||
lastRun: 'all',
|
||||
agentIds: []
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isAutomationListFilterActive({
|
||||
status: 'paused',
|
||||
lastRun: 'all',
|
||||
agentIds: []
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
countAutomationListFilters({
|
||||
status: 'paused',
|
||||
lastRun: 'failed',
|
||||
agentIds: []
|
||||
})
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it('toggles sort direction and defaults last run to newest first', () => {
|
||||
expect(nextAutomationListSort(null, 'name')).toEqual({ field: 'name', direction: 'asc' })
|
||||
expect(nextAutomationListSort(null, 'lastRun')).toEqual({ field: 'lastRun', direction: 'desc' })
|
||||
expect(nextAutomationListSort(null, 'name')).toEqual({
|
||||
field: 'name',
|
||||
direction: 'asc'
|
||||
})
|
||||
expect(nextAutomationListSort(null, 'lastRun')).toEqual({
|
||||
field: 'lastRun',
|
||||
direction: 'desc'
|
||||
})
|
||||
expect(nextAutomationListSort({ field: 'name', direction: 'asc' }, 'name')).toEqual({
|
||||
field: 'name',
|
||||
direction: 'desc'
|
||||
@@ -146,82 +167,62 @@ describe('automation-list-view', () => {
|
||||
|
||||
it('filters by enabled state and last-run outcome', () => {
|
||||
const items = applyAutomationListView({
|
||||
automations: [
|
||||
makeAutomation({ id: 'paused', name: 'Paused', enabled: false }),
|
||||
makeAutomation({ id: 'ok', name: 'Healthy' })
|
||||
rows: [
|
||||
makeCatalogRow('paused', { name: 'Paused', enabled: false }, 'completed'),
|
||||
makeCatalogRow('ok', { name: 'Healthy' }, 'dispatch_failed')
|
||||
],
|
||||
externalEntries: [makeExternalEntry()],
|
||||
runs: [
|
||||
makeRun({ automationId: 'paused', status: 'completed' }),
|
||||
makeRun({ automationId: 'ok', status: 'dispatch_failed' })
|
||||
],
|
||||
filter: { status: 'enabled', lastRun: 'failed', agentIds: [] },
|
||||
sort: null
|
||||
sort: null,
|
||||
locale: 'en'
|
||||
})
|
||||
expect(items.map((item) => item.id)).toEqual(['ok', 'manager-1:job-1'])
|
||||
expect(items.map((item) => item.id)).toEqual([rowKey('ok'), 'manager-1:job-1'])
|
||||
})
|
||||
|
||||
it('filters local rows by multiple agents and leaves external rows out of agent scopes', () => {
|
||||
const items = applyAutomationListView({
|
||||
automations: [
|
||||
makeAutomation({ id: 'codex-job', agentId: 'codex' }),
|
||||
makeAutomation({ id: 'claude-job', agentId: 'claude' })
|
||||
rows: [
|
||||
makeCatalogRow('codex-job', { agentId: 'codex' }),
|
||||
makeCatalogRow('claude-job', { agentId: 'claude' })
|
||||
],
|
||||
externalEntries: [makeExternalEntry()],
|
||||
runs: [],
|
||||
filter: { status: 'all', lastRun: 'all', agentIds: ['codex', 'claude'] },
|
||||
sort: null
|
||||
sort: null,
|
||||
locale: 'en'
|
||||
})
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(['codex-job', 'claude-job'])
|
||||
expect(items.map((item) => item.id)).toEqual([rowKey('codex-job'), rowKey('claude-job')])
|
||||
})
|
||||
|
||||
it('counts an agent filter alongside status and last-run filters', () => {
|
||||
expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: ['codex'] })
|
||||
isAutomationListFilterActive({
|
||||
status: 'all',
|
||||
lastRun: 'all',
|
||||
agentIds: []
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
countAutomationListFilters({
|
||||
status: 'paused',
|
||||
lastRun: 'failed',
|
||||
agentIds: ['codex']
|
||||
})
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
it('sorts by name across local and external rows', () => {
|
||||
const items = applyAutomationListView({
|
||||
automations: [makeAutomation({ name: 'Zebra job' })],
|
||||
rows: [makeCatalogRow('zebra', { name: 'Zebra job' })],
|
||||
externalEntries: [makeExternalEntry({ name: 'Alpha digest' })],
|
||||
runs: [],
|
||||
filter: { status: 'all', lastRun: 'all', agentIds: [] },
|
||||
sort: { field: 'name', direction: 'asc' }
|
||||
sort: { field: 'name', direction: 'asc' },
|
||||
locale: 'en'
|
||||
})
|
||||
expect(items.map((item) => item.name)).toEqual(['Alpha digest', 'Zebra job'])
|
||||
})
|
||||
|
||||
it('filters catalog rows by status, agent, and the projected last-run status', () => {
|
||||
function makeCatalogRow(
|
||||
id: string,
|
||||
overrides: Partial<Automation>,
|
||||
lastRunStatus?: AutomationRunStatus
|
||||
): AutomationListRow {
|
||||
return {
|
||||
key: `row|host|${id}`,
|
||||
automation: makeAutomation({ id, ...overrides }),
|
||||
hostLabel: 'This computer',
|
||||
usageSummary: lastRunStatus
|
||||
? {
|
||||
knownRuns: 1,
|
||||
unavailableRuns: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
reasoningOutputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCostUsd: null,
|
||||
lastRunStatus,
|
||||
lastRunAt: 111
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
const rows = [
|
||||
makeCatalogRow('paused-codex', { enabled: false, agentId: 'codex' }),
|
||||
makeCatalogRow('failed-claude', { agentId: 'claude' }, 'dispatch_failed'),
|
||||
@@ -229,9 +230,10 @@ describe('automation-list-view', () => {
|
||||
makeCatalogRow('never-codex', { agentId: 'codex' })
|
||||
]
|
||||
const ids = (filter: Partial<AutomationListFilter>) =>
|
||||
filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, ...filter }).map(
|
||||
(row) => row.automation.id
|
||||
)
|
||||
filterAutomationListRows(rows, {
|
||||
...EMPTY_AUTOMATION_LIST_FILTER,
|
||||
...filter
|
||||
}).map((row) => row.automation.id)
|
||||
|
||||
expect(ids({ status: 'paused' })).toEqual(['paused-codex'])
|
||||
expect(ids({ agentIds: ['claude'] })).toEqual(['failed-claude'])
|
||||
@@ -249,7 +251,10 @@ describe('automation-list-view', () => {
|
||||
catalogRef:
|
||||
targetId === null
|
||||
? null
|
||||
: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } },
|
||||
: {
|
||||
authority: { kind: 'desktop' },
|
||||
selector: { kind: 'ssh', targetId }
|
||||
},
|
||||
hostLabel: targetId ?? '',
|
||||
usageSummary: null
|
||||
})
|
||||
@@ -257,9 +262,10 @@ describe('automation-list-view', () => {
|
||||
const keyOf = (row: AutomationListRow): string =>
|
||||
row.catalogRef ? hostStableKey(row.catalogRef) : ''
|
||||
const ids = (hostStableKeys: readonly string[]) =>
|
||||
filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, hostStableKeys }).map(
|
||||
(row) => row.automation.id
|
||||
)
|
||||
filterAutomationListRows(rows, {
|
||||
...EMPTY_AUTOMATION_LIST_FILTER,
|
||||
hostStableKeys
|
||||
}).map((row) => row.automation.id)
|
||||
|
||||
// Multi-select is any-of; a pre-catalog row names no host and is excluded.
|
||||
expect(ids([keyOf(rows[0]), keyOf(rows[1])])).toEqual(['on-a', 'on-b'])
|
||||
@@ -290,15 +296,22 @@ describe('automation-list-view', () => {
|
||||
|
||||
it('sorts by last run newest first and keeps never-run rows last', () => {
|
||||
const items = applyAutomationListView({
|
||||
automations: [
|
||||
makeAutomation({ id: 'old', name: 'Old' }),
|
||||
makeAutomation({ id: 'never', name: 'Never' })
|
||||
rows: [
|
||||
makeCatalogRow('old', {
|
||||
name: 'Old',
|
||||
lastRunAt: Date.parse('2026-08-11T09:00:00Z')
|
||||
}),
|
||||
makeCatalogRow('never', { name: 'Never' })
|
||||
],
|
||||
externalEntries: [makeExternalEntry({ lastRunAt: '2026-08-12T09:00:00Z' })],
|
||||
runs: [makeRun({ automationId: 'old', dispatchedAt: Date.parse('2026-08-11T09:00:00Z') })],
|
||||
filter: { status: 'all', lastRun: 'all', agentIds: [] },
|
||||
sort: { field: 'lastRun', direction: 'desc' }
|
||||
sort: { field: 'lastRun', direction: 'desc' },
|
||||
locale: 'en'
|
||||
})
|
||||
expect(items.map((item) => item.id)).toEqual(['manager-1:job-1', 'old', 'never'])
|
||||
expect(items.map((item) => item.id)).toEqual([
|
||||
'manager-1:job-1',
|
||||
rowKey('old'),
|
||||
rowKey('never')
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getIntlLocale } from '@/i18n/i18n'
|
||||
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { hostStableKey } from '../../../../shared/automation-owner-key'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
@@ -7,8 +5,6 @@ import type { ExternalAutomationListEntry } from './external-automation-list-ent
|
||||
import {
|
||||
getAutomationRowLastRunSnapshot,
|
||||
getExternalAutomationLastRunSnapshot,
|
||||
getLocalAutomationLastRunSnapshot,
|
||||
indexLatestAutomationRuns,
|
||||
type AutomationLastRunSnapshot
|
||||
} from './automation-list-last-run'
|
||||
|
||||
@@ -22,6 +18,13 @@ export type AutomationListSort = {
|
||||
direction: AutomationListSortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* A row and an external job flattened to what the shared list renders and sorts.
|
||||
*
|
||||
* `id` is the row's own key, never the bare automation ID: under All hosts two
|
||||
* authorities can return the same ID, and the sort tie-break decides render
|
||||
* order, so a bare ID would collapse them. See `automation-list-row-identity`.
|
||||
*/
|
||||
export type AutomationListViewItem =
|
||||
| {
|
||||
kind: 'local'
|
||||
@@ -31,7 +34,7 @@ export type AutomationListViewItem =
|
||||
lastRunAt: number | null
|
||||
lastRun: AutomationLastRunSnapshot
|
||||
agentId: TuiAgent
|
||||
automation: Automation
|
||||
row: AutomationListRow
|
||||
}
|
||||
| {
|
||||
kind: 'external'
|
||||
@@ -117,30 +120,26 @@ function matchesLastRunFilter(
|
||||
return snapshot.tone === filter
|
||||
}
|
||||
|
||||
/** Flattens the two rendered collections into one sortable list, preserving row identity. */
|
||||
export function buildAutomationListViewItems({
|
||||
automations,
|
||||
externalEntries,
|
||||
runs
|
||||
rows,
|
||||
externalEntries
|
||||
}: {
|
||||
automations: readonly Automation[]
|
||||
rows: readonly AutomationListRow[]
|
||||
externalEntries: readonly ExternalAutomationListEntry[]
|
||||
runs: readonly AutomationRun[]
|
||||
}): AutomationListViewItem[] {
|
||||
const lastRunByAutomationId = indexLatestAutomationRuns(runs)
|
||||
const locals: AutomationListViewItem[] = automations.map((automation) => {
|
||||
const lastRun = getLocalAutomationLastRunSnapshot(
|
||||
automation,
|
||||
lastRunByAutomationId.get(automation.id)
|
||||
)
|
||||
const locals: AutomationListViewItem[] = rows.map((row) => {
|
||||
// Why: the same snapshot the row cell renders, so the sort matches the column.
|
||||
const lastRun = getAutomationRowLastRunSnapshot(row)
|
||||
return {
|
||||
kind: 'local',
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
enabled: automation.enabled,
|
||||
id: row.key,
|
||||
name: row.automation.name,
|
||||
enabled: row.automation.enabled,
|
||||
lastRunAt: lastRun.at,
|
||||
lastRun,
|
||||
agentId: automation.agentId,
|
||||
automation
|
||||
agentId: row.automation.agentId,
|
||||
row
|
||||
}
|
||||
})
|
||||
const externals: AutomationListViewItem[] = externalEntries.map((entry) => {
|
||||
@@ -217,34 +216,21 @@ export function filterExternalAutomationListEntries(
|
||||
)
|
||||
}
|
||||
|
||||
export function filterAutomationListViewItems(
|
||||
items: readonly AutomationListViewItem[],
|
||||
filter: AutomationListFilter
|
||||
): AutomationListViewItem[] {
|
||||
if (!isAutomationListFilterActive(filter)) {
|
||||
return [...items]
|
||||
}
|
||||
return items.filter(
|
||||
(item) =>
|
||||
matchesStatusFilter(item.enabled, filter.status) &&
|
||||
matchesLastRunFilter(item.lastRun, filter.lastRun) &&
|
||||
(filter.agentIds.length === 0 ||
|
||||
(item.agentId !== null && filter.agentIds.includes(item.agentId)))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `locale` is a parameter, not a `getIntlLocale()` read, so callers memoizing this
|
||||
* can declare it — a hidden read is invisible to a dependency array.
|
||||
*/
|
||||
export function sortAutomationListViewItems(
|
||||
items: readonly AutomationListViewItem[],
|
||||
sort: AutomationListSort | null
|
||||
sort: AutomationListSort | null,
|
||||
locale: string
|
||||
): AutomationListViewItem[] {
|
||||
if (!sort || items.length < 2) {
|
||||
return [...items]
|
||||
}
|
||||
const next = [...items]
|
||||
const compareNames =
|
||||
sort.field === 'name'
|
||||
? new Intl.Collator(getIntlLocale(), { sensitivity: 'base' }).compare
|
||||
: null
|
||||
sort.field === 'name' ? new Intl.Collator(locale, { sensitivity: 'base' }).compare : null
|
||||
next.sort((left, right) => {
|
||||
const compared = compareNames
|
||||
? compareNames(left.name, right.name)
|
||||
@@ -257,24 +243,26 @@ export function sortAutomationListViewItems(
|
||||
return next
|
||||
}
|
||||
|
||||
/** The rendered list: filter each collection with its own rules, then sort as one. */
|
||||
export function applyAutomationListView({
|
||||
automations,
|
||||
rows,
|
||||
externalEntries,
|
||||
runs,
|
||||
filter,
|
||||
sort
|
||||
sort,
|
||||
locale
|
||||
}: {
|
||||
automations: readonly Automation[]
|
||||
rows: readonly AutomationListRow[]
|
||||
externalEntries: readonly ExternalAutomationListEntry[]
|
||||
runs: readonly AutomationRun[]
|
||||
filter: AutomationListFilter
|
||||
sort: AutomationListSort | null
|
||||
locale: string
|
||||
}): AutomationListViewItem[] {
|
||||
return sortAutomationListViewItems(
|
||||
filterAutomationListViewItems(
|
||||
buildAutomationListViewItems({ automations, externalEntries, runs }),
|
||||
filter
|
||||
),
|
||||
sort
|
||||
buildAutomationListViewItems({
|
||||
rows: filterAutomationListRows(rows, filter),
|
||||
externalEntries: filterExternalAutomationListEntries(externalEntries, filter)
|
||||
}),
|
||||
sort,
|
||||
locale
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* What the page actually listed, read back from the mocked list panel.
|
||||
*
|
||||
* Tests act through the same authority-qualified keys and render order the
|
||||
* user's click carries, rather than synthesizing either.
|
||||
*/
|
||||
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
import { mocks } from './automations-page-test-harness'
|
||||
|
||||
function listedItems() {
|
||||
return mocks.listPanel?.sortedListItems ?? []
|
||||
}
|
||||
|
||||
/** Local rows the page listed, in render order. */
|
||||
export function listedRows(): readonly AutomationListRow[] {
|
||||
return listedItems().flatMap((item) => (item.kind === 'local' ? [item.row] : []))
|
||||
}
|
||||
|
||||
/** External entries the page listed, in render order. */
|
||||
export function listedExternalEntries(): readonly ExternalAutomationListEntry[] {
|
||||
return listedItems().flatMap((item) => (item.kind === 'external' ? [item.entry] : []))
|
||||
}
|
||||
|
||||
export function listedRow(automationId: string): AutomationListRow {
|
||||
const row = listedRows().find((entry) => entry.automation.id === automationId)
|
||||
if (!row) {
|
||||
throw new Error(`no listed row for ${automationId}`)
|
||||
}
|
||||
return row
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
|
||||
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
|
||||
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import type { AutomationListViewItem } from './automation-list-view'
|
||||
import { resetAutomationCapabilityProbes } from './automation-scoped-list-client'
|
||||
import {
|
||||
addRuntimeProject as addRuntimeProjectFixture,
|
||||
@@ -39,7 +40,7 @@ export const RUNTIME_REPO_ID = RUNTIME_REPO_ID_FIXTURE
|
||||
export const RUNTIME_WORKSPACE_ID = RUNTIME_WORKSPACE_ID_FIXTURE
|
||||
|
||||
export type ListPanelProps = {
|
||||
filteredExternalAutomationEntries: ExternalAutomationListEntry[]
|
||||
sortedListItems: readonly AutomationListViewItem[]
|
||||
selectedExternal: ExternalAutomationListEntry | null
|
||||
openEditExternalDialog: (
|
||||
manager: ExternalAutomationListEntry['manager'],
|
||||
@@ -55,7 +56,6 @@ export type ListPanelProps = {
|
||||
) => void
|
||||
hasListItems: boolean
|
||||
hasFilteredListItems: boolean
|
||||
filteredRows: readonly AutomationListRow[]
|
||||
selectedRowKey: string | null
|
||||
selectedExternalKey: string | null
|
||||
hostCatalog: AutomationHostCatalogView
|
||||
@@ -211,30 +211,31 @@ vi.mock('./AutomationsListPanel', () => ({
|
||||
return (
|
||||
<div data-testid="list-panel">
|
||||
<button aria-label="Refresh automations" onClick={props.onRefresh} />
|
||||
{props.filteredRows.map((row) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="automation-row"
|
||||
key={row.key}
|
||||
onClick={() => selectAutomationRow(row.key)}
|
||||
>
|
||||
{row.automation.name}
|
||||
</button>
|
||||
))}
|
||||
{props.filteredExternalAutomationEntries.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="external-row"
|
||||
key={entry.key}
|
||||
onClick={() => {
|
||||
props.selectAutomationRow(null)
|
||||
props.selectExternalKey(entry.key)
|
||||
props.onOpenDetail()
|
||||
}}
|
||||
>
|
||||
{entry.job.name}
|
||||
</button>
|
||||
))}
|
||||
{props.sortedListItems.map((item) =>
|
||||
item.kind === 'local' ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="automation-row"
|
||||
key={item.id}
|
||||
onClick={() => selectAutomationRow(item.id)}
|
||||
>
|
||||
{item.row.automation.name}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="external-row"
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
props.selectAutomationRow(null)
|
||||
props.selectExternalKey(item.id)
|
||||
props.onOpenDetail()
|
||||
}}
|
||||
>
|
||||
{item.entry.job.name}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{props.hasListItems ? null : <div data-testid="empty-state" />}
|
||||
</div>
|
||||
)
|
||||
@@ -407,18 +408,6 @@ export async function refreshOnFocus(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The row the page actually listed for an ID, so tests act through the same
|
||||
* authority-qualified key the user's click carries rather than a synthesized one.
|
||||
*/
|
||||
export function listedRow(automationId: string): AutomationListRow {
|
||||
const row = mocks.listPanel?.filteredRows.find((entry) => entry.automation.id === automationId)
|
||||
if (!row) {
|
||||
throw new Error(`no listed row for ${automationId}`)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
export function rows(container: HTMLElement, testId: string): string[] {
|
||||
return [...container.querySelectorAll(`[data-testid="${testId}"]`)].map(
|
||||
(node) => node.textContent ?? ''
|
||||
|
||||
@@ -4,9 +4,12 @@ import { buildExternalAutomationListEntries } from './external-automation-list-e
|
||||
import { externalAutomationScopeEntries } from './external-automation-scope-gating'
|
||||
import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts'
|
||||
import {
|
||||
buildAutomationListViewItems,
|
||||
filterAutomationListRows,
|
||||
filterExternalAutomationListEntries
|
||||
filterExternalAutomationListEntries,
|
||||
sortAutomationListViewItems
|
||||
} from './automation-list-view'
|
||||
import { getIntlLocale } from '@/i18n/i18n'
|
||||
import { unscopedAutomationListRows } from './automation-list-row-identity'
|
||||
import { useAutomationHostCatalog } from './use-automation-host-catalog'
|
||||
import { useAutomationListSearch } from './use-automation-list-search'
|
||||
@@ -28,6 +31,7 @@ export function useAutomationsPageListState({
|
||||
failedAuthorityKeys,
|
||||
listSearchQuery,
|
||||
listFilter,
|
||||
listSort,
|
||||
selectedRowKey,
|
||||
selectedExternalKey,
|
||||
selectedAutomationRuns,
|
||||
@@ -129,6 +133,21 @@ export function useAutomationsPageListState({
|
||||
() => externalAutomationUncheckedNotice(scopedExternal.failures, hostCatalog.entries),
|
||||
[hostCatalog.entries, scopedExternal.failures]
|
||||
)
|
||||
// Why: a language switch changes collation without touching rows, so the locale
|
||||
// has to reach the memo as a value.
|
||||
const sortLocale = getIntlLocale()
|
||||
const sortedListItems = useMemo(
|
||||
() =>
|
||||
sortAutomationListViewItems(
|
||||
buildAutomationListViewItems({
|
||||
rows: filteredRows,
|
||||
externalEntries: filteredExternalAutomationEntries
|
||||
}),
|
||||
listSort,
|
||||
sortLocale
|
||||
),
|
||||
[filteredExternalAutomationEntries, filteredRows, listSort, sortLocale]
|
||||
)
|
||||
|
||||
return {
|
||||
hostCatalog,
|
||||
@@ -146,6 +165,7 @@ export function useAutomationsPageListState({
|
||||
isListSearchQueryTooLarge,
|
||||
filteredRows,
|
||||
filteredExternalAutomationEntries,
|
||||
sortedListItems,
|
||||
hasListItems,
|
||||
hasFilteredListItems,
|
||||
searchCounts,
|
||||
|
||||
@@ -12,7 +12,11 @@ import type { AutomationActionNotice } from './automation-row-action-dispatch'
|
||||
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
|
||||
import type { AutomationCreateDestination } from './automation-create-destination'
|
||||
import type { AutomationListRow } from './automation-list-row-identity'
|
||||
import { EMPTY_AUTOMATION_LIST_FILTER, type AutomationListFilter } from './automation-list-view'
|
||||
import {
|
||||
EMPTY_AUTOMATION_LIST_FILTER,
|
||||
type AutomationListFilter,
|
||||
type AutomationListSort
|
||||
} from './automation-list-view'
|
||||
import type {
|
||||
AutomationPaneTab,
|
||||
AutomationRunPageOrigin,
|
||||
@@ -54,6 +58,7 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [listSearchQuery, setListSearchQuery] = useState('')
|
||||
const [listFilter, setListFilter] = useState<AutomationListFilter>(EMPTY_AUTOMATION_LIST_FILTER)
|
||||
const [listSort, setListSort] = useState<AutomationListSort | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createTarget, setCreateTarget] = useState<AutomationCreateTarget>('orca')
|
||||
const [editingAutomationId, setEditingAutomationId] = useState<string | null>(null)
|
||||
@@ -178,6 +183,8 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
|
||||
setListSearchQuery,
|
||||
listFilter,
|
||||
setListFilter,
|
||||
listSort,
|
||||
setListSort,
|
||||
createOpen,
|
||||
setCreateOpen,
|
||||
createTarget,
|
||||
|
||||
@@ -56,7 +56,7 @@ const INVENTORY: readonly InventoryGroup[] = [
|
||||
'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx',
|
||||
2
|
||||
],
|
||||
['src/renderer/src/components/automations/AutomationListLocalRows.tsx', 2],
|
||||
['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2],
|
||||
'src/renderer/src/components/automations/automation-draft-model.ts',
|
||||
['src/renderer/src/components/automations/automation-list-search-rows.ts', 2],
|
||||
['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2],
|
||||
|
||||
Reference in New Issue
Block a user