fix: replace home navigation with work dashboard
This commit is contained in:
@@ -183,9 +183,11 @@ approved immutable artifact channel when durable review evidence is required.
|
|||||||
|
|
||||||
## Workspace Home fixture
|
## Workspace Home fixture
|
||||||
|
|
||||||
`scenarios/workspace-home.json` captures the production Home with owner, non-owner, empty-resource,
|
`scenarios/workspace-home.json` captures the production work dashboard with owner, non-owner,
|
||||||
and long-name Workspace projections at 1440px, 768px, and 390px. It uses no real account or private
|
long-name, empty, and intentionally failing-review Workspace projections at 1440px, 768px, and 390px.
|
||||||
Workspace data. Build `web/workspace` first, then run from `tools/web-ux`:
|
The partial-error route deliberately returns HTTP 503 only for Merge Requests; captures should retain
|
||||||
|
that request/console/visible error while showing successful active-work and recent-update feeds.
|
||||||
|
It uses no real account or private Workspace data. Build `web/workspace` first, then run from `tools/web-ux`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
deno task web-ux capture --scenario scenarios/workspace-home.json --output ../../target/web-ux --run-id home-review
|
deno task web-ux capture --scenario scenarios/workspace-home.json --output ../../target/web-ux --run-id home-review
|
||||||
@@ -193,7 +195,8 @@ deno task web-ux capture --scenario scenarios/workspace-home.json --output ../..
|
|||||||
|
|
||||||
The scenario owns a fixture server on localhost port 15281 and stops only that server. The production
|
The scenario owns a fixture server on localhost port 15281 and stops only that server. The production
|
||||||
Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included in `test:browser`)
|
Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included in `test:browser`)
|
||||||
also checks 320px, both themes, keyboard navigation, permission-gated links, and absence of Host requests.
|
also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error
|
||||||
|
feeds, explicit refresh, and absence of Host requests.
|
||||||
|
|
||||||
## Adding a scenario
|
## Adding a scenario
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||||
|
import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
|
||||||
|
|
||||||
const port = Number(Deno.args[0]);
|
const port = Number(Deno.args[0]);
|
||||||
const buildRoot = Deno.args[1];
|
const buildRoot = Deno.args[1];
|
||||||
@@ -6,6 +7,8 @@ if (!Number.isInteger(port) || !buildRoot) throw new Error("usage: server <port>
|
|||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
"home-owner": "Workspace Home Review",
|
"home-owner": "Workspace Home Review",
|
||||||
"home-member": "Shared Workspace",
|
"home-member": "Shared Workspace",
|
||||||
|
"home-empty": "New Workspace",
|
||||||
|
"home-error": "Partial failure Workspace",
|
||||||
"home-long":
|
"home-long":
|
||||||
"Workspace with a long name — international documentation and distributed development",
|
"Workspace with a long name — international documentation and distributed development",
|
||||||
};
|
};
|
||||||
@@ -38,6 +41,11 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
if (match) {
|
if (match) {
|
||||||
const [, workspaceId, path] = match;
|
const [, workspaceId, path] = match;
|
||||||
const owner = workspaceId !== "home-member";
|
const owner = workspaceId !== "home-member";
|
||||||
|
if (workspaceId === "home-error" && path === "/merge-requests") {
|
||||||
|
return json({ error: "fixture unavailable" }, 503);
|
||||||
|
}
|
||||||
|
const dashboardData = dashboardFixture(path, url.searchParams, workspaceId);
|
||||||
|
if (dashboardData) return json(dashboardData);
|
||||||
if (path === "/workspace") {
|
if (path === "/workspace") {
|
||||||
return json({
|
return json({
|
||||||
workspace_id: workspaceId,
|
workspace_id: workspaceId,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/// <reference lib="dom" />
|
/// <reference lib="dom" />
|
||||||
import { assert, assertEquals } from "@std/assert";
|
import { assert, assertEquals } from "@std/assert";
|
||||||
import { dirname, fromFileUrl, join } from "@std/path";
|
import { dirname, fromFileUrl, join } from "@std/path";
|
||||||
import { chromium } from "playwright";
|
import { chromium, type Page } from "playwright";
|
||||||
|
|
||||||
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||||
|
const ready = async (page: Page) => {
|
||||||
Deno.test("production Home preserves scoped navigation, permission boundaries, responsive layout, and keyboard access", async () => {
|
for (const id of ["home-attention", "home-active", "home-recent"]) {
|
||||||
|
await page.locator(`[data-feed-ready="${id}"]`).waitFor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Deno.test("production Home dashboard isolates failures, shows authoritative records, and supports responsive keyboard navigation", async () => {
|
||||||
const build = await new Deno.Command(Deno.execPath(), {
|
const build = await new Deno.Command(Deno.execPath(), {
|
||||||
args: ["task", "build"],
|
args: ["task", "build"],
|
||||||
cwd: join(root, "web/workspace"),
|
cwd: join(root, "web/workspace"),
|
||||||
@@ -30,19 +34,19 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r
|
|||||||
stderr: "null",
|
stderr: "null",
|
||||||
}).spawn();
|
}).spawn();
|
||||||
try {
|
try {
|
||||||
let ready = false;
|
let started = false;
|
||||||
for (let n = 0; n < 100; n++) {
|
for (let n = 0; n < 100; n++) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${url}/health`);
|
const response = await fetch(`${url}/health`);
|
||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
ready = true;
|
started = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch { /* owned fixture is starting */ }
|
} catch { /* fixture is starting */ }
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
assert(ready, "fixture did not start");
|
assert(started);
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
try {
|
try {
|
||||||
for (const colorScheme of ["light", "dark"] as const) {
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
@@ -64,66 +68,69 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r
|
|||||||
page.on("request", (request) => requests.push(request.url()));
|
page.on("request", (request) => requests.push(request.url()));
|
||||||
for (const workspace of ["home-owner", "home-member", "home-long"]) {
|
for (const workspace of ["home-owner", "home-member", "home-long"]) {
|
||||||
await page.goto(`${url}/w/${workspace}`);
|
await page.goto(`${url}/w/${workspace}`);
|
||||||
const work = page.getByRole("navigation", { name: "Work", exact: true });
|
await ready(page);
|
||||||
await work.waitFor();
|
assertEquals(await page.locator("main h1").allTextContents(), ["Activity"]);
|
||||||
assertEquals(
|
assertEquals(await page.locator("main nav").count(), 0);
|
||||||
(await work.getByRole("link").allTextContents()).map((text) => text.trim()),
|
assertEquals(await page.locator("main [role=alert]").count(), 0);
|
||||||
["Tickets", "Objectives", "Merge Requests", "Memory", "Workers"],
|
assertEquals(await page.locator("[data-feed-ready=home-attention] a").count(), 3);
|
||||||
);
|
assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2);
|
||||||
const settings = page.locator("main").getByRole("navigation", {
|
assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4);
|
||||||
name: "Settings",
|
await page.getByText("Blocked by T-99", { exact: true }).waitFor();
|
||||||
exact: true,
|
await page.getByText("W-7", { exact: true }).waitFor();
|
||||||
});
|
|
||||||
assertEquals(
|
|
||||||
await settings.getByRole("link").count(),
|
|
||||||
workspace === "home-member" ? 2 : 6,
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
await settings.getByRole("link", { name: "Runtimes", exact: true }).count(),
|
|
||||||
workspace === "home-member" ? 0 : 1,
|
|
||||||
);
|
|
||||||
assertEquals(await page.locator("main h1").allTextContents(), ["Work"]);
|
|
||||||
const geometry = await page.locator(".workspace-home").evaluate((home) => {
|
const geometry = await page.locator(".workspace-home").evaluate((home) => {
|
||||||
const main = home.closest("main")!;
|
const main = home.closest("main")!;
|
||||||
const work = home.querySelector("section")!.getBoundingClientRect();
|
const sections = [...home.querySelectorAll("section")].map((section) =>
|
||||||
const settings = home.querySelector(".home-settings")!.getBoundingClientRect();
|
section.getBoundingClientRect()
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
pageOverflow: document.documentElement.scrollWidth > innerWidth,
|
pageOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||||
mainOverflow: main.scrollWidth > main.clientWidth,
|
mainOverflow: main.scrollWidth > main.clientWidth,
|
||||||
firstLinkBottom: home.querySelector("a")!.getBoundingClientRect().bottom,
|
firstLinkTop: home.querySelector("a")!.getBoundingClientRect().top,
|
||||||
sideBySide: settings.left > work.right,
|
sideBySide: sections[1].left > sections[0].right &&
|
||||||
stacked: settings.top >= work.bottom,
|
sections[2].left > sections[1].right,
|
||||||
smallTargets: [...home.querySelectorAll("a")].filter((link) =>
|
stacked: sections[1].top >= sections[0].bottom &&
|
||||||
link.getBoundingClientRect().height < 44
|
sections[2].top >= sections[1].bottom,
|
||||||
).length,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assertEquals(geometry.pageOverflow, false);
|
assertEquals(geometry.pageOverflow, false);
|
||||||
assertEquals(geometry.mainOverflow, false);
|
assertEquals(geometry.mainOverflow, false);
|
||||||
assert(geometry.firstLinkBottom < 900);
|
assert(geometry.firstLinkTop < 900);
|
||||||
assertEquals(geometry.smallTargets, 0);
|
|
||||||
assert(width === 1440 ? geometry.sideBySide : geometry.stacked);
|
assert(width === 1440 ? geometry.sideBySide : geometry.stacked);
|
||||||
const first = work.getByRole("link", { name: "Tickets", exact: true });
|
const links = page.locator("main a");
|
||||||
await first.focus();
|
await links.first().focus();
|
||||||
const outline = await first.evaluate((link) => getComputedStyle(link).outlineStyle);
|
assertEquals(
|
||||||
assertEquals(outline, "solid");
|
await links.first().evaluate((link) => getComputedStyle(link).outlineStyle),
|
||||||
|
"solid",
|
||||||
|
);
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
await page.evaluate(() => document.activeElement?.textContent?.trim()),
|
await links.nth(1).evaluate((link) => document.activeElement === link),
|
||||||
"Objectives",
|
true,
|
||||||
);
|
);
|
||||||
for (const link of await page.locator("main a").all()) {
|
for (const link of await links.all()) {
|
||||||
assert((await link.getAttribute("href"))!.startsWith(`/w/${workspace}/`));
|
assert(
|
||||||
|
new RegExp(`/w/${workspace}/(tickets|objectives|merge-requests)/[^/]+$`).test(
|
||||||
|
(await link.getAttribute("href"))!,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const memory = page.getByRole("navigation", { name: "Work", exact: true }).getByRole(
|
const beforeRefresh = requests.filter((request) =>
|
||||||
"link",
|
request.includes("/merge-requests?")
|
||||||
{ name: "Memory", exact: true },
|
).length;
|
||||||
|
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Refresh", exact: true }).waitFor();
|
||||||
|
await ready(page);
|
||||||
|
assertEquals(
|
||||||
|
requests.filter((request) => request.includes("/merge-requests?")).length,
|
||||||
|
beforeRefresh + 1,
|
||||||
);
|
);
|
||||||
await memory.focus();
|
const objective = page.locator("main a[href*='/objectives/O-12-']");
|
||||||
|
await objective.focus();
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await page.waitForURL(`**/w/home-long/memory`);
|
await page.waitForURL("**/objectives/O-12-*");
|
||||||
await page.getByRole("heading", { name: "Home navigation destination" }).waitFor();
|
await page.getByText("Home dashboard navigation destination.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
assertEquals(
|
assertEquals(
|
||||||
requests.some((request) => new URL(request).pathname.endsWith("/hosts")),
|
requests.some((request) => new URL(request).pathname.endsWith("/hosts")),
|
||||||
false,
|
false,
|
||||||
@@ -134,6 +141,37 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
try {
|
||||||
|
await page.goto(`${url}/w/home-empty`);
|
||||||
|
await ready(page);
|
||||||
|
assertEquals(await page.locator("main a").count(), 0);
|
||||||
|
await page.getByText("No open Merge Requests.", { exact: true }).waitFor();
|
||||||
|
await page.goto(`${url}/w/home-error`);
|
||||||
|
await ready(page);
|
||||||
|
assertEquals(
|
||||||
|
await page.locator("[data-feed-ready=home-attention] [role=alert]").count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assertEquals(await page.getByText("No open Merge Requests.", { exact: true }).count(), 0);
|
||||||
|
assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2);
|
||||||
|
assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4);
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await page.route("**/merge-requests?*", async (route) => {
|
||||||
|
await held;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
await page.goto(`${url}/w/home-owner`);
|
||||||
|
await page.locator("[data-feed-ready=home-active]").waitFor();
|
||||||
|
assertEquals(await page.getByText("Loading needs attention…", { exact: true }).count(), 1);
|
||||||
|
release();
|
||||||
|
await ready(page);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "workspace-home",
|
"id": "workspace-home",
|
||||||
"title": "Workspace Home task entry and permission boundaries",
|
"title": "Workspace Home operational dashboard",
|
||||||
"baseUrl": "http://127.0.0.1:15281",
|
"baseUrl": "http://127.0.0.1:15281",
|
||||||
"colorScheme": "light",
|
"colorScheme": "light",
|
||||||
"reducedMotion": "reduce",
|
"reducedMotion": "reduce",
|
||||||
@@ -22,28 +22,61 @@
|
|||||||
"id": "owner",
|
"id": "owner",
|
||||||
"label": "Owner Home",
|
"label": "Owner Home",
|
||||||
"path": "/w/home-owner",
|
"path": "/w/home-owner",
|
||||||
"goal": "Choose a daily resource without exposing infrastructure details; settings remain secondary.",
|
"goal": "Identify review work, blocked or assigned active tickets, and recent updates; open an actual record.",
|
||||||
"dataState": "Workspace owner; empty repositories and workers; one Host available for before-change comparison.",
|
"dataState": "Mixed review states, in-progress/queued tickets with a coder and dependency blocker, done/closed tickets and an Objective.",
|
||||||
"ready": { "kind": "selector", "selector": "main a[href='/w/home-owner/tickets']" },
|
"ready": {
|
||||||
"capturePoints": [{ "id": "initial", "label": "Home" }]
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||||
|
},
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "non-owner",
|
"id": "non-owner",
|
||||||
"label": "Non-owner Home",
|
"label": "Non-owner Home",
|
||||||
"path": "/w/home-member",
|
"path": "/w/home-member",
|
||||||
"goal": "Reach daily resources without seeing owner-only management links.",
|
"goal": "Read the same work records without owner-only management controls.",
|
||||||
"dataState": "All management permissions false; empty resource collections.",
|
"dataState": "All management permissions false; same authorized read-only work projections as owner.",
|
||||||
"ready": { "kind": "selector", "selector": "main a[href='/w/home-member/tickets']" },
|
"ready": {
|
||||||
"capturePoints": [{ "id": "initial", "label": "Home" }]
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||||
|
},
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "long-name",
|
"id": "long-name",
|
||||||
"label": "Long Workspace name",
|
"label": "Long names and selectors",
|
||||||
"path": "/w/home-long",
|
"path": "/w/home-long",
|
||||||
"goal": "Keep resource navigation visible and avoid horizontal page overflow with long Workspace identity.",
|
"goal": "Read long record titles and branch selectors without page-wide overflow.",
|
||||||
"dataState": "Owner with a long Workspace name.",
|
"dataState": "Owner with long Workspace name, Objective title, and Merge Request selectors.",
|
||||||
"ready": { "kind": "selector", "selector": "main a[href='/w/home-long/tickets']" },
|
"ready": {
|
||||||
"capturePoints": [{ "id": "initial", "label": "Home" }]
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||||
|
},
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "empty",
|
||||||
|
"label": "Empty Workspace",
|
||||||
|
"path": "/w/home-empty",
|
||||||
|
"goal": "Distinguish empty work scopes without fabricated status totals.",
|
||||||
|
"dataState": "All dashboard resource queries successfully return empty collections.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||||
|
},
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Empty scopes" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "partial-error",
|
||||||
|
"label": "Reviews unavailable",
|
||||||
|
"path": "/w/home-error",
|
||||||
|
"goal": "Keep active work and recent updates usable when reviews fail; never claim all-clear.",
|
||||||
|
"dataState": "Intentional HTTP 503 from Merge Requests. Expected visible error and browser HTTP console diagnostic; other resource requests succeed.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||||
|
},
|
||||||
|
"capturePoints": [{ "id": "initial", "label": "Scoped review failure" }]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"processes": [{
|
"processes": [{
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
|
|||||||
- `src/routes/+layout.svelte`: root application shell and override contexts.
|
- `src/routes/+layout.svelte`: root application shell and override contexts.
|
||||||
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
|
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
|
||||||
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
|
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
|
||||||
|
- `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation.
|
||||||
- `src/lib/workspace/header/`: Header frame, context, and overrides.
|
- `src/lib/workspace/header/`: Header frame, context, and overrides.
|
||||||
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
||||||
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
|
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
|
||||||
|
|||||||
@@ -245,12 +245,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
workspacePage.includes("path: '/tickets'") &&
|
workspacePage.includes("DashboardSection") &&
|
||||||
workspacePage.includes("path: '/settings/runtimes'") &&
|
!workspacePage.includes("<nav") &&
|
||||||
workspacePage.includes("permissions.manage_runtimes === true") &&
|
!workspacePage.includes("/settings/runtimes"),
|
||||||
workspacePage.includes("path: '/workers'") &&
|
"Home should show work records without duplicating Sidebar navigation",
|
||||||
workspacePage.includes("workspaceRoute(data.workspace.workspace_id, resource.path)"),
|
|
||||||
"Home should link to daily resources and permission-gated Settings without owning their inventories",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!workspacePage.includes("workerConsoleHref") &&
|
!workspacePage.includes("workerConsoleHref") &&
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
|
||||||
|
import type { DashboardFeed } from './dashboard';
|
||||||
|
|
||||||
|
let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise<DashboardFeed> } = $props();
|
||||||
|
function timestamp(value?: string | null): string | null {
|
||||||
|
return value && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
|
||||||
|
}
|
||||||
|
function displayDate(value: string): string {
|
||||||
|
return new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-labelledby={id} class="dashboard-section">
|
||||||
|
<h2 {id}>{title}</h2>
|
||||||
|
{#await result}
|
||||||
|
<p class="feed-state" role="status">Loading {title.toLowerCase()}…</p>
|
||||||
|
{:then feed}
|
||||||
|
<div data-feed-ready={id}>
|
||||||
|
{#each feed.errors as error}<p class="feed-error" role="alert">{error}</p>{/each}
|
||||||
|
{#if feed.rows.length}
|
||||||
|
<ul>
|
||||||
|
{#each feed.rows as row, index (row.key)}
|
||||||
|
{@const date = timestamp(row.updatedAt)}
|
||||||
|
<li>
|
||||||
|
<a href={row.href} class:attention={row.attention}>
|
||||||
|
<div class="row-meta"><span>{row.kind}</span><code>{row.reference}</code></div>
|
||||||
|
<strong class:machine-title={row.kind === 'Merge Request'}>{row.title}</strong>
|
||||||
|
<span class="row-status">{row.status}</span>
|
||||||
|
{#if row.workerKey}<span class="row-detail">Coder <code>{row.workerKey}</code></span>{/if}
|
||||||
|
{#if row.detail}<span class="row-detail">{row.detail}</span>{/if}
|
||||||
|
{#if date}<time datetime={date}>Updated {displayDate(date)}</time>{:else}<span class="row-detail">Update time unavailable</span>{/if}
|
||||||
|
</a>
|
||||||
|
{#if index < feed.rows.length - 1}<BevelLine decorative />{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if !feed.errors.length}
|
||||||
|
<p class="feed-state">{empty}</p>
|
||||||
|
{/if}
|
||||||
|
{#if feed.notice}<p class="feed-notice">{feed.notice}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{:catch}
|
||||||
|
<p class="feed-error" role="alert">Could not load {title.toLowerCase()}. Refresh to try again.</p>
|
||||||
|
{/await}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section { min-width: 0; }
|
||||||
|
h2 { margin: 0 0 var(--space-2); font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); }
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; }
|
||||||
|
a { display: grid; gap: var(--space-1); min-width: 0; padding-block: var(--space-3); text-decoration: none; color: var(--text); overflow-wrap: anywhere; }
|
||||||
|
a:hover, a:focus-visible { background: var(--interactive-hover); }
|
||||||
|
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
a:active { background: var(--interactive-selected); }
|
||||||
|
strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text-strong); font-size: var(--font-size-body); line-height: var(--line-height-body); font-weight: 600; }
|
||||||
|
.machine-title { font-family: var(--font-mono); }
|
||||||
|
.row-meta { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||||
|
.row-meta, .row-detail, time, .row-status, .feed-notice { font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
|
||||||
|
.row-meta, .row-detail, time, .feed-state, .feed-notice { color: var(--text-muted); }
|
||||||
|
code { font-family: var(--font-mono); }
|
||||||
|
.row-status { font-weight: 600; }
|
||||||
|
.attention .row-status { color: var(--warning); }
|
||||||
|
.feed-state { margin: var(--space-3) 0; }
|
||||||
|
.feed-error { margin: var(--space-3) 0; color: var(--danger); overflow-wrap: anywhere; }
|
||||||
|
.feed-notice { margin: var(--space-3) 0 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Deterministic read-only fixtures shared by Home component and browser tests.
|
||||||
|
export const fixturePage = (returned: number, more = false) => ({
|
||||||
|
returned,
|
||||||
|
limit: 5,
|
||||||
|
sort: "priority",
|
||||||
|
has_more: more,
|
||||||
|
next_cursor: more ? "next" : null,
|
||||||
|
source_limit: null,
|
||||||
|
source_truncated: false,
|
||||||
|
});
|
||||||
|
const updated = (hour: number) =>
|
||||||
|
`2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`;
|
||||||
|
export function fixtureTicket(key: string, state: string, hour = 10) {
|
||||||
|
return {
|
||||||
|
id: `internal-${key}`,
|
||||||
|
resource_key: key,
|
||||||
|
title: key === "T-101"
|
||||||
|
? "Restore queued Worker submissions"
|
||||||
|
: `${state} ticket ${key}`,
|
||||||
|
state,
|
||||||
|
priority: "normal",
|
||||||
|
queued_at: null,
|
||||||
|
queued_by: null,
|
||||||
|
updated_at: updated(hour),
|
||||||
|
record_source: "fixture",
|
||||||
|
workspace_action_priority: state === "inprogress" || state === "queued"
|
||||||
|
? "active_work"
|
||||||
|
: "background",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function fixtureDetail(key = "T-101") {
|
||||||
|
const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued");
|
||||||
|
const { workspace_action_priority: _, ...ticket } = summary;
|
||||||
|
return {
|
||||||
|
...ticket,
|
||||||
|
item_revision: "revision-1",
|
||||||
|
created_at: updated(1),
|
||||||
|
readiness: null,
|
||||||
|
body: "Implementation in progress.",
|
||||||
|
body_truncated: false,
|
||||||
|
resolution: null,
|
||||||
|
artifacts: [],
|
||||||
|
artifact_count: 0,
|
||||||
|
assignments: [],
|
||||||
|
assignment_diagnostics: [],
|
||||||
|
events: [],
|
||||||
|
event_count: 0,
|
||||||
|
event_page: fixturePage(0),
|
||||||
|
implementation_reports: [],
|
||||||
|
evidence: {
|
||||||
|
has_merge_request: false,
|
||||||
|
has_current_subject_ref: false,
|
||||||
|
has_review_request: false,
|
||||||
|
has_commit: false,
|
||||||
|
review_status: null,
|
||||||
|
approved_current_subject: false,
|
||||||
|
review_after_rescope: false,
|
||||||
|
unresolved_request_changes: false,
|
||||||
|
complete_for_integration: false,
|
||||||
|
missing: [],
|
||||||
|
},
|
||||||
|
current_coder: key === "T-101"
|
||||||
|
? {
|
||||||
|
assignment_id: "a-1",
|
||||||
|
runtime_id: "internal-runtime",
|
||||||
|
worker_id: "internal-worker",
|
||||||
|
worker_resource_key: "W-7",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
action_eligibility: {
|
||||||
|
blockers: [],
|
||||||
|
can_assign_orchestrator: false,
|
||||||
|
can_queue: false,
|
||||||
|
can_start_manual_coder: false,
|
||||||
|
can_unassign_orchestrator: false,
|
||||||
|
queue_tickets: [],
|
||||||
|
},
|
||||||
|
linked_objectives: [],
|
||||||
|
merge_request: null,
|
||||||
|
merge_requests: [],
|
||||||
|
targets: [],
|
||||||
|
risk_flags: [],
|
||||||
|
relations: {
|
||||||
|
outgoing: [],
|
||||||
|
incoming: [],
|
||||||
|
notices: [],
|
||||||
|
blockers: key === "T-101"
|
||||||
|
? [{
|
||||||
|
blocking_resource_key: "T-99",
|
||||||
|
blocking_state: "ready",
|
||||||
|
blocking_ticket: "internal-T-99",
|
||||||
|
note: null,
|
||||||
|
reason_kind: "dependency",
|
||||||
|
relation_kind: "depends_on",
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function dashboardFixture(
|
||||||
|
path: string,
|
||||||
|
search: URLSearchParams,
|
||||||
|
workspaceId = "home-owner",
|
||||||
|
): unknown | null {
|
||||||
|
const empty = workspaceId === "home-empty";
|
||||||
|
const long = workspaceId === "home-long";
|
||||||
|
if (path === "/tickets") {
|
||||||
|
const state = search.get("states")!;
|
||||||
|
const items = empty
|
||||||
|
? []
|
||||||
|
: state === "inprogress,queued"
|
||||||
|
? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")]
|
||||||
|
: state === "done"
|
||||||
|
? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)]
|
||||||
|
: [fixtureTicket("T-105", "closed", 14)];
|
||||||
|
if (long && items[0]) {
|
||||||
|
items[0].title = "長いTicket名 — ".repeat(8) +
|
||||||
|
"preserve-the-full-title-without-horizontal-overflow";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
limit: 5,
|
||||||
|
items,
|
||||||
|
page: fixturePage(items.length, !empty),
|
||||||
|
invalid_records: [],
|
||||||
|
record_authority: "fixture",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/^\/tickets\/T-10[12]/.test(path)) {
|
||||||
|
return fixtureDetail(path.includes("T-101") ? "T-101" : "T-102");
|
||||||
|
}
|
||||||
|
const objective = {
|
||||||
|
id: "internal-objective",
|
||||||
|
resource_key: "O-12",
|
||||||
|
title: long ? "Objective ".repeat(18) : "Improve daily Workspace operation",
|
||||||
|
state: "active",
|
||||||
|
created_at: updated(1),
|
||||||
|
updated_at: updated(13),
|
||||||
|
summary: "",
|
||||||
|
linked_tickets: [],
|
||||||
|
record_source: "fixture",
|
||||||
|
};
|
||||||
|
if (path === "/objectives") {
|
||||||
|
return {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
limit: 5,
|
||||||
|
items: empty ? [] : [objective],
|
||||||
|
invalid_records: [],
|
||||||
|
record_authority: "fixture",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path.startsWith("/objectives/O-12")) {
|
||||||
|
const { summary: _, ...detail } = objective;
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
body: "# Objective detail\n\nHome dashboard navigation destination.",
|
||||||
|
body_truncated: false,
|
||||||
|
revision: "revision-1",
|
||||||
|
linked_ticket_summaries: [],
|
||||||
|
resources: [],
|
||||||
|
events: [],
|
||||||
|
event_page: fixturePage(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === "/merge-requests") {
|
||||||
|
return {
|
||||||
|
items: empty
|
||||||
|
? []
|
||||||
|
: ["request_changes", "pending", "approved"].map((status, index) => ({
|
||||||
|
summary: {
|
||||||
|
merge_request_id: `internal-merge-${index}`,
|
||||||
|
repository_key: "main",
|
||||||
|
state: "open",
|
||||||
|
selector_from: long
|
||||||
|
? "feature/" + "long-branch-".repeat(15)
|
||||||
|
: ["fix/queue", "feat/home", "fix/sidebar"][index],
|
||||||
|
selector_to: "develop",
|
||||||
|
review_status: status,
|
||||||
|
updated_at: updated(15 - index),
|
||||||
|
review_excerpt: null,
|
||||||
|
review_requested_at: updated(10),
|
||||||
|
review_submitted_at: null,
|
||||||
|
current_subject_ref: "abc123",
|
||||||
|
review_subject_ref: "abc123",
|
||||||
|
},
|
||||||
|
thread_event_count: 1,
|
||||||
|
ticket_ids: [],
|
||||||
|
ref_diagnostics: [],
|
||||||
|
})),
|
||||||
|
next_cursor: empty ? null : "older-reviews",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import type { TicketListItemSummary } from "$lib/generated/ticket-api";
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import {
|
||||||
|
parseMergeRequestListResponse,
|
||||||
|
parseObjectiveListResponse,
|
||||||
|
parseTicketDetail,
|
||||||
|
parseTicketListResponse,
|
||||||
|
TICKET_BROWSER_API_LOAD_POLICY,
|
||||||
|
} from "$lib/workspace/api/ticket-browser";
|
||||||
|
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
|
||||||
|
import { objectiveHref, ticketHref } from "$lib/workspace/resource-links";
|
||||||
|
|
||||||
|
export const HOME_LIMIT = 5;
|
||||||
|
export type DashboardRow = {
|
||||||
|
key: string;
|
||||||
|
kind: "Ticket" | "Objective" | "Merge Request";
|
||||||
|
reference: string;
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
status: string;
|
||||||
|
detail?: string;
|
||||||
|
workerKey?: string;
|
||||||
|
attention?: boolean;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
export type DashboardFeed = {
|
||||||
|
rows: DashboardRow[];
|
||||||
|
errors: string[];
|
||||||
|
notice?: string;
|
||||||
|
};
|
||||||
|
export type Dashboard = {
|
||||||
|
reviews: Promise<DashboardFeed>;
|
||||||
|
active: Promise<DashboardFeed>;
|
||||||
|
recent: Promise<DashboardFeed>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const states: Record<string, string> = {
|
||||||
|
inprogress: "In progress",
|
||||||
|
queued: "Queued",
|
||||||
|
done: "Done",
|
||||||
|
closed: "Closed",
|
||||||
|
active: "Active",
|
||||||
|
completed: "Completed",
|
||||||
|
paused: "Paused",
|
||||||
|
};
|
||||||
|
const reviewStates: Record<string, string> = {
|
||||||
|
none: "Not reviewed",
|
||||||
|
pending: "Review pending",
|
||||||
|
approved: "Approved",
|
||||||
|
request_changes: "Changes requested",
|
||||||
|
unresolved_changes: "Unresolved changes",
|
||||||
|
};
|
||||||
|
function ticketRow(
|
||||||
|
workspaceId: string,
|
||||||
|
ticket: TicketListItemSummary,
|
||||||
|
): DashboardRow {
|
||||||
|
return {
|
||||||
|
key: `ticket:${ticket.resource_key}`,
|
||||||
|
kind: "Ticket",
|
||||||
|
reference: ticket.resource_key,
|
||||||
|
title: ticket.title,
|
||||||
|
href: ticketHref(workspaceId, ticket),
|
||||||
|
status: states[ticket.state] ?? ticket.state,
|
||||||
|
updatedAt: ticket.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function failure(label: string, error: string | null): string {
|
||||||
|
const status = error?.match(/HTTP \d{3}/)?.[0];
|
||||||
|
return `${label} unavailable.${
|
||||||
|
status ? ` ${status}.` : ""
|
||||||
|
} Refresh to retry.`;
|
||||||
|
}
|
||||||
|
function dateValue(value?: string | null): number {
|
||||||
|
const timestamp = value ? Date.parse(value) : NaN;
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : -Infinity;
|
||||||
|
}
|
||||||
|
export function recentRows(rows: DashboardRow[]): DashboardRow[] {
|
||||||
|
const latest = new Map<string, DashboardRow>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const previous = latest.get(row.key);
|
||||||
|
if (
|
||||||
|
!previous || dateValue(row.updatedAt) >= dateValue(previous.updatedAt)
|
||||||
|
) latest.set(row.key, row);
|
||||||
|
}
|
||||||
|
return [...latest.values()]
|
||||||
|
.sort((a, b) =>
|
||||||
|
dateValue(b.updatedAt) - dateValue(a.updatedAt) ||
|
||||||
|
a.key.localeCompare(b.key)
|
||||||
|
)
|
||||||
|
.slice(0, HOME_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are bounded, read-only browser endpoints. QueryTicket/QueryObjective are
|
||||||
|
// tool-only capabilities and must not be approximated by guessed HTTP routes.
|
||||||
|
export function loadDashboard(
|
||||||
|
fetchFn: typeof fetch,
|
||||||
|
workspaceId: string,
|
||||||
|
): Dashboard {
|
||||||
|
function get<T>(path: string, parse: (value: unknown) => T) {
|
||||||
|
return loadJson(
|
||||||
|
fetchFn,
|
||||||
|
workspaceApiPath(workspaceId, path),
|
||||||
|
{ signal: AbortSignal.timeout(10_000) },
|
||||||
|
parse,
|
||||||
|
TICKET_BROWSER_API_LOAD_POLICY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tickets = (state: string) =>
|
||||||
|
get(
|
||||||
|
`/tickets?${new URLSearchParams({
|
||||||
|
states: state,
|
||||||
|
limit: String(HOME_LIMIT),
|
||||||
|
})}`,
|
||||||
|
parseTicketListResponse,
|
||||||
|
);
|
||||||
|
const activeResult = tickets("inprogress,queued");
|
||||||
|
const doneResult = tickets("done");
|
||||||
|
const closedResult = tickets("closed");
|
||||||
|
const objectivesResult = get(
|
||||||
|
`/objectives?limit=${HOME_LIMIT}`,
|
||||||
|
parseObjectiveListResponse,
|
||||||
|
);
|
||||||
|
const reviewsResult = get(
|
||||||
|
`/merge-requests?state=open&limit=${HOME_LIMIT}`,
|
||||||
|
parseMergeRequestListResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reviews: reviewsResult.then((result): DashboardFeed => {
|
||||||
|
if (!result.data) {
|
||||||
|
return { rows: [], errors: [failure("Merge Requests", result.error)] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rows: result.data.items.slice(0, HOME_LIMIT).map((
|
||||||
|
{ summary, ref_diagnostics },
|
||||||
|
) => ({
|
||||||
|
key: `merge:${summary.merge_request_id}`,
|
||||||
|
kind: "Merge Request",
|
||||||
|
reference: summary.repository_key,
|
||||||
|
title: `${
|
||||||
|
summary.selector_from ?? "Source unavailable"
|
||||||
|
} → ${summary.selector_to}`,
|
||||||
|
href: mergeRequestPagePath(workspaceId, summary.merge_request_id),
|
||||||
|
status: reviewStates[summary.review_status] ?? summary.review_status,
|
||||||
|
attention: ["request_changes", "unresolved_changes"].includes(
|
||||||
|
summary.review_status,
|
||||||
|
) || Boolean(ref_diagnostics?.length),
|
||||||
|
detail: ref_diagnostics?.length
|
||||||
|
? "Source or target needs attention"
|
||||||
|
: undefined,
|
||||||
|
updatedAt: summary.updated_at,
|
||||||
|
})),
|
||||||
|
errors: [],
|
||||||
|
notice: result.data.next_cursor
|
||||||
|
? `Showing ${
|
||||||
|
Math.min(result.data.items.length, HOME_LIMIT)
|
||||||
|
} most recently updated open Merge Requests.`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
active: activeResult.then(async (result): Promise<DashboardFeed> => {
|
||||||
|
if (!result.data) {
|
||||||
|
return { rows: [], errors: [failure("Active tickets", result.error)] };
|
||||||
|
}
|
||||||
|
const details = await Promise.all(
|
||||||
|
result.data.items.slice(0, HOME_LIMIT).map(async (ticket) => {
|
||||||
|
const detail = await get(
|
||||||
|
`/tickets/${encodeURIComponent(ticket.resource_key)}`,
|
||||||
|
parseTicketDetail,
|
||||||
|
);
|
||||||
|
if (!detail.data) {
|
||||||
|
return {
|
||||||
|
row: {
|
||||||
|
...ticketRow(workspaceId, ticket),
|
||||||
|
detail: "Assignment and blockers unavailable",
|
||||||
|
},
|
||||||
|
error: failure(`${ticket.resource_key} details`, detail.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const current = detail.data;
|
||||||
|
// A concurrent transition can remove a Ticket from this snapshot's scope.
|
||||||
|
if (!["inprogress", "queued"].includes(current.state)) {
|
||||||
|
return { changed: true };
|
||||||
|
}
|
||||||
|
const blockers = current.relations.blockers;
|
||||||
|
const row = ticketRow(workspaceId, { ...ticket, ...current });
|
||||||
|
const blockedKeys = blockers.map((blocker) =>
|
||||||
|
blocker.blocking_resource_key
|
||||||
|
).filter(Boolean);
|
||||||
|
return {
|
||||||
|
row: {
|
||||||
|
...row,
|
||||||
|
status: blockers.length ? `${row.status} · Blocked` : row.status,
|
||||||
|
attention: blockers.length > 0,
|
||||||
|
detail: blockers.length
|
||||||
|
? (blockedKeys.length === blockers.length
|
||||||
|
? `Blocked by ${blockedKeys.join(", ")}`
|
||||||
|
: `${blockers.length} unresolved dependencies`)
|
||||||
|
: current.current_coder
|
||||||
|
? undefined
|
||||||
|
: "No coder assigned",
|
||||||
|
workerKey: current.current_coder?.worker_resource_key ??
|
||||||
|
undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
rows: details.flatMap((item) => item.row ? [item.row] : []),
|
||||||
|
errors: [
|
||||||
|
...details.flatMap((item) => item.error ? [item.error] : []),
|
||||||
|
...(result.data.invalid_records.length
|
||||||
|
? ["Some Ticket records could not be read."]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
notice: details.some((item) => item.changed)
|
||||||
|
? "Work changed while loading. Refresh for the latest list."
|
||||||
|
: result.data.page.has_more || result.data.page.source_truncated
|
||||||
|
? `Showing ${
|
||||||
|
Math.min(result.data.items.length, HOME_LIMIT)
|
||||||
|
} active tickets, with in-progress work first.`
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
recent: Promise.all([doneResult, closedResult, objectivesResult]).then((
|
||||||
|
[done, closed, objectives],
|
||||||
|
): DashboardFeed => ({
|
||||||
|
rows: recentRows([
|
||||||
|
...(done.data?.items ?? []).map((ticket) =>
|
||||||
|
ticketRow(workspaceId, ticket)
|
||||||
|
),
|
||||||
|
...(closed.data?.items ?? []).map((ticket) =>
|
||||||
|
ticketRow(workspaceId, ticket)
|
||||||
|
),
|
||||||
|
...(objectives.data?.items ?? []).map((objective): DashboardRow => ({
|
||||||
|
key: `objective:${objective.resource_key}`,
|
||||||
|
kind: "Objective",
|
||||||
|
reference: objective.resource_key,
|
||||||
|
title: objective.title,
|
||||||
|
href: objectiveHref(workspaceId, objective),
|
||||||
|
status: states[objective.state] ?? objective.state,
|
||||||
|
updatedAt: objective.updated_at,
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
errors: [
|
||||||
|
...(!done.data ? [failure("Done tickets", done.error)] : []),
|
||||||
|
...(!closed.data ? [failure("Closed tickets", closed.error)] : []),
|
||||||
|
...(!objectives.data ? [failure("Objectives", objectives.error)] : []),
|
||||||
|
...([done.data, closed.data, objectives.data].some((data) =>
|
||||||
|
data?.invalid_records.length
|
||||||
|
)
|
||||||
|
? ["Some records could not be read."]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
notice:
|
||||||
|
[done.data, closed.data, objectives.data].some((data) =>
|
||||||
|
data?.items.length
|
||||||
|
)
|
||||||
|
? "Up to 5 latest updates to done / closed tickets and Objectives."
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,126 +1,53 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { invalidate } from '$app/navigation';
|
||||||
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
|
import DashboardSection from '$lib/workspace/home/DashboardSection.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
const resources = [
|
let refreshing = $state(false);
|
||||||
{ label: 'Tickets', path: '/tickets' },
|
let refreshError = $state<string | null>(null);
|
||||||
{ label: 'Objectives', path: '/objectives' },
|
async function refresh() {
|
||||||
{ label: 'Merge Requests', path: '/merge-requests' },
|
if (refreshing) return;
|
||||||
{ label: 'Memory', path: '/memory' },
|
refreshing = true;
|
||||||
{ label: 'Workers', path: '/workers' },
|
refreshError = null;
|
||||||
];
|
try {
|
||||||
const settings = $derived([
|
await invalidate('workspace:home');
|
||||||
{ label: 'Runtimes', path: '/settings/runtimes', visible: data.workspace?.permissions.manage_runtimes === true },
|
await Promise.all(Object.values(data.dashboard));
|
||||||
{ label: 'Configuration Sources', path: '/settings/configuration', visible: true },
|
} catch {
|
||||||
{ label: 'Repositories', path: '/settings/repositories', visible: data.workspace?.permissions.manage_repositories === true },
|
refreshError = 'Could not refresh. Try again.';
|
||||||
{ label: 'Repository Access', path: '/settings/repository-access', visible: data.workspace?.permissions.manage_secrets === true },
|
} finally {
|
||||||
{ label: 'Profile Sources', path: '/settings/profiles', visible: true },
|
refreshing = false;
|
||||||
{ label: 'Workspace Identity', path: '/settings', visible: data.workspace?.permissions.delete_workspace === true },
|
}
|
||||||
].filter((item) => item.visible));
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head><title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title></svelte:head>
|
||||||
<title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="workspace-home">
|
<div class="workspace-home">
|
||||||
{#if data.workspace}
|
<header class="home-header">
|
||||||
<div class="home-columns">
|
<h1>Activity</h1>
|
||||||
<section aria-labelledby="home-work-heading">
|
<button type="button" onclick={refresh} disabled={refreshing}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
|
||||||
<h1 id="home-work-heading">Work</h1>
|
</header>
|
||||||
<nav aria-labelledby="home-work-heading">
|
{#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
|
||||||
<ul>
|
<div class="home-columns">
|
||||||
{#each resources as resource, index (resource.path)}
|
<DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
|
||||||
<li>
|
<DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} />
|
||||||
<a class="resource-link" href={workspaceRoute(data.workspace.workspace_id, resource.path)}>
|
<DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} />
|
||||||
<span>{resource.label}</span>
|
</div>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
|
||||||
</a>
|
|
||||||
{#if index < resources.length - 1}<BevelLine decorative />{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
<section class="home-settings" aria-labelledby="home-settings-heading">
|
|
||||||
<h2 id="home-settings-heading">Settings</h2>
|
|
||||||
<nav aria-labelledby="home-settings-heading">
|
|
||||||
<ul>
|
|
||||||
{#each settings as item (item.path)}
|
|
||||||
<li><a href={workspaceRoute(data.workspace.workspace_id, item.path)}>{item.label}</a></li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{:else if data.workspaceError}
|
|
||||||
<p class="home-error" role="alert">{data.workspaceError}</p>
|
|
||||||
<a class="recovery-link" href="/">Choose a workspace</a>
|
|
||||||
{:else}
|
|
||||||
<p class="home-loading" role="status">Loading workspace…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.workspace-home {
|
.workspace-home { container-type: inline-size; min-width: 0; }
|
||||||
container-type: inline-size;
|
.home-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
|
||||||
min-width: 0;
|
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
|
||||||
}
|
button { flex: 0 0 auto; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); color: var(--text); background: transparent; cursor: pointer; }
|
||||||
.home-columns {
|
button:hover:not(:disabled), button:focus-visible { background: var(--interactive-hover); }
|
||||||
display: grid;
|
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
gap: var(--space-6);
|
button:active:not(:disabled) { background: var(--interactive-selected); }
|
||||||
align-items: start;
|
button:disabled { opacity: 0.6; cursor: wait; }
|
||||||
}
|
.home-columns { display: grid; gap: var(--space-6); align-items: start; }
|
||||||
section { min-width: 0; }
|
.home-error { color: var(--danger); overflow-wrap: anywhere; }
|
||||||
h1 {
|
@container (min-width: 52rem) {
|
||||||
margin: 0 0 var(--space-2);
|
.home-columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: var(--font-size-title);
|
|
||||||
line-height: var(--line-height-title);
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 var(--space-2);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
line-height: var(--line-height-body);
|
|
||||||
}
|
|
||||||
ul { list-style: none; margin: 0; padding: 0; }
|
|
||||||
a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
min-height: 44px;
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
line-height: var(--line-height-body);
|
|
||||||
text-decoration: none;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.resource-link { font-weight: 600; }
|
|
||||||
a:hover, a:focus-visible { background: var(--interactive-hover); }
|
|
||||||
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
a:active { background: var(--interactive-selected); }
|
|
||||||
svg {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.home-error, .home-loading { margin: 0; overflow-wrap: anywhere; }
|
|
||||||
.home-error { color: var(--danger); }
|
|
||||||
.home-loading { color: var(--text-muted); }
|
|
||||||
.recovery-link { width: fit-content; text-decoration: underline; }
|
|
||||||
@container (min-width: 48rem) {
|
|
||||||
.home-columns { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
|
|
||||||
.home-settings { padding-top: var(--space-3); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { loadDashboard } from "$lib/workspace/home/dashboard";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
// Home uses the Workspace identity and permissions already loaded by its layout.
|
export const load: PageLoad = ({ fetch, params, depends }) => {
|
||||||
// Infrastructure inventories belong to Settings and must not delay task navigation.
|
depends("workspace:home");
|
||||||
export const load: PageLoad = ({ params }) => ({ workspaceId: params.workspaceId });
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
dashboard: loadDashboard(fetch, params.workspaceId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,175 +1,266 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { cleanup, render, screen, within } from "@testing-library/svelte";
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/svelte";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { invalidate } from "$app/navigation";
|
||||||
import Home from "./+page.svelte";
|
import Home from "./+page.svelte";
|
||||||
import { load } from "./+page";
|
import { load } from "./+page";
|
||||||
import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model";
|
import {
|
||||||
|
type Dashboard,
|
||||||
|
type DashboardFeed,
|
||||||
|
loadDashboard,
|
||||||
|
recentRows,
|
||||||
|
} from "$lib/workspace/home/dashboard";
|
||||||
|
import {
|
||||||
|
dashboardFixture,
|
||||||
|
fixtureDetail,
|
||||||
|
} from "$lib/workspace/home/dashboard.test-fixtures";
|
||||||
|
|
||||||
|
vi.mock("$app/navigation", () => ({ invalidate: vi.fn(async () => {}) }));
|
||||||
type Data = ComponentProps<typeof Home>["data"];
|
type Data = ComponentProps<typeof Home>["data"];
|
||||||
function homeData(owner = true, id = "home-review"): Data {
|
function fixtureFetch(failPath?: string) {
|
||||||
|
return vi.fn(async (path: RequestInfo | URL, _init?: RequestInit) => {
|
||||||
|
const url = new URL(String(path), "https://example.test");
|
||||||
|
const [, id, resource] = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/)!;
|
||||||
|
return resource === failPath
|
||||||
|
? Response.json({}, { status: 503 })
|
||||||
|
: Response.json(
|
||||||
|
dashboardFixture(resource, url.searchParams, decodeURIComponent(id)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function data(id = "home-owner", fetchFn: typeof fetch = fixtureFetch()): Data {
|
||||||
return {
|
return {
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
workspace: parseWorkspaceResponse({
|
workspace: { workspace_id: id, display_name: "Home review" },
|
||||||
workspace_id: id,
|
dashboard: loadDashboard(fetchFn, id),
|
||||||
display_name: "Workspace Home Review",
|
} as unknown as Data;
|
||||||
record_authority: "private-authority",
|
|
||||||
schema_version: 1,
|
|
||||||
auth: {
|
|
||||||
Passkey: {
|
|
||||||
rp_id: "example.test",
|
|
||||||
origin: "https://example.test",
|
|
||||||
public_base_url: "https://example.test",
|
|
||||||
cookie_name: "fixture",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
manage_repositories: owner,
|
|
||||||
manage_secrets: owner,
|
|
||||||
manage_runtimes: owner,
|
|
||||||
delete_workspace: owner,
|
|
||||||
},
|
|
||||||
extension_points: {
|
|
||||||
store: "private-store",
|
|
||||||
event_stream: { status: "ready", note: "", diagnostics: [] },
|
|
||||||
host_worker_bridge: { status: "ready", note: "", diagnostics: [] },
|
|
||||||
companion_console: { status: "ready", note: "", diagnostics: [] },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
workspaceError: null,
|
|
||||||
repositories: null,
|
|
||||||
repositoriesError: null,
|
|
||||||
accessibleWorkspaces: [],
|
|
||||||
workspaceCatalogError: null,
|
|
||||||
} as Data;
|
|
||||||
}
|
}
|
||||||
afterEach(cleanup);
|
function mount(value = data()) {
|
||||||
|
return render(Home, {
|
||||||
test("Home presents daily resources in product order, without internal metadata or repeated identity", () => {
|
props: { params: { workspaceId: value.workspaceId }, data: value },
|
||||||
const { container } = render(Home, {
|
|
||||||
props: { params: { workspaceId: "home-review" }, data: homeData() },
|
|
||||||
});
|
});
|
||||||
const work = screen.getByRole("navigation", { name: "Work" });
|
}
|
||||||
expect(
|
afterEach(() => {
|
||||||
within(work).getAllByRole("link").map((link) => link.textContent?.trim()),
|
cleanup();
|
||||||
).toEqual([
|
vi.clearAllMocks();
|
||||||
"Tickets",
|
});
|
||||||
"Objectives",
|
|
||||||
"Merge Requests",
|
test("Home shows actual reviews, active assignments/blockers, and recent records, not navigation tiles", async () => {
|
||||||
"Memory",
|
mount();
|
||||||
"Workers",
|
expect(await screen.findByText("Changes requested")).toBeTruthy();
|
||||||
]);
|
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
|
||||||
expect(
|
expect(screen.getByText("W-7")).toBeTruthy();
|
||||||
within(work).getAllByRole("link").map((link) => link.getAttribute("href")),
|
expect(await screen.findByText("Improve daily Workspace operation"))
|
||||||
).toEqual([
|
.toBeTruthy();
|
||||||
"/w/home-review/tickets",
|
expect(screen.getByText("Review pending")).toBeTruthy();
|
||||||
"/w/home-review/objectives",
|
expect(screen.getByText("Approved")).toBeTruthy();
|
||||||
"/w/home-review/merge-requests",
|
expect(screen.queryByRole("navigation")).toBeNull();
|
||||||
"/w/home-review/memory",
|
expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull();
|
||||||
"/w/home-review/workers",
|
expect(screen.getAllByRole("button")).toHaveLength(1);
|
||||||
]);
|
expect(document.body.textContent).not.toMatch(
|
||||||
expect(container.querySelectorAll("h1")).toHaveLength(1);
|
/internal-worker|internal-merge|internal-runtime|Record authority/,
|
||||||
expect(container.textContent).not.toMatch(
|
|
||||||
/private-authority|private-store|home-review|Workspace Home Review|Hosts|Record authority|API/,
|
|
||||||
);
|
);
|
||||||
expect(
|
|
||||||
container.querySelectorAll(
|
|
||||||
"button, .card, .workspace-action-card, .state-pill",
|
|
||||||
),
|
|
||||||
).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("owner management destinations are separate from daily resources", () => {
|
|
||||||
render(Home, {
|
|
||||||
props: { params: { workspaceId: "home-review" }, data: homeData() },
|
|
||||||
});
|
|
||||||
const settings = screen.getByRole("navigation", { name: "Settings" });
|
|
||||||
expect(within(settings).getAllByRole("link").map((link) => link.textContent))
|
|
||||||
.toEqual([
|
|
||||||
"Runtimes",
|
|
||||||
"Configuration Sources",
|
|
||||||
"Repositories",
|
|
||||||
"Repository Access",
|
|
||||||
"Profile Sources",
|
|
||||||
"Workspace Identity",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-owner retains read destinations without owner-only controls", () => {
|
|
||||||
render(Home, {
|
|
||||||
props: { params: { workspaceId: "home-review" }, data: homeData(false) },
|
|
||||||
});
|
|
||||||
const settings = screen.getByRole("navigation", { name: "Settings" });
|
|
||||||
expect(within(settings).getAllByRole("link").map((link) => link.textContent))
|
|
||||||
.toEqual(["Configuration Sources", "Profile Sources"]);
|
|
||||||
expect(screen.getAllByRole("link")).toHaveLength(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(
|
|
||||||
[
|
|
||||||
["manage_runtimes", "Runtimes"],
|
|
||||||
["manage_repositories", "Repositories"],
|
|
||||||
["manage_secrets", "Repository Access"],
|
|
||||||
["delete_workspace", "Workspace Identity"],
|
|
||||||
] as const,
|
|
||||||
)(
|
|
||||||
"%s alone exposes only its matching management destination",
|
|
||||||
(permission, label) => {
|
|
||||||
const data = homeData(false);
|
|
||||||
data.workspace!.permissions[permission] = true;
|
|
||||||
render(Home, { props: { params: { workspaceId: "home-review" }, data } });
|
|
||||||
const settings = screen.getByRole("navigation", { name: "Settings" });
|
|
||||||
expect(within(settings).getAllByRole("link")).toHaveLength(3);
|
|
||||||
expect(within(settings).getByRole("link", { name: label })).toBeTruthy();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("route reuse updates all destinations and permissions from the new Workspace", async () => {
|
|
||||||
const view = render(Home, {
|
|
||||||
props: { params: { workspaceId: "home-review" }, data: homeData() },
|
|
||||||
});
|
|
||||||
await view.rerender({ data: homeData(false, "space / 日本") });
|
|
||||||
expect(screen.queryByRole("link", { name: "Runtimes" })).toBeNull();
|
|
||||||
for (const link of screen.getAllByRole("link")) {
|
for (const link of screen.getAllByRole("link")) {
|
||||||
expect(link.getAttribute("href")).toMatch(
|
expect(link.getAttribute("href")).toMatch(
|
||||||
/^\/w\/space%20%2F%20%E6%97%A5%E6%9C%AC\//,
|
/^\/w\/home-owner\/(tickets|objectives|merge-requests)\/[^/]+$/,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loading and failure do not invent resources or management permissions", async () => {
|
test("loader only uses bounded read-only supported endpoints and advertises explicit refresh dependency", async () => {
|
||||||
const data = { ...homeData(), workspace: null } as unknown as Data;
|
const fetch = fixtureFetch();
|
||||||
const view = render(Home, {
|
const depends = vi.fn();
|
||||||
props: { params: { workspaceId: "home-review" }, data },
|
const result = await load(
|
||||||
});
|
{
|
||||||
expect(screen.getByRole("status").textContent).toBe("Loading workspace…");
|
fetch,
|
||||||
expect(screen.queryAllByRole("link")).toHaveLength(0);
|
params: { workspaceId: "home-owner" },
|
||||||
await view.rerender({
|
depends,
|
||||||
data: {
|
} as unknown as Parameters<typeof load>[0],
|
||||||
...data,
|
|
||||||
workspaceError: "Workspace access unavailable",
|
|
||||||
} as unknown as Data,
|
|
||||||
});
|
|
||||||
expect(screen.getByRole("alert").textContent).toBe(
|
|
||||||
"Workspace access unavailable",
|
|
||||||
);
|
);
|
||||||
|
await Promise.all(Object.values(result!.dashboard));
|
||||||
|
expect(depends).toHaveBeenCalledWith("workspace:home");
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(7);
|
||||||
|
for (const [path, init] of fetch.mock.calls) {
|
||||||
|
const url = new URL(String(path), "https://example.test");
|
||||||
|
expect(init?.method ?? "GET").toBe("GET");
|
||||||
|
expect(init?.signal).toBeTruthy();
|
||||||
|
expect(url.pathname).not.toMatch(/query|hosts|workers|settings/);
|
||||||
|
if (!url.pathname.includes("/tickets/T-")) {
|
||||||
|
expect(url.searchParams.get("limit")).toBe("5");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done")))
|
||||||
|
.toBe(true);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: "Choose a workspace" }).getAttribute(
|
fetch.mock.calls.some(([url]) => String(url).includes("states=closed")),
|
||||||
"href",
|
).toBe(true);
|
||||||
),
|
|
||||||
).toBe("/");
|
|
||||||
expect(screen.queryByRole("navigation")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Home does not fetch infrastructure inventories", async () => {
|
test("recent updates merge independently limited sources in actual update order, not state priority", async () => {
|
||||||
const fetch = vi.fn();
|
const feed = await data().dashboard.recent;
|
||||||
|
expect(feed.errors).toEqual([]);
|
||||||
|
expect(feed.rows.map((row) => row.reference)).toEqual([
|
||||||
|
"T-105",
|
||||||
|
"O-12",
|
||||||
|
"T-103",
|
||||||
|
"T-104",
|
||||||
|
]);
|
||||||
|
const rows = Array.from(
|
||||||
|
{ length: 10 },
|
||||||
|
(_, index) => ({
|
||||||
|
...feed.rows[0],
|
||||||
|
key: String(index),
|
||||||
|
updatedAt: index === 9 ? null : `2026-01-01T0${index}:00:00Z`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
await load(
|
recentRows([...rows, { ...rows[8], updatedAt: rows[0].updatedAt }]).map((
|
||||||
{
|
row,
|
||||||
params: { workspaceId: "home-review" },
|
) => row.key),
|
||||||
fetch,
|
).toEqual([
|
||||||
} as unknown as Parameters<typeof load>[0],
|
"8",
|
||||||
),
|
"7",
|
||||||
).toEqual({ workspaceId: "home-review" });
|
"6",
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
"5",
|
||||||
|
"4",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial failure is not an empty/all-clear state and leaves sibling sections usable", async () => {
|
||||||
|
mount(data("home-owner", fixtureFetch("/merge-requests")));
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||||
|
"Merge Requests",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("No open Merge Requests.")).toBeNull();
|
||||||
|
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("Improve daily Workspace operation"))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one recent source failure preserves the other records and does not assert an empty feed", async () => {
|
||||||
|
const value = data("home-owner", fixtureFetch("/objectives"));
|
||||||
|
mount(value);
|
||||||
|
expect((await value.dashboard.recent).rows.map((row) => row.reference))
|
||||||
|
.toEqual(["T-105", "T-103", "T-104"]);
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||||
|
"Objectives",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("No done / closed tickets or Objectives yet."))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed Ticket detail preserves the summary without inventing no-assignee or unblocked state", async () => {
|
||||||
|
const feed = await data("home-owner", fixtureFetch("/tickets/T-101"))
|
||||||
|
.dashboard.active;
|
||||||
|
expect(feed.rows[0].detail).toBe("Assignment and blockers unavailable");
|
||||||
|
expect(feed.rows[0].workerKey).toBeUndefined();
|
||||||
|
expect(feed.rows[0].attention).toBeUndefined();
|
||||||
|
expect(feed.errors[0]).toContain("T-101");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a concurrent Ticket transition leaves the active scope with a refresh notice", async () => {
|
||||||
|
const fallback = fixtureFetch();
|
||||||
|
const fetchFn: typeof fetch = (path, init) =>
|
||||||
|
String(path).endsWith("/tickets/T-101")
|
||||||
|
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" }))
|
||||||
|
: fallback(path, init);
|
||||||
|
const feed = await data("home-owner", fetchFn).dashboard.active;
|
||||||
|
expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]);
|
||||||
|
expect(feed.notice).toContain("Work changed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty resources have scoped empty states and no fabricated totals", async () => {
|
||||||
|
mount(data("home-empty"));
|
||||||
|
expect(await screen.findByText("No open Merge Requests.")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("No in-progress or queued tickets."))
|
||||||
|
.toBeTruthy();
|
||||||
|
expect(await screen.findByText("No done / closed tickets or Objectives yet."))
|
||||||
|
.toBeTruthy();
|
||||||
|
expect(screen.queryAllByRole("link")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slow review loading does not hide the other completed sections", async () => {
|
||||||
|
const value = data();
|
||||||
|
value.dashboard.reviews = new Promise(() => {});
|
||||||
|
mount(value);
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("region", { name: "Needs attention" })).getByRole(
|
||||||
|
"status",
|
||||||
|
).textContent,
|
||||||
|
).toContain("Loading needs attention");
|
||||||
|
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("Improve daily Workspace operation"))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("route reuse drops pending old Workspace results and updates scoped links", async () => {
|
||||||
|
let finish!: (feed: DashboardFeed) => void;
|
||||||
|
const value = data();
|
||||||
|
value.dashboard.reviews = new Promise((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
const view = mount(value);
|
||||||
|
await view.rerender({ data: data("space / 日本") });
|
||||||
|
await screen.findByText("Changes requested");
|
||||||
|
finish({ rows: [], errors: ["OLD WORKSPACE ERROR"] });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(screen.queryByText("OLD WORKSPACE ERROR")).toBeNull();
|
||||||
|
for (const link of screen.getAllByRole("link")) {
|
||||||
|
expect(link.getAttribute("href")).toContain(
|
||||||
|
"/w/space%20%2F%20%E6%97%A5%E6%9C%AC/",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed review responses are unavailable rather than empty", async () => {
|
||||||
|
const fallback = fixtureFetch();
|
||||||
|
const fetchFn: typeof fetch = (path, init) =>
|
||||||
|
String(path).includes("/merge-requests?")
|
||||||
|
? Promise.resolve(Response.json({ items: "invalid" }))
|
||||||
|
: fallback(path, init);
|
||||||
|
const feed = await data("home-owner", fetchFn).dashboard.reviews;
|
||||||
|
expect(feed.rows).toEqual([]);
|
||||||
|
expect(feed.errors).toEqual([
|
||||||
|
"Merge Requests unavailable. Refresh to retry.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown review states are preserved and branch text never becomes HTML", async () => {
|
||||||
|
const fallback = fixtureFetch();
|
||||||
|
const fetchFn: typeof fetch = async (path, init) => {
|
||||||
|
const response = await fallback(path, init);
|
||||||
|
if (!String(path).includes("/merge-requests?")) return response;
|
||||||
|
const body = await response.json();
|
||||||
|
body.items[0].summary.review_status = "future_status";
|
||||||
|
body.items[0].summary.selector_from = '<img src=x onerror="alert(1)">';
|
||||||
|
return Response.json(body);
|
||||||
|
};
|
||||||
|
const view = mount(data("home-owner", fetchFn));
|
||||||
|
expect(await screen.findByText("future_status")).toBeTruthy();
|
||||||
|
expect(view.container.querySelector("img")).toBeNull();
|
||||||
|
expect(await screen.findByText(/<img src=x/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unavailable timestamps are not presented as current activity", async () => {
|
||||||
|
const value = data();
|
||||||
|
const feed = await value.dashboard.recent;
|
||||||
|
feed.rows[0].updatedAt = "invalid";
|
||||||
|
value.dashboard.recent = Promise.resolve(feed);
|
||||||
|
mount(value);
|
||||||
|
expect(await screen.findByText("Update time unavailable")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Refresh invalidates only the Home data dependency", async () => {
|
||||||
|
const value = data();
|
||||||
|
mount(value);
|
||||||
|
await Promise.all(Object.values(value.dashboard));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
|
expect(invalidate).toHaveBeenCalledWith("workspace:home");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user