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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion web/src/components/display-options.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
GROUPINGS,
ORDERING_LABELS,
ORDERINGS,
SUBGROUPING_LABELS,
type ViewConfig,
} from '@/sessions/view'
import { DisplayOptions } from './display-options'
Expand Down Expand Up @@ -112,14 +113,77 @@ describe('DisplayOptions', () => {
})

it('edits the grouping and nothing else', async () => {
const { user, onChange, view } = await open()
const { user, onChange, view } = await open({ subgrouping: 'none' })

await pick(user, 'Grouping', 'Tag')

expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith({ ...view, grouping: 'tag' })
})

describe('the second cut', () => {
it('offers every grouping but the first, and calls the way out "Nothing"', async () => {
const { user } = await open({ grouping: 'machine' })
screen.getByRole('combobox', { name: 'Then by' }).focus()
await user.keyboard('{Enter}')

// The first cut again would be one subheading under every heading — a
// choice that can only ever draw nothing.
expect(screen.queryByRole('option', { name: 'Machine' })).toBeNull()
for (const grouping of GROUPINGS) {
if (grouping === 'machine') continue
expect(
screen.getByRole('option', { name: SUBGROUPING_LABELS[grouping] }),
grouping,
).toBeTruthy()
}
expect(screen.getByRole('option', { name: 'Nothing' })).toBeTruthy()
})

it('reads back the second cut it was handed', async () => {
await open({ grouping: 'machine', subgrouping: 'tag' })
expect(screen.getByRole('combobox', { name: 'Then by' }).textContent).toContain('Tag')
})

it('edits the second cut and nothing else', async () => {
const { user, onChange, view } = await open({ grouping: 'machine', subgrouping: 'none' })

await pick(user, 'Then by', 'Tag')

expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith({ ...view, subgrouping: 'tag' })
})

it('turns the second cut off when the first cut takes its key', async () => {
// Machine then tag, and the reader picks Tag for the first cut: a view
// cut by tag then tag would draw exactly as tag alone, and a select
// still reading "Tag" under it would claim a second cut that is not
// there.
const { user, onChange, view } = await open({ grouping: 'machine', subgrouping: 'tag' })

await pick(user, 'Grouping', 'Tag')

expect(onChange).toHaveBeenCalledWith({ ...view, grouping: 'tag', subgrouping: 'none' })
})

it('turns the second cut off, and the control with it, when grouping is off', async () => {
// Nothing cut once cannot be cut twice.
const { user, onChange, view } = await open({ grouping: 'machine', subgrouping: 'tag' })

await pick(user, 'Grouping', 'No grouping')

expect(onChange).toHaveBeenCalledWith({ ...view, grouping: 'none', subgrouping: 'none' })
})

it('refuses the second cut while there is no first', async () => {
await open({ grouping: 'none', subgrouping: 'none' })
const then = screen.getByRole('combobox', { name: 'Then by' })
expect(then.hasAttribute('disabled') || then.getAttribute('aria-disabled') === 'true').toBe(
true,
)
})
})

