Skip to content

Commit 528ee77

Browse files
authored
feat(shared,admin): admin workflow polish — action grammar, members surface, explicit-save settings (#562)
## Summary One bounded polish PR across the four admin workflows — Submit Work, Review Work, Garden Settings, and Garden Members — so they read as one professional cockpit instead of mixed legacy forms. ### Action grammar — one primary per mode - Every route header now shows exactly **one mode-specific primary**; other actions fold into the overflow kebab (`AdminViewActions maxInline` driven by the marked primary). - **Hub**: Work → Submit Work, Assess → Create Assessment, Certify → Create Hypercert. **History declares no creation CTA** (no real target) and hides the FAB. - **Garden**: Overview → Edit garden; **Members → Add member** (opens the real add flow); Activity and Settings keep a clean header (settings form owns Save/Cancel). - **Community**: Treasury → Deposit / withdraw (owner-gated, opens the vault sheet); Governance → **New proposal now targets the hypercert signal-pool register flow** (the existing proposal-creation write) instead of self-navigating; Payouts has no competing header primary (the cookie-jar panel owns Withdraw / Fund / Manage in local context); People shows no role-management CTA. - `useViewActions` no longer promotes an arbitrary first action: the FAB exists only when a mode declares an explicit primary (read-only modes hide it, per the handoff design note). ### Garden Members = the management surface - New [`GardenMembersPanel`](packages/admin/src/views/Garden/components/GardenMembersPanel.tsx) on `/garden/members`: search by **ENS or address** (name queries resolve via `useEnsAddress`), wallet display with **copy actions**, role chips, member count. - Existing write path wired in — header **Add member** → `AddMemberModal`; **Manage Roles** → `GardenRolesPanel` per-role add/remove + `MembersModal` view-all — all through `useGardenOperations`, gated on `canManage`. **No new contract/API behavior.** - Removed the inert "Pending" filter (no pending-member data exists). Fixed the filter chips to the actual `AdminFilterChip` `label`/`onToggle` contract — they previously rendered label-less and never attached a handler (dead controls, pre-existing). ### Community People stays engagement/read-only - The role chips on People rows are now **static indicators** — the old buttons navigated back to the same route (no-ops). - Rail "Roles overview" rows render as plain counts on People (still navigate from other modes). - One clear **Manage members** affordance links to Garden → Members (header overflow + section header). ### Garden Settings — explicit save with draft preview - Rewrote [`GardenSettingsEditor`](packages/admin/src/components/Garden/GardenSettingsEditor.tsx) as a draft form: name, description, location, banner, open joining, max gardeners all edit local state. - Stable footer with a **dirty count** (`N unsaved changes` / `All changes saved` / `Saving…`), Cancel (resets), Save changes (disabled when clean/invalid). - **Banner selection shows a local object-URL preview** with a "Preview — uploads on save" badge; IPFS upload + on-chain write happen **only during Save**. Save runs only dirty fields through their existing per-field mutations, sequentially (one wallet confirmation at a time; a failure stops the run with the draft intact). - Removed "hero image" wording from admin copy; en/es/pt updated. ### Submit Work + Review Work polish - Submit Work sheet now uses admin sheet/form anatomy: fields directly on the sheet surface (no nested card), pinned `SheetFooter` with `AdminButton` actions, identical grouping in page and sheet layouts. Validation, upload requirements, query state (`sort`, `gardenAddress`), and cancel/close are preserved (pinned by the existing submit test suite). - Review Work decision row uses the M3 hierarchy: **Approve = filled primary** (disabled until confidence + method valid), **Reject = outlined error** (clearly destructive, secondary), right-aligned row, validation hints in a **stable block above the actions** so the buttons never shift. ## Validation - `bun format` / `bun lint` — 0 errors (remaining warnings pre-existing, untouched files) - Admin tests: **52 files / 451 passed** (incl. new `GardenSettingsEditor.test.tsx` ×7 pinning draft/save/cancel/banner-deferral, updated `memberRoles.test.ts`, existing `SubmitWork.submit.test.tsx` ×7 green) - Shared tests: **261 files / 3138 passed** (incl. expanded `fab-config.test.ts` ×27 pinning the per-mode primary grammar and real targets) - `node scripts/dev/ci-local.js --quick` — all green - `bun run check:design-md` ✅ · `check:design-tokens` ✅ (material boundary + token guard) · `lint:vocab` ✅ (3 locales) - `check:stories` ✅ · `check:story-quality` ✅ (149 files) · `test:stories:ci` 138 passed - `bun run --filter @green-goods/admin build` ✅ ## Browser proof (Brave, dev:prod against production data, mockAuth) Captured at **1440×900 and 1280×800, light + dark** to `.codex-artifacts/admin-polish-proof/` (18 screenshots + DOM/click evidence; mock-auth sessions have no wallet client, so no writes were possible): - `/hub/work`: one Submit Work primary + overflow holding Create Assessment / Create Hypercert; sort → submit → close preserves `sort=oldest&gardenAddress` in URL; filled sheet with media draft preview; required-field hold blocks submit with zero network calls. - `/hub/work/:workId`: Approve disabled with the stable hint ("Select a confidence level…"); selecting High enables Approve (filled, workspace tone `rgb(37,71,208)`); Reject outlined error. Dark-mode contrast measured on the real backdrop: reject label **4.76:1**, hint **10.1:1** (AA). - `/garden/settings`: edits → "3 unsaved changes" + enabled Save/Cancel + banner "Preview — uploads on save" badge with **no IPFS/write calls**; Cancel restored saved values ("Mama Earth") and disabled the footer. - `/garden/members`: ENS search `afo.eth` resolved and narrowed 30 → 1 row; address fragment `0xb1` → 1 row; copy shows the copied check; Add member opens `AddMemberModal` ("Add Gardener"); Manage Roles opens the per-role panel (6 add / 10 remove controls); Operators filter narrows 30 → 9. - `/community/members`: **48 role chips, 0 are buttons**; no header primary; rail rows static; the one "Manage members" button lands on `/garden/members` with garden context. - All eight route modes asserted in live DOM for the primary/overflow table; **no horizontal overflow** at either viewport; console clean of app errors (only Brave-shields analytics blocks + pre-existing IPFS-gateway 403 thumbnails). ## Out of scope, flagged separately - **Pre-existing bug (reproduced on develop with this PR's changes stashed)**: opening Submit Work from the Hub header leaves the LeftSheet parked off-screen (`translateX(-427px)`, spring never runs); work-detail opens fine and direct URL loads render in place. Spawned as its own task with full diagnostics. - The admin `tsc --noEmit -p tsconfig.json` gate is a no-op (`files: []` solution config) — it's how the dead `AdminFilterChip` props survived. Worth a follow-up on the build script. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 8c2e684 + c13a2ef commit 528ee77

71 files changed

Lines changed: 2816 additions & 2193 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/admin/src/__tests__/components/Garden/memberRoles.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* GardenMembersList role-derivation tests
2+
* GardenMembersPanel role-derivation tests
33
*
44
* Pins the role-set construction + per-address role lookup that drives the
55
* cleanup A5 chip strip on the Garden Members tab. Lowercase normalization +
@@ -13,7 +13,7 @@ import { describe, expect, it } from "vitest";
1313
import {
1414
buildMemberRoleSets,
1515
memberRolesForAddress,
16-
} from "@/views/Garden/components/GardenWorkspaceContent";
16+
} from "@/views/Garden/components/GardenMembersPanel";
1717
import type { Address } from "@green-goods/shared";
1818

