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 is npx tsc --noEmit + npm run build per 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 gql import 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 collapsible existsls src/components/ui/collapsible.tsx. If missing, add it via npx 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.tsx to find the <Routes> block and the /dashboard route 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.tsx to find the per-org card markup (the organizations.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 --noEmit and npm 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.

Self-review

  • Spec coverage: placement/route (Task 6), data layer + organizationManufacturer resolve + 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/ManufacturerInfo fields match the GraphQL selection sets and the API’s ManufacturerInfo/NamespaceNode SDL (prefixDecimal, parentNodeId, levelIndex, manufacturerIndex, walletVersion). DEV_WALLET_VERSION = 3 is the single source for the wallet.
  • Merge order: merges AFTER the Plings-API organizationManufacturer PR + the Pauls setup, else the query 404s/returns null for everyone.