Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the schedules, notification flows and flow editor show placeholders while they load

The schedules card, the notification router card (flows and recent
deliveries) and the flow editor showed a bare 'loading…' line. They now show
grey placeholders in the layout of what is coming: schedule cards with the
real card's row heights, flow cards with a block for the graph, the
deliveries table's header over blank rows, and lines for the editor.
This commit is contained in:
iris 2026-11-06 19:26:20 +01:00 • committed by mara
commit 5eef1f4a3d
8 changed files with 111 additions and 7 deletions

View file

@ -322,6 +322,8 @@ when it fires next. **edit** turns the card into its own form; **pause** and
list by name, agent or state (`active`, `paused`, or `past` for a one-shot
whose time has passed). `past` is the controller's clock against the start time,
not a record that an agent fired, so you can still pause a `past` schedule.
While the schedules load for the first time, the card shows grey placeholder
cards with the same layout as the real ones.
A schedule repeats **every** some number of seconds, minutes, hours or days, or
fires **once**. Its start time is the moment a one-shot fires, and the origin of
@ -357,7 +359,8 @@ and **duplicate** buttons. **create flow**, at the top of the page beside the
refresh interval, starts a custom flow with just a source. Creating and
duplicating open the editor on a draft and make nothing: the flow exists once
you **save** it there. Leaving the draft without saving, or discarding it, leaves
the flows as they were.
the flows as they were. While the flows and the recent deliveries load for
the first time, the card shows grey placeholders of the same shape.
A switch sends the whole flows document with the revision the page read, `0` if
it read no document. The controller refuses the switch when the stored revision

View file