it('edits the ordering, and turns the direction back to that key’s own', async () => {
// The default view reads by last active, newest first. Directory is a
// textual key that naturally reads a to z — a direction remembered from
Expand Down
29 changes: 27 additions & 2 deletions web/src/components/display-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
GROUPINGS,
ORDERING_LABELS,
ORDERINGS,
SUBGROUPING_LABELS,
type ColumnKey,
type ViewConfig,
} from '@/sessions/view'
Expand Down Expand Up @@ -80,7 +81,28 @@ export function DisplayOptions({ view, onChange }: DisplayOptionsProps) {
value={view.grouping}
options={GROUPINGS}
labels={GROUPING_LABELS}
onPick={(grouping) => onChange({ ...view, grouping })}
// The first cut taking the second's key, or going away entirely,
// takes the second cut with it: tag then tag draws as tag alone,
// and nothing cut once cannot be cut twice. A select left reading
// "Tag" underneath would claim a second cut that is not there.
onPick={(grouping) =>
onChange({
...view,
grouping,
subgrouping:
grouping === 'none' || grouping === view.subgrouping ? 'none' : view.subgrouping,
})
}
/>
<Choice
label="Then by"
value={view.subgrouping}
// Every key but the first cut's own — see above for why — and the
// way out, which reads "Nothing" here rather than "No grouping".
options={GROUPINGS.filter((g) => g !== view.grouping)}
labels={SUBGROUPING_LABELS}
disabled={view.grouping === 'none'}
onPick={(subgrouping) => onChange({ ...view, subgrouping })}
/>
<Choice
label="Ordering"
Expand Down Expand Up @@ -207,12 +229,15 @@ function Choice<T extends string>({
value,
options,
labels,
disabled = false,
onPick,
}: {
label: string
value: T
options: readonly T[]
labels: Record<T, string>
/** A choice with nothing to choose right now; it keeps its place and its word. */
disabled?: boolean
onPick(value: T): void
}) {
return (
Expand All @@ -224,7 +249,7 @@ function Choice<T extends string>({
`onValueChange` as a bare string; the only values it can emit are the
ones rendered below, which are `T`.
*/}
<Select value={value} onValueChange={(next) => onPick(next as T)}>
<Select value={value} disabled={disabled} onValueChange={(next) => onPick(next as T)}>
<SelectTrigger size="sm" aria-label={label} className="w-40">
<SelectValue />
</SelectTrigger>
Expand Down
84 changes: 83 additions & 1 deletion web/src/components/session-table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'

import type { FleetSession } from '@/fleet/types'
import { renderWithRouter } from '@/testing/render'
import { COLUMN_KEYS, type Group } from '@/sessions/view'
import { COLUMN_KEYS, SUBKEY_SEP, type Group } from '@/sessions/view'
import { SessionTable } from './session-table'

/**
Expand Down Expand Up @@ -150,6 +150,88 @@ describe('SessionTable', () => {
})
})

describe('groups cut twice', () => {
const api = fs({ id: 'a1', title: 'api-shell', tags: ['api'] })
const ops = fs({ id: 'b2', title: 'ops-shell', tags: ['ops'] })
const nested: Group = {
...group('machine:m1', 'MacBook Pro', [api, ops]),
children: [
group(`machine:m1${SUBKEY_SEP}tag:api`, 'api', [api]),
group(`machine:m1${SUBKEY_SEP}tag:ops`, 'ops', [ops]),
],
}

it('draws a subheading per child, with its own tally, under the parent', async () => {
const { container } = await renderTable({ groups: [nested] })

expect(screen.getByRole('button', { name: 'api' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'ops' })).toBeTruthy()
const text = container.textContent!
expect(text.indexOf('MacBook Pro')).toBeLessThan(text.indexOf('api'))
expect(text.indexOf('api')).toBeLessThan(text.indexOf('ops'))
})

it('draws every row once, under its child and not again under the parent', async () => {
// The parent carries the whole run for its tally; the table must not
// print that run a second time above the subheadings.
await renderTable({ groups: [nested] })
expect(screen.getAllByRole('link', { name: /api-shell/ })).toHaveLength(1)
expect(screen.getAllByRole('link', { name: /ops-shell/ })).toHaveLength(1)
})

it('tallies the parent over every row and the child over its own', async () => {
await renderTable({ groups: [nested] })
expect(screen.getByText('2 running')).toBeTruthy()
expect(screen.getAllByText('1 running')).toHaveLength(2)
})

it('folds a child on its own key, leaving its siblings open', async () => {
await renderTable({
groups: [nested],
collapsed: new Set([`machine:m1${SUBKEY_SEP}tag:api`]),
})

expect(screen.getByRole('button', { name: 'api' }).getAttribute('aria-expanded')).toBe(
'false',
)
expect(screen.queryByRole('link', { name: /api-shell/ })).toBeNull()
expect(screen.getByRole('link', { name: /ops-shell/ })).toBeTruthy()
})

it('folds the parent over every child', async () => {
await renderTable({ groups: [nested], collapsed: new Set(['machine:m1']) })
expect(screen.queryByRole('button', { name: 'api' })).toBeNull()
expect(screen.queryByRole('link')).toBeNull()
})

it('reports a child toggle by the child’s key', async () => {
const { props } = await renderTable({ groups: [nested] })
await userEvent.click(screen.getByRole('button', { name: 'api' }))
expect(props.onToggleGroup).toHaveBeenCalledWith(`machine:m1${SUBKEY_SEP}tag:api`)
})

it('offers a spawn control on a child, named for the child', async () => {
const onSpawnIn = vi.fn()
await renderTable({
groups: [nested],
onSpawnIn,
spawnLabel: (g) => (g.children === undefined ? `New session tagged ${g.label}` : undefined),
})

expect(screen.queryByRole('button', { name: /on MacBook Pro/ })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'New session tagged api' }))
expect(onSpawnIn).toHaveBeenCalledWith(nested.children![0])
})

it('wears a grip when only the children could take a drop', async () => {
await renderTable({
groups: [nested],
drag: { droppable: (g) => g.children === undefined, onDrop: vi.fn() },
})
expect(screen.getAllByTitle('Drag to move to another group').length).toBeGreaterThan(0)
})
})

describe('the fields a row carries', () => {
it('draws no header row of field names at all', async () => {
// The rows are one list, not a spreadsheet: what each piece is, its
Expand Down
87 changes: 67 additions & 20 deletions web/src/components/session-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,8 @@ export function SessionTable({
// refusals are part of what it says — but an advertisement is a promise,
// and a grip down a list with nowhere to go promises a move that every
// release would refuse.
const liftable = drag !== undefined && groups.some((g) => drag.droppable(g))
const liftable =
drag !== undefined && groups.some((g) => drag.droppable(g) || g.children?.some(drag.droppable))

/*
* The drag layer's own bookkeeping, all of it about saying things: who the
Expand Down Expand Up @@ -628,21 +629,11 @@ export function SessionTable({
<GroupSection
key={g.key}
g={g}
open={!collapsed.has(g.key)}
depth={0}
// Where the pinned prefix ends; 0 and -1 both mean "no rule".
boundary={ungrouped ? g.sessions.findIndex((s) => !s.pinned) : 0}
/*
* What this heading's `+` would be called, and therefore whether
* it exists at all.
*
* Resolved once, per group, and checked — not merely passed to
* aria-label. A group that refuses one answers undefined (see
* `spawnFromGroup`, and "Exited" for the case that motivates it),
* and an unchecked answer rendered a button with no accessible
* name: a `+` a pointer can press, a click the caller then
* refuses, and nothing for a screen reader to announce it as.
*/
spawn={onSpawnIn === undefined ? undefined : spawnLabel?.(g)}
collapsed={collapsed}
spawnLabel={spawnLabel}
drag={drag}
handle={liftable}
panes={panes}
Expand Down Expand Up @@ -688,9 +679,10 @@ export function SessionTable({
*/
function GroupSection({
g,
open,
depth,
boundary,
spawn,
collapsed,
spawnLabel,
drag,
handle,
panes,
Expand All @@ -703,9 +695,11 @@ function GroupSection({
peek,
}: {
g: Group
open: boolean
/** How many headings sit above this one: 0 for a group, 1 for its children. */
depth: number
boundary: number
spawn?: string
collapsed: ReadonlySet<string>
spawnLabel?(group: Group): string | undefined
drag?: DragToGroup
handle: boolean
panes?: ReadonlyMap<string, number>
Expand All @@ -729,6 +723,19 @@ function GroupSection({
disabled: drag === undefined,
})
const droppable = drag !== undefined && drag.droppable(g)
const open = !collapsed.has(g.key)
/*
* What this heading's `+` would be called, and therefore whether it exists
* at all.
*
* Resolved once, per group, and checked — not merely passed to aria-label.
* A group that refuses one answers undefined (see `spawnFromGroup`, and
* "Exited" for the case that motivates it), and an unchecked answer
* rendered a button with no accessible name: a `+` a pointer can press, a
* click the caller then refuses, and nothing for a screen reader to
* announce it as.
*/
const spawn = onSpawnIn === undefined ? undefined : spawnLabel?.(g)
return (
<section ref={setNodeRef} className="flex flex-col">
{/*
Expand Down Expand Up @@ -766,7 +773,15 @@ function GroupSection({
!open && '-rotate-90',
)}
/>
<span className="truncate">{g.label}</span>
{/*
A subheading reads a step quieter than the heading over it, so the
two levels tell apart by colour and indent alone — the heading's
weight would make every tag under a machine as loud as the
machine.
*/}
<span className={cn('truncate', depth > 0 && 'text-zinc-600 dark:text-zinc-300')}>
{g.label}
</span>
</button>
<span className="shrink-0 text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
{tally(g.sessions)}
Expand All @@ -791,7 +806,39 @@ function GroupSection({
</Button>
)}
</div>
{open && (
{open && g.children !== undefined && (
/*
* The second cut, when there is one: the same rows again under
* subheadings, each a section of its own with its own fold, `+` and
* drop target. Indented by the width of the chevron and its gap, so
* a child's chevron sits under its parent's label and the tree reads
* as one. The parent's own `sessions` are not drawn — every one of
* them is under some child — but they are what its tally counted.
*/
<div className="mt-1 flex flex-col gap-y-2 pl-5">
{g.children.map((c) => (
<GroupSection
key={c.key}
g={c}
depth={depth + 1}
boundary={0}
collapsed={collapsed}
spawnLabel={spawnLabel}
drag={drag}
handle={handle}
panes={panes}
shown={shown}
selected={selected}
onToggleSelect={onToggleSelect}
onToggleGroup={onToggleGroup}
onAction={onAction}
onSpawnIn={onSpawnIn}
peek={peek}
/>
))}
</div>
)}
{open && g.children === undefined && (
<ul className="mt-1 flex flex-col">
{g.sessions.map((s, at) => (
// Keyed by the same composite the selection uses: unique
Expand Down
4 changes: 3 additions & 1 deletion web/src/routes/sessions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,9 @@ describe('SessionsRoute', () => {
listed(sock, [info({ id: 's1', name: 'alpha', tags: ['api', 'prod'] })])
listed(attic.sockets[0]!, [info({ id: 's2', name: 'beta', tags: ['api'] })])

await userEvent.click(screen.getByRole('checkbox', { name: 'Select alpha' }))
// The default view cuts each machine by tag, and alpha carries two — so
// it has a row under each, and either row's box selects the session.
await userEvent.click(screen.getAllByRole('checkbox', { name: 'Select alpha' })[0]!)
await userEvent.click(screen.getByRole('checkbox', { name: 'Select beta' }))
expect(screen.getByText('2 selected')).toBeTruthy()
return mounted
Expand Down
Loading
Loading