Namespace tree web view — Implementation Plan (Plings-Web)
Namespace tree web view — Implementation Plan (Plings-Web)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. There is no unit-test runner in Plings-Web — verification isnpx tsc --noEmit+npm run buildper task, plus the manual in-app checklist at the end.
Goal: A page in the existing app where an organization’s members build/name/browse that org’s namespace (category) tree — reachable from each Organization card on the dashboard, scoped to the dev wallet (3).
Architecture: A new protected route /organizations/:orgId/namespace renders NamespacePage, which uses a useNamespaceTree(orgId) hook. The hook resolves the org’s (walletVersion=3, manufacturerIndex) via organizationManufacturer, then queries namespaceTree and exposes createNamespaceNode/renameNamespaceNode/retireNamespaceNode. A recursive NamespaceTree component renders the flat node list with shadcn Collapsible. The Apollo authLink attaches the Supabase JWT automatically.
Tech Stack: React + TypeScript + Vite, @apollo/client, react-router-dom, Tailwind, shadcn/ui (Collapsible, Dialog, AlertDialog, Card, Button, Input, Badge, Skeleton), lucide-react.
Depends on: Plings-API organizationManufacturer query (merge that first). Spec: docs/specs/2026-06-26-namespace-tree-web-view-design.md.
Constant: dev wallet is hardcoded this slice: const DEV_WALLET_VERSION = 3; (defined in the hook). Wallet switching is future.
Task 1: GraphQL documents
Files:
- Modify:
src/graphql/queries.ts(append two documents) -
Modify:
src/graphql/mutations.ts(append three documents) - Step 1: Read both files to confirm the
gqlimport style and append location.
Run: open src/graphql/queries.ts and src/graphql/mutations.ts; note the existing import { gql } from '@apollo/client' (or graphql-tag) and that documents are exported consts.
- Step 2: Append the queries to
src/graphql/queries.ts
export const ORGANIZATION_MANUFACTURER = gql`
query OrganizationManufacturer($organizationId: ID!, $walletVersion: Int!) {
organizationManufacturer(organizationId: $organizationId, walletVersion: $walletVersion) {
id
walletVersion
manufacturerIndex
manufacturerName
status
}
}
`;
export const NAMESPACE_TREE = gql`
query NamespaceTree($walletVersion: Int!, $manufacturerIndex: Int!) {
namespaceTree(walletVersion: $walletVersion, manufacturerIndex: $manufacturerIndex) {
id
parentNodeId
levelIndex
name
prefixDecimal
status
}
}
`;
- Step 3: Append the mutations to
src/graphql/mutations.ts
export const CREATE_NAMESPACE_NODE = gql`
mutation CreateNamespaceNode($input: CreateNamespaceNodeInput!) {
createNamespaceNode(input: $input) {
id parentNodeId levelIndex name prefixDecimal status
}
}
`;
export const RENAME_NAMESPACE_NODE = gql`
mutation RenameNamespaceNode($id: ID!, $name: String!) {
renameNamespaceNode(id: $id, name: $name) { id name prefixDecimal status }
}
`;
export const RETIRE_NAMESPACE_NODE = gql`
mutation RetireNamespaceNode($id: ID!) {
retireNamespaceNode(id: $id) { id status }
}
`;
-
Step 4: Verify
npx tsc --noEmit— expected: clean (no type errors from the new consts). -
Step 5: Commit
git add src/graphql/queries.ts src/graphql/mutations.ts
git commit -m "feat(web): namespace registry GraphQL documents"
Task 2: TypeScript types
Files:
-
Create:
src/types/namespace.ts -
Step 1: Write the types
// src/types/namespace.ts
export interface NamespaceNode {
id: string;
parentNodeId: string | null;
levelIndex: number;
name: string;
prefixDecimal: string;
status: string; // 'active' | 'retired'
}
export interface ManufacturerInfo {
id: string;
walletVersion: number;
manufacturerIndex: number;
manufacturerName: string;
status: string;
}
export interface NamespaceTreeData {
namespaceTree: NamespaceNode[];
}
export interface OrganizationManufacturerData {
organizationManufacturer: ManufacturerInfo | null;
}
export interface CreateNamespaceNodeInput {
walletVersion: number;
manufacturerIndex: number;
parentNodeId?: string | null;
name: string;
}
-
Step 2: Verify
npx tsc --noEmit— clean. -
Step 3: Commit
git add src/types/namespace.ts
git commit -m "feat(web): namespace registry TS types"
Task 3: useNamespaceTree hook
Files:
-
Create:
src/hooks/useNamespaceTree.ts -
Step 1: Write the hook (mirrors
useMyOrganizations)
// src/hooks/useNamespaceTree.ts
import { useQuery, useMutation } from '@apollo/client';
import { ORGANIZATION_MANUFACTURER, NAMESPACE_TREE } from '@/graphql/queries';
import {
CREATE_NAMESPACE_NODE, RENAME_NAMESPACE_NODE, RETIRE_NAMESPACE_NODE,
} from '@/graphql/mutations';
import type {
NamespaceNode, NamespaceTreeData, OrganizationManufacturerData,
} from '@/types/namespace';
export const DEV_WALLET_VERSION = 3;
export function useNamespaceTree(organizationId: string) {
const mfrQuery = useQuery<OrganizationManufacturerData>(ORGANIZATION_MANUFACTURER, {
variables: { organizationId, walletVersion: DEV_WALLET_VERSION },
errorPolicy: 'all',
fetchPolicy: 'cache-first',
});
const manufacturer = mfrQuery.data?.organizationManufacturer ?? null;
const manufacturerIndex = manufacturer?.manufacturerIndex;
const treeQuery = useQuery<NamespaceTreeData>(NAMESPACE_TREE, {
variables: { walletVersion: DEV_WALLET_VERSION, manufacturerIndex: manufacturerIndex ?? -1 },
skip: manufacturerIndex == null,
errorPolicy: 'all',
notifyOnNetworkStatusChange: true,
});
const refetchTree = treeQuery.refetch;
const [createMut, createState] = useMutation(CREATE_NAMESPACE_NODE, {
onCompleted: () => refetchTree(),
});
const [renameMut] = useMutation(RENAME_NAMESPACE_NODE, { onCompleted: () => refetchTree() });
const [retireMut] = useMutation(RETIRE_NAMESPACE_NODE, { onCompleted: () => refetchTree() });
async function createNode(name: string, parentNodeId: string | null) {
if (manufacturerIndex == null) throw new Error('Organization is not a tag issuer');
await createMut({
variables: {
input: {
walletVersion: DEV_WALLET_VERSION,
manufacturerIndex,
parentNodeId: parentNodeId ?? null,
name,
},
},
});
}
const renameNode = (id: string, name: string) => renameMut({ variables: { id, name } });
const retireNode = (id: string) => retireMut({ variables: { id } });
const nodes: NamespaceNode[] = treeQuery.data?.namespaceTree ?? [];
return {
manufacturer,
isIssuer: manufacturerIndex != null,
nodes,
loading: mfrQuery.loading || treeQuery.loading,
error: mfrQuery.error || treeQuery.error,
refetchTree,
createNode,
renameNode,
retireNode,
creating: createState.loading,
};
}
-
Step 2: Verify
npx tsc --noEmit— clean. -
Step 3: Commit
git add src/hooks/useNamespaceTree.ts
git commit -m "feat(web): useNamespaceTree hook (resolve manufacturer + tree + mutations)"
Task 4: Recursive NamespaceTree component
Files:
-
Create:
src/components/namespace/NamespaceTree.tsx -
Step 1: Write the component
// src/components/namespace/NamespaceTree.tsx
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Plus, Pencil, Archive } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import type { NamespaceNode } from '@/types/namespace';
interface Props {
nodes: NamespaceNode[];
onAddChild: (parent: NamespaceNode) => void;
onRename: (node: NamespaceNode) => void;
onRetire: (node: NamespaceNode) => void;
}
function childrenOf(nodes: NamespaceNode[], parentId: string | null) {
return nodes
.filter((n) => n.parentNodeId === parentId)
.sort((a, b) => a.levelIndex - b.levelIndex);
}
function NodeRow({ node, nodes, depth, ...handlers }: { node: NamespaceNode; nodes: NamespaceNode[]; depth: number } & Omit<Props, 'nodes'>) {
const [open, setOpen] = useState(true);
const kids = childrenOf(nodes, node.id);
const retired = node.status === 'retired';
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-2 py-1.5" style=>
<CollapsibleTrigger asChild>
<button className="text-gray-400 hover:text-gray-700" aria-label="toggle">
{kids.length ? (open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />) : <span className="inline-block w-4" />}
</button>
</CollapsibleTrigger>
<span className={`font-medium ${retired ? 'text-gray-400 line-through' : 'text-gray-900'}`}>{node.name}</span>
<span className="text-xs text-gray-400 font-mono">{node.prefixDecimal}</span>
{retired && <Badge variant="secondary">retired</Badge>}
<div className="ml-auto flex items-center gap-1">
<Button size="icon" variant="ghost" onClick={() => handlers.onAddChild(node)} aria-label="add child"><Plus className="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" onClick={() => handlers.onRename(node)} aria-label="rename"><Pencil className="h-4 w-4" /></Button>
{!retired && <Button size="icon" variant="ghost" onClick={() => handlers.onRetire(node)} aria-label="retire"><Archive className="h-4 w-4" /></Button>}
</div>
</div>
<CollapsibleContent>
{kids.map((c) => <NodeRow key={c.id} node={c} nodes={nodes} depth={depth + 1} {...handlers} />)}
</CollapsibleContent>
</Collapsible>
);
}
export function NamespaceTree({ nodes, onAddChild, onRename, onRetire }: Props) {
const roots = useMemo(() => childrenOf(nodes, null), [nodes]);
if (!nodes.length) return <p className="text-sm text-gray-500 py-6 text-center">No nodes yet. Add your first one.</p>;
return <div>{roots.map((r) => <NodeRow key={r.id} node={r} nodes={nodes} depth={0} onAddChild={onAddChild} onRename={onRename} onRetire={onRetire} />)}</div>;
}
-
Step 2: Confirm
collapsibleexists —ls src/components/ui/collapsible.tsx. If missing, add it vianpx shadcn-ui@latest add collapsible(the report listed it as present; verify). -
Step 3: Verify
npx tsc --noEmit— clean. -
Step 4: Commit
git add src/components/namespace/NamespaceTree.tsx
git commit -m "feat(web): recursive NamespaceTree component"
Task 5: NamespacePage (the route component, with add/rename/retire dialogs)
Files:
-
Create:
src/pages/NamespacePage.tsx -
Step 1: Write the page
// src/pages/NamespacePage.tsx
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { Plus } from 'lucide-react';
import { useNamespaceTree } from '@/hooks/useNamespaceTree';
import { NamespaceTree } from '@/components/namespace/NamespaceTree';
import type { NamespaceNode } from '@/types/namespace';
export default function NamespacePage() {
const { orgId = '' } = useParams();
const navigate = useNavigate();
const { manufacturer, isIssuer, nodes, loading, error, createNode, renameNode, retireNode, creating } = useNamespaceTree(orgId);
const [addParent, setAddParent] = useState<NamespaceNode | null | undefined>(undefined); // undefined=closed, null=root
const [renameTarget, setRenameTarget] = useState<NamespaceNode | null>(null);
const [retireTarget, setRetireTarget] = useState<NamespaceNode | null>(null);
const [name, setName] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const openAdd = (parent: NamespaceNode | null) => { setAddParent(parent); setName(''); setFormError(null); };
const openRename = (n: NamespaceNode) => { setRenameTarget(n); setName(n.name); setFormError(null); };
async function submitAdd() {
try { await createNode(name.trim(), addParent ?? null); setAddParent(undefined); }
catch (e: any) { setFormError(e.message ?? 'Failed to create node'); }
}
async function submitRename() {
if (!renameTarget) return;
try { await renameNode(renameTarget.id, name.trim()); setRenameTarget(null); }
catch (e: any) { setFormError(e.message ?? 'Failed to rename'); }
}
return (
<div className="min-h-screen bg-gray-50">
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Button variant="ghost" onClick={() => navigate('/dashboard')} className="mb-4">← Dashboard</Button>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Namespace{manufacturer ? ` — ${manufacturer.manufacturerName} (3.${manufacturer.manufacturerIndex}.*)` : ''}</span>
{isIssuer && <Button onClick={() => openAdd(null)}><Plus className="h-4 w-4 mr-1" /> Add root node</Button>}
</CardTitle>
<CardDescription>Build the category tree for this organization (dev wallet). Tags are generated under a node.</CardDescription>
</CardHeader>
<CardContent>
{loading && <div className="space-y-2"><Skeleton className="h-6 w-2/3" /><Skeleton className="h-6 w-1/2" /></div>}
{!loading && error && <p className="text-sm text-red-600">{error.message}</p>}
{!loading && !error && !isIssuer && (
<p className="text-sm text-gray-600 py-6 text-center">This organization isn't set up as a tag issuer yet — contact Plings.</p>
)}
{!loading && !error && isIssuer && (
<NamespaceTree nodes={nodes} onAddChild={openAdd} onRename={openRename} onRetire={setRetireTarget} />
)}
</CardContent>
</Card>
</main>
{/* Add / rename dialog */}
<Dialog open={addParent !== undefined || renameTarget !== null} onOpenChange={(o) => { if (!o) { setAddParent(undefined); setRenameTarget(null); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{renameTarget ? 'Rename node' : addParent ? `Add child under ${addParent.name}` : 'Add root node'}</DialogTitle>
</DialogHeader>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Node name (e.g. Kartonger)" autoFocus />
{formError && <p className="text-sm text-red-600">{formError}</p>}
<DialogFooter>
<Button disabled={!name.trim() || creating} onClick={renameTarget ? submitRename : submitAdd}>
{renameTarget ? 'Rename' : 'Add'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Retire confirm */}
<AlertDialog open={retireTarget !== null} onOpenChange={(o) => { if (!o) setRetireTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Retire “{retireTarget?.name}”?</AlertDialogTitle>
<AlertDialogDescription>It will be hidden from active use but not deleted. Allocations under it remain.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={async () => { if (retireTarget) await retireNode(retireTarget.id); setRetireTarget(null); }}>Retire</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
-
Step 2: Verify
npx tsc --noEmit— clean. (If any shadcn primitive import path differs, fix to match the repo’s actual@/components/ui/*exports — read the file to confirm export names.) -
Step 3: Commit
git add src/pages/NamespacePage.tsx
git commit -m "feat(web): NamespacePage with add/rename/retire"
Task 6: Route + dashboard entry point
Files:
- Modify:
src/App.tsx(add the route) -
Modify:
src/components/dashboard/OrganizationsSection.tsx(add a per-org link) -
Step 1: Read
src/App.tsxto find the<Routes>block and the/dashboardroute line. - Step 2: Add the route (lazy or direct import matching the file’s convention). Add near
/dashboard:
import NamespacePage from "./pages/NamespacePage";
// ...inside <Routes>:
<Route path="/organizations/:orgId/namespace" element={<NamespacePage />} />
(If the file uses a <ProtectedRoute>/session wrapper for /dashboard, wrap this route the same way.)
-
Step 3: Read
src/components/dashboard/OrganizationsSection.tsxto find the per-org card markup (theorganizations.map((org) => ...)block). -
Step 4: Add a link inside each org card (below the members/date row):
import { Link } from 'react-router-dom';
// inside the org card:
<Link to={`/organizations/${org.id}/namespace`} className="text-sm text-teal-700 hover:underline" onClick={(e) => e.stopPropagation()}>
Namespace →
</Link>
-
Step 5: Verify
npx tsc --noEmitandnpm run build— both succeed. -
Step 6: Commit
git add src/App.tsx src/components/dashboard/OrganizationsSection.tsx
git commit -m "feat(web): route + dashboard entry point for the namespace tree view"
Task 7: Build verification + manual checklist
- Step 1:
npx tsc --noEmit→ clean.npm run build→ succeeds. - Step 2 (manual, against a backend that has the API query + the Pauls setup): open
/organizations/<Pauls-org-id>/namespace:- Empty state → “Add root node” → create “Kartonger” → appears as
3.2.1. - Add “Påsar” →
3.2.2. Add child under “Påsar” → “Små påsar”3.2.2.1. - Rename a node; retire a node (shows struck-through + “retired” badge).
- Open the page for an org with no manufacturer (e.g. “Familjen”) → “not set up as a tag issuer” message.
- Depth: nest 7 category levels → the 7th add shows the server depth-cap error in the dialog.
- Empty state → “Add root node” → create “Kartonger” → appears as
Self-review
- Spec coverage: placement/route (Task 6), data layer +
organizationManufacturerresolve + tree query (Task 3), recursive Collapsible tree (Task 4), add/rename/retire + empty/loading/error states (Task 5), GraphQL docs/types (Tasks 1–2). All spec UI points covered. - Placeholders: new files have complete code; the three edit tasks (1, 6) instruct reading the file first because the exact insertion anchors live in files not fully quoted here — the code to insert is given verbatim.
- Type consistency:
NamespaceNode/ManufacturerInfofields match the GraphQL selection sets and the API’sManufacturerInfo/NamespaceNodeSDL (prefixDecimal,parentNodeId,levelIndex,manufacturerIndex,walletVersion).DEV_WALLET_VERSION = 3is the single source for the wallet. - Merge order: merges AFTER the Plings-API
organizationManufacturerPR + the Pauls setup, else the query 404s/returns null for everyone.