1919
const A_LOWER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Address;

packages/admin/src/__tests__/components/PageTransition.test.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { renderWithProviders, screen, userEvent, waitFor } from "../test-utils";
2424
const mockOrchestrator = vi.hoisted(() => ({
2525
activeSheet: null as "left" | "right" | null,
2626
activeContentId: null as string | null,
27-
isReceded: false,
2827
openSheet: vi.fn(),
2928
closeSheet: vi.fn(),
3029
onNavigateAway: vi.fn().mockResolvedValue(undefined),
@@ -142,7 +141,6 @@ describe("PageTransition", () => {
142141
vi.clearAllMocks();
143142
mockOrchestrator.activeSheet = null;
144143
mockOrchestrator.activeContentId = null;
145-
mockOrchestrator.isReceded = false;
146144
mockOrchestrator.onNavigateAway.mockResolvedValue(undefined);
147145
mockOrchestrator.onNavigateArrive.mockReturnValue(null);
148146
});
@@ -182,7 +180,6 @@ describe("PageTransition", () => {
182180
// Sheet is open on initial render
183181
mockOrchestrator.activeSheet = "right";
184182
mockOrchestrator.activeContentId = "members";
185-
mockOrchestrator.isReceded = true;
186183

187184
renderPageTransition();
188185
const user = userEvent.setup();
@@ -375,4 +372,47 @@ describe("PageTransition", () => {
375372
// startViewTransition should not be called for same-path navigation
376373
expect(mockStartViewTransition).not.toHaveBeenCalled();
377374
});
375+
376+
it("swaps instantly without a cross-fade for same-view tab changes", async () => {
377+
// Both paths live under the same top-level view (/hub/*), i.e. a tab change.
378+
renderPageTransition("/hub/history", ["/hub/history", "/hub/work/item-7"]);
379+
const user = userEvent.setup();
380+
381+
await user.click(screen.getByTestId("nav-/hub/work/item-7"));
382+
383+
// Outlet still swaps and arrive bookkeeping runs, but no View Transitions
384+
// cross-fade fires for an intra-view tab change.
385+
await waitFor(() => {
386+
expect(mockOrchestrator.onNavigateArrive).toHaveBeenCalledWith("/hub/work/item-7");
387+
});
388+
expect(mockStartViewTransition).not.toHaveBeenCalled();
389+
});
390+
391+
it("resets scroll to top on a view change but not on a same-view tab change", async () => {
392+
const main = document.createElement("main");
393+
main.id = "main-content";
394+
const scrollTo = vi.fn();
395+
main.scrollTo = scrollTo as unknown as typeof main.scrollTo;
396+
document.body.appendChild(main);
397+
398+
try {
399+
renderPageTransition("/hub/history", ["/hub/history", "/hub/work/item-7", "/page-b"]);
400+
const user = userEvent.setup();
401+
402+
// Same view (hub → hub): scroll position is preserved.
403+
await user.click(screen.getByTestId("nav-/hub/work/item-7"));
404+
await waitFor(() => {
405+
expect(mockOrchestrator.onNavigateArrive).toHaveBeenCalledWith("/hub/work/item-7");
406+
});
407+
expect(scrollTo).not.toHaveBeenCalled();
408+
409+
// Cross view (hub → page-b): the new view lands at the top.
410+
await user.click(screen.getByTestId("nav-/page-b"));
411+
await waitFor(() => {
412+
expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
413+
});
414+
} finally {
415+
document.body.removeChild(main);
416+
}
417+
});
378418
});
Lines changed: 59 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
RiAddLine,
3-
RiExternalLinkLine,
4-
RiRefreshLine,
5-
RiSettings3Line,
6-
RiUserAddLine,
7-
} from "@remixicon/react";
1+
import { RiAddLine, RiCheckLine, RiExternalLinkLine, RiMedalLine } from "@remixicon/react";
82
import type { Meta, StoryObj } from "@storybook/react";
93
import { fn } from "storybook/test";
104
import { withAdminPrimitiveFrame } from "../../../shared/.storybook/decorators";
@@ -19,7 +13,7 @@ const meta: Meta<typeof AdminViewActions> = {
1913
docs: {
2014
description: {
2115
component:
22-
"Desktop view-action row for admin workspaces. It keeps the primary action rightmost and folds lower-priority actions into a Radix overflow menu.",
16+
"Desktop view-action row for admin workspaces — the stable-trio grammar. The workspace's actions render in declaration order on every tab; only the active tab's action carries the filled variant, so button positions never shift while the emphasis moves with the tab.",
2317
},
2418
},
2519
},
@@ -28,75 +22,78 @@ const meta: Meta<typeof AdminViewActions> = {
2822
export default meta;
2923
type Story = StoryObj<typeof AdminViewActions>;
3024

31-
const baseActions = [
32-
{
33-
id: "refresh",
34-
label: "Refresh",
35-
labelId: "app.common.refresh",
36-
icon: RiRefreshLine,
37-
onClick: fn(),
38-
variant: "ghost" as const,
39-
},
40-
{
41-
id: "invite",
42-
label: "Invite member",
43-
shortLabel: "Invite",
44-
labelId: "admin.community.inviteMember",
45-
icon: RiUserAddLine,
46-
onClick: fn(),
47-
variant: "secondary" as const,
48-
},
49-
{
50-
id: "create",
51-
label: "Create action",
52-
shortLabel: "Create",
53-
labelId: "admin.actions.create",
54-
icon: RiAddLine,
55-
onClick: fn(),
56-
variant: "primary" as const,
57-
primary: true,
58-
},
59-
];
25+
/** The Hub trio with a different tab active per story — positions identical,
26+
* only the fill moves. */
27+
function hubTrio(activeId: "submit-work" | "create-assessment" | "create-hypercert" | "none") {
28+
return [
29+
{
30+
id: "submit-work",
31+
label: "Submit Work",
32+
labelId: "cockpit.hub.action.submitWork",
33+
icon: RiAddLine,
34+
onClick: fn(),
35+
variant: (activeId === "submit-work" ? "primary" : "secondary") as "primary" | "secondary",
36+
primary: activeId === "submit-work",
37+
},
38+
{
39+
id: "create-assessment",
40+
label: "Create Assessment",
41+
labelId: "cockpit.hub.action.createAssessment",
42+
icon: RiCheckLine,
43+
onClick: fn(),
44+
variant: (activeId === "create-assessment" ? "primary" : "secondary") as
45+
| "primary"
46+
| "secondary",
47+
primary: activeId === "create-assessment",
48+
},
49+
{
50+
id: "create-hypercert",
51+
label: "Create Hypercert",
52+
labelId: "cockpit.hub.action.createHypercert",
53+
icon: RiMedalLine,
54+
onClick: fn(),
55+
variant: (activeId === "create-hypercert" ? "primary" : "secondary") as
56+
| "primary"
57+
| "secondary",
58+
primary: activeId === "create-hypercert",
59+
},
60+
];
61+
}
6062

61-
export const InlineActions: Story = {
62-
args: {
63-
items: baseActions,
64-
},
63+
export const WorkTabActive: Story = {
64+
args: { items: hubTrio("submit-work") },
65+
};
66+
67+
export const CertifyTabActive: Story = {
68+
args: { items: hubTrio("create-hypercert") },
6569
};
6670

67-
export const Overflow: Story = {
71+
/** Read surfaces (Hub History, Garden Activity…) keep the trio outlined —
72+
* no filled action, no FAB on mobile. */
73+
export const ReadSurfaceAllOutlined: Story = {
74+
args: { items: hubTrio("none") },
75+
};
76+
77+
export const WithGhostLink: Story = {
6878
args: {
6979
items: [
7080
{
71-
id: "open-public",
72-
label: "Open public page",
73-
labelId: "admin.garden.openPublic",
81+
id: "view-public",
82+
label: "View public",
83+
labelId: "cockpit.garden.action.viewPublic",
7484
icon: RiExternalLinkLine,
7585
onClick: fn(),
7686
variant: "ghost",
7787
},
78-
{
79-
id: "settings",
80-
label: "Garden settings",
81-
shortLabel: "Settings",
82-
labelId: "admin.garden.settings",
83-
icon: RiSettings3Line,
84-
onClick: fn(),
85-
variant: "secondary",
86-
},
87-
...baseActions,
88+
...hubTrio("submit-work").slice(0, 2),
8889
],
8990
},
9091
};
9192

9293
export const DisabledAction: Story = {
9394
args: {
94-
items: [
95-
{
96-
...baseActions[1],
97-
disabled: true,
98-
},
99-
baseActions[2],
100-
],
95+
items: hubTrio("submit-work").map((action) =>
96+
action.id === "create-assessment" ? { ...action, disabled: true } : action
97+
),
10198
},
10299
};
Lines changed: 7 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
import { type ViewAction, cn } from "@green-goods/shared";
2-
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
3-
import { RiMoreLine } from "@remixicon/react";
1+
import type { ViewAction } from "@green-goods/shared";
42
import { AdminButton } from "./AdminButton";
53

64
interface AdminViewActionsProps {
75
items: ViewAction[];
8-
/**
9-
* Maximum buttons rendered inline before the rest fold into an overflow menu.
10-
* Defaults to 3 (matches the design reference: ghost + secondary + primary).
11-
*/
12-
maxInline?: number;
136
}
147

158
const VARIANT_TO_ADMIN_BUTTON = {
@@ -20,30 +13,23 @@ const VARIANT_TO_ADMIN_BUTTON = {
2013
} as const;
2114

2215
/**
23-
* Renders the desktop view-action row. Maps `ViewAction` variants to
24-
* `AdminButton` variants, keeps the primary action rightmost, and folds
25-
* any actions beyond `maxInline` into an overflow kebab.
16+
* Renders the desktop view-action row — the stable-trio grammar: a
17+
* workspace's actions render in declaration order on every tab, and only the
18+
* active tab's action carries the filled (`primary`) variant. No overflow
19+
* menu and no reordering, so button positions never shift between tabs.
2620
*
2721
* Pair with `useViewActions` so the same `ViewAction[]` drives both this
2822
* component (desktop) and the FAB speed-dial (tablet/mobile).
2923
*/
30-
export function AdminViewActions({ items, maxInline = 3 }: AdminViewActionsProps) {
24+
export function AdminViewActions({ items }: AdminViewActionsProps) {
3125
if (items.length === 0) return null;
3226

33-
// The hook hands us actions in left-to-right order with the primary last.
34-
// When overflow is needed, keep the primary inline (rightmost) and push
35-
// the lowest-priority secondaries into the overflow menu.
36-
const inlineCount = Math.min(items.length, maxInline);
37-
const inline = items.length <= maxInline ? items : items.slice(items.length - inlineCount);
38-
const overflow = items.length <= maxInline ? [] : items.slice(0, items.length - inlineCount);
39-
4027
return (
4128
<div
4229
className="flex flex-shrink-0 flex-wrap items-center justify-end gap-2"
4330
data-component="AdminViewActions"
4431
>
45-
{overflow.length > 0 ? <OverflowMenu items={overflow} /> : null}
46-
{inline.map((action) => (
32+
{items.map((action) => (
4733
<AdminViewActionButton key={action.id} action={action} />
4834
))}
4935
</div>
@@ -68,63 +54,3 @@ function AdminViewActionButton({ action }: { action: ViewAction }) {
6854
</AdminButton>
6955
);
7056
}
71-
72-
function OverflowMenu({ items }: { items: ViewAction[] }) {
73-
return (
74-
<DropdownMenu.Root>
75-
<DropdownMenu.Trigger asChild>
76-
<button
77-
type="button"
78-
className={cn(
79-
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
80-
"border border-[rgb(var(--m3-outline))] text-[rgb(var(--m3-on-surface))]",
81-
"hover:bg-[rgb(var(--m3-on-surface)/0.06)] active:bg-[rgb(var(--m3-on-surface)/0.10)]",
82-
"transition-colors duration-[var(--spring-spatial-fast-duration)] ease-[var(--spring-spatial-fast-easing)]",
83-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--m3-primary))]"
84-
)}
85-
aria-label="More actions"
86-
title="More actions"
87-
data-component="AdminViewActions"
88-
data-slot="overflow-trigger"
89-
>
90-
<RiMoreLine className="h-5 w-5" />
91-
</button>
92-
</DropdownMenu.Trigger>
93-
<DropdownMenu.Portal>
94-
<DropdownMenu.Content
95-
side="bottom"
96-
align="end"
97-
sideOffset={6}
98-
className={cn(
99-
"z-overlay min-w-[200px] rounded-2xl bg-bg-white p-1.5 shadow-lg",
100-
"border border-stroke-soft",
101-
"animate-in fade-in-0 zoom-in-95"
102-
)}
103-
>
104-
{items.map((action) => {
105-
const Icon = action.icon;
106-
const isDanger = action.variant === "danger";
107-
return (
108-
<DropdownMenu.Item
109-
key={action.id}
110-
onSelect={action.onClick}
111-
disabled={action.disabled}
112-
data-action-id={action.id}
113-
className={cn(
114-
"flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none",
115-
isDanger
116-
? "text-error-base hover:bg-error-lighter focus:bg-error-lighter"
117-
: "text-text-sub hover:bg-bg-weak focus:bg-bg-weak",
118-
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
119-
)}
120-
>
121-
<Icon className="h-4 w-4 shrink-0" />
122-
{action.label}
123-
</DropdownMenu.Item>
124-
);
125-
})}
126-
</DropdownMenu.Content>
127-
</DropdownMenu.Portal>
128-
</DropdownMenu.Root>
129-
);
130-
}

packages/admin/src/components/Garden/GardenSettingsEditor.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const meta: Meta<typeof GardenSettingsEditor> = {
3131
docs: {
3232
description: {
3333
component:
34-
"Real `GardenSettingsEditor` rendered against the Storybook mock wagmi connector and `DevAuthProvider`. The banner field is an upload-to-IPFS control with a live preview (PRD-513); the other fields use inline `EditableField` save. The underlying update mutations (`useUpdateGardenName`, `useUpdateGarden{Description,Location,BannerImage}`, `useSetOpenJoining`, `useSetMaxGardeners`) are wired but inert — actions trigger the real mutation which fails silently against the mock transport. Render states (view / edit / read-only) reflect the live component.",
34+
"Real `GardenSettingsEditor` rendered against the Storybook mock wagmi connector and `DevAuthProvider`. Explicit-save form: every field (name, description, location, banner, open joining, max gardeners) edits a local draft; the pinned footer shows the dirty state with Save changes / Cancel. Banner selection shows a local object-URL preview and uploads to IPFS only during Save. The underlying mutations (`useUpdateGardenName`, `useUpdateGarden{Description,Location,BannerImage}`, `useSetOpenJoining`, `useSetMaxGardeners`) are wired but inert against the mock transport. Render states (editable / read-only / dirty footer) reflect the live component.",
3535
},
3636
},
3737
},

0 commit comments

Comments
 (0)