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
7 changed files with 111 additions and 7 deletions

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}