@ -4,6 +4,7 @@
import { EmptyState } from "../../ui/empty-state/EmptyState.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { Table, type TableColumn } from "../../ui/table/Table.js";
import { TableSkeleton } from "../../ui/table/TableSkeleton.js";
import { issueRef, outcomeText, type Delivery } from "./RouterLive.js";
// A delivery with its place in the answer, which is what tells two identical rows
@ -48,6 +49,11 @@ const COLUMNS: TableColumn<Row>[] = [
},
];
/** The table's twin while the deliveries load. */
export function DeliveriesSkeleton() {
return <TableSkeleton columns={COLUMNS} label="the deliveries" />;
}
export function Deliveries({ rows }: { rows: Delivery[] }) {
if (rows.length === 0) return <EmptyState>no deliveries yet</EmptyState>;
const placed = rows.map((d, place) => ({ ...d, place }));

View file

@ -26,6 +26,7 @@ import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Panel } from "../../ui/panel/Panel.js";
import { SkeletonLines } from "../../ui/skeleton/Skeleton.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { fetchJson, ProblemError } from "../../ui/polled/usePolled.js";
import { FlowCanvas } from "./FlowCanvas.js";
@ -248,7 +249,7 @@ export function FlowEditorPage({
if (!view) {
return (
<Panel title="notification flow" icon="🔔" actions={back}>
<p>loading…</p>
<SkeletonLines label="the flow" />
</Panel>
);
}

View file

@ -0,0 +1,30 @@
// The twin of the flow cards for a list that is loading: the same card and
// head layout (`notify-flow-head`), a placeholder for the name and the
// buttons, and a block where the flow's graph goes. The list is in the
// container the real one uses (`notify-flows`), so the gap is the same.
import { Card } from "../../ui/card/Card.js";
import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
function FlowCardSkeleton() {
return (
<Card>
<div class="notify-flow-head">
<Skeleton width="12em" class="notify-flow-name" />
<Skeleton width="9em" height="2.4em" />
</div>
<Skeleton shape="block" width="100%" height="6em" />
</Card>
);
}
export function FlowListSkeleton({ count = 3 }: { count?: number }) {
return (
<SkeletonRegion label="the flows">
<div class="notify-flows">
{Array.from({ length: count }, (_, i) => (
<FlowCardSkeleton key={i} />
))}
</div>
</SkeletonRegion>
);
}

View file

@ -20,7 +20,8 @@ import {
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { Deliveries } from "./Deliveries.js";
import { Deliveries, DeliveriesSkeleton } from "./Deliveries.js";
import { FlowListSkeleton } from "./FlowListSkeleton.js";
import { NEW_FLOW_PATH } from "./FlowDraft.js";
import { FlowGraph } from "./FlowGraph.js";
import { withBuiltin, type FlowsView } from "./FlowTypes.js";
@ -124,7 +125,7 @@ export function NotificationRouter() {
problem={actionError}
/>
) : null}
{!error && view === null ? <p>loading…</p> : null}
{!error && view === null ? <FlowListSkeleton /> : null}
{view ? (
<div class="notify-flows">
{dryError ? (
@ -191,7 +192,7 @@ export function NotificationRouter() {
) : deliveries ? (
<Deliveries rows={deliveries.rows} />
) : (
<p>loading…</p>
<DeliveriesSkeleton />
)}
</div>
) : null}

View file

@ -31,3 +31,19 @@
.ui-schedule-card-actions {
gap: 0.5em;
}
/* The loading twin's list: the gap between cards in the real list. */
.ui-schedule-card-list {
display: flex;
flex-direction: column;
gap: 0.5em;
}
/* The loading twin's rows take the height of a line of text, and its head the
height of the title, so the card is the size of a real one. */
.ui-schedule-card-head-skeleton {
min-height: 1.76em;
}
.ui-schedule-card-line {
display: flex;
align-items: center;
min-height: 1.6em;
}

View file

@ -7,6 +7,7 @@ import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
import {
fmtInterval,
fmtLocal,
@ -87,3 +88,49 @@ export function ScheduleCard({
</Card>
);
}
// The twin of `ScheduleCard` for a list that is loading: the same card and
// layout classes, with a placeholder in each slot. The cards sit in the
// container the real list uses, so the gap between them is the same.
function ScheduleCardSkeleton() {
return (
<div>
<Card class="ui-schedule-card">
<div class="ui-schedule-card-head ui-schedule-card-head-skeleton">
<Skeleton width="10em" height="1.2em" class="ui-schedule-card-name" />
<Skeleton width="4em" height="1.6em" />
<Skeleton width="6em" height="1.6em" />
</div>
<div class="ui-schedule-card-line">
<Skeleton width="14em" />
</div>
<div class="ui-schedule-card-line">
<Skeleton width="90%" />
</div>
<div class="ui-schedule-card-line">
<Skeleton width="60%" />
</div>
<div class="ui-schedule-card-line">
<Skeleton width="12em" />
</div>
<ActionRow align="end" class="ui-schedule-card-actions">
<Skeleton width="4em" height="2.75em" />
<Skeleton width="4.5em" height="2.75em" />
<Skeleton width="5em" height="2.75em" />
</ActionRow>
</Card>
</div>
);
}
export function ScheduleListSkeleton({ count = 3 }: { count?: number }) {
return (
<SkeletonRegion label="the schedules">
<div class="ui-schedule-card-list">
{Array.from({ length: count }, (_, i) => (
<ScheduleCardSkeleton key={i} />
))}
</div>
</SkeletonRegion>
);
}

View file

@ -24,7 +24,7 @@ import {
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import type { TableColumn } from "../../ui/table/Table.js";
import type { AgentRow } from "../../agent/AgentTypes.js";
import { ScheduleCard } from "./ScheduleCard.js";
import { ScheduleCard, ScheduleListSkeleton } from "./ScheduleCard.js";
import { agentNamesFrom, agentOptionsFor } from "./agent-options.js";
import { ScheduleForm } from "./ScheduleForm.js";
import { scheduleState, type Schedule } from "./ScheduleTypes.js";
@ -184,7 +184,7 @@ function SchedulesBody() {
onCancel={() => setCreating(false)}
/>
) : null}
{!error && schedules === null ? <p>loading…</p> : null}
{!error && schedules === null ? <ScheduleListSkeleton /> : null}
{schedules ? (
<FilterableView
columns={COLUMNS}