mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
fix(ui): user-defined empty-state semantics + sidebar expand-on-click (slice 18 Checkpoint C repair)
Two blockers from velocity-guard 18.C at a523c56.
Blocker 1 — emptyState contract drift:
IMPLEMENTATION-PRD §1.5 + T5 + HG-7 require the empty-state guidance
to render when no user-defined items exist, even if built-ins are
present. Prior commit only rendered emptyState when items.length === 0,
so an operator with only built-in skills never saw the "add a skill to
.openrig/skills/..." guidance.
Repair:
- LibraryTopLevelEntry adds isUserDefined?: (item: T) => boolean prop.
When supplied, emptyState renders whenever NO item satisfies the
predicate. Folder list still renders alongside so built-ins remain
browsable (emptyState becomes a guidance banner above the list).
Without the predicate, the prior items.length===0 fallback applies
(back-compat for consumers who don't care about the distinction).
- SkillsIndexPage passes isUserDefined={(s) => s.source === "workspace"}.
Regressions (would all fail without isUserDefined semantics):
- emptyState renders when ALL items are built-ins (no user-defined)
- folder list STILL renders alongside emptyState in that case
- emptyState hidden when at least one user-defined item present
- without isUserDefined predicate, emptyState hidden if items non-empty (back-compat)
Blocker 2 — sidebar Skills link does not expand the Skills section:
PRD §3.3 + T7 require "click expands list below + opens page". Prior
commit added a plain Link to /specs/skills; clicking navigated but
left the Skills section in SpecsTreeView collapsed.
Repair:
- SpecsTreeView sidebar Skills link adds onClick that calls
setExpanded((prev) => ({ ...prev, skills: true })). TanStack Router's
Link runs onClick before its default navigation, so click both
expands AND navigates.
Regression (specs-tree-view-sidebar.test.tsx):
- Skills section collapsed by default (no "No skills yet." placeholder)
- After click on sidebar-skills-top-level, the "No skills yet."
placeholder appears inside the now-expanded section. Without the
onClick handler this test fails because section state stays false.
Test counts after repair:
- LibraryTopLevelEntry: 13/13 (was 9; +4 isUserDefined regressions)
- SpecsTreeView sidebar: 3/3 NEW
- Full UI suite: 714 pass + 1 skipped + 49 pre-existing AppShell pollution fails. Net vs prior tip: +7 passing, 0 new failures.
SC-29 EXCEPTION declared: NONE
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
a523c56f0e
commit
0fb4bf86d5
5 files changed
+188
-6
No files matched your search
@@ -30,6 +30,15 @@ export interface LibraryTopLevelEntryProps<T extends LibraryItem> {
|
||||
onItemClick: (item: T) => void;
|
||||
formatFolderLabel?: (folder: unknown) => string;
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Predicate identifying user-defined items (vs. built-ins). When
|
||||
* provided, emptyState renders whenever NO item satisfies the
|
||||
* predicate — surfacing guidance for adding user content even
|
||||
* when built-ins are present. Without the predicate, emptyState
|
||||
* renders only when items is empty (back-compat for consumers
|
||||
* that don't care about the user/built-in distinction).
|
||||
*/
|
||||
isUserDefined?: (item: T) => boolean;
|
||||
}
|
||||
|
||||
interface FolderGroup<T extends LibraryItem> {
|
||||
@@ -63,8 +72,19 @@ export function LibraryTopLevelEntry<T extends LibraryItem>({
|
||||
onItemClick,
|
||||
formatFolderLabel,
|
||||
isLoading,
|
||||
isUserDefined,
|
||||
}: LibraryTopLevelEntryProps<T>) {
|
||||
const folders = useMemo(() => groupItems(items, folderField), [items, folderField]);
|
||||
const userDefinedCount = useMemo(() => {
|
||||
if (!isUserDefined) return items.length;
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (isUserDefined(item)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}, [items, isUserDefined]);
|
||||
const showEmptyState = userDefinedCount === 0;
|
||||
const showFolders = items.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -78,9 +98,12 @@ export function LibraryTopLevelEntry<T extends LibraryItem>({
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div data-testid={`library-top-level-${slug}-empty`}>{emptyState}</div>
|
||||
) : (
|
||||
{showEmptyState ? (
|
||||
<div data-testid={`library-top-level-${slug}-empty`} className="mb-4">
|
||||
{emptyState}
|
||||
</div>
|
||||
) : null}
|
||||
{showFolders ? (
|
||||
<div className="space-y-4">
|
||||
{folders.map((folder) => {
|
||||
const label = formatFolderLabel
|
||||
@@ -122,7 +145,7 @@ export function LibraryTopLevelEntry<T extends LibraryItem>({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export function SkillsIndexPage() {
|
||||
emptyState={<SkillsEmptyState />}
|
||||
onItemClick={handleSkillClick}
|
||||
isLoading={isLoading}
|
||||
isUserDefined={(skill) => skill.source === "workspace"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -201,13 +201,16 @@ export function SpecsTreeView() {
|
||||
|
||||
{/* Slice 18 — top-level Library entries (Skills + Plugins) sit
|
||||
above the grouped tree as direct navigation to the index pages.
|
||||
The existing Section tree below continues to group entries for
|
||||
browsing in place. */}
|
||||
Clicking the link both navigates AND expands the matching
|
||||
section below (per IMPL-PRD §3.3 + T7 — "click expands list
|
||||
below + opens page"). The existing Section tree below continues
|
||||
to group entries for browsing in place. */}
|
||||
<ul className="px-2 mb-2 space-y-0.5">
|
||||
<li>
|
||||
<Link
|
||||
to="/specs/skills"
|
||||
data-testid="sidebar-skills-top-level"
|
||||
onClick={() => setExpanded((prev) => ({ ...prev, skills: true }))}
|
||||
className="block font-mono text-[11px] uppercase tracking-wide text-stone-700 hover:text-stone-900 hover:bg-surface-low px-2 py-1"
|
||||
>
|
||||
{"> "}Skills
|
||||
|
||||
@@ -174,4 +174,86 @@ describe("LibraryTopLevelEntry", () => {
|
||||
);
|
||||
expect(screen.getByTestId("library-top-level-demo")).toBeTruthy();
|
||||
});
|
||||
|
||||
// velocity-guard 18.C BLOCKING-CONCERN repair (Blocker 1):
|
||||
// isUserDefined predicate must surface emptyState guidance when no
|
||||
// item satisfies the predicate, even if built-ins are present.
|
||||
describe("isUserDefined predicate (user-defined-aware empty state)", () => {
|
||||
interface SkillLike extends DemoItem {
|
||||
source: "workspace" | "openrig-managed";
|
||||
}
|
||||
const builtInsOnly: SkillLike[] = [
|
||||
{ id: "b1", name: "built-in 1", group: "managed", source: "openrig-managed" },
|
||||
{ id: "b2", name: "built-in 2", group: "managed", source: "openrig-managed" },
|
||||
];
|
||||
const withUserDefined: SkillLike[] = [
|
||||
{ id: "u1", name: "user 1", group: "user", source: "workspace" },
|
||||
{ id: "b1", name: "built-in 1", group: "managed", source: "openrig-managed" },
|
||||
];
|
||||
|
||||
it("renders emptyState guidance when ALL items are built-ins (no user-defined)", () => {
|
||||
render(
|
||||
<LibraryTopLevelEntry<SkillLike>
|
||||
slug="skills"
|
||||
displayName="Skills"
|
||||
iconKind="skill"
|
||||
items={builtInsOnly}
|
||||
folderField="source"
|
||||
emptyState={<div data-testid="empty-guidance">add a skill to .openrig/skills/</div>}
|
||||
onItemClick={() => {}}
|
||||
isUserDefined={(s) => s.source === "workspace"}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("empty-guidance")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("STILL renders the built-in folder list alongside emptyState (built-ins remain browsable)", () => {
|
||||
render(
|
||||
<LibraryTopLevelEntry<SkillLike>
|
||||
slug="skills"
|
||||
displayName="Skills"
|
||||
iconKind="skill"
|
||||
items={builtInsOnly}
|
||||
folderField="source"
|
||||
emptyState={<div data-testid="empty-guidance">guidance</div>}
|
||||
onItemClick={() => {}}
|
||||
isUserDefined={(s) => s.source === "workspace"}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("library-folder-openrig-managed")).toBeTruthy();
|
||||
expect(screen.getByTestId("library-item-b1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render emptyState when at least one item is user-defined", () => {
|
||||
render(
|
||||
<LibraryTopLevelEntry<SkillLike>
|
||||
slug="skills"
|
||||
displayName="Skills"
|
||||
iconKind="skill"
|
||||
items={withUserDefined}
|
||||
folderField="source"
|
||||
emptyState={<div data-testid="empty-guidance">guidance</div>}
|
||||
onItemClick={() => {}}
|
||||
isUserDefined={(s) => s.source === "workspace"}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("empty-guidance")).toBeNull();
|
||||
});
|
||||
|
||||
it("without isUserDefined predicate, emptyState is hidden when items has anything (back-compat)", () => {
|
||||
render(
|
||||
<LibraryTopLevelEntry<SkillLike>
|
||||
slug="skills"
|
||||
displayName="Skills"
|
||||
iconKind="skill"
|
||||
items={builtInsOnly}
|
||||
folderField="source"
|
||||
emptyState={<div data-testid="empty-guidance">guidance</div>}
|
||||
onItemClick={() => {}}
|
||||
// no isUserDefined → falls back to items.length === 0 semantics
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("empty-guidance")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { SpecsTreeView } from "../src/components/specs/SpecsTreeView.js";
|
||||
import { createTestRouter } from "./helpers/test-router.js";
|
||||
|
||||
// velocity-guard 18.C BLOCKING-CONCERN repair (Blocker 2):
|
||||
// Top-level "Skills" sidebar link must both navigate to /specs/skills
|
||||
// AND expand the Skills section below (per IMPL-PRD §3.3 + T7).
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
// All Library endpoints return empty so the Section renders its
|
||||
// "No skills yet." placeholder when expanded — visible expansion
|
||||
// proof without needing real Library data.
|
||||
mockFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
}));
|
||||
Object.defineProperty(window, "scrollTo", { configurable: true, value: vi.fn() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
function renderTree() {
|
||||
return render(
|
||||
createTestRouter({
|
||||
path: "/specs",
|
||||
initialPath: "/specs",
|
||||
component: () => <SpecsTreeView />,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("SpecsTreeView — top-level sidebar Skills link", () => {
|
||||
it("renders a Skills top-level link in the sidebar", async () => {
|
||||
renderTree();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("sidebar-skills-top-level")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Skills section is collapsed by default (chevron right; no placeholder visible)", async () => {
|
||||
renderTree();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("specs-section-skills")).toBeTruthy();
|
||||
});
|
||||
// Default expanded state for "skills" is false; placeholder not rendered.
|
||||
expect(screen.queryByText(/no skills yet/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking the sidebar Skills top-level link expands the Skills section below", async () => {
|
||||
renderTree();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("sidebar-skills-top-level")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText(/no skills yet/i)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("sidebar-skills-top-level"));
|
||||
|
||||
await waitFor(() => {
|
||||
// After the click, the section's expanded body renders. Because
|
||||
// useLibrarySkills mock returns [], the Section shows its
|
||||
// "No skills yet." placeholder — proof of expansion.
|
||||
expect(screen.getByText(/no skills yet/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user