NYM-1199: Add Node Families wallet feature with comprehensive UI and E2E tests
This commit introduces the complete frontend implementation for the Node Families feature. It includes: - All owner-side UI (create, edit, invite, manage members, disband) and operator-side UI (view/accept/reject invites, leave family). - Client-side state management, query hooks, and Tauri IPC bindings. - A full in-memory mock contract for isolated testing. - Extensive Storybook component, page, and flow stories. - Playwright end-to-end tests running against Storybook. - Updates spec documentation with Figma design sources and resolves open questions.
This commit is contained in:
@@ -1,16 +1,27 @@
|
||||
import path from 'path';
|
||||
import type { StorybookConfig } from '@storybook/react-webpack5';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
"stories": [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-mcp',
|
||||
],
|
||||
"addons": [
|
||||
"@storybook/addon-webpack5-compiler-swc",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-mcp"
|
||||
],
|
||||
"framework": "@storybook/react-webpack5"
|
||||
framework: '@storybook/react-webpack5',
|
||||
// The app resolves bare `src/...` imports and the `@assets` alias via the shared
|
||||
// `@nymproject/webpack` base config, which Storybook does not inherit — replicate it here.
|
||||
webpackFinal: async (cfg) => {
|
||||
// `main.ts` loads as an ESM module (no __dirname); Storybook runs from the wallet dir.
|
||||
const walletRoot = process.cwd();
|
||||
cfg.resolve = cfg.resolve ?? {};
|
||||
cfg.resolve.modules = [walletRoot, 'node_modules', ...(cfg.resolve.modules ?? [])];
|
||||
cfg.resolve.alias = {
|
||||
...(cfg.resolve.alias ?? {}),
|
||||
'@assets': path.resolve(walletRoot, '../assets'),
|
||||
};
|
||||
return cfg;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
export default config;
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
import type { Preview } from '@storybook/react-webpack5'
|
||||
import React from 'react';
|
||||
import type { Preview } from '@storybook/react-webpack5';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { getDesignTokens } from '../src/theme/theme';
|
||||
import { muiEmotionCache } from '../src/theme/emotionCache';
|
||||
|
||||
// Build the theme directly from design tokens + emotion cache. We deliberately
|
||||
// avoid `NymWalletTheme`, which imports `@assets/...fonts.css` (a tsconfig path
|
||||
// alias Storybook's webpack can't resolve). Fonts fall back to system defaults
|
||||
// in Storybook, which is fine for component/flow rendering.
|
||||
const storybookTheme = createTheme(getDesignTokens('dark'));
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<CacheProvider value={muiEmotionCache}>
|
||||
<ThemeProvider theme={storybookTheme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
||||
<Story />
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
export default preview;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* e2e coverage of the owner + operator flows (tasks.md §8.4), driven against the
|
||||
* Storybook flow stories. Each flow story's `play` function runs automatically when
|
||||
* the story iframe loads, so we navigate to the story and assert the post-flow DOM.
|
||||
*/
|
||||
|
||||
const storyUrl = (id: string) => `/iframe.html?id=${id}&viewMode=story`;
|
||||
|
||||
test.describe('Families flows', () => {
|
||||
test('owner lifecycle: create → invite → accept → kick → disband', async ({ page }) => {
|
||||
await page.goto(storyUrl('families-flows--owner-lifecycle'));
|
||||
// After disband the family is gone, so the create entry point returns.
|
||||
await expect(page.getByTestId('create-family-name')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('operator lifecycle: accept → leave, then reject', async ({ page }) => {
|
||||
await page.goto(storyUrl('families-flows--operator-lifecycle'));
|
||||
// After rejecting the last invite, the reject-node group is empty.
|
||||
await expect(page.getByTestId('node-invite-group-204-empty')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('operator page shows multi-node invite states', async ({ page }) => {
|
||||
await page.goto(storyUrl('families-pages-operatorinvitespage--multi-node'));
|
||||
await expect(page.getByTestId('node-invite-group-201')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('node-invite-group-203-empty')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,32 @@ Because the production app is Tauri (not a plain web target), Playwright e2e spe
|
||||
### D9: Creation fee and limits are read from chain config
|
||||
The UI reads `create_family_fee`, `family_name_length_limit`, and `family_description_length_limit` from contract `Config` (mocked in fixtures), never hardcoding 100 NYM or character counts. Validation is byte-length based to match the contract.
|
||||
|
||||
## Design Source (Figma)
|
||||
|
||||
UI is built from the **Nym 2.0** Figma file, board **"Nym_Wallet – Node families added"**:
|
||||
|
||||
- **File key:** `moIK1E6AaXhFz8lI1pZVrI`
|
||||
- **Board node:** `1859:981`
|
||||
- **Board URL:** https://www.figma.com/design/moIK1E6AaXhFz8lI1pZVrI/%F0%9F%94%A5%F0%9F%94%A5Nym.2.0%F0%9F%94%A5%F0%9F%94%A5?node-id=1859-981
|
||||
- Open any frame below via the same URL with `?node-id=<id>` (dash form, e.g. `2474-1935`). Pull frames during apply with the Figma MCP `get_design_context` tool (`fileKey` + `nodeId`).
|
||||
|
||||
The board holds two overlapping mockups. The **newer polished wireframe set (`2474:*`, "nym-wallet-ui-wireframes", 28/05) is the canonical build target.** The **ticket-annotated composite (`1861:*`, "family-wallet-composite", 13/05) is the reference** for component-level detail and per-ticket intent (its sections carry the NYM-12xx numbers). Where the two disagree, the `2474:*` set wins; reconcile any drift at apply time.
|
||||
|
||||
**Canonical surfaces (`2474:*`):**
|
||||
- `2474:1935` — Family · all 4 user states → `2474:1945` No family yet · `2474:1980` Owner · `2474:2063` Member, pending invite · `2474:2134` Member, active
|
||||
- `2474:1360` — Balance — Overview · `2474:1449` — Balance — Family tab
|
||||
- `2474:1305` — Dissolve · `2474:1311` — Member (remove/offline states)
|
||||
|
||||
**Reference composite sections (`1861:*`), ticket-mapped:**
|
||||
- `1861:393` SECTION 1 — intro / 4 family states
|
||||
- `1861:638` SECTION 2 — Create Family · **NYM-1210**
|
||||
- `1861:794` SECTION 3 — Family Detail (roster + settings) · **NYM-1211** edit · **NYM-1213** view roster · **NYM-1214** remove member · **NYM-1215** dissolve empty family
|
||||
- `1861:1150` SECTION 4 — Invite Node · **NYM-1212**
|
||||
- `1861:1349` SECTION 5 — Incoming Invite popups · **NYM-1216 / 1217 / 1218**
|
||||
- `1861:1711` SECTION 6 — Leave family · **NYM-1219**
|
||||
|
||||
Also on the board: a wireframe **Components** column (`2474:863`) and ten full-screen render frames (`2386:2352` … `2464:3976`) showing each state in the full app shell. Per-requirement frame links are recorded inline in `specs/node-families-owner/spec.md` and `specs/node-families-operator/spec.md`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[`UpdateFamily` lands in a separate contract change]** → Build the edit UI + mock against the decided shape (see Resolved); verify on rebase per task 9.5 and reconcile any drift in the request binding, mock execute, and TS types. No feature flag needed since the wallet branch only merges after the contract change lands.
|
||||
@@ -71,6 +97,6 @@ Additive only — new tab, context, requests, types, stories, tests. No existing
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Figma file/frame URLs for each component and page (to be supplied at apply time via Figma MCP).
|
||||
_(none open)_
|
||||
|
||||
_Resolved:_ no family key concept in V1 (acceptance is a pure membership record; owner-acts-for-node is V2 per NYM-1217); `family_id` is **internal**, the UI identifies families by **name**; names are unique among **live** families only (released for reuse on disband); the Family tab is **always visible**; large lists are **paginated via the contract's `start_after` cursor** (default 50, max 100); the **`UpdateFamily` message shape** is `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` with `None` meaning "field unchanged" and `Some(_)` meaning "set to this value", sender must be the family owner; this lands in a separate contract change and is verified on rebase per task 9.5.
|
||||
_Resolved:_ **Figma file/frame URLs** for each component and page are now captured — see "Design Source (Figma)" above (file `moIK1E6AaXhFz8lI1pZVrI`, board `1859:981`); the `2474:*` polished set is canonical and the `1861:*` ticket-annotated composite is the per-component reference, with per-requirement node IDs recorded inline in the two spec files. No family key concept in V1 (acceptance is a pure membership record; owner-acts-for-node is V2 per NYM-1217); `family_id` is **internal**, the UI identifies families by **name**; names are unique among **live** families only (released for reuse on disband); the Family tab is **always visible**; large lists are **paginated via the contract's `start_after` cursor** (default 50, max 100); the **`UpdateFamily` message shape** is `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` with `None` meaning "field unchanged" and `Some(_)` meaning "set to this value", sender must be the family owner; this lands in a separate contract change and is verified on rebase per task 9.5.
|
||||
|
||||
@@ -47,4 +47,4 @@ Node Families is a new on-chain capability (see the `node-families-contract` spe
|
||||
- **Storybook**: new stories tree for components → pages → flows.
|
||||
- **Tests**: Playwright e2e specs; Jest/RTL hook + integration tests against mocks; Storybook interaction tests.
|
||||
- **Dependencies / blockers**: `UpdateFamily` edit lands in a separate contract change; this wallet branch will rebase onto that change before merge, and the edit path swaps from the mock to the real IPC binding at rebase time (verified per task 9.5). Creation fee is configurable on-chain (not a hardcoded 100 NYM); UI must read it from config.
|
||||
- **External**: Figma designs (via Figma MCP) required during implementation.
|
||||
- **External**: Figma designs (via Figma MCP) required during implementation — Nym 2.0 file `moIK1E6AaXhFz8lI1pZVrI`, board "Nym_Wallet – Node families added" (`1859:981`); per-frame mapping in design.md "Design Source (Figma)".
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
## ADDED Requirements
|
||||
|
||||
<!-- Design source: Figma file `moIK1E6AaXhFz8lI1pZVrI`, board `1859:981`. Canonical = `2474:*` set; reference = `1861:*` ticket composite. See design.md "Design Source (Figma)". Per-requirement frame IDs are noted as _Design:_ lines below; open via ?node-id=<id> (dash form). -->
|
||||
|
||||
### Requirement: Node operator can view family invites per node
|
||||
|
||||
_Design: canonical `2474:2063` (Member, pending invite); ref `1861:1349` (SECTION 5 · Incoming Invite popups · NYM-1216)._
|
||||
|
||||
The wallet SHALL display, in the Family tab, the pending family invitations addressed to each node the operator controls. When the operator controls multiple nodes, invitations SHALL be shown separately per node. Each invitation SHALL show the family name, the inviting family owner, and the expiry (TTL). Invitations whose contract `expired` flag is true SHALL be shown as **expired** and SHALL NOT be actionable (no accept/reject). Invitations are sourced from `GetPendingInvitationsForNodePaged` per controlled node.
|
||||
|
||||
#### Scenario: Active invite is shown with details
|
||||
@@ -18,6 +22,8 @@ The wallet SHALL display, in the Family tab, the pending family invitations addr
|
||||
|
||||
### Requirement: Node operator can accept an invite
|
||||
|
||||
_Design: ref `1861:1349` (SECTION 5 · accept · NYM-1218), incl. on-chain-consequences confirm; canonical `2474:2063` (Member, pending invite)._
|
||||
|
||||
The wallet SHALL let the operator accept a pending, not-yet-expired invitation from the invite view, triggering `AcceptFamilyInvitation { family_id, node_id }`. On success the wallet MUST show a confirmation and the node MUST appear as **Joined** in the family member list. In V1 acceptance records membership only; the family owner gains no control over the node itself (owner-acts-for-node is V2 per NYM-1217). Accepting an expired invitation MUST be prevented (`InvitationExpired`).
|
||||
|
||||
#### Scenario: Successful acceptance
|
||||
@@ -30,6 +36,8 @@ The wallet SHALL let the operator accept a pending, not-yet-expired invitation f
|
||||
|
||||
### Requirement: Node operator can reject an invite
|
||||
|
||||
_Design: ref `1861:1349` (SECTION 5 · reject · NYM-1217/1218)._
|
||||
|
||||
The wallet SHALL let the operator reject a pending invitation from the invite view, behind a confirmation prompt, triggering `RejectFamilyInvitation { family_id, node_id }`. After rejection the invitation MUST no longer appear in the operator's pending list, and the node MUST appear under **Rejected** in the family member list.
|
||||
|
||||
#### Scenario: Successful rejection
|
||||
@@ -42,6 +50,8 @@ The wallet SHALL let the operator reject a pending invitation from the invite vi
|
||||
|
||||
### Requirement: Node operator can leave a family
|
||||
|
||||
_Design: ref `1861:1711` (SECTION 6 · Leave family · NYM-1219); canonical `2474:2134` (Member, active)._
|
||||
|
||||
The wallet SHALL let an operator whose node is a member of a family leave it voluntarily from the Family tab, behind a confirmation prompt, triggering `LeaveFamily { node_id }`. After leaving, the node MUST be removed from the family member list (shown as Removed) and the operator MUST subsequently be able to receive and accept invitations from other families.
|
||||
|
||||
#### Scenario: Successful leave
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
## ADDED Requirements
|
||||
|
||||
<!-- Design source: Figma file `moIK1E6AaXhFz8lI1pZVrI`, board `1859:981`. Canonical = `2474:*` set; reference = `1861:*` ticket composite. See design.md "Design Source (Figma)". Per-requirement frame IDs are noted as _Design:_ lines below; open via ?node-id=<id> (dash form). -->
|
||||
|
||||
### Requirement: Family Tab is always visible and exposes create or management based on ownership
|
||||
|
||||
_Design: `2474:1935` (4 user states) → `2474:1945` No family yet, `2474:1980` Owner; `2474:1449` Balance — Family tab; ref `1861:393` (SECTION 1)._
|
||||
|
||||
The wallet SHALL display the **Family** tab for every connected wallet account, regardless of whether the account owns a family or controls a bonded node, so that any account can start a new family. When the connected address does not own a family, the tab SHALL present a create-family entry point. When the address already owns a family, the tab SHALL show the family management surface instead of the create entry point.
|
||||
|
||||
#### Scenario: Account without a family sees the create entry point
|
||||
@@ -14,6 +18,8 @@ The wallet SHALL display the **Family** tab for every connected wallet account,
|
||||
|
||||
### Requirement: Family owner can create a family with the creation fee
|
||||
|
||||
_Design: ref `1861:638` (SECTION 2 · Create Family · NYM-1210); canonical entry point `2474:1945` (No family yet)._
|
||||
|
||||
The wallet SHALL allow an eligible user to create a family by submitting a name and description and attaching the contract's configured creation fee (`Config::create_family_fee`, read from chain — NOT a hardcoded amount). The wallet MUST display the required fee before submission, deduct it on success, and show a success confirmation that surfaces the new family. The wallet MUST surface an insufficient-balance error before submitting when the balance is below fee + estimated gas, and MUST surface contract fee errors (`InvalidFamilyCreationFee`, `InvalidDeposit`) clearly.
|
||||
|
||||
#### Scenario: Successful creation
|
||||
@@ -30,6 +36,8 @@ The wallet SHALL allow an eligible user to create a family by submitting a name
|
||||
|
||||
### Requirement: Family owner can add and edit the family name and description
|
||||
|
||||
_Design: ref `1861:794` (SECTION 3 · NYM-1211 edit); canonical `2474:1980` (Owner state)._
|
||||
|
||||
The wallet SHALL let the owner set a name and description on creation and edit either after creation. Inputs MUST be validated against the contract byte-length limits (`Config::family_name_length_limit`, `Config::family_description_length_limit`) measured in bytes, and MUST be sanitised so that scripts, control characters, and injection attempts are neutralised before submission. Over-limit input MUST be surfaced with an inline error and MUST NOT be submitted. Editing uses the contract's `UpdateFamily` handler.
|
||||
|
||||
#### Scenario: Valid name and description are accepted
|
||||
@@ -50,6 +58,8 @@ The wallet SHALL let the owner set a name and description on creation and edit e
|
||||
|
||||
### Requirement: Family owner can invite a node by node ID
|
||||
|
||||
_Design: ref `1861:1150` (SECTION 4 · Invite Node · NYM-1212), incl. the three warning states; canonical `2474:1980` (Owner state)._
|
||||
|
||||
The wallet SHALL let the owner invite a node by entering its node ID, triggering `InviteToFamily` (with optional `validity_secs` for the TTL/nonce). On success the wallet MUST show a confirmation. The wallet MUST NOT send the invite and MUST warn the owner when: the node is already in a family (`NodeAlreadyInFamily`), the node does not exist or is unbonding (`NodeDoesntExist`), or a pending invite from this family already exists (`PendingInvitationAlreadyExists`). Malformed node IDs MUST be surfaced with a clear validation error.
|
||||
|
||||
#### Scenario: Successful invite
|
||||
@@ -70,6 +80,8 @@ The wallet SHALL let the owner invite a node by entering its node ID, triggering
|
||||
|
||||
### Requirement: Family owner can withdraw pending invites and clear expired ones
|
||||
|
||||
_Design: ref `1861:794` (SECTION 3 · roster/invite management) and `1861:1150` (SECTION 4 · pending invite + expired states)._
|
||||
|
||||
The wallet SHALL list the family's pending invitations with their expiry state (using the contract `expired` flag). For an active (not-yet-expired) invite the owner SHALL be able to withdraw it via `RevokeFamilyInvitation` behind a confirmation prompt. Expired invites SHALL be shown as expired with a dismiss/clear option, also behind a confirmation prompt. After either action the invite MUST be removed from the pending list and the displayed contract state refreshed.
|
||||
|
||||
#### Scenario: Withdraw an active invite
|
||||
@@ -86,6 +98,8 @@ The wallet SHALL list the family's pending invitations with their expiry state (
|
||||
|
||||
### Requirement: Family owner can view the member list grouped by status
|
||||
|
||||
_Design: ref `1861:794` (SECTION 3 · view roster · NYM-1213); canonical `2474:1980` (Owner state)._
|
||||
|
||||
The wallet SHALL display the family's records grouped into four sections: **Pending** (active pending invitations), **Joined** (current members), **Rejected** (invitations the node declined), and **Removed** (members that left or were kicked). Each section is sourced from a distinct contract query and paginates independently using the contract's exclusive `start_after` cursor (default page size 50, max 100), fetching subsequent pages via the returned `start_next_after`. Because the contract stores per-`(family, node)` archive records that accumulate over time, a single node MAY appear in more than one section when its history justifies it (e.g., currently Joined and previously Removed); each row represents a record, not a node. `Revoked` past invitations are owner-side actions and SHALL NOT be shown in the member list. The list SHALL refresh to reflect current contract state and SHALL render an empty state for any section with no entries.
|
||||
|
||||
#### Scenario: Large section is paginated by cursor
|
||||
@@ -114,6 +128,8 @@ The wallet SHALL display the family's records grouped into four sections: **Pend
|
||||
|
||||
### Requirement: Family owner can remove a node from the family
|
||||
|
||||
_Design: ref `1861:794` (SECTION 3 · remove member · NYM-1214); canonical `2474:1311` (Member remove state)._
|
||||
|
||||
The wallet SHALL let the owner remove (kick) a Joined member via `KickFromFamily`, behind a confirmation prompt. On confirmation the kick is submitted and the node MUST move to **Removed** in the member list. Cancelling the prompt MUST make no contract call and leave state unchanged.
|
||||
|
||||
#### Scenario: Successful removal
|
||||
@@ -126,6 +142,8 @@ The wallet SHALL let the owner remove (kick) a Joined member via `KickFromFamily
|
||||
|
||||
### Requirement: Family owner can delete an empty family
|
||||
|
||||
_Design: ref `1861:794` (SECTION 3 · dissolve empty family · NYM-1215); canonical `2474:1305` (Dissolve)._
|
||||
|
||||
The wallet SHALL offer a delete-family option to the owner. Deletion (via `DisbandFamily`) SHALL be permitted only when the family has zero members and SHALL be behind a confirmation prompt. Attempting to delete a non-empty family MUST surface a clear error (`FamilyNotEmpty`) and MUST NOT remove the family.
|
||||
|
||||
#### Scenario: Successful deletion of an empty family
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
## 1. Types & request bindings
|
||||
|
||||
- [ ] 1.1 Add TS types in `src/types` for `NodeFamily`, `FamilyMembership`, `FamilyInvitation`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation` (with status), `PastFamilyMember`, and contract `Config` (fee + limits)
|
||||
- [ ] 1.2 Add `src/requests/families.ts` Tauri IPC bindings for execute msgs: createFamily, updateFamily, disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily
|
||||
- [ ] 1.3 Add query bindings: getFamilyByOwner, getFamilyMembership, family members paged, pending invitations for family/node paged, past invitations for family paged, past members for family paged, config
|
||||
- [ ] 1.4 Export new requests from `src/requests/index.ts`
|
||||
- [x] 1.1 Add TS types in `src/types` for `NodeFamily`, `FamilyMembership`, `FamilyInvitation`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation` (with status), `PastFamilyMember`, and contract `Config` (fee + limits)
|
||||
- [x] 1.2 Add `src/requests/families.ts` Tauri IPC bindings for execute msgs: createFamily, updateFamily, disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily
|
||||
- [x] 1.3 Add query bindings: getFamilyByOwner, getFamilyMembership, family members paged, pending invitations for family/node paged, past invitations for family paged, past members for family paged, config
|
||||
- [x] 1.4 Export new requests from `src/requests/index.ts`
|
||||
|
||||
## 2. Context, hooks & query keys
|
||||
|
||||
- [ ] 2.1 Create `src/context/families.tsx` (`FamiliesContext` + provider) exposing owner + operator operations, loading/error/refresh; wire into `src/context/index.tsx`
|
||||
- [ ] 2.2 Add `familyQueryKeys` module mirroring `delegationQueryKeys`
|
||||
- [ ] 2.3 Add TanStack Query read hooks: useFamilyByOwner, useFamilyConfig, useFamilyMembers, usePendingInvitationsForFamily, usePastInvitationsForFamily, usePastMembersForFamily, usePendingInvitationsForNode, useFamilyMembership
|
||||
- [ ] 2.4 Add a `useFamilyMemberList` aggregator hook combining the four section queries (Pending, Joined, Rejected, Removed) into one consumable shape for the UI; each section maps 1:1 to its underlying query (no cross-section deduplication, no priority cascade); Revoked past invitations are not surfaced in any section
|
||||
- [ ] 2.5 Add execute hooks/methods with optimistic refresh + error surfacing for all nine execute msgs
|
||||
- [x] 2.1 Create `src/context/families.tsx` (`FamiliesContext` + provider) exposing owner + operator operations, loading/error/refresh; wire into `src/context/index.tsx`
|
||||
- [x] 2.2 Add `familyQueryKeys` module mirroring `delegationQueryKeys`
|
||||
- [x] 2.3 Add TanStack Query read hooks: useFamilyByOwner, useFamilyConfig, useFamilyMembers, usePendingInvitationsForFamily, usePastInvitationsForFamily, usePastMembersForFamily, usePendingInvitationsForNode, useFamilyMembership
|
||||
- [x] 2.4 Add a `useFamilyMemberList` aggregator hook combining the four section queries (Pending, Joined, Rejected, Removed) into one consumable shape for the UI; each section maps 1:1 to its underlying query (no cross-section deduplication, no priority cascade); Revoked past invitations are not surfaced in any section
|
||||
- [x] 2.5 Add execute hooks/methods with optimistic refresh + error surfacing for all nine execute msgs
|
||||
|
||||
## 3. node-families-contract mock & fixtures (derived from `openspec/specs/node-families-contract/spec.md`)
|
||||
|
||||
- [ ] 3.1 Create `src/context/mocks/families.fixtures.ts` with a `Config` (create_family_fee, name/description byte limits, default_invitation_validity_secs) and typed fixtures for `NodeFamily`, `FamilyMembership`, `FamilyInvitation`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation` (Accepted/Rejected/Revoked), `PastFamilyMember`
|
||||
- [ ] 3.2 Seed fixtures: a sample owned family; members across Joined and Removed (left + kicked); past invitations as Rejected and Revoked; pending invitations including at least one expired and one active
|
||||
- [ ] 3.3 Add a multi-node operator fixture: two controlled nodes with different invite states (active, expired, none)
|
||||
- [ ] 3.4 Create `src/context/mocks/families.tsx` mirroring the context with `mockSleep` latency and mutable in-memory state (follow the `mocks/bonding.tsx` convention; return `TxResultMock` from execute methods)
|
||||
- [ ] 3.5 Implement mock execute methods that mutate fixtures and honor contract invariants: createFamily, updateFamily (`updated_name`/`updated_description` `Option<String>`; None = unchanged), disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily, plus an `onNymNodeUnbond` test helper
|
||||
- [ ] 3.6 Implement mock query functions for every contract query: getFamilyById, getFamilyByName (normalised), getFamilyByOwner, getFamilyMembership, and all paginated queries with exclusive `start_after`, default limit 50, max 100, and `start_next_after`
|
||||
- [ ] 3.7 Enforce contract invariants in the mock: one family per owner, one family per node, monotonic non-recycled ids starting at 1, ASCII name normalisation + global uniqueness, byte-length limits, live `expired = now >= expires_at`, per-`(family, node)` archive counters from 0
|
||||
- [ ] 3.8 Model the contract error set as typed mock errors (InvalidFamilyCreationFee, FamilyNameAlreadyTaken/TooLong, EmptyFamilyName, SenderAlreadyOwnsAFamily, NodeAlreadyInFamily, NodeDoesntExist, PendingInvitationAlreadyExists, ZeroInvitationValidity, InvitationExpired/NotFound, FamilyNotEmpty, SenderDoesntControlNode, NodeNotMemberOfFamily) so warning/error UI states are reachable
|
||||
- [ ] 3.9 Have mock execute returns carry the spec's event names/attributes (family_creation, family_disband, family_invitation, family_invitation_revoked/accepted/rejected, family_member_left/kicked, family_node_unbond_cleanup)
|
||||
- [x] 3.1 Create `src/context/mocks/families.fixtures.ts` with a `Config` (create_family_fee, name/description byte limits, default_invitation_validity_secs) and typed fixtures for `NodeFamily`, `FamilyMembership`, `FamilyInvitation`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation` (Accepted/Rejected/Revoked), `PastFamilyMember`
|
||||
- [x] 3.2 Seed fixtures: a sample owned family; members across Joined and Removed (left + kicked); past invitations as Rejected and Revoked; pending invitations including at least one expired and one active
|
||||
- [x] 3.3 Add a multi-node operator fixture: two controlled nodes with different invite states (active, expired, none)
|
||||
- [x] 3.4 Create `src/context/mocks/families.tsx` mirroring the context with `mockSleep` latency and mutable in-memory state (follow the `mocks/bonding.tsx` convention; return `TxResultMock` from execute methods)
|
||||
- [x] 3.5 Implement mock execute methods that mutate fixtures and honor contract invariants: createFamily, updateFamily (`updated_name`/`updated_description` `Option<String>`; None = unchanged), disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily, plus an `onNymNodeUnbond` test helper
|
||||
- [x] 3.6 Implement mock query functions for every contract query: getFamilyById, getFamilyByName (normalised), getFamilyByOwner, getFamilyMembership, and all paginated queries with exclusive `start_after`, default limit 50, max 100, and `start_next_after`
|
||||
- [x] 3.7 Enforce contract invariants in the mock: one family per owner, one family per node, monotonic non-recycled ids starting at 1, ASCII name normalisation + global uniqueness, byte-length limits, live `expired = now >= expires_at`, per-`(family, node)` archive counters from 0
|
||||
- [x] 3.8 Model the contract error set as typed mock errors (InvalidFamilyCreationFee, FamilyNameAlreadyTaken/TooLong, EmptyFamilyName, SenderAlreadyOwnsAFamily, NodeAlreadyInFamily, NodeDoesntExist, PendingInvitationAlreadyExists, ZeroInvitationValidity, InvitationExpired/NotFound, FamilyNotEmpty, SenderDoesntControlNode, NodeNotMemberOfFamily) so warning/error UI states are reachable
|
||||
- [x] 3.9 Have mock execute returns carry the spec's event names/attributes (family_creation, family_disband, family_invitation, family_invitation_revoked/accepted/rejected, family_member_left/kicked, family_node_unbond_cleanup)
|
||||
|
||||
## 4. Owner UI components (from Figma)
|
||||
|
||||
- [ ] 4.1 Pull owner-side designs via Figma MCP and implement: CreateFamily form (name, description, fee display, balance/fee errors), with byte-limit validation + input sanitisation
|
||||
- [ ] 4.2 EditFamily form (name/description, byte limits, inline over-limit error): send only changed fields as `Some(value)` and unchanged ones as `None`; if nothing changed, do not submit
|
||||
- [ ] 4.3 InviteNode form (node ID input, validation) with confirmation and the three warning states (already-in-family, non-existent, duplicate pending)
|
||||
- [ ] 4.4 PendingInvites list with withdraw (active, confirmation) and clear-expired (confirmation) actions and `expired` badges
|
||||
- [ ] 4.5 MemberList grouped by Pending/Joined/Rejected/Removed with per-status empty states and refresh
|
||||
- [ ] 4.6 Kick action with confirmation prompt; DeleteFamily action (empty-only, confirmation, `FamilyNotEmpty` error)
|
||||
- [x] 4.1 Pull owner-side designs via Figma MCP and implement: CreateFamily form (name, description, fee display, balance/fee errors), with byte-limit validation + input sanitisation
|
||||
- [x] 4.2 EditFamily form (name/description, byte limits, inline over-limit error): send only changed fields as `Some(value)` and unchanged ones as `None`; if nothing changed, do not submit
|
||||
- [x] 4.3 InviteNode form (node ID input, validation) with confirmation and the three warning states (already-in-family, non-existent, duplicate pending)
|
||||
- [x] 4.4 PendingInvites list with withdraw (active, confirmation) and clear-expired (confirmation) actions and `expired` badges
|
||||
- [x] 4.5 MemberList grouped by Pending/Joined/Rejected/Removed with per-status empty states and refresh
|
||||
- [x] 4.6 Kick action with confirmation prompt; DeleteFamily action (empty-only, confirmation, `FamilyNotEmpty` error)
|
||||
|
||||
## 5. Operator UI components (from Figma)
|
||||
|
||||
- [ ] 5.1 Pull operator-side designs via Figma MCP and implement: per-node InviteCard (family name, inviting owner, expiry/TTL) with expired = non-actionable
|
||||
- [ ] 5.2 Multi-node grouping of invites
|
||||
- [ ] 5.3 Accept action (confirmation) and Reject action (confirmation)
|
||||
- [ ] 5.4 LeaveFamily action with confirmation
|
||||
- [x] 5.1 Pull operator-side designs via Figma MCP and implement: per-node InviteCard (family name, inviting owner, expiry/TTL) with expired = non-actionable
|
||||
- [x] 5.2 Multi-node grouping of invites
|
||||
- [x] 5.3 Accept action (confirmation) and Reject action (confirmation)
|
||||
- [x] 5.4 LeaveFamily action with confirmation
|
||||
|
||||
## 6. Family Tab & pages
|
||||
|
||||
- [ ] 6.1 Add the Family tab, always visible for every wallet account (no eligibility gating)
|
||||
- [ ] 6.2 Owner management page composing components from section 4
|
||||
- [ ] 6.3 Operator invites page composing components from section 5
|
||||
- [ ] 6.4 Route between create entry point and management surface based on ownership
|
||||
- [x] 6.1 Add the Family tab, always visible for every wallet account (no eligibility gating)
|
||||
- [x] 6.2 Owner management page composing components from section 4
|
||||
- [x] 6.3 Operator invites page composing components from section 5
|
||||
- [x] 6.4 Route between create entry point and management surface based on ownership
|
||||
|
||||
## 7. Storybook (three levels)
|
||||
|
||||
- [ ] 7.1 Add a `withFamiliesMock` decorator backed by the mock provider
|
||||
- [ ] 7.2 Component stories with explicit state args (empty, loading, error, expired, over-limit, success) for every component in sections 4 & 5
|
||||
- [ ] 7.3 Page stories for the owner management page and operator invites page
|
||||
- [ ] 7.4 Flow stories with `@storybook/test` play functions: owner flow (create → invite → accept → kick → disband) and operator flow (receive → accept/reject → leave)
|
||||
- [x] 7.1 Add a `withFamiliesMock` decorator backed by the mock provider
|
||||
- [x] 7.2 Component stories with explicit state args (empty, loading, error, expired, over-limit, success) for every component in sections 4 & 5
|
||||
- [x] 7.3 Page stories for the owner management page and operator invites page
|
||||
- [x] 7.4 Flow stories with `@storybook/test` play functions: owner flow (create → invite → accept → kick → disband) and operator flow (receive → accept/reject → leave)
|
||||
|
||||
## 8. Tests
|
||||
|
||||
- [ ] 8.1 Hook/integration tests (Jest + RTL) for every execute method and the status-derivation selector against the mock provider
|
||||
- [ ] 8.2 Storybook interaction tests assert play-function outcomes for component and page stories
|
||||
- [ ] 8.3 Add Playwright as a dev dependency and a config that serves the static Storybook build
|
||||
- [ ] 8.4 Playwright e2e specs covering the owner flow and operator flow against the flow stories
|
||||
- [ ] 8.5 Test the spec scenarios explicitly: successful create, insufficient balance, over-limit/special-char input, invite warnings, withdrawal of active invite, expired-invite state, kick + cancel, delete empty vs blocked non-empty, accept→Joined, reject→Rejected, leave→Removed + can rejoin, multi-node invite states
|
||||
- [x] 8.1 Hook/integration tests (Jest) for every execute method and the status-derivation selector against the mock engine. NOTE: jest is `node` env, `*.test.ts` only (no jsdom/RTL render) — coverage is at the mock-engine + extracted pure `deriveMemberSections` selector level, which is the mock provider's logic. Files: `familiesMockState.test.ts`, `familyMemberSections.test.ts`, `Families/helpers.test.ts` (47 tests).
|
||||
- [x] 8.2 Storybook interaction tests: assertive `play` functions on the flow stories (run by `@storybook/test-runner` via `pnpm test:storybook`). Requires `pnpm install` + browsers to execute.
|
||||
- [x] 8.3 Added `@playwright/test` devDep + `playwright.config.ts` (serves Storybook on :6006 via `webServer`). Requires `pnpm install` + `npx playwright install chromium` to run.
|
||||
- [x] 8.4 Playwright e2e specs `e2e/families.spec.ts` covering owner + operator flows (+ multi-node states) against the flow stories.
|
||||
- [x] 8.5 Spec scenarios covered in `familiesMockState.test.ts` (create success/fee errors, over-limit + special-char normalisation, invite warnings, revoke active, expired-invite flag, kick, disband empty vs blocked, accept→Joined, reject→Rejected, leave→Removed + rejoin, multi-node) and `helpers.test.ts` (insufficient-balance pre-check, sanitisation). "kick + cancel" cancellation path is UI-only (no contract call) — exercised by the flow stories, not the engine.
|
||||
|
||||
## 9. Wiring & verification
|
||||
|
||||
- [ ] 9.1 Replace mock provider with the real `FamiliesContext` in the app (mocks remain for Storybook/tests)
|
||||
- [ ] 9.2 Confirm fee/limits are read from contract config, not hardcoded
|
||||
- [ ] 9.3 Run `pnpm test`, `build-storybook`, and Playwright; fix failures
|
||||
- [ ] 9.4 Manual Tauri smoke check of the IPC wiring for at least create + invite + accept (since e2e runs against Storybook, not native)
|
||||
- [ ] 9.5 On rebase onto the contract change that adds `UpdateFamily`: verify the `ExecuteMsg::UpdateFamily` variant exists, confirm fields are exactly `updated_name: Option<String>` and `updated_description: Option<String>` with None-means-unchanged semantics, confirm sender-must-be-owner auth, and reconcile any drift in `src/requests/families.ts`, the mock execute, and TS types
|
||||
- [x] 9.1 App route `/family` uses the real `FamiliesContextProvider` (via `pages/families/FamilyPageRoute.tsx` → `context/FamiliesContextProvider.tsx`); the mock provider is confined to Storybook/tests. The real provider is the ONLY families module importing `./main` (keeps Storybook free of Tauri-runtime code).
|
||||
- [x] 9.2 Fee + limits are read from contract `Config` via `useFamilyConfig` (`create_family_fee`, `family_name_length_limit`, `family_description_length_limit`); no hardcoded 100 NYM / char counts. (The `?? 30/120` fallbacks are load-time display defaults only; the submitted fee is always `config.data.create_family_fee`.)
|
||||
- [x] 9.3 `pnpm test` (85/85 green incl. 47 family) and `build-storybook` (succeeds) run clean; tsc + eslint clean. Playwright (`test:e2e`) and `test:storybook` are NOT run here — blocked on `pnpm install` of the new devDeps + `npx playwright install chromium` (no network/browsers in this env).
|
||||
- [ ] 9.4 **BLOCKED (manual / env):** Tauri IPC smoke (create + invite + accept) needs the native app + a chain + the Rust contract command handlers, none of which exist in this env. Must be done by a human on a wired build.
|
||||
- [ ] 9.5 **BLOCKED (external dependency):** `UpdateFamily` rebase verification depends on the separate `node-families-contract` change landing the Rust `ExecuteMsg::UpdateFamily` handler. The root contract SPEC already defines it as `{ updated_name: Option<String>, updated_description: Option<String> }` (None = unchanged, owner-gated) — the wallet's `requests/families.ts`, mock execute, and TS types are already built to that shape; reconcile on rebase when the IPC binding exists.
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"webpack:dev": "webpack serve --config webpack.dev.js",
|
||||
"webpack:prod": "webpack --progress --config webpack.prod.js",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"build-storybook": "storybook build",
|
||||
"test:e2e": "playwright test",
|
||||
"test:storybook": "test-storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-simple-access": "catalog:",
|
||||
@@ -143,6 +145,8 @@
|
||||
"@nymproject/eslint-config-react-typescript": "workspace:*",
|
||||
"@nymproject/webpack": "workspace:*",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "catalog:",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@storybook/addon-a11y": "^10.4.1",
|
||||
"@storybook/addon-docs": "^10.4.1",
|
||||
"@storybook/addon-mcp": "^0.6.0",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright e2e config (tasks.md §8.3). The production app is Tauri, so e2e runs
|
||||
* against the Storybook flow stories served as a real browser session (design D8).
|
||||
*
|
||||
* Requires `pnpm install` (adds @playwright/test) + `npx playwright install chromium`.
|
||||
* The webServer serves Storybook on :6006 (reused if already running).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: 'list',
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:6006',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run storybook -- --ci -p 6006',
|
||||
url: 'http://localhost:6006',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, ButtonProps, Stack } from '@mui/material';
|
||||
import { ConfirmationModal } from '../Modals/ConfirmationModal';
|
||||
|
||||
export interface ConfirmActionButtonProps {
|
||||
label: React.ReactNode;
|
||||
title: string;
|
||||
body?: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
disabled?: boolean;
|
||||
color?: ButtonProps['color'];
|
||||
variant?: ButtonProps['variant'];
|
||||
size?: ButtonProps['size'];
|
||||
dataTestid?: string;
|
||||
}
|
||||
|
||||
/** A button that gates its action behind a confirmation prompt (used across all gated family actions). */
|
||||
export const ConfirmActionButton = ({
|
||||
label,
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
disabled,
|
||||
color = 'primary',
|
||||
variant = 'outlined',
|
||||
size = 'small',
|
||||
dataTestid,
|
||||
}: ConfirmActionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={variant}
|
||||
color={color}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
data-testid={dataTestid}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<ConfirmationModal
|
||||
open={open}
|
||||
title={title}
|
||||
confirmButton={
|
||||
<Stack direction="row" spacing={2} width="100%">
|
||||
<Button fullWidth variant="outlined" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color={color}
|
||||
onClick={handleConfirm}
|
||||
data-testid={dataTestid ? `${dataTestid}-confirm` : undefined}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
{body}
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { CreateFamilyForm } from './CreateFamilyForm';
|
||||
|
||||
const meta: Meta<typeof CreateFamilyForm> = {
|
||||
title: 'Families/Components/CreateFamilyForm',
|
||||
component: CreateFamilyForm,
|
||||
args: {
|
||||
fee: { denom: 'nym', amount: '100' },
|
||||
nameLimit: 30,
|
||||
descriptionLimit: 120,
|
||||
onSubmit: () => undefined,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof CreateFamilyForm>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const InsufficientBalance: Story = { args: { balance: { denom: 'nym', amount: '5' } } };
|
||||
export const Submitting: Story = { args: { isSubmitting: true } };
|
||||
export const ContractError: Story = { args: { errorMessage: 'The attached creation fee is incorrect.' } };
|
||||
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { NymCard } from '../NymCard';
|
||||
import { byteLength, formatCoin, isInsufficientBalance, sanitizeInput } from './helpers';
|
||||
|
||||
export interface CreateFamilyFormProps {
|
||||
/** Creation fee read from chain config (never hardcoded). */
|
||||
fee: DecCoin;
|
||||
nameLimit: number;
|
||||
descriptionLimit: number;
|
||||
/** Connected account balance, used to pre-check insufficient funds. */
|
||||
balance?: DecCoin;
|
||||
isSubmitting?: boolean;
|
||||
/** Contract/fee error surfaced after a failed submit. */
|
||||
errorMessage?: string;
|
||||
onSubmit: (name: string, description: string) => void;
|
||||
}
|
||||
|
||||
export const CreateFamilyForm = ({
|
||||
fee,
|
||||
nameLimit,
|
||||
descriptionLimit,
|
||||
balance,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
}: CreateFamilyFormProps) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const nameBytes = byteLength(name);
|
||||
const descBytes = byteLength(description);
|
||||
const nameTooLong = nameBytes > nameLimit;
|
||||
const descTooLong = descBytes > descriptionLimit;
|
||||
const insufficient = isInsufficientBalance(balance, fee);
|
||||
const canSubmit = name.trim().length > 0 && !nameTooLong && !descTooLong && !insufficient && !isSubmitting;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
onSubmit(sanitizeInput(name), sanitizeInput(description));
|
||||
};
|
||||
|
||||
return (
|
||||
<NymCard title="Create a family" data-testid="create-family-form">
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Group your node with others under a family wallet. Creating a family requires a refundable fee of{' '}
|
||||
<strong>{formatCoin(fee)}</strong>, returned in full when the family is dissolved.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Family name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={nameTooLong}
|
||||
helperText={
|
||||
nameTooLong ? `Name is ${nameBytes}/${nameLimit} bytes — too long` : `${nameBytes}/${nameLimit} bytes`
|
||||
}
|
||||
fullWidth
|
||||
inputProps={{ 'data-testid': 'create-family-name' }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
error={descTooLong}
|
||||
helperText={
|
||||
descTooLong
|
||||
? `Description is ${descBytes}/${descriptionLimit} bytes — too long`
|
||||
: `${descBytes}/${descriptionLimit} bytes`
|
||||
}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
inputProps={{ 'data-testid': 'create-family-description' }}
|
||||
/>
|
||||
|
||||
{insufficient && (
|
||||
<Alert severity="error" data-testid="create-family-insufficient">
|
||||
Insufficient balance — you need at least {formatCoin(fee)} plus gas to create a family.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert severity="error" data-testid="create-family-error">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button variant="contained" disabled={!canSubmit} onClick={handleSubmit} data-testid="create-family-submit">
|
||||
{isSubmitting ? 'Creating…' : `Create family · ${formatCoin(fee)}`}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</NymCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { DeleteFamilyButton } from './DeleteFamilyButton';
|
||||
|
||||
const meta: Meta<typeof DeleteFamilyButton> = {
|
||||
title: 'Families/Components/DeleteFamilyButton',
|
||||
component: DeleteFamilyButton,
|
||||
args: { memberCount: 0, onDelete: () => undefined },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DeleteFamilyButton>;
|
||||
|
||||
export const Deletable: Story = {};
|
||||
export const BlockedNonEmpty: Story = { args: { memberCount: 3 } };
|
||||
export const WithError: Story = { args: { errorMessage: 'The family must be empty before it can be deleted.' } };
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Alert, Stack, Typography } from '@mui/material';
|
||||
import { ConfirmActionButton } from './ConfirmActionButton';
|
||||
|
||||
export interface DeleteFamilyButtonProps {
|
||||
/** Current member count; deletion is only allowed at zero. */
|
||||
memberCount: number;
|
||||
isBusy?: boolean;
|
||||
/** `FamilyNotEmpty` (or other) error surfaced from a failed attempt. */
|
||||
errorMessage?: string;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const DeleteFamilyButton = ({ memberCount, isBusy, errorMessage, onDelete }: DeleteFamilyButtonProps) => {
|
||||
const blocked = memberCount > 0;
|
||||
return (
|
||||
<Stack spacing={2} data-testid="delete-family">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
A family must be empty before it can be dissolved. Dissolving returns your creation fee.
|
||||
</Typography>
|
||||
{blocked && (
|
||||
<Alert severity="info" data-testid="delete-family-blocked">
|
||||
This family still has {memberCount} member{memberCount === 1 ? '' : 's'}. Remove or wait for members to leave
|
||||
before dissolving.
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert severity="error" data-testid="delete-family-error">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
<ConfirmActionButton
|
||||
label="Dissolve family"
|
||||
color="error"
|
||||
title="Dissolve this family?"
|
||||
body="This permanently removes the family and refunds your creation fee. This cannot be undone."
|
||||
confirmLabel="Dissolve family"
|
||||
disabled={blocked || isBusy}
|
||||
onConfirm={onDelete}
|
||||
dataTestid="delete-family-button"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { EditFamilyForm } from './EditFamilyForm';
|
||||
|
||||
const meta: Meta<typeof EditFamilyForm> = {
|
||||
title: 'Families/Components/EditFamilyForm',
|
||||
component: EditFamilyForm,
|
||||
args: {
|
||||
initialName: 'Tatry Operators',
|
||||
initialDescription: 'Operators coordinating routing in the Tatra mountains.',
|
||||
nameLimit: 30,
|
||||
descriptionLimit: 120,
|
||||
onSubmit: () => undefined,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof EditFamilyForm>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const OverLimitName: Story = {
|
||||
args: { initialName: 'This family name is far too long to be accepted on chain' },
|
||||
};
|
||||
export const Submitting: Story = { args: { isSubmitting: true, initialName: 'Tatry Operators v2' } };
|
||||
export const ContractError: Story = { args: { errorMessage: 'That family name is already taken.' } };
|
||||
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Box, Button, Stack, TextField } from '@mui/material';
|
||||
import { NymCard } from '../NymCard';
|
||||
import { byteLength, sanitizeInput } from './helpers';
|
||||
|
||||
export interface EditFamilyFormProps {
|
||||
initialName: string;
|
||||
initialDescription: string;
|
||||
nameLimit: number;
|
||||
descriptionLimit: number;
|
||||
isSubmitting?: boolean;
|
||||
errorMessage?: string;
|
||||
/** Sends only changed fields: `string` to set, `null` to leave unchanged. No-op when nothing changed. */
|
||||
onSubmit: (updatedName: string | null, updatedDescription: string | null) => void;
|
||||
}
|
||||
|
||||
export const EditFamilyForm = ({
|
||||
initialName,
|
||||
initialDescription,
|
||||
nameLimit,
|
||||
descriptionLimit,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
}: EditFamilyFormProps) => {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
|
||||
const nameBytes = byteLength(name);
|
||||
const descBytes = byteLength(description);
|
||||
const nameTooLong = nameBytes > nameLimit;
|
||||
const descTooLong = descBytes > descriptionLimit;
|
||||
|
||||
const nameChanged = name !== initialName;
|
||||
const descChanged = description !== initialDescription;
|
||||
const nothingChanged = !nameChanged && !descChanged;
|
||||
const canSubmit = !nothingChanged && !nameTooLong && !descTooLong && name.trim().length > 0 && !isSubmitting;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
onSubmit(nameChanged ? sanitizeInput(name) : null, descChanged ? sanitizeInput(description) : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<NymCard title="Edit family" data-testid="edit-family-form">
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label="Family name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={nameTooLong}
|
||||
helperText={
|
||||
nameTooLong ? `Name is ${nameBytes}/${nameLimit} bytes — too long` : `${nameBytes}/${nameLimit} bytes`
|
||||
}
|
||||
fullWidth
|
||||
inputProps={{ 'data-testid': 'edit-family-name' }}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
error={descTooLong}
|
||||
helperText={
|
||||
descTooLong
|
||||
? `Description is ${descBytes}/${descriptionLimit} bytes — too long`
|
||||
: `${descBytes}/${descriptionLimit} bytes`
|
||||
}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
inputProps={{ 'data-testid': 'edit-family-description' }}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert severity="error" data-testid="edit-family-error">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button variant="contained" disabled={!canSubmit} onClick={handleSubmit} data-testid="edit-family-submit">
|
||||
{isSubmitting ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</NymCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { MOCK_NOW_SECS, MOCK_OTHER_OWNER_ADDRESS } from 'src/context/mocks/families.fixtures';
|
||||
import { InviteCard } from './InviteCard';
|
||||
|
||||
const meta: Meta<typeof InviteCard> = {
|
||||
title: 'Families/Components/InviteCard',
|
||||
component: InviteCard,
|
||||
args: {
|
||||
nowSecs: MOCK_NOW_SECS,
|
||||
onAccept: () => undefined,
|
||||
onReject: () => undefined,
|
||||
invite: {
|
||||
family_id: 2,
|
||||
family_name: 'Alpine Routers',
|
||||
owner_address: MOCK_OTHER_OWNER_ADDRESS,
|
||||
expires_at: MOCK_NOW_SECS + 7200,
|
||||
expired: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof InviteCard>;
|
||||
|
||||
export const Active: Story = {};
|
||||
export const Expired: Story = {
|
||||
args: {
|
||||
invite: {
|
||||
family_id: 2,
|
||||
family_name: 'Alpine Routers',
|
||||
owner_address: MOCK_OTHER_OWNER_ADDRESS,
|
||||
expires_at: MOCK_NOW_SECS - 1,
|
||||
expired: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React from 'react';
|
||||
import { Box, Chip, Stack, Typography } from '@mui/material';
|
||||
import { OperatorInviteView } from 'src/types/families';
|
||||
import { NymCard } from '../NymCard';
|
||||
import { ConfirmActionButton } from './ConfirmActionButton';
|
||||
import { formatExpiry, truncateAddress } from './helpers';
|
||||
|
||||
export type InviteCardData = OperatorInviteView;
|
||||
|
||||
export interface InviteCardProps {
|
||||
invite: InviteCardData;
|
||||
nowSecs: number;
|
||||
isBusy?: boolean;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
export const InviteCard = ({ invite, nowSecs, isBusy, onAccept, onReject }: InviteCardProps) => (
|
||||
<NymCard borderless title={invite.family_name} data-testid={`invite-card-${invite.family_id}`}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Invited by {truncateAddress(invite.owner_address)}
|
||||
</Typography>
|
||||
{invite.expired ? (
|
||||
<Chip size="small" label="Expired" data-testid={`invite-card-${invite.family_id}-expired`} />
|
||||
) : (
|
||||
<Chip size="small" color="primary" variant="outlined" label={formatExpiry(invite.expires_at, nowSecs)} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{invite.expired ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
This invitation has expired and can no longer be accepted.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<ConfirmActionButton
|
||||
label="Accept"
|
||||
variant="contained"
|
||||
title="Accept this invite?"
|
||||
body="Accepting this invite has on-chain consequences and records your node as a member of this family."
|
||||
confirmLabel="Accept invite"
|
||||
disabled={isBusy}
|
||||
onConfirm={onAccept}
|
||||
dataTestid={`invite-card-${invite.family_id}-accept`}
|
||||
/>
|
||||
<ConfirmActionButton
|
||||
label="Reject"
|
||||
color="error"
|
||||
title="Reject this invite?"
|
||||
body="Reject this family invitation? It will no longer be shown."
|
||||
confirmLabel="Reject invite"
|
||||
disabled={isBusy}
|
||||
onConfirm={onReject}
|
||||
dataTestid={`invite-card-${invite.family_id}-reject`}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</NymCard>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { InviteNodeForm } from './InviteNodeForm';
|
||||
|
||||
const meta: Meta<typeof InviteNodeForm> = {
|
||||
title: 'Families/Components/InviteNodeForm',
|
||||
component: InviteNodeForm,
|
||||
args: { onSubmit: () => undefined },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof InviteNodeForm>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const WarningAlreadyInFamily: Story = { args: { warning: 'already-in-family' } };
|
||||
export const WarningNonExistent: Story = { args: { warning: 'non-existent' } };
|
||||
export const WarningDuplicatePending: Story = { args: { warning: 'duplicate-pending' } };
|
||||
export const Submitting: Story = { args: { isSubmitting: true } };
|
||||
export const ContractError: Story = { args: { errorMessage: 'Something went wrong.' } };
|
||||
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import { NymCard } from '../NymCard';
|
||||
import { ConfirmationModal } from '../Modals/ConfirmationModal';
|
||||
import { INVITE_WARNING_MESSAGES, InviteWarning } from './helpers';
|
||||
|
||||
export interface InviteNodeFormProps {
|
||||
isSubmitting?: boolean;
|
||||
/** Set after a failed attempt to surface one of the three warning states. */
|
||||
warning?: InviteWarning;
|
||||
errorMessage?: string;
|
||||
onSubmit: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
const isValidNodeId = (raw: string): boolean => /^\d+$/.test(raw.trim()) && Number(raw.trim()) > 0;
|
||||
|
||||
export const InviteNodeForm = ({ isSubmitting, warning, errorMessage, onSubmit }: InviteNodeFormProps) => {
|
||||
const [nodeId, setNodeId] = useState('');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const trimmed = nodeId.trim();
|
||||
const malformed = trimmed.length > 0 && !isValidNodeId(trimmed);
|
||||
const canSubmit = isValidNodeId(trimmed) && !isSubmitting;
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirmOpen(false);
|
||||
if (canSubmit) onSubmit(Number(trimmed));
|
||||
};
|
||||
|
||||
return (
|
||||
<NymCard title="Invite a node" data-testid="invite-node-form">
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Only invite nodes you control and that are already bonded and operational.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Node ID"
|
||||
value={nodeId}
|
||||
onChange={(e) => setNodeId(e.target.value)}
|
||||
error={malformed}
|
||||
helperText={malformed ? 'Enter a valid numeric node ID' : ' '}
|
||||
fullWidth
|
||||
inputProps={{ 'data-testid': 'invite-node-id', inputMode: 'numeric' }}
|
||||
/>
|
||||
|
||||
{warning && (
|
||||
<Alert severity="warning" data-testid="invite-node-warning">
|
||||
{INVITE_WARNING_MESSAGES[warning]}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert severity="error" data-testid="invite-node-error">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
data-testid="invite-node-submit"
|
||||
>
|
||||
{isSubmitting ? 'Sending…' : 'Send invite'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<ConfirmationModal
|
||||
open={confirmOpen}
|
||||
title="Confirm invite"
|
||||
subTitle={`Send a family invitation to node ${trimmed}?`}
|
||||
confirmButton={
|
||||
<Stack direction="row" spacing={2} width="100%">
|
||||
<Button fullWidth variant="outlined" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button fullWidth variant="contained" onClick={handleConfirm} data-testid="invite-node-confirm">
|
||||
Confirm & send invite
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</NymCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { LeaveFamilyButton } from './LeaveFamilyButton';
|
||||
|
||||
const meta: Meta<typeof LeaveFamilyButton> = {
|
||||
title: 'Families/Components/LeaveFamilyButton',
|
||||
component: LeaveFamilyButton,
|
||||
args: { familyName: 'Alpine Routers', onLeave: () => undefined },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LeaveFamilyButton>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Busy: Story = { args: { isBusy: true } };
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { ConfirmActionButton } from './ConfirmActionButton';
|
||||
|
||||
export interface LeaveFamilyButtonProps {
|
||||
familyName: string;
|
||||
isBusy?: boolean;
|
||||
onLeave: () => void;
|
||||
}
|
||||
|
||||
export const LeaveFamilyButton = ({ familyName, isBusy, onLeave }: LeaveFamilyButtonProps) => (
|
||||
<ConfirmActionButton
|
||||
label="Leave family"
|
||||
color="error"
|
||||
title="Leave family?"
|
||||
body={`Leave "${familyName}"? Your node will be removed from the family. You can be invited again afterwards.`}
|
||||
confirmLabel="Leave family"
|
||||
disabled={isBusy}
|
||||
onConfirm={onLeave}
|
||||
dataTestid="leave-family-button"
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { FamilyMemberSections } from 'src/types/families';
|
||||
import { MOCK_NOW_SECS } from 'src/context/mocks/families.fixtures';
|
||||
import { MemberList } from './MemberList';
|
||||
|
||||
const emptySections: FamilyMemberSections = { pending: [], joined: [], rejected: [], removed: [] };
|
||||
|
||||
const populatedSections: FamilyMemberSections = {
|
||||
pending: [
|
||||
{ section: 'pending', node_id: 107, expires_at: MOCK_NOW_SECS + 3600, expired: false },
|
||||
{ section: 'pending', node_id: 108, expires_at: MOCK_NOW_SECS - 1, expired: true },
|
||||
],
|
||||
joined: [
|
||||
{ section: 'joined', node_id: 101, joined_at: MOCK_NOW_SECS },
|
||||
{ section: 'joined', node_id: 102, joined_at: MOCK_NOW_SECS },
|
||||
],
|
||||
rejected: [{ section: 'rejected', node_id: 105, rejected_at: MOCK_NOW_SECS }],
|
||||
removed: [
|
||||
{ section: 'removed', node_id: 103, removed_at: MOCK_NOW_SECS },
|
||||
{ section: 'removed', node_id: 104, removed_at: MOCK_NOW_SECS },
|
||||
],
|
||||
};
|
||||
|
||||
const meta: Meta<typeof MemberList> = {
|
||||
title: 'Families/Components/MemberList',
|
||||
component: MemberList,
|
||||
args: {
|
||||
nowSecs: MOCK_NOW_SECS,
|
||||
sections: emptySections,
|
||||
onKick: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof MemberList>;
|
||||
|
||||
export const Loading: Story = { args: { isLoading: true } };
|
||||
export const ErrorState: Story = { args: { isError: true } };
|
||||
export const Empty: Story = {};
|
||||
export const Populated: Story = { args: { sections: populatedSections } };
|
||||
@@ -0,0 +1,145 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React from 'react';
|
||||
import { Box, Button, Chip, Stack, Table, TableBody, TableCell, TableRow, Typography } from '@mui/material';
|
||||
import { FamilyMemberSections, MemberListSectionKey } from 'src/types/families';
|
||||
import { NymCard } from '../NymCard';
|
||||
import { ConfirmActionButton } from './ConfirmActionButton';
|
||||
import { formatExpiry } from './helpers';
|
||||
|
||||
export interface MemberListProps {
|
||||
sections: FamilyMemberSections;
|
||||
nowSecs: number;
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
isBusy?: boolean;
|
||||
onKick: (nodeId: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const SECTION_TITLES: Record<MemberListSectionKey, string> = {
|
||||
pending: 'Pending',
|
||||
joined: 'Joined',
|
||||
rejected: 'Rejected',
|
||||
removed: 'Removed',
|
||||
};
|
||||
|
||||
const Section = ({
|
||||
sectionKey,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
sectionKey: MemberListSectionKey;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Box data-testid={`member-section-${sectionKey}`}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
{SECTION_TITLES[sectionKey]} ({count})
|
||||
</Typography>
|
||||
{count === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" data-testid={`member-section-${sectionKey}-empty`}>
|
||||
No {SECTION_TITLES[sectionKey].toLowerCase()} entries.
|
||||
</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableBody>{children}</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const MemberList = ({ sections, nowSecs, isLoading, isError, isBusy, onKick, onRefresh }: MemberListProps) => {
|
||||
if (isError) {
|
||||
return (
|
||||
<NymCard title="Members" data-testid="member-list">
|
||||
<Stack spacing={2}>
|
||||
<Typography color="error" data-testid="member-list-error">
|
||||
Failed to load the member list.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button variant="outlined" size="small" onClick={onRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</NymCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NymCard
|
||||
title="Members"
|
||||
data-testid="member-list"
|
||||
Action={
|
||||
<Button variant="text" size="small" onClick={onRefresh} disabled={isLoading} data-testid="member-list-refresh">
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Typography variant="body2" color="text.secondary" data-testid="member-list-loading">
|
||||
Loading members…
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Section sectionKey="pending" count={sections.pending.length}>
|
||||
{sections.pending.map((r) => (
|
||||
<TableRow key={`pending-${r.node_id}`} data-testid={`member-pending-${r.node_id}`}>
|
||||
<TableCell>{r.node_id}</TableCell>
|
||||
<TableCell align="right">
|
||||
{r.expired ? (
|
||||
<Chip size="small" label="Expired" />
|
||||
) : (
|
||||
<Typography variant="body2">{formatExpiry(r.expires_at, nowSecs)}</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section sectionKey="joined" count={sections.joined.length}>
|
||||
{sections.joined.map((r) => (
|
||||
<TableRow key={`joined-${r.node_id}`} data-testid={`member-joined-${r.node_id}`}>
|
||||
<TableCell>{r.node_id}</TableCell>
|
||||
<TableCell align="right">
|
||||
<ConfirmActionButton
|
||||
label="Remove"
|
||||
color="error"
|
||||
title="Remove member?"
|
||||
body={`Remove node ${r.node_id} from the family? This cannot be undone.`}
|
||||
confirmLabel="Remove member"
|
||||
disabled={isBusy}
|
||||
onConfirm={() => onKick(r.node_id)}
|
||||
dataTestid={`member-joined-${r.node_id}-kick`}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section sectionKey="rejected" count={sections.rejected.length}>
|
||||
{sections.rejected.map((r) => (
|
||||
<TableRow key={`rejected-${r.node_id}`} data-testid={`member-rejected-${r.node_id}`}>
|
||||
<TableCell>{r.node_id}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip size="small" label="Rejected" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section sectionKey="removed" count={sections.removed.length}>
|
||||
{sections.removed.map((r) => (
|
||||
<TableRow key={`removed-${r.node_id}`} data-testid={`member-removed-${r.node_id}`}>
|
||||
<TableCell>{r.node_id}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip size="small" label="Removed" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Section>
|
||||
</Stack>
|
||||
)}
|
||||
</NymCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { MOCK_NOW_SECS, MOCK_OTHER_OWNER_ADDRESS } from 'src/context/mocks/families.fixtures';
|
||||
import { NodeInviteGroup } from './NodeInviteGroup';
|
||||
|
||||
const meta: Meta<typeof NodeInviteGroup> = {
|
||||
title: 'Families/Components/NodeInviteGroup',
|
||||
component: NodeInviteGroup,
|
||||
args: {
|
||||
nodeId: 201,
|
||||
nowSecs: MOCK_NOW_SECS,
|
||||
onAccept: () => undefined,
|
||||
onReject: () => undefined,
|
||||
invites: [],
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof NodeInviteGroup>;
|
||||
|
||||
export const Empty: Story = {};
|
||||
export const WithInvites: Story = {
|
||||
args: {
|
||||
invites: [
|
||||
{
|
||||
family_id: 2,
|
||||
family_name: 'Alpine Routers',
|
||||
owner_address: MOCK_OTHER_OWNER_ADDRESS,
|
||||
expires_at: MOCK_NOW_SECS + 7200,
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
family_id: 3,
|
||||
family_name: 'Carpathian Mixers',
|
||||
owner_address: MOCK_OTHER_OWNER_ADDRESS,
|
||||
expires_at: MOCK_NOW_SECS - 1,
|
||||
expired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { NymCard } from '../NymCard';
|
||||
import { InviteCard, InviteCardData } from './InviteCard';
|
||||
|
||||
export interface NodeInviteGroupProps {
|
||||
nodeId: number;
|
||||
invites: InviteCardData[];
|
||||
nowSecs: number;
|
||||
isBusy?: boolean;
|
||||
onAccept: (familyId: number) => void;
|
||||
onReject: (familyId: number) => void;
|
||||
}
|
||||
|
||||
/** Invitations addressed to a single controlled node (multi-node aware grouping). */
|
||||
export const NodeInviteGroup = ({ nodeId, invites, nowSecs, isBusy, onAccept, onReject }: NodeInviteGroupProps) => (
|
||||
<NymCard title={`Node ${nodeId}`} data-testid={`node-invite-group-${nodeId}`}>
|
||||
{invites.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" data-testid={`node-invite-group-${nodeId}-empty`}>
|
||||
No invitations for this node.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{invites.map((invite) => (
|
||||
<InviteCard
|
||||
key={invite.family_id}
|
||||
invite={invite}
|
||||
nowSecs={nowSecs}
|
||||
isBusy={isBusy}
|
||||
onAccept={() => onAccept(invite.family_id)}
|
||||
onReject={() => onReject(invite.family_id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</NymCard>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { MOCK_NOW_SECS } from 'src/context/mocks/families.fixtures';
|
||||
import { PendingInvitesList } from './PendingInvitesList';
|
||||
|
||||
const meta: Meta<typeof PendingInvitesList> = {
|
||||
title: 'Families/Components/PendingInvitesList',
|
||||
component: PendingInvitesList,
|
||||
args: {
|
||||
nowSecs: MOCK_NOW_SECS,
|
||||
onRevoke: () => undefined,
|
||||
onClearExpired: () => undefined,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof PendingInvitesList>;
|
||||
|
||||
export const Empty: Story = { args: { invites: [] } };
|
||||
export const ActiveAndExpired: Story = {
|
||||
args: {
|
||||
invites: [
|
||||
{ section: 'pending', node_id: 107, expires_at: MOCK_NOW_SECS + 3600, expired: false },
|
||||
{ section: 'pending', node_id: 108, expires_at: MOCK_NOW_SECS - 1, expired: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React from 'react';
|
||||
import { Chip, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
|
||||
import { PendingMemberRow } from 'src/types/families';
|
||||
import { NymCard } from '../NymCard';
|
||||
import { ConfirmActionButton } from './ConfirmActionButton';
|
||||
import { formatExpiry } from './helpers';
|
||||
|
||||
export interface PendingInvitesListProps {
|
||||
invites: PendingMemberRow[];
|
||||
nowSecs: number;
|
||||
isBusy?: boolean;
|
||||
/** Withdraw an active (not-yet-expired) invite. */
|
||||
onRevoke: (nodeId: number) => void;
|
||||
/** Dismiss/clear an expired invite. */
|
||||
onClearExpired: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
export const PendingInvitesList = ({ invites, nowSecs, isBusy, onRevoke, onClearExpired }: PendingInvitesListProps) => (
|
||||
<NymCard title="Pending invites" data-testid="pending-invites-list">
|
||||
{invites.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" data-testid="pending-invites-empty">
|
||||
No pending invites.
|
||||
</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Node</TableCell>
|
||||
<TableCell>Expiry</TableCell>
|
||||
<TableCell align="right">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invites.map((inv) => (
|
||||
<TableRow key={inv.node_id} data-testid={`pending-invite-${inv.node_id}`}>
|
||||
<TableCell>{inv.node_id}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{inv.expired ? (
|
||||
<Chip
|
||||
size="small"
|
||||
color="default"
|
||||
label="Expired"
|
||||
data-testid={`pending-invite-${inv.node_id}-expired`}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2">{formatExpiry(inv.expires_at, nowSecs)}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{inv.expired ? (
|
||||
<ConfirmActionButton
|
||||
label="Clear"
|
||||
title="Clear expired invite?"
|
||||
body={`Remove the expired invitation for node ${inv.node_id}?`}
|
||||
confirmLabel="Clear invite"
|
||||
disabled={isBusy}
|
||||
onConfirm={() => onClearExpired(inv.node_id)}
|
||||
dataTestid={`pending-invite-${inv.node_id}-clear`}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmActionButton
|
||||
label="Withdraw"
|
||||
title="Withdraw invite?"
|
||||
body={`Withdraw the pending invitation for node ${inv.node_id}?`}
|
||||
confirmLabel="Withdraw invite"
|
||||
disabled={isBusy}
|
||||
onConfirm={() => onRevoke(inv.node_id)}
|
||||
dataTestid={`pending-invite-${inv.node_id}-withdraw`}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</NymCard>
|
||||
);
|
||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import Big from 'big.js';
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { FamilyError, FamilyErrorKind, isFamilyError } from 'src/types/families';
|
||||
|
||||
/** Byte length (matches the contract's `String::len` limit checks). */
|
||||
export const byteLength = (s: string): number => new TextEncoder().encode(s).length;
|
||||
|
||||
/** Strip control characters / angle-bracket tags before submission. React escapes on render, but we neutralise eagerly. */
|
||||
export const sanitizeInput = (s: string): string =>
|
||||
// eslint-disable-next-line no-control-regex
|
||||
s.replace(/[\u0000-\u001F\u007F]/g, '').replace(/[<>]/g, '');
|
||||
|
||||
export const formatCoin = (coin?: DecCoin): string => (coin ? `${coin.amount} ${coin.denom.toUpperCase()}` : '—');
|
||||
|
||||
export const truncateAddress = (addr: string, head = 8, tail = 6): string =>
|
||||
addr.length <= head + tail + 1 ? addr : `${addr.slice(0, head)}…${addr.slice(-tail)}`;
|
||||
|
||||
/** Human-readable remaining TTL, or "Expired". */
|
||||
export const formatExpiry = (expiresAt: number, nowSecs: number): string => {
|
||||
const remaining = expiresAt - nowSecs;
|
||||
if (remaining <= 0) return 'Expired';
|
||||
if (remaining < 60) return `in ${remaining}s`;
|
||||
if (remaining < 3600) return `in ${Math.floor(remaining / 60)} min`;
|
||||
if (remaining < 86400) return `in ${Math.floor(remaining / 3600)}h`;
|
||||
return `in ${Math.floor(remaining / 86400)}d`;
|
||||
};
|
||||
|
||||
/** True when balance is below fee + a gas headroom (best-effort, pre-submit). */
|
||||
export const isInsufficientBalance = (balance: DecCoin | undefined, fee: DecCoin, gasHeadroom = '0.1'): boolean => {
|
||||
if (!balance) return false;
|
||||
try {
|
||||
return Big(balance.amount).lt(Big(fee.amount).plus(gasHeadroom));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ERROR_MESSAGES: Record<FamilyErrorKind, string> = {
|
||||
InvalidFamilyCreationFee: 'The attached creation fee is incorrect.',
|
||||
InvalidDeposit: 'The attached funds are invalid for this operation.',
|
||||
FamilyNameAlreadyTaken: 'That family name is already taken.',
|
||||
FamilyNameTooLong: 'The family name is too long.',
|
||||
EmptyFamilyName: 'The family name cannot be empty after normalisation.',
|
||||
FamilyDescriptionTooLong: 'The family description is too long.',
|
||||
SenderAlreadyOwnsAFamily: 'You already own a family.',
|
||||
SenderDoesntOwnAFamily: 'You do not own a family.',
|
||||
NodeAlreadyInFamily: 'That node is already in a family.',
|
||||
AlreadyInFamily: 'Your node is already a member of a family.',
|
||||
NodeDoesntExist: 'That node does not exist or is unbonding.',
|
||||
PendingInvitationAlreadyExists: 'There is already a pending invitation for that node.',
|
||||
ZeroInvitationValidity: 'Invitation validity must be greater than zero.',
|
||||
InvitationExpired: 'That invitation has expired.',
|
||||
InvitationNotFound: 'No pending invitation was found.',
|
||||
FamilyNotEmpty: 'The family must be empty before it can be deleted.',
|
||||
FamilyNotFound: 'That family no longer exists.',
|
||||
SenderDoesntControlNode: 'You do not control that node.',
|
||||
NodeNotMemberOfFamily: 'That node is not a member of your family.',
|
||||
NodeNotInFamily: 'That node is not in any family.',
|
||||
UnauthorisedMixnetCallback: 'Unauthorised callback.',
|
||||
};
|
||||
|
||||
export const familyErrorMessage = (e: unknown): string => {
|
||||
if (isFamilyError(e)) return ERROR_MESSAGES[e.kind] ?? e.message;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
|
||||
export type InviteWarning = 'already-in-family' | 'non-existent' | 'duplicate-pending';
|
||||
|
||||
/** Map an invite-time contract error to the spec's three warning states. */
|
||||
export const inviteWarningFromError = (e: unknown): InviteWarning | undefined => {
|
||||
const kind: FamilyErrorKind | undefined = isFamilyError(e) ? (e as FamilyError).kind : undefined;
|
||||
switch (kind) {
|
||||
case 'NodeAlreadyInFamily':
|
||||
return 'already-in-family';
|
||||
case 'NodeDoesntExist':
|
||||
return 'non-existent';
|
||||
case 'PendingInvitationAlreadyExists':
|
||||
return 'duplicate-pending';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const INVITE_WARNING_MESSAGES: Record<InviteWarning, string> = {
|
||||
'already-in-family': 'This node is already in a family — the invite was not sent.',
|
||||
'non-existent': 'This node does not exist or is unbonding — the invite was not sent.',
|
||||
'duplicate-pending': 'This node already has a pending invite from your family.',
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './helpers';
|
||||
export * from './ConfirmActionButton';
|
||||
export * from './CreateFamilyForm';
|
||||
export * from './EditFamilyForm';
|
||||
export * from './InviteNodeForm';
|
||||
export * from './PendingInvitesList';
|
||||
export * from './MemberList';
|
||||
export * from './DeleteFamilyButton';
|
||||
export * from './InviteCard';
|
||||
export * from './NodeInviteGroup';
|
||||
export * from './LeaveFamilyButton';
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MockFamiliesContextProvider } from 'src/context/mocks/families';
|
||||
import type { MockStore } from 'src/context/mocks/familiesMockState';
|
||||
|
||||
export interface WithFamiliesMockOptions {
|
||||
sender?: string;
|
||||
/** Factory so each story mount gets a fresh, isolated store. */
|
||||
makeStore?: () => MockStore;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storybook decorator: wraps a story in a fresh QueryClient + the mock families
|
||||
* provider, so pages/flows run against the in-memory contract model (design D3, D7).
|
||||
*/
|
||||
export const withFamiliesMock =
|
||||
(options: WithFamiliesMockOptions = {}) =>
|
||||
// eslint-disable-next-line react/display-name
|
||||
(Story: React.ComponentType) => {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
}),
|
||||
);
|
||||
const store = useMemo(() => options.makeStore?.(), []);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<MockFamiliesContextProvider store={store} sender={options.sender} latencyMs={options.latencyMs ?? 150}>
|
||||
<Story />
|
||||
</MockFamiliesContextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,13 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Divider, List, ListItemButton, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { AccountBalanceWalletOutlined, Description, Settings, VpnKeyOutlined } from '@mui/icons-material';
|
||||
import {
|
||||
AccountBalanceWalletOutlined,
|
||||
Description,
|
||||
GroupsOutlined,
|
||||
Settings,
|
||||
VpnKeyOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { safeOpenUrl } from 'src/utils/safeOpenUrl';
|
||||
import { AppContext } from '../context/main';
|
||||
import { Delegate, Bonding } from '../svg-icons';
|
||||
@@ -40,6 +46,13 @@ export const Nav = () => {
|
||||
Icon: Bonding,
|
||||
onClick: () => navigate('/bonding'),
|
||||
},
|
||||
{
|
||||
label: 'Family',
|
||||
description: 'Manage node families',
|
||||
route: '/family',
|
||||
Icon: GroupsOutlined,
|
||||
onClick: () => navigate('/family'),
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
description: 'Internal wallet notes',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React, { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import * as familyRequests from 'src/requests/families';
|
||||
import { Console } from 'src/utils/console';
|
||||
import { FamilyTxResult, NodeId } from 'src/types/families';
|
||||
import { AppContext } from './main';
|
||||
import { FamiliesContext, TFamiliesContext, defaultQueries } from './families';
|
||||
import { familyQueryKeys } from './familyQueryKeys';
|
||||
|
||||
/**
|
||||
* Real, Tauri-backed FamiliesContext provider. Kept in its own module so it is the
|
||||
* ONLY families file importing `./main` (which pulls Tauri-runtime code at load).
|
||||
* Storybook/tests use `MockFamiliesContextProvider` instead and never load this.
|
||||
*/
|
||||
export const FamiliesContextProvider: FCWithChildren = ({ children }): React.JSX.Element => {
|
||||
const queryClient = useQueryClient();
|
||||
const { clientDetails } = useContext(AppContext);
|
||||
const ownerAddress = clientDetails?.client_address;
|
||||
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
// TODO(§9 wiring): derive controlled node ids from bonded-node details once the
|
||||
// real IPC layer lands. Until then the real provider exposes none; the mock
|
||||
// provider supplies them for Storybook/tests.
|
||||
const controlledNodeIds = useMemo<NodeId[]>(() => [], []);
|
||||
|
||||
const nowSecs = useMemo(() => Math.floor(Date.now() / 1000), []);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: familyQueryKeys.all });
|
||||
}, [queryClient]);
|
||||
|
||||
const clearError = useCallback(() => setError(undefined), []);
|
||||
|
||||
/** Run an execute call: toggle flag, surface + rethrow errors, refresh reads on success. */
|
||||
const run = useCallback(
|
||||
async (op: () => Promise<FamilyTxResult>): Promise<FamilyTxResult> => {
|
||||
setIsExecuting(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await op();
|
||||
await refreshAll();
|
||||
return result;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setError(message);
|
||||
Console.error(e);
|
||||
throw e;
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
},
|
||||
[refreshAll],
|
||||
);
|
||||
|
||||
const memoizedValue = useMemo<TFamiliesContext>(
|
||||
() => ({
|
||||
ownerAddress,
|
||||
controlledNodeIds,
|
||||
nowSecs,
|
||||
queries: defaultQueries,
|
||||
isExecuting,
|
||||
error,
|
||||
clearError,
|
||||
refreshAll,
|
||||
createFamily: (args) => run(() => familyRequests.createFamily(args)),
|
||||
updateFamily: (args) => run(() => familyRequests.updateFamily(args)),
|
||||
disbandFamily: () => run(() => familyRequests.disbandFamily()),
|
||||
inviteToFamily: (args) => run(() => familyRequests.inviteToFamily(args)),
|
||||
revokeFamilyInvitation: (args) => run(() => familyRequests.revokeFamilyInvitation(args)),
|
||||
kickFromFamily: (args) => run(() => familyRequests.kickFromFamily(args)),
|
||||
acceptFamilyInvitation: (args) => run(() => familyRequests.acceptFamilyInvitation(args)),
|
||||
rejectFamilyInvitation: (args) => run(() => familyRequests.rejectFamilyInvitation(args)),
|
||||
leaveFamily: (args) => run(() => familyRequests.leaveFamily(args)),
|
||||
}),
|
||||
[ownerAddress, controlledNodeIds, nowSecs, isExecuting, error, clearError, refreshAll, run],
|
||||
);
|
||||
|
||||
return <FamiliesContext.Provider value={memoizedValue}>{children}</FamiliesContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
AcceptFamilyInvitationArgs,
|
||||
CreateFamilyArgs,
|
||||
FamilyConfig,
|
||||
FamilyCursor,
|
||||
FamilyMemberSections,
|
||||
FamilyPagedResponse,
|
||||
FamilyTxResult,
|
||||
FAMILY_PAGE_MAX_LIMIT,
|
||||
InviteToFamilyArgs,
|
||||
KickFromFamilyArgs,
|
||||
LeaveFamilyArgs,
|
||||
NodeFamily,
|
||||
NodeFamilyId,
|
||||
NodeFamilyMembershipResponse,
|
||||
NodeId,
|
||||
OperatorInviteView,
|
||||
PastFamilyInvitation,
|
||||
PastFamilyMember,
|
||||
PendingFamilyInvitationDetails,
|
||||
RejectFamilyInvitationArgs,
|
||||
RevokeFamilyInvitationArgs,
|
||||
UpdateFamilyArgs,
|
||||
} from 'src/types/families';
|
||||
import * as familyRequests from 'src/requests/families';
|
||||
import { familyQueryKeys } from './familyQueryKeys';
|
||||
import { deriveMemberSections } from './familyMemberSections';
|
||||
|
||||
/**
|
||||
* Read functions the context exposes. Swapping this object (real Tauri requests
|
||||
* vs. the mock) is the single seam that lets every read hook and the aggregator
|
||||
* work unchanged under Storybook/tests (design D3).
|
||||
*/
|
||||
export interface FamilyQueries {
|
||||
getFamilyConfig: () => Promise<FamilyConfig>;
|
||||
getFamilyById: (familyId: NodeFamilyId) => Promise<NodeFamily | null>;
|
||||
getFamilyByOwner: (owner: string) => Promise<NodeFamily | null>;
|
||||
getFamilyMembership: (nodeId: NodeId) => Promise<NodeFamilyMembershipResponse>;
|
||||
getFamilyMembersPaged: (
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
) => Promise<FamilyPagedResponse<{ node_id: NodeId; joined_at: number }>>;
|
||||
getPendingInvitationsForFamilyPaged: (
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
) => Promise<FamilyPagedResponse<PendingFamilyInvitationDetails>>;
|
||||
getPendingInvitationsForNodePaged: (
|
||||
nodeId: NodeId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
) => Promise<FamilyPagedResponse<PendingFamilyInvitationDetails>>;
|
||||
getPastInvitationsForFamilyPaged: (
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
) => Promise<FamilyPagedResponse<PastFamilyInvitation>>;
|
||||
getPastMembersForFamilyPaged: (
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
) => Promise<FamilyPagedResponse<PastFamilyMember>>;
|
||||
}
|
||||
|
||||
export interface TFamiliesContext {
|
||||
/** Connected wallet address (the prospective/actual family owner). */
|
||||
ownerAddress?: string;
|
||||
/** Node ids this account controls (drives the operator invite view). */
|
||||
controlledNodeIds: NodeId[];
|
||||
/** Current chain time (unix seconds) used for TTL/expiry display. */
|
||||
nowSecs: number;
|
||||
/** Read seam — consumed by the read hooks below. */
|
||||
queries: FamilyQueries;
|
||||
/** True while an execute call is in flight. */
|
||||
isExecuting: boolean;
|
||||
/** Last execute error message (cleared via `clearError`). */
|
||||
error?: string;
|
||||
clearError: () => void;
|
||||
createFamily: (args: CreateFamilyArgs) => Promise<FamilyTxResult>;
|
||||
updateFamily: (args: UpdateFamilyArgs) => Promise<FamilyTxResult>;
|
||||
disbandFamily: () => Promise<FamilyTxResult>;
|
||||
inviteToFamily: (args: InviteToFamilyArgs) => Promise<FamilyTxResult>;
|
||||
revokeFamilyInvitation: (args: RevokeFamilyInvitationArgs) => Promise<FamilyTxResult>;
|
||||
kickFromFamily: (args: KickFromFamilyArgs) => Promise<FamilyTxResult>;
|
||||
acceptFamilyInvitation: (args: AcceptFamilyInvitationArgs) => Promise<FamilyTxResult>;
|
||||
rejectFamilyInvitation: (args: RejectFamilyInvitationArgs) => Promise<FamilyTxResult>;
|
||||
leaveFamily: (args: LeaveFamilyArgs) => Promise<FamilyTxResult>;
|
||||
/** Invalidate every families query (used after an execute call). */
|
||||
refreshAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
const notImplemented = async (): Promise<never> => {
|
||||
throw new Error('FamiliesContext not implemented');
|
||||
};
|
||||
|
||||
/** Real Tauri-backed read functions; the mock provider swaps in its own. */
|
||||
export const defaultQueries: FamilyQueries = {
|
||||
getFamilyConfig: familyRequests.getFamilyConfig,
|
||||
getFamilyById: familyRequests.getFamilyById,
|
||||
getFamilyByOwner: familyRequests.getFamilyByOwner,
|
||||
getFamilyMembership: familyRequests.getFamilyMembership,
|
||||
getFamilyMembersPaged: familyRequests.getFamilyMembersPaged,
|
||||
getPendingInvitationsForFamilyPaged: familyRequests.getPendingInvitationsForFamilyPaged,
|
||||
getPendingInvitationsForNodePaged: familyRequests.getPendingInvitationsForNodePaged,
|
||||
getPastInvitationsForFamilyPaged: familyRequests.getPastInvitationsForFamilyPaged,
|
||||
getPastMembersForFamilyPaged: familyRequests.getPastMembersForFamilyPaged,
|
||||
};
|
||||
|
||||
export const FamiliesContext = createContext<TFamiliesContext>({
|
||||
controlledNodeIds: [],
|
||||
nowSecs: 0,
|
||||
queries: defaultQueries,
|
||||
isExecuting: false,
|
||||
clearError: () => undefined,
|
||||
createFamily: notImplemented,
|
||||
updateFamily: notImplemented,
|
||||
disbandFamily: notImplemented,
|
||||
inviteToFamily: notImplemented,
|
||||
revokeFamilyInvitation: notImplemented,
|
||||
kickFromFamily: notImplemented,
|
||||
acceptFamilyInvitation: notImplemented,
|
||||
rejectFamilyInvitation: notImplemented,
|
||||
leaveFamily: notImplemented,
|
||||
refreshAll: async () => undefined,
|
||||
});
|
||||
|
||||
export const useFamiliesContext = () => useContext<TFamiliesContext>(FamiliesContext);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination helper — walks the contract's exclusive `start_after` cursor to the
|
||||
// end of a section, exercising start_after/start_next_after page-by-page.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SAFETY_BOUND = 1000;
|
||||
|
||||
async function fetchAllPages<T>(
|
||||
fetchPage: (startAfter?: FamilyCursor, limit?: number) => Promise<FamilyPagedResponse<T>>,
|
||||
): Promise<T[]> {
|
||||
const out: T[] = [];
|
||||
let cursor: FamilyCursor = null;
|
||||
for (let i = 0; i < PAGE_SAFETY_BOUND; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const page = await fetchPage(cursor ?? undefined, FAMILY_PAGE_MAX_LIMIT);
|
||||
out.push(...page.items);
|
||||
if (!page.start_next_after || page.items.length === 0) break;
|
||||
cursor = page.start_next_after;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const READ_STALE_TIME = 60 * 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read hooks (TanStack Query)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useFamilyConfig = () => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.config,
|
||||
queryFn: () => queries.getFamilyConfig(),
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFamilyByOwner = (owner?: string) => {
|
||||
const { queries, ownerAddress } = useFamiliesContext();
|
||||
const addr = owner ?? ownerAddress;
|
||||
return useQuery({
|
||||
queryKey: addr ? familyQueryKeys.byOwner(addr) : familyQueryKeys.byOwnerDisabled,
|
||||
queryFn: () => queries.getFamilyByOwner(addr as string),
|
||||
enabled: Boolean(addr),
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFamilyById = (familyId?: NodeFamilyId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.byId(familyId ?? -1),
|
||||
queryFn: () => queries.getFamilyById(familyId as NodeFamilyId),
|
||||
enabled: familyId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Operator view: pending invitations addressed to a node, each resolved with its
|
||||
* family's name + owner so the invite card can render the full detail.
|
||||
*/
|
||||
export const useOperatorNodeInvites = (nodeId?: NodeId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery<OperatorInviteView[]>({
|
||||
queryKey: familyQueryKeys.operatorInvites(nodeId ?? -1),
|
||||
enabled: nodeId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
queryFn: async () => {
|
||||
const pending = await fetchAllPages((startAfter, limit) =>
|
||||
queries.getPendingInvitationsForNodePaged(nodeId as NodeId, startAfter, limit),
|
||||
);
|
||||
const views = await Promise.all(
|
||||
pending.map(async (d) => {
|
||||
const family = await queries.getFamilyById(d.invitation.family_id);
|
||||
return {
|
||||
family_id: d.invitation.family_id,
|
||||
family_name: family?.name ?? `Family #${d.invitation.family_id}`,
|
||||
owner_address: family?.owner ?? '',
|
||||
expires_at: d.invitation.expires_at,
|
||||
expired: d.expired,
|
||||
} satisfies OperatorInviteView;
|
||||
}),
|
||||
);
|
||||
return views;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useFamilyMembership = (nodeId?: NodeId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.membership(nodeId ?? -1),
|
||||
queryFn: () => queries.getFamilyMembership(nodeId as NodeId),
|
||||
enabled: nodeId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFamilyMembers = (familyId?: NodeFamilyId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.members(familyId ?? -1),
|
||||
queryFn: () =>
|
||||
fetchAllPages((startAfter, limit) => queries.getFamilyMembersPaged(familyId as NodeFamilyId, startAfter, limit)),
|
||||
enabled: familyId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePendingInvitationsForFamily = (familyId?: NodeFamilyId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.pendingForFamily(familyId ?? -1),
|
||||
queryFn: () =>
|
||||
fetchAllPages((startAfter, limit) =>
|
||||
queries.getPendingInvitationsForFamilyPaged(familyId as NodeFamilyId, startAfter, limit),
|
||||
),
|
||||
enabled: familyId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePastInvitationsForFamily = (familyId?: NodeFamilyId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.pastInvitationsForFamily(familyId ?? -1),
|
||||
queryFn: () =>
|
||||
fetchAllPages((startAfter, limit) =>
|
||||
queries.getPastInvitationsForFamilyPaged(familyId as NodeFamilyId, startAfter, limit),
|
||||
),
|
||||
enabled: familyId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePastMembersForFamily = (familyId?: NodeFamilyId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.pastMembersForFamily(familyId ?? -1),
|
||||
queryFn: () =>
|
||||
fetchAllPages((startAfter, limit) =>
|
||||
queries.getPastMembersForFamilyPaged(familyId as NodeFamilyId, startAfter, limit),
|
||||
),
|
||||
enabled: familyId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePendingInvitationsForNode = (nodeId?: NodeId) => {
|
||||
const { queries } = useFamiliesContext();
|
||||
return useQuery({
|
||||
queryKey: familyQueryKeys.pendingForNode(nodeId ?? -1),
|
||||
queryFn: () =>
|
||||
fetchAllPages((startAfter, limit) =>
|
||||
queries.getPendingInvitationsForNodePaged(nodeId as NodeId, startAfter, limit),
|
||||
),
|
||||
enabled: nodeId !== undefined,
|
||||
staleTime: READ_STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Member-list aggregator (D4): four sections, each 1:1 with a contract query.
|
||||
// No cross-section dedup, no priority cascade. Revoked past invitations are
|
||||
// NOT surfaced — only Rejected ones populate the Rejected section.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseFamilyMemberListResult {
|
||||
sections: FamilyMemberSections;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const useFamilyMemberList = (familyId?: NodeFamilyId): UseFamilyMemberListResult => {
|
||||
const pending = usePendingInvitationsForFamily(familyId);
|
||||
const joined = useFamilyMembers(familyId);
|
||||
const pastInvitations = usePastInvitationsForFamily(familyId);
|
||||
const pastMembers = usePastMembersForFamily(familyId);
|
||||
|
||||
const sections = useMemo<FamilyMemberSections>(
|
||||
() =>
|
||||
deriveMemberSections({
|
||||
pending: pending.data ?? [],
|
||||
joined: joined.data ?? [],
|
||||
pastInvitations: pastInvitations.data ?? [],
|
||||
pastMembers: pastMembers.data ?? [],
|
||||
}),
|
||||
[pending.data, joined.data, pastInvitations.data, pastMembers.data],
|
||||
);
|
||||
|
||||
return {
|
||||
sections,
|
||||
isLoading: pending.isPending || joined.isPending || pastInvitations.isPending || pastMembers.isPending,
|
||||
isError: pending.isError || joined.isError || pastInvitations.isError || pastMembers.isError,
|
||||
refetch: () => {
|
||||
pending.refetch();
|
||||
joined.refetch();
|
||||
pastInvitations.refetch();
|
||||
pastMembers.refetch();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PastFamilyInvitation, PastFamilyMember, PendingFamilyInvitationDetails } from 'src/types/families';
|
||||
import { deriveMemberSections } from './familyMemberSections';
|
||||
|
||||
const pending: PendingFamilyInvitationDetails[] = [
|
||||
{ invitation: { family_id: 1, node_id: 10, expires_at: 100 }, expired: false },
|
||||
{ invitation: { family_id: 1, node_id: 11, expires_at: 50 }, expired: true },
|
||||
];
|
||||
const joined = [
|
||||
{ node_id: 20, joined_at: 5 },
|
||||
{ node_id: 21, joined_at: 6 },
|
||||
];
|
||||
const pastInvitations: PastFamilyInvitation[] = [
|
||||
{ invitation: { family_id: 1, node_id: 30, expires_at: 0 }, status: { kind: 'Rejected', at: 7 } },
|
||||
{ invitation: { family_id: 1, node_id: 31, expires_at: 0 }, status: { kind: 'Revoked', at: 8 } },
|
||||
{ invitation: { family_id: 1, node_id: 32, expires_at: 0 }, status: { kind: 'Accepted', at: 9 } },
|
||||
];
|
||||
const pastMembers: PastFamilyMember[] = [{ family_id: 1, node_id: 20, removed_at: 12 }];
|
||||
|
||||
describe('deriveMemberSections', () => {
|
||||
const sections = deriveMemberSections({ pending, joined, pastInvitations, pastMembers });
|
||||
|
||||
it('maps each section 1:1 to its source query', () => {
|
||||
expect(sections.pending.map((r) => r.node_id)).toStrictEqual([10, 11]);
|
||||
expect(sections.joined.map((r) => r.node_id)).toStrictEqual([20, 21]);
|
||||
expect(sections.removed.map((r) => r.node_id)).toStrictEqual([20]);
|
||||
});
|
||||
|
||||
it('surfaces only Rejected past invitations (not Revoked or Accepted)', () => {
|
||||
expect(sections.rejected.map((r) => r.node_id)).toStrictEqual([30]);
|
||||
});
|
||||
|
||||
it('carries the expired flag and timestamps', () => {
|
||||
expect(sections.pending.find((r) => r.node_id === 11)?.expired).toBe(true);
|
||||
expect(sections.rejected[0].rejected_at).toBe(7);
|
||||
expect(sections.removed[0].removed_at).toBe(12);
|
||||
});
|
||||
|
||||
it('lets one node appear in multiple sections (record-per-row, no dedup)', () => {
|
||||
// node 20 is both currently Joined and previously Removed
|
||||
expect(sections.joined.some((r) => r.node_id === 20)).toBe(true);
|
||||
expect(sections.removed.some((r) => r.node_id === 20)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
FamilyMemberSections,
|
||||
NodeId,
|
||||
PastFamilyInvitation,
|
||||
PastFamilyMember,
|
||||
PendingFamilyInvitationDetails,
|
||||
} from 'src/types/families';
|
||||
|
||||
export interface MemberSectionInputs {
|
||||
pending: PendingFamilyInvitationDetails[];
|
||||
joined: { node_id: NodeId; joined_at: number }[];
|
||||
pastInvitations: PastFamilyInvitation[];
|
||||
pastMembers: PastFamilyMember[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure status-derivation selector (design D4): four sections, each 1:1 with a
|
||||
* contract query. No cross-section dedup, no priority cascade. Only `Rejected`
|
||||
* past invitations populate the Rejected section — `Revoked` ones are owner-side
|
||||
* actions and are NOT surfaced. One row per record, so a node may legitimately
|
||||
* appear in more than one section.
|
||||
*/
|
||||
export const deriveMemberSections = ({
|
||||
pending,
|
||||
joined,
|
||||
pastInvitations,
|
||||
pastMembers,
|
||||
}: MemberSectionInputs): FamilyMemberSections => ({
|
||||
pending: pending.map((d) => ({
|
||||
section: 'pending',
|
||||
node_id: d.invitation.node_id,
|
||||
expires_at: d.invitation.expires_at,
|
||||
expired: d.expired,
|
||||
})),
|
||||
joined: joined.map((m) => ({
|
||||
section: 'joined',
|
||||
node_id: m.node_id,
|
||||
joined_at: m.joined_at,
|
||||
})),
|
||||
rejected: pastInvitations
|
||||
.filter((p) => p.status.kind === 'Rejected')
|
||||
.map((p) => ({
|
||||
section: 'rejected',
|
||||
node_id: p.invitation.node_id,
|
||||
rejected_at: p.status.at,
|
||||
})),
|
||||
removed: pastMembers.map((m) => ({
|
||||
section: 'removed',
|
||||
node_id: m.node_id,
|
||||
removed_at: m.removed_at,
|
||||
})),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NodeFamilyId, NodeId } from 'src/types/families';
|
||||
|
||||
const familyRoot = ['families'] as const;
|
||||
|
||||
/** TanStack Query key registry for node-families reads (mirrors `delegationQueryKeys`). */
|
||||
export const familyQueryKeys = {
|
||||
all: familyRoot,
|
||||
config: [...familyRoot, 'config'] as const,
|
||||
/** Used when there is no owner address so React Query never caches `byOwner('')`. */
|
||||
byOwnerDisabled: [...familyRoot, 'byOwner', '__disabled__'] as const,
|
||||
byOwner: (owner: string) => [...familyRoot, 'byOwner', owner] as const,
|
||||
byId: (familyId: NodeFamilyId) => [...familyRoot, 'byId', familyId] as const,
|
||||
operatorInvites: (nodeId: NodeId) => [...familyRoot, 'operatorInvites', nodeId] as const,
|
||||
membership: (nodeId: NodeId) => [...familyRoot, 'membership', nodeId] as const,
|
||||
members: (familyId: NodeFamilyId) => [...familyRoot, 'members', familyId] as const,
|
||||
pendingForFamily: (familyId: NodeFamilyId) => [...familyRoot, 'pendingForFamily', familyId] as const,
|
||||
pendingForNode: (nodeId: NodeId) => [...familyRoot, 'pendingForNode', nodeId] as const,
|
||||
pastInvitationsForFamily: (familyId: NodeFamilyId) => [...familyRoot, 'pastInvitationsForFamily', familyId] as const,
|
||||
pastMembersForFamily: (familyId: NodeFamilyId) => [...familyRoot, 'pastMembersForFamily', familyId] as const,
|
||||
};
|
||||
@@ -3,4 +3,6 @@ export * from './auth';
|
||||
export * from './accounts';
|
||||
export * from './bonding';
|
||||
export * from './delegations';
|
||||
export * from './families';
|
||||
export * from './FamiliesContextProvider';
|
||||
export * from './rewards';
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention, no-restricted-syntax, no-param-reassign */
|
||||
import type { FamilyConfig, NodeId } from 'src/types/families';
|
||||
import {
|
||||
MockStore,
|
||||
mockAcceptFamilyInvitation,
|
||||
mockCreateFamily,
|
||||
mockInviteToFamily,
|
||||
mockKickFromFamily,
|
||||
mockLeaveFamily,
|
||||
mockRejectFamilyInvitation,
|
||||
mockRevokeFamilyInvitation,
|
||||
} from './familiesMockState';
|
||||
|
||||
/** Stable clock for deterministic fixtures (unix seconds). */
|
||||
export const MOCK_NOW_SECS = 1_700_000_000;
|
||||
|
||||
export const FAMILY_FIXTURE_CONFIG: FamilyConfig = {
|
||||
create_family_fee: { denom: 'nym', amount: '100' },
|
||||
family_name_length_limit: 30,
|
||||
family_description_length_limit: 120,
|
||||
default_invitation_validity_secs: 3600,
|
||||
};
|
||||
|
||||
// Personas / addresses ------------------------------------------------------
|
||||
export const MOCK_OWNER_ADDRESS = 'n1owner000000000000000000000000000000owner';
|
||||
export const MOCK_OPERATOR_ADDRESS = 'n1operator00000000000000000000000operator';
|
||||
export const MOCK_OTHER_OWNER_ADDRESS = 'n1alpine0000000000000000000000000000alpine';
|
||||
|
||||
export const MOCK_OWNER_FAMILY_NAME = 'Tatry Operators';
|
||||
export const MOCK_OTHER_FAMILY_NAME = 'Alpine Routers';
|
||||
|
||||
// Node ids ------------------------------------------------------------------
|
||||
/** Operator's controlled nodes: active invite / expired invite / no invite. */
|
||||
export const MOCK_OPERATOR_NODE_ACTIVE: NodeId = 201;
|
||||
export const MOCK_OPERATOR_NODE_EXPIRED: NodeId = 202;
|
||||
export const MOCK_OPERATOR_NODE_NONE: NodeId = 203;
|
||||
export const MOCK_OPERATOR_NODE_IDS = [MOCK_OPERATOR_NODE_ACTIVE, MOCK_OPERATOR_NODE_EXPIRED, MOCK_OPERATOR_NODE_NONE];
|
||||
|
||||
export const createEmptyStore = (now: number = MOCK_NOW_SECS): MockStore => ({
|
||||
config: { ...FAMILY_FIXTURE_CONFIG },
|
||||
nowSecs: now,
|
||||
nextFamilyId: 1,
|
||||
families: new Map(),
|
||||
members: new Map(),
|
||||
pending: new Map(),
|
||||
pastInvitations: [],
|
||||
pastMembers: [],
|
||||
seq: 0,
|
||||
bondedNodes: new Map(),
|
||||
});
|
||||
|
||||
const bond = (store: MockStore, nodeId: NodeId, owner: string) =>
|
||||
store.bondedNodes.set(nodeId, { owner, isUnbonding: false });
|
||||
|
||||
/**
|
||||
* Build a richly-seeded store exercising the full owner + operator surface:
|
||||
* - an owned family (#1) with Joined members, Removed (kicked + left), past
|
||||
* Rejected and Revoked invitations, and pending invites (one active, one expired);
|
||||
* - a second family (#2, other owner) inviting the operator's nodes so the
|
||||
* operator persona sees active / expired / no-invite states across its nodes.
|
||||
*/
|
||||
export const buildSeededStore = (): MockStore => {
|
||||
const s = createEmptyStore(MOCK_NOW_SECS);
|
||||
const fee = s.config.create_family_fee;
|
||||
|
||||
// bonded nodes the owner family will act on (each controlled by its own address)
|
||||
const ctrl = (n: NodeId) => `n1ctrl${n}000000000000000000000000000ctrl`;
|
||||
for (const n of [101, 102, 103, 104, 105, 106, 107, 108]) bond(s, n, ctrl(n));
|
||||
// operator's three nodes
|
||||
for (const n of MOCK_OPERATOR_NODE_IDS) bond(s, n, MOCK_OPERATOR_ADDRESS);
|
||||
|
||||
// --- Family #1, owned by MOCK_OWNER_ADDRESS ---
|
||||
mockCreateFamily(s, MOCK_OWNER_ADDRESS, {
|
||||
name: MOCK_OWNER_FAMILY_NAME,
|
||||
description: 'Operators coordinating routing in the Tatra mountains.',
|
||||
fee,
|
||||
});
|
||||
|
||||
// Joined members (101, 102)
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 101 });
|
||||
mockAcceptFamilyInvitation(s, ctrl(101), { family_id: 1, node_id: 101 });
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 102 });
|
||||
mockAcceptFamilyInvitation(s, ctrl(102), { family_id: 1, node_id: 102 });
|
||||
|
||||
// Removed — kicked (103)
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 103 });
|
||||
mockAcceptFamilyInvitation(s, ctrl(103), { family_id: 1, node_id: 103 });
|
||||
mockKickFromFamily(s, MOCK_OWNER_ADDRESS, { node_id: 103 });
|
||||
|
||||
// Removed — left (104)
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 104 });
|
||||
mockAcceptFamilyInvitation(s, ctrl(104), { family_id: 1, node_id: 104 });
|
||||
mockLeaveFamily(s, ctrl(104), { node_id: 104 });
|
||||
|
||||
// Past invitation — Rejected (105)
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 105 });
|
||||
mockRejectFamilyInvitation(s, ctrl(105), { family_id: 1, node_id: 105 });
|
||||
|
||||
// Past invitation — Revoked (106)
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 106 });
|
||||
mockRevokeFamilyInvitation(s, MOCK_OWNER_ADDRESS, { node_id: 106 });
|
||||
|
||||
// Pending — active (107) and expired (108)
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 107 }); // expires NOW + 3600 (active)
|
||||
mockInviteToFamily(s, MOCK_OWNER_ADDRESS, { node_id: 108 });
|
||||
s.pending.get('1:108')!.expires_at = s.nowSecs - 1; // force expired
|
||||
|
||||
// --- Family #2, owned by MOCK_OTHER_OWNER_ADDRESS, invites the operator's nodes ---
|
||||
mockCreateFamily(s, MOCK_OTHER_OWNER_ADDRESS, {
|
||||
name: MOCK_OTHER_FAMILY_NAME,
|
||||
description: 'Alpine routing collective.',
|
||||
fee,
|
||||
});
|
||||
mockInviteToFamily(s, MOCK_OTHER_OWNER_ADDRESS, { node_id: MOCK_OPERATOR_NODE_ACTIVE }); // active
|
||||
mockInviteToFamily(s, MOCK_OTHER_OWNER_ADDRESS, { node_id: MOCK_OPERATOR_NODE_EXPIRED });
|
||||
s.pending.get(`2:${MOCK_OPERATOR_NODE_EXPIRED}`)!.expires_at = s.nowSecs - 1; // force expired
|
||||
// MOCK_OPERATOR_NODE_NONE: intentionally no invitation
|
||||
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Node the owner-flow account both owns (the family) and controls (so one sender can drive create→invite→accept→kick→disband). */
|
||||
export const MOCK_OWNER_FLOW_NODE: NodeId = 301;
|
||||
|
||||
/** Empty store for the owner flow story: the owner account also controls node 301. */
|
||||
export const buildOwnerFlowStore = (): MockStore => {
|
||||
const s = createEmptyStore(MOCK_NOW_SECS);
|
||||
bond(s, MOCK_OWNER_FLOW_NODE, MOCK_OWNER_ADDRESS);
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Operator-flow nodes: one to accept-then-leave, one to reject. */
|
||||
export const MOCK_OPERATOR_FLOW_ACCEPT_NODE: NodeId = 201;
|
||||
export const MOCK_OPERATOR_FLOW_REJECT_NODE: NodeId = 204;
|
||||
|
||||
/** Store for the operator flow story: two active invites addressed to the operator's nodes. */
|
||||
export const buildOperatorFlowStore = (): MockStore => {
|
||||
const s = createEmptyStore(MOCK_NOW_SECS);
|
||||
bond(s, MOCK_OPERATOR_FLOW_ACCEPT_NODE, MOCK_OPERATOR_ADDRESS);
|
||||
bond(s, MOCK_OPERATOR_FLOW_REJECT_NODE, MOCK_OPERATOR_ADDRESS);
|
||||
mockCreateFamily(s, MOCK_OTHER_OWNER_ADDRESS, {
|
||||
name: MOCK_OTHER_FAMILY_NAME,
|
||||
description: 'Alpine routing collective.',
|
||||
fee: s.config.create_family_fee,
|
||||
});
|
||||
mockInviteToFamily(s, MOCK_OTHER_OWNER_ADDRESS, { node_id: MOCK_OPERATOR_FLOW_ACCEPT_NODE });
|
||||
mockInviteToFamily(s, MOCK_OTHER_OWNER_ADDRESS, { node_id: MOCK_OPERATOR_FLOW_REJECT_NODE });
|
||||
return s;
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { TransactionExecuteResult } from '@nymproject/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { FamiliesContext, FamilyQueries, TFamiliesContext } from 'src/context/families';
|
||||
import { familyQueryKeys } from 'src/context/familyQueryKeys';
|
||||
import { FamilyEvent, FamilyTxResult, NodeId } from 'src/types/families';
|
||||
import { mockSleep } from './utils';
|
||||
import { buildSeededStore, MOCK_OWNER_ADDRESS } from './families.fixtures';
|
||||
import {
|
||||
MockStore,
|
||||
mockAcceptFamilyInvitation,
|
||||
mockCreateFamily,
|
||||
mockDisbandFamily,
|
||||
mockGetFamilyById,
|
||||
mockGetFamilyByOwner,
|
||||
mockGetFamilyConfig as getConfig,
|
||||
mockGetFamilyMembersPaged,
|
||||
mockGetFamilyMembership,
|
||||
mockGetPastInvitationsForFamilyPaged,
|
||||
mockGetPastMembersForFamilyPaged,
|
||||
mockGetPendingInvitationsForFamilyPaged,
|
||||
mockGetPendingInvitationsForNodePaged,
|
||||
mockInviteToFamily,
|
||||
mockKickFromFamily,
|
||||
mockLeaveFamily,
|
||||
mockRejectFamilyInvitation,
|
||||
mockRevokeFamilyInvitation,
|
||||
mockUpdateFamily,
|
||||
} from './familiesMockState';
|
||||
|
||||
const TxResultMock: TransactionExecuteResult = {
|
||||
logs_json: '',
|
||||
msg_responses_json: '',
|
||||
transaction_hash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354',
|
||||
gas_info: {
|
||||
gas_wanted: { gas_units: BigInt(1) },
|
||||
gas_used: { gas_units: BigInt(1) },
|
||||
},
|
||||
fee: { amount: '1', denom: 'nym' },
|
||||
};
|
||||
|
||||
const buildTxResult = (family_events: FamilyEvent[]): FamilyTxResult => ({ ...TxResultMock, family_events });
|
||||
|
||||
const controlledFor = (store: MockStore, sender: string): NodeId[] =>
|
||||
[...store.bondedNodes.entries()].filter(([, b]) => b.owner === sender && !b.isUnbonding).map(([nodeId]) => nodeId);
|
||||
|
||||
export interface MockFamiliesProviderProps {
|
||||
/** Pre-built store; defaults to the richly-seeded fixture store. */
|
||||
store?: MockStore;
|
||||
/** Connected wallet address (the persona). */
|
||||
sender?: string;
|
||||
/** Simulated IPC latency in ms. */
|
||||
latencyMs?: number;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MockFamiliesContextProvider = ({
|
||||
store: storeProp,
|
||||
sender = MOCK_OWNER_ADDRESS,
|
||||
latencyMs = 400,
|
||||
children,
|
||||
}: MockFamiliesProviderProps): React.JSX.Element => {
|
||||
const queryClient = useQueryClient();
|
||||
const storeRef = useRef<MockStore>(storeProp ?? buildSeededStore());
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const controlledNodeIds = useMemo(() => controlledFor(storeRef.current, sender), [sender]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: familyQueryKeys.all });
|
||||
}, [queryClient]);
|
||||
|
||||
const clearError = useCallback(() => setError(undefined), []);
|
||||
|
||||
const queries = useMemo<FamilyQueries>(
|
||||
() => ({
|
||||
getFamilyConfig: async () => {
|
||||
await mockSleep(latencyMs);
|
||||
return getConfig(storeRef.current);
|
||||
},
|
||||
getFamilyById: async (familyId) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetFamilyById(storeRef.current, familyId);
|
||||
},
|
||||
getFamilyByOwner: async (owner) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetFamilyByOwner(storeRef.current, owner);
|
||||
},
|
||||
getFamilyMembership: async (nodeId) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetFamilyMembership(storeRef.current, nodeId);
|
||||
},
|
||||
getFamilyMembersPaged: async (familyId, startAfter, limit) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetFamilyMembersPaged(storeRef.current, familyId, startAfter, limit);
|
||||
},
|
||||
getPendingInvitationsForFamilyPaged: async (familyId, startAfter, limit) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetPendingInvitationsForFamilyPaged(storeRef.current, familyId, startAfter, limit);
|
||||
},
|
||||
getPendingInvitationsForNodePaged: async (nodeId, startAfter, limit) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetPendingInvitationsForNodePaged(storeRef.current, nodeId, startAfter, limit);
|
||||
},
|
||||
getPastInvitationsForFamilyPaged: async (familyId, startAfter, limit) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetPastInvitationsForFamilyPaged(storeRef.current, familyId, startAfter, limit);
|
||||
},
|
||||
getPastMembersForFamilyPaged: async (familyId, startAfter, limit) => {
|
||||
await mockSleep(latencyMs);
|
||||
return mockGetPastMembersForFamilyPaged(storeRef.current, familyId, startAfter, limit);
|
||||
},
|
||||
}),
|
||||
[latencyMs],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (mutate: (store: MockStore) => FamilyEvent[]): Promise<FamilyTxResult> => {
|
||||
setIsExecuting(true);
|
||||
setError(undefined);
|
||||
await mockSleep(latencyMs);
|
||||
try {
|
||||
const events = mutate(storeRef.current);
|
||||
await refreshAll();
|
||||
return buildTxResult(events);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setError(message);
|
||||
throw e;
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
},
|
||||
[latencyMs, refreshAll],
|
||||
);
|
||||
|
||||
const value = useMemo<TFamiliesContext>(
|
||||
() => ({
|
||||
ownerAddress: sender,
|
||||
controlledNodeIds,
|
||||
nowSecs: storeRef.current.nowSecs,
|
||||
queries,
|
||||
isExecuting,
|
||||
error,
|
||||
clearError,
|
||||
refreshAll,
|
||||
createFamily: (args) => run((s) => mockCreateFamily(s, sender, args)),
|
||||
updateFamily: (args) => run((s) => mockUpdateFamily(s, sender, args)),
|
||||
disbandFamily: () => run((s) => mockDisbandFamily(s, sender)),
|
||||
inviteToFamily: (args) => run((s) => mockInviteToFamily(s, sender, args)),
|
||||
revokeFamilyInvitation: (args) => run((s) => mockRevokeFamilyInvitation(s, sender, args)),
|
||||
kickFromFamily: (args) => run((s) => mockKickFromFamily(s, sender, args)),
|
||||
acceptFamilyInvitation: (args) => run((s) => mockAcceptFamilyInvitation(s, sender, args)),
|
||||
rejectFamilyInvitation: (args) => run((s) => mockRejectFamilyInvitation(s, sender, args)),
|
||||
leaveFamily: (args) => run((s) => mockLeaveFamily(s, sender, args)),
|
||||
}),
|
||||
[sender, controlledNodeIds, queries, isExecuting, error, clearError, refreshAll, run],
|
||||
);
|
||||
|
||||
return <FamiliesContext.Provider value={value}>{children}</FamiliesContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,400 @@
|
||||
import { DecCoin } from '@nymproject/types';
|
||||
import { FamilyError, FamilyErrorKind, isFamilyError } from 'src/types/families';
|
||||
import { createEmptyStore } from './families.fixtures';
|
||||
import {
|
||||
MockStore,
|
||||
mockAcceptFamilyInvitation,
|
||||
mockCreateFamily,
|
||||
mockDisbandFamily,
|
||||
mockGetFamilyById,
|
||||
mockGetFamilyByName,
|
||||
mockGetFamilyByOwner,
|
||||
mockGetFamilyMembersPaged,
|
||||
mockGetFamilyMembership,
|
||||
mockGetPastInvitationsForFamilyPaged,
|
||||
mockGetPastMembersForFamilyPaged,
|
||||
mockGetPendingInvitationsForFamilyPaged,
|
||||
mockGetPendingInvitationsForNodePaged,
|
||||
mockInviteToFamily,
|
||||
mockKickFromFamily,
|
||||
mockLeaveFamily,
|
||||
mockOnNymNodeUnbond,
|
||||
mockRejectFamilyInvitation,
|
||||
mockRevokeFamilyInvitation,
|
||||
mockUpdateFamily,
|
||||
normaliseFamilyName,
|
||||
} from './familiesMockState';
|
||||
|
||||
const coin = (denom: string, amount: string): DecCoin => ({ denom, amount } as unknown as DecCoin);
|
||||
const FEE = coin('nym', '100');
|
||||
const OWNER = 'owner';
|
||||
const NOW = 1000;
|
||||
|
||||
/** Fresh store at NOW with a set of bonded nodes (id -> controller). */
|
||||
const setup = (nodes: Record<number, string> = {}): MockStore => {
|
||||
const s = createEmptyStore(NOW);
|
||||
Object.entries(nodes).forEach(([id, owner]) => s.bondedNodes.set(Number(id), { owner, isUnbonding: false }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const expectError = (fn: () => void, kind: FamilyErrorKind) => {
|
||||
expect(fn).toThrow(FamilyError);
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
expect(isFamilyError(e)).toBe(true);
|
||||
expect((e as FamilyError).kind).toBe(kind);
|
||||
}
|
||||
};
|
||||
|
||||
const create = (s: MockStore, name = 'Tatry', owner = OWNER) =>
|
||||
mockCreateFamily(s, owner, { name, description: 'desc', fee: FEE });
|
||||
|
||||
describe('normaliseFamilyName', () => {
|
||||
it('strips punctuation, whitespace, casing, and non-ASCII', () => {
|
||||
['Foo Bar', 'foobar', 'FOO-BAR', ' f.o.o.b.a.r '].forEach((n) => expect(normaliseFamilyName(n)).toBe('foobar'));
|
||||
expect(normaliseFamilyName('café')).toBe('caf');
|
||||
expect(normaliseFamilyName('⭐stars')).toBe('stars');
|
||||
expect(normaliseFamilyName('!!!---')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFamily', () => {
|
||||
it('persists the family and emits family_creation', () => {
|
||||
const s = setup();
|
||||
const events = create(s);
|
||||
const fam = mockGetFamilyByOwner(s, OWNER)!;
|
||||
expect(fam.id).toBe(1);
|
||||
expect(fam.members).toBe(0);
|
||||
expect(fam.created_at).toBe(NOW);
|
||||
expect(fam.normalised_name).toBe('tatry');
|
||||
expect(events[0].ty).toBe('family_creation');
|
||||
expect(events[0].attributes).toMatchObject({ family_id: '1', owner_address: OWNER });
|
||||
});
|
||||
|
||||
it('rejects a wrong fee denom (InvalidDeposit) and wrong amount (InvalidFamilyCreationFee)', () => {
|
||||
expectError(
|
||||
() => mockCreateFamily(setup(), OWNER, { name: 'A', description: '', fee: coin('foo', '100') }),
|
||||
'InvalidDeposit',
|
||||
);
|
||||
expectError(
|
||||
() => mockCreateFamily(setup(), OWNER, { name: 'A', description: '', fee: coin('nym', '5') }),
|
||||
'InvalidFamilyCreationFee',
|
||||
);
|
||||
});
|
||||
|
||||
it('enforces byte-length name limit (multi-byte counts full bytes)', () => {
|
||||
const s = setup();
|
||||
s.config.family_name_length_limit = 8;
|
||||
// "🚀rocket" = 4-byte emoji + 6 = 10 bytes > 8
|
||||
expectError(() => mockCreateFamily(s, OWNER, { name: '🚀rocket', description: '', fee: FEE }), 'FamilyNameTooLong');
|
||||
});
|
||||
|
||||
it('rejects an all-symbol name as EmptyFamilyName', () => {
|
||||
expectError(
|
||||
() => mockCreateFamily(setup(), OWNER, { name: '!!!---', description: '', fee: FEE }),
|
||||
'EmptyFamilyName',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a colliding normalised name', () => {
|
||||
const s = setup();
|
||||
create(s, 'Shared');
|
||||
expectError(
|
||||
() => mockCreateFamily(s, 'other', { name: '$$shared$$', description: '', fee: FEE }),
|
||||
'FamilyNameAlreadyTaken',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a second family for the same owner', () => {
|
||||
const s = setup();
|
||||
create(s);
|
||||
expectError(() => create(s, 'Another'), 'SenderAlreadyOwnsAFamily');
|
||||
});
|
||||
|
||||
it('rejects when the owner controls a bonded node already in a family', () => {
|
||||
const s = setup({ 5: OWNER });
|
||||
// put node 5 in some family via another owner's invite+accept
|
||||
s.bondedNodes.set(5, { owner: OWNER, isUnbonding: false });
|
||||
create(s, 'X', 'otherowner');
|
||||
mockInviteToFamily(s, 'otherowner', { node_id: 5 });
|
||||
mockAcceptFamilyInvitation(s, OWNER, { family_id: 1, node_id: 5 });
|
||||
expectError(() => create(s, 'Mine', OWNER), 'AlreadyInFamily');
|
||||
});
|
||||
|
||||
it('assigns monotonic, non-recycled ids', () => {
|
||||
const s = setup();
|
||||
create(s, 'First');
|
||||
mockDisbandFamily(s, OWNER);
|
||||
create(s, 'Second');
|
||||
expect(mockGetFamilyByOwner(s, OWNER)!.id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFamily', () => {
|
||||
it('updates name only and emits conditional attribute', () => {
|
||||
const s = setup();
|
||||
create(s);
|
||||
const events = mockUpdateFamily(s, OWNER, { updated_name: 'Renamed', updated_description: null });
|
||||
const fam = mockGetFamilyByOwner(s, OWNER)!;
|
||||
expect(fam.name).toBe('Renamed');
|
||||
expect(fam.description).toBe('desc');
|
||||
expect(events[0].attributes).toMatchObject({ updated_name: 'Renamed' });
|
||||
expect(events[0].attributes.updated_description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no-op (both None) emits no event and does not require ownership', () => {
|
||||
const s = setup();
|
||||
expect(mockUpdateFamily(s, 'nobody', {})).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a set field from a non-owner', () => {
|
||||
expectError(() => mockUpdateFamily(setup(), 'nobody', { updated_name: 'X' }), 'SenderDoesntOwnAFamily');
|
||||
});
|
||||
|
||||
it('allows a case-only rename (same normalised) and rejects collision with another family', () => {
|
||||
const s = setup();
|
||||
create(s, 'Shared');
|
||||
create(s, 'Other', 'owner2');
|
||||
// case-only rename of owner's family
|
||||
mockUpdateFamily(s, OWNER, { updated_name: 'SHARED' });
|
||||
expect(mockGetFamilyByOwner(s, OWNER)!.normalised_name).toBe('shared');
|
||||
// rename to collide with owner2's family
|
||||
expectError(() => mockUpdateFamily(s, OWNER, { updated_name: 'other' }), 'FamilyNameAlreadyTaken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disbandFamily', () => {
|
||||
it('disbands an empty family and emits family_disband', () => {
|
||||
const s = setup();
|
||||
create(s);
|
||||
const events = mockDisbandFamily(s, OWNER);
|
||||
expect(mockGetFamilyByOwner(s, OWNER)).toBeNull();
|
||||
expect(events[0].ty).toBe('family_disband');
|
||||
});
|
||||
|
||||
it('rejects a non-empty family', () => {
|
||||
const s = setup({ 7: 'ctrl7' });
|
||||
create(s);
|
||||
mockInviteToFamily(s, OWNER, { node_id: 7 });
|
||||
mockAcceptFamilyInvitation(s, 'ctrl7', { family_id: 1, node_id: 7 });
|
||||
expectError(() => mockDisbandFamily(s, OWNER), 'FamilyNotEmpty');
|
||||
});
|
||||
|
||||
it('sweeps still-pending invitations as Revoked', () => {
|
||||
const s = setup({ 8: 'ctrl8' });
|
||||
create(s);
|
||||
mockInviteToFamily(s, OWNER, { node_id: 8 });
|
||||
mockDisbandFamily(s, OWNER);
|
||||
const past = mockGetPastInvitationsForFamilyPaged(s, 1).items;
|
||||
expect(past).toHaveLength(1);
|
||||
expect(past[0].status.kind).toBe('Revoked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inviteToFamily', () => {
|
||||
const seeded = () => {
|
||||
const s = setup({ 10: 'ctrl10', 11: 'ctrl11' });
|
||||
create(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
it('invites with the computed expiry and emits family_invitation', () => {
|
||||
const s = seeded();
|
||||
const events = mockInviteToFamily(s, OWNER, { node_id: 10, validity_secs: 500 });
|
||||
expect(s.pending.get('1:10')!.expires_at).toBe(NOW + 500);
|
||||
expect(events[0].ty).toBe('family_invitation');
|
||||
});
|
||||
|
||||
it('falls back to the default validity', () => {
|
||||
const s = seeded();
|
||||
mockInviteToFamily(s, OWNER, { node_id: 10 });
|
||||
expect(s.pending.get('1:10')!.expires_at).toBe(NOW + s.config.default_invitation_validity_secs);
|
||||
});
|
||||
|
||||
it('rejects zero validity, non-existent node, and duplicate pending', () => {
|
||||
const s = seeded();
|
||||
expectError(() => mockInviteToFamily(s, OWNER, { node_id: 10, validity_secs: 0 }), 'ZeroInvitationValidity');
|
||||
expectError(() => mockInviteToFamily(s, OWNER, { node_id: 999 }), 'NodeDoesntExist');
|
||||
mockInviteToFamily(s, OWNER, { node_id: 11 });
|
||||
expectError(() => mockInviteToFamily(s, OWNER, { node_id: 11 }), 'PendingInvitationAlreadyExists');
|
||||
});
|
||||
|
||||
it('rejects inviting a node already in a family', () => {
|
||||
const s = seeded();
|
||||
mockInviteToFamily(s, OWNER, { node_id: 10 });
|
||||
mockAcceptFamilyInvitation(s, 'ctrl10', { family_id: 1, node_id: 10 });
|
||||
expectError(() => mockInviteToFamily(s, OWNER, { node_id: 10 }), 'NodeAlreadyInFamily');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accept / reject / revoke', () => {
|
||||
const invited = (validity = 500) => {
|
||||
const s = setup({ 20: 'ctrl20' });
|
||||
create(s);
|
||||
mockInviteToFamily(s, OWNER, { node_id: 20, validity_secs: validity });
|
||||
return s;
|
||||
};
|
||||
|
||||
it('accept records membership, increments count, archives Accepted, emits event', () => {
|
||||
const s = invited();
|
||||
const events = mockAcceptFamilyInvitation(s, 'ctrl20', { family_id: 1, node_id: 20 });
|
||||
expect(mockGetFamilyMembership(s, 20).family_id).toBe(1);
|
||||
expect(mockGetFamilyById(s, 1)!.members).toBe(1);
|
||||
expect(s.pending.has('1:20')).toBe(false);
|
||||
expect(mockGetPastInvitationsForFamilyPaged(s, 1).items[0].status.kind).toBe('Accepted');
|
||||
expect(events[0].ty).toBe('family_invitation_accepted');
|
||||
});
|
||||
|
||||
it('accept rejects non-controller, expired, and missing invitation', () => {
|
||||
expectError(
|
||||
() => mockAcceptFamilyInvitation(invited(), 'someoneelse', { family_id: 1, node_id: 20 }),
|
||||
'SenderDoesntControlNode',
|
||||
);
|
||||
const expired = invited();
|
||||
expired.pending.get('1:20')!.expires_at = NOW; // now >= expires_at => expired
|
||||
expectError(
|
||||
() => mockAcceptFamilyInvitation(expired, 'ctrl20', { family_id: 1, node_id: 20 }),
|
||||
'InvitationExpired',
|
||||
);
|
||||
const s = setup({ 21: 'ctrl21' });
|
||||
create(s);
|
||||
expectError(() => mockAcceptFamilyInvitation(s, 'ctrl21', { family_id: 1, node_id: 21 }), 'InvitationNotFound');
|
||||
});
|
||||
|
||||
it('reject archives Rejected and works even on expired invitations', () => {
|
||||
const s = invited();
|
||||
s.pending.get('1:20')!.expires_at = NOW - 1; // expired
|
||||
const events = mockRejectFamilyInvitation(s, 'ctrl20', { family_id: 1, node_id: 20 });
|
||||
expect(s.pending.has('1:20')).toBe(false);
|
||||
expect(mockGetPastInvitationsForFamilyPaged(s, 1).items[0].status.kind).toBe('Rejected');
|
||||
expect(events[0].ty).toBe('family_invitation_rejected');
|
||||
});
|
||||
|
||||
it('revoke (owner) archives Revoked; missing pending throws', () => {
|
||||
const s = invited();
|
||||
mockRevokeFamilyInvitation(s, OWNER, { node_id: 20 });
|
||||
expect(mockGetPastInvitationsForFamilyPaged(s, 1).items[0].status.kind).toBe('Revoked');
|
||||
expectError(() => mockRevokeFamilyInvitation(s, OWNER, { node_id: 20 }), 'InvitationNotFound');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kick / leave', () => {
|
||||
const joined = (node = 30, ctrl = 'ctrl30') => {
|
||||
const s = setup({ [node]: ctrl });
|
||||
create(s);
|
||||
mockInviteToFamily(s, OWNER, { node_id: node });
|
||||
mockAcceptFamilyInvitation(s, ctrl, { family_id: 1, node_id: node });
|
||||
return s;
|
||||
};
|
||||
|
||||
it('kick moves the node to Removed and emits family_member_kicked', () => {
|
||||
const s = joined();
|
||||
const events = mockKickFromFamily(s, OWNER, { node_id: 30 });
|
||||
expect(mockGetFamilyMembership(s, 30).family_id).toBeNull();
|
||||
expect(mockGetPastMembersForFamilyPaged(s, 1).items).toHaveLength(1);
|
||||
expect(events[0].ty).toBe('family_member_kicked');
|
||||
});
|
||||
|
||||
it('kick rejects a node not in any family and one in a different family', () => {
|
||||
const s = joined(); // node 30 in OWNER's family 1
|
||||
expectError(() => mockKickFromFamily(s, OWNER, { node_id: 999 }), 'NodeNotInFamily');
|
||||
// node 31 joins a different family (owner2); OWNER cannot kick it
|
||||
s.bondedNodes.set(31, { owner: 'ctrl31', isUnbonding: false });
|
||||
create(s, 'Other', 'owner2');
|
||||
mockInviteToFamily(s, 'owner2', { node_id: 31 });
|
||||
mockAcceptFamilyInvitation(s, 'ctrl31', { family_id: 2, node_id: 31 });
|
||||
expectError(() => mockKickFromFamily(s, OWNER, { node_id: 31 }), 'NodeNotMemberOfFamily');
|
||||
});
|
||||
|
||||
it('leave removes membership, emits family_member_left, and the node can rejoin', () => {
|
||||
const s = joined();
|
||||
mockLeaveFamily(s, 'ctrl30', { node_id: 30 });
|
||||
expect(mockGetFamilyMembership(s, 30).family_id).toBeNull();
|
||||
// can rejoin
|
||||
mockInviteToFamily(s, OWNER, { node_id: 30 });
|
||||
mockAcceptFamilyInvitation(s, 'ctrl30', { family_id: 1, node_id: 30 });
|
||||
expect(mockGetFamilyMembership(s, 30).family_id).toBe(1);
|
||||
// two removed records would exist after a second leave (sequential archive slots)
|
||||
mockLeaveFamily(s, 'ctrl30', { node_id: 30 });
|
||||
expect(mockGetPastMembersForFamilyPaged(s, 1).items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leave rejects a non-controller', () => {
|
||||
expectError(() => mockLeaveFamily(joined(), 'someoneelse', { node_id: 30 }), 'SenderDoesntControlNode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onNymNodeUnbond', () => {
|
||||
it('removes membership and sweeps pending invitations as Rejected', () => {
|
||||
const s = setup({ 40: 'ctrl40', 41: 'ctrl41' });
|
||||
create(s);
|
||||
mockInviteToFamily(s, OWNER, { node_id: 40 });
|
||||
mockAcceptFamilyInvitation(s, 'ctrl40', { family_id: 1, node_id: 40 });
|
||||
mockInviteToFamily(s, OWNER, { node_id: 41 }); // pending
|
||||
|
||||
const events = mockOnNymNodeUnbond(s, 40); // member -> removed
|
||||
expect(mockGetFamilyMembership(s, 40).family_id).toBeNull();
|
||||
expect(events[0].ty).toBe('family_node_unbond_cleanup');
|
||||
|
||||
mockOnNymNodeUnbond(s, 41); // pending -> swept as Rejected
|
||||
expect(s.pending.has('1:41')).toBe(false);
|
||||
const past = mockGetPastInvitationsForFamilyPaged(s, 1).items;
|
||||
expect(past.some((p) => p.invitation.node_id === 41 && p.status.kind === 'Rejected')).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op for a node with no family and no invitations', () => {
|
||||
const s = setup();
|
||||
expect(mockOnNymNodeUnbond(s, 123)[0].ty).toBe('family_node_unbond_cleanup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queries & pagination', () => {
|
||||
it('getFamilyByName is invariant under formatting; getFamilyMembership returns None for unknown', () => {
|
||||
const s = setup();
|
||||
create(s, 'MyFamily');
|
||||
expect(mockGetFamilyByName(s, 'my family')!.id).toBe(1);
|
||||
expect(mockGetFamilyMembership(s, 777).family_id).toBeNull();
|
||||
});
|
||||
|
||||
it('pending query stamps the live expired flag', () => {
|
||||
const s = setup({ 50: 'c', 51: 'c' });
|
||||
create(s);
|
||||
mockInviteToFamily(s, OWNER, { node_id: 50, validity_secs: 500 }); // active
|
||||
mockInviteToFamily(s, OWNER, { node_id: 51, validity_secs: 500 });
|
||||
s.pending.get('1:51')!.expires_at = NOW - 1; // expired
|
||||
const { items } = mockGetPendingInvitationsForFamilyPaged(s, 1);
|
||||
const byNode = Object.fromEntries(items.map((i) => [i.invitation.node_id, i.expired]));
|
||||
expect(byNode[50]).toBe(false);
|
||||
expect(byNode[51]).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults limit to 50, clamps to 100, and pages exclusively via start_after', () => {
|
||||
const s = setup();
|
||||
create(s);
|
||||
// 60 members
|
||||
for (let i = 1; i <= 60; i += 1) {
|
||||
s.bondedNodes.set(1000 + i, { owner: `c${i}`, isUnbonding: false });
|
||||
mockInviteToFamily(s, OWNER, { node_id: 1000 + i });
|
||||
mockAcceptFamilyInvitation(s, `c${i}`, { family_id: 1, node_id: 1000 + i });
|
||||
}
|
||||
const page1 = mockGetFamilyMembersPaged(s, 1);
|
||||
expect(page1.items).toHaveLength(50);
|
||||
expect(page1.start_next_after).not.toBeNull();
|
||||
const page2 = mockGetFamilyMembersPaged(s, 1, page1.start_next_after ?? undefined);
|
||||
expect(page2.items).toHaveLength(10);
|
||||
const page3 = mockGetFamilyMembersPaged(s, 1, page2.start_next_after ?? undefined);
|
||||
expect(page3.items).toHaveLength(0);
|
||||
expect(page3.start_next_after).toBeNull();
|
||||
// limit clamps to 100; only 60 members exist so all 60 return
|
||||
expect(mockGetFamilyMembersPaged(s, 1, undefined, 10_000).items).toHaveLength(60);
|
||||
});
|
||||
|
||||
it('multi-node operator: per-node pending queries are isolated', () => {
|
||||
const s = setup({ 70: 'op', 71: 'op' });
|
||||
create(s, 'F', 'owner2');
|
||||
mockInviteToFamily(s, 'owner2', { node_id: 70 });
|
||||
expect(mockGetPendingInvitationsForNodePaged(s, 70).items).toHaveLength(1);
|
||||
expect(mockGetPendingInvitationsForNodePaged(s, 71).items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention, no-restricted-syntax, no-param-reassign */
|
||||
import {
|
||||
AcceptFamilyInvitationArgs,
|
||||
CreateFamilyArgs,
|
||||
FamilyConfig,
|
||||
FamilyCursor,
|
||||
FamilyError,
|
||||
FamilyEvent,
|
||||
FamilyPagedResponse,
|
||||
FAMILY_PAGE_DEFAULT_LIMIT,
|
||||
FAMILY_PAGE_MAX_LIMIT,
|
||||
FamilyInvitation,
|
||||
InviteToFamilyArgs,
|
||||
KickFromFamilyArgs,
|
||||
LeaveFamilyArgs,
|
||||
NodeFamily,
|
||||
NodeFamilyId,
|
||||
NodeFamilyMembershipResponse,
|
||||
NodeId,
|
||||
PastFamilyInvitation,
|
||||
PastFamilyMember,
|
||||
PastInvitationStatusKind,
|
||||
PendingFamilyInvitationDetails,
|
||||
RejectFamilyInvitationArgs,
|
||||
RevokeFamilyInvitationArgs,
|
||||
UpdateFamilyArgs,
|
||||
} from 'src/types/families';
|
||||
|
||||
/**
|
||||
* Pure, framework-free in-memory model of the `node-families-contract`.
|
||||
*
|
||||
* Mutators throw `FamilyError` and return the emitted events; queries are
|
||||
* read-only. The React mock provider (and Jest, once §8 lands) drive this engine
|
||||
* — keeping the contract logic in one testable place (design D3).
|
||||
*/
|
||||
|
||||
interface BondedNode {
|
||||
owner: string;
|
||||
isUnbonding: boolean;
|
||||
}
|
||||
|
||||
interface ArchivedInvitation {
|
||||
invitation: FamilyInvitation;
|
||||
status: { kind: PastInvitationStatusKind; at: number };
|
||||
seq: number;
|
||||
}
|
||||
|
||||
interface ArchivedMember {
|
||||
record: PastFamilyMember;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface MockStore {
|
||||
config: FamilyConfig;
|
||||
/** Controllable clock (unix seconds) used for expiry + timestamps. */
|
||||
nowSecs: number;
|
||||
/** Monotonic, never recycled, starts at 1. */
|
||||
nextFamilyId: NodeFamilyId;
|
||||
families: Map<NodeFamilyId, NodeFamily>;
|
||||
/** node_id -> membership (one family per node). */
|
||||
members: Map<NodeId, { family_id: NodeFamilyId; joined_at: number }>;
|
||||
/** `${family_id}:${node_id}` -> pending invitation. */
|
||||
pending: Map<string, FamilyInvitation>;
|
||||
pastInvitations: ArchivedInvitation[];
|
||||
pastMembers: ArchivedMember[];
|
||||
/** insertion order for archive cursors. */
|
||||
seq: number;
|
||||
/** Simulated mixnet bond table for node existence/control checks. */
|
||||
bondedNodes: Map<NodeId, BondedNode>;
|
||||
}
|
||||
|
||||
const pendingKey = (familyId: NodeFamilyId, nodeId: NodeId) => `${familyId}:${nodeId}`;
|
||||
|
||||
const byteLen = (s: string): number => new TextEncoder().encode(s).length;
|
||||
|
||||
/** ASCII-only normalisation: lowercase ASCII letters, keep digits, drop everything else. */
|
||||
export const normaliseFamilyName = (name: string): string => {
|
||||
let out = '';
|
||||
for (const ch of name) {
|
||||
if (ch >= 'A' && ch <= 'Z') out += ch.toLowerCase();
|
||||
else if (ch >= 'a' && ch <= 'z') out += ch;
|
||||
else if (ch >= '0' && ch <= '9') out += ch;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const event = (ty: FamilyEvent['ty'], attributes: Record<string, string | number>): FamilyEvent => ({
|
||||
ty,
|
||||
attributes: Object.fromEntries(Object.entries(attributes).map(([k, v]) => [k, String(v)])),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal lookups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const findFamilyByOwner = (store: MockStore, owner: string): NodeFamily | undefined => {
|
||||
for (const fam of store.families.values()) if (fam.owner === owner) return fam;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const findFamilyByNormalisedName = (store: MockStore, normalised: string): NodeFamily | undefined => {
|
||||
for (const fam of store.families.values()) if (fam.normalised_name === normalised) return fam;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const requireOwnedFamily = (store: MockStore, sender: string): NodeFamily => {
|
||||
const fam = findFamilyByOwner(store, sender);
|
||||
if (!fam) throw new FamilyError('SenderDoesntOwnAFamily', 'You do not own a family', { address: sender });
|
||||
return fam;
|
||||
};
|
||||
|
||||
const controlsNode = (store: MockStore, sender: string, nodeId: NodeId): boolean => {
|
||||
const bond = store.bondedNodes.get(nodeId);
|
||||
return Boolean(bond) && bond!.owner === sender && !bond!.isUnbonding;
|
||||
};
|
||||
|
||||
const archiveInvitation = (store: MockStore, invitation: FamilyInvitation, kind: PastInvitationStatusKind) => {
|
||||
store.seq += 1;
|
||||
store.pastInvitations.push({ invitation, status: { kind, at: store.nowSecs }, seq: store.seq });
|
||||
};
|
||||
|
||||
const archiveMember = (store: MockStore, familyId: NodeFamilyId, nodeId: NodeId) => {
|
||||
store.seq += 1;
|
||||
store.pastMembers.push({
|
||||
record: { family_id: familyId, node_id: nodeId, removed_at: store.nowSecs },
|
||||
seq: store.seq,
|
||||
});
|
||||
};
|
||||
|
||||
const removeMembership = (store: MockStore, nodeId: NodeId, familyId: NodeFamilyId) => {
|
||||
store.members.delete(nodeId);
|
||||
const fam = store.families.get(familyId);
|
||||
if (fam && fam.members > 0) fam.members -= 1;
|
||||
archiveMember(store, familyId, nodeId);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute mutators (throw FamilyError, return emitted events)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mockCreateFamily(store: MockStore, sender: string, args: CreateFamilyArgs): FamilyEvent[] {
|
||||
// fee check
|
||||
const fee = store.config.create_family_fee;
|
||||
if (args.fee.denom !== fee.denom) {
|
||||
throw new FamilyError('InvalidDeposit', `Expected fee in ${fee.denom}`, { expected: fee, received: args.fee });
|
||||
}
|
||||
if (args.fee.amount !== fee.amount) {
|
||||
throw new FamilyError('InvalidFamilyCreationFee', 'Incorrect creation fee', { expected: fee, received: args.fee });
|
||||
}
|
||||
// name length (bytes) + normalisation
|
||||
if (byteLen(args.name) > store.config.family_name_length_limit) {
|
||||
throw new FamilyError('FamilyNameTooLong', 'Family name too long', {
|
||||
length: byteLen(args.name),
|
||||
limit: store.config.family_name_length_limit,
|
||||
});
|
||||
}
|
||||
const normalised = normaliseFamilyName(args.name);
|
||||
if (normalised.length === 0) throw new FamilyError('EmptyFamilyName', 'Family name normalises to empty');
|
||||
// description length
|
||||
if (byteLen(args.description) > store.config.family_description_length_limit) {
|
||||
throw new FamilyError('FamilyDescriptionTooLong', 'Family description too long', {
|
||||
length: byteLen(args.description),
|
||||
limit: store.config.family_description_length_limit,
|
||||
});
|
||||
}
|
||||
// one family per owner
|
||||
const existing = findFamilyByOwner(store, sender);
|
||||
if (existing) {
|
||||
throw new FamilyError('SenderAlreadyOwnsAFamily', 'You already own a family', {
|
||||
address: sender,
|
||||
family_id: existing.id,
|
||||
});
|
||||
}
|
||||
// name uniqueness
|
||||
const taken = findFamilyByNormalisedName(store, normalised);
|
||||
if (taken) {
|
||||
throw new FamilyError('FamilyNameAlreadyTaken', 'Family name already taken', {
|
||||
name: normalised,
|
||||
family_id: taken.id,
|
||||
});
|
||||
}
|
||||
// sender's bonded node must not already be in a family
|
||||
for (const [nodeId, bond] of store.bondedNodes) {
|
||||
if (bond.owner === sender && store.members.has(nodeId)) {
|
||||
throw new FamilyError('AlreadyInFamily', 'Your node is already in a family', {
|
||||
address: sender,
|
||||
node_id: nodeId,
|
||||
family_id: store.members.get(nodeId)!.family_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const id = store.nextFamilyId;
|
||||
store.nextFamilyId += 1;
|
||||
const family: NodeFamily = {
|
||||
id,
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
normalised_name: normalised,
|
||||
members: 0,
|
||||
created_at: store.nowSecs,
|
||||
paid_fee: args.fee,
|
||||
owner: sender,
|
||||
};
|
||||
store.families.set(id, family);
|
||||
return [
|
||||
event('family_creation', {
|
||||
family_name: args.name,
|
||||
owner_address: sender,
|
||||
family_id: id,
|
||||
paid_fee: `${args.fee.amount} ${args.fee.denom}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function mockUpdateFamily(store: MockStore, sender: string, args: UpdateFamilyArgs): FamilyEvent[] {
|
||||
const setName = args.updated_name !== undefined && args.updated_name !== null;
|
||||
const setDesc = args.updated_description !== undefined && args.updated_description !== null;
|
||||
// no-op short-circuit BEFORE the ownership check
|
||||
if (!setName && !setDesc) return [];
|
||||
|
||||
const fam = requireOwnedFamily(store, sender);
|
||||
|
||||
if (setName) {
|
||||
const name = args.updated_name as string;
|
||||
if (byteLen(name) > store.config.family_name_length_limit) {
|
||||
throw new FamilyError('FamilyNameTooLong', 'Family name too long', {
|
||||
length: byteLen(name),
|
||||
limit: store.config.family_name_length_limit,
|
||||
});
|
||||
}
|
||||
const normalised = normaliseFamilyName(name);
|
||||
if (normalised.length === 0) throw new FamilyError('EmptyFamilyName', 'Family name normalises to empty');
|
||||
const clash = findFamilyByNormalisedName(store, normalised);
|
||||
if (clash && clash.id !== fam.id) {
|
||||
throw new FamilyError('FamilyNameAlreadyTaken', 'Family name already taken', {
|
||||
name: normalised,
|
||||
family_id: clash.id,
|
||||
});
|
||||
}
|
||||
fam.name = name;
|
||||
fam.normalised_name = normalised;
|
||||
}
|
||||
|
||||
if (setDesc) {
|
||||
const desc = args.updated_description as string;
|
||||
if (byteLen(desc) > store.config.family_description_length_limit) {
|
||||
throw new FamilyError('FamilyDescriptionTooLong', 'Family description too long', {
|
||||
length: byteLen(desc),
|
||||
limit: store.config.family_description_length_limit,
|
||||
});
|
||||
}
|
||||
fam.description = desc;
|
||||
}
|
||||
|
||||
const attributes: Record<string, string | number> = { family_id: fam.id, owner_address: sender };
|
||||
if (setName) attributes.updated_name = fam.name;
|
||||
if (setDesc) attributes.updated_description = fam.description;
|
||||
return [event('family_update', attributes)];
|
||||
}
|
||||
|
||||
export function mockDisbandFamily(store: MockStore, sender: string): FamilyEvent[] {
|
||||
const fam = requireOwnedFamily(store, sender);
|
||||
if (fam.members > 0) {
|
||||
throw new FamilyError('FamilyNotEmpty', 'Family is not empty', { family_id: fam.id, members: fam.members });
|
||||
}
|
||||
// sweep still-pending invitations issued by this family -> Revoked
|
||||
for (const [key, invitation] of [...store.pending]) {
|
||||
if (invitation.family_id === fam.id) {
|
||||
archiveInvitation(store, invitation, 'Revoked');
|
||||
store.pending.delete(key);
|
||||
}
|
||||
}
|
||||
store.families.delete(fam.id);
|
||||
return [
|
||||
event('family_disband', {
|
||||
family_id: fam.id,
|
||||
owner_address: sender,
|
||||
refunded_fee: `${fam.paid_fee.amount} ${fam.paid_fee.denom}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function mockInviteToFamily(store: MockStore, sender: string, args: InviteToFamilyArgs): FamilyEvent[] {
|
||||
const fam = requireOwnedFamily(store, sender);
|
||||
const validity = args.validity_secs ?? store.config.default_invitation_validity_secs;
|
||||
if (validity === 0) throw new FamilyError('ZeroInvitationValidity', 'Invitation validity must be positive');
|
||||
const bond = store.bondedNodes.get(args.node_id);
|
||||
if (!bond || bond.isUnbonding) {
|
||||
throw new FamilyError('NodeDoesntExist', 'Node does not exist or is unbonding', { node_id: args.node_id });
|
||||
}
|
||||
if (store.members.has(args.node_id)) {
|
||||
throw new FamilyError('NodeAlreadyInFamily', 'Node already in a family', {
|
||||
node_id: args.node_id,
|
||||
family_id: store.members.get(args.node_id)!.family_id,
|
||||
});
|
||||
}
|
||||
const key = pendingKey(fam.id, args.node_id);
|
||||
if (store.pending.has(key)) {
|
||||
throw new FamilyError('PendingInvitationAlreadyExists', 'A pending invitation already exists', {
|
||||
family_id: fam.id,
|
||||
node_id: args.node_id,
|
||||
});
|
||||
}
|
||||
const expires_at = store.nowSecs + validity;
|
||||
store.pending.set(key, { family_id: fam.id, node_id: args.node_id, expires_at });
|
||||
return [event('family_invitation', { family_id: fam.id, node_id: args.node_id, expires_at })];
|
||||
}
|
||||
|
||||
export function mockRevokeFamilyInvitation(
|
||||
store: MockStore,
|
||||
sender: string,
|
||||
args: RevokeFamilyInvitationArgs,
|
||||
): FamilyEvent[] {
|
||||
const fam = requireOwnedFamily(store, sender);
|
||||
const key = pendingKey(fam.id, args.node_id);
|
||||
const invitation = store.pending.get(key);
|
||||
if (!invitation) {
|
||||
throw new FamilyError('InvitationNotFound', 'No pending invitation', { family_id: fam.id, node_id: args.node_id });
|
||||
}
|
||||
archiveInvitation(store, invitation, 'Revoked');
|
||||
store.pending.delete(key);
|
||||
return [event('family_invitation_revoked', { family_id: fam.id, node_id: args.node_id })];
|
||||
}
|
||||
|
||||
export function mockKickFromFamily(store: MockStore, sender: string, args: KickFromFamilyArgs): FamilyEvent[] {
|
||||
const fam = requireOwnedFamily(store, sender);
|
||||
const membership = store.members.get(args.node_id);
|
||||
if (!membership) throw new FamilyError('NodeNotInFamily', 'Node is not in any family', { node_id: args.node_id });
|
||||
if (membership.family_id !== fam.id) {
|
||||
throw new FamilyError('NodeNotMemberOfFamily', 'Node is not a member of your family', {
|
||||
node_id: args.node_id,
|
||||
family_id: fam.id,
|
||||
});
|
||||
}
|
||||
removeMembership(store, args.node_id, fam.id);
|
||||
return [event('family_member_kicked', { family_id: fam.id, node_id: args.node_id })];
|
||||
}
|
||||
|
||||
export function mockAcceptFamilyInvitation(
|
||||
store: MockStore,
|
||||
sender: string,
|
||||
args: AcceptFamilyInvitationArgs,
|
||||
): FamilyEvent[] {
|
||||
if (!controlsNode(store, sender, args.node_id)) {
|
||||
throw new FamilyError('SenderDoesntControlNode', 'You do not control this node', {
|
||||
address: sender,
|
||||
node_id: args.node_id,
|
||||
});
|
||||
}
|
||||
const existing = store.members.get(args.node_id);
|
||||
if (existing) {
|
||||
throw new FamilyError('NodeAlreadyInFamily', 'Node already in a family', {
|
||||
node_id: args.node_id,
|
||||
family_id: existing.family_id,
|
||||
});
|
||||
}
|
||||
const key = pendingKey(args.family_id, args.node_id);
|
||||
const invitation = store.pending.get(key);
|
||||
if (!invitation) {
|
||||
throw new FamilyError('InvitationNotFound', 'No pending invitation', {
|
||||
family_id: args.family_id,
|
||||
node_id: args.node_id,
|
||||
});
|
||||
}
|
||||
if (store.nowSecs >= invitation.expires_at) {
|
||||
throw new FamilyError('InvitationExpired', 'Invitation has expired', {
|
||||
family_id: args.family_id,
|
||||
node_id: args.node_id,
|
||||
expires_at: invitation.expires_at,
|
||||
now: store.nowSecs,
|
||||
});
|
||||
}
|
||||
const fam = store.families.get(args.family_id);
|
||||
if (!fam) throw new FamilyError('FamilyNotFound', 'Family no longer exists', { family_id: args.family_id });
|
||||
|
||||
store.pending.delete(key);
|
||||
store.members.set(args.node_id, { family_id: args.family_id, joined_at: store.nowSecs });
|
||||
fam.members += 1;
|
||||
archiveInvitation(store, invitation, 'Accepted');
|
||||
return [event('family_invitation_accepted', { family_id: args.family_id, node_id: args.node_id })];
|
||||
}
|
||||
|
||||
export function mockRejectFamilyInvitation(
|
||||
store: MockStore,
|
||||
sender: string,
|
||||
args: RejectFamilyInvitationArgs,
|
||||
): FamilyEvent[] {
|
||||
if (!controlsNode(store, sender, args.node_id)) {
|
||||
throw new FamilyError('SenderDoesntControlNode', 'You do not control this node', {
|
||||
address: sender,
|
||||
node_id: args.node_id,
|
||||
});
|
||||
}
|
||||
const key = pendingKey(args.family_id, args.node_id);
|
||||
const invitation = store.pending.get(key);
|
||||
if (!invitation) {
|
||||
throw new FamilyError('InvitationNotFound', 'No pending invitation', {
|
||||
family_id: args.family_id,
|
||||
node_id: args.node_id,
|
||||
});
|
||||
}
|
||||
archiveInvitation(store, invitation, 'Rejected');
|
||||
store.pending.delete(key);
|
||||
return [event('family_invitation_rejected', { family_id: args.family_id, node_id: args.node_id })];
|
||||
}
|
||||
|
||||
export function mockLeaveFamily(store: MockStore, sender: string, args: LeaveFamilyArgs): FamilyEvent[] {
|
||||
if (!controlsNode(store, sender, args.node_id)) {
|
||||
throw new FamilyError('SenderDoesntControlNode', 'You do not control this node', {
|
||||
address: sender,
|
||||
node_id: args.node_id,
|
||||
});
|
||||
}
|
||||
const membership = store.members.get(args.node_id);
|
||||
if (!membership) throw new FamilyError('NodeNotInFamily', 'Node is not in any family', { node_id: args.node_id });
|
||||
removeMembership(store, args.node_id, membership.family_id);
|
||||
return [event('family_member_left', { family_id: membership.family_id, node_id: args.node_id })];
|
||||
}
|
||||
|
||||
/** Test helper simulating the mixnet's unbond callback (no auth in the mock). */
|
||||
export function mockOnNymNodeUnbond(store: MockStore, nodeId: NodeId): FamilyEvent[] {
|
||||
const membership = store.members.get(nodeId);
|
||||
if (membership) removeMembership(store, nodeId, membership.family_id);
|
||||
for (const [key, invitation] of [...store.pending]) {
|
||||
if (invitation.node_id === nodeId) {
|
||||
archiveInvitation(store, invitation, 'Rejected');
|
||||
store.pending.delete(key);
|
||||
}
|
||||
}
|
||||
return [event('family_node_unbond_cleanup', { node_id: nodeId })];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function paginate<T>(
|
||||
all: { cursor: number; value: T }[],
|
||||
startAfter: FamilyCursor | undefined,
|
||||
limit?: number,
|
||||
): FamilyPagedResponse<T> {
|
||||
const lim = Math.min(limit ?? FAMILY_PAGE_DEFAULT_LIMIT, FAMILY_PAGE_MAX_LIMIT);
|
||||
const after = typeof startAfter === 'number' ? startAfter : null;
|
||||
const sorted = [...all].sort((a, b) => a.cursor - b.cursor);
|
||||
const filtered = after == null ? sorted : sorted.filter((x) => x.cursor > after);
|
||||
const page = filtered.slice(0, lim);
|
||||
return {
|
||||
items: page.map((p) => p.value),
|
||||
start_next_after: page.length > 0 ? page[page.length - 1].cursor : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockGetFamilyConfig = (store: MockStore): FamilyConfig => store.config;
|
||||
|
||||
export const mockGetFamilyById = (store: MockStore, familyId: NodeFamilyId): NodeFamily | null =>
|
||||
store.families.get(familyId) ?? null;
|
||||
|
||||
export const mockGetFamilyByName = (store: MockStore, name: string): NodeFamily | null =>
|
||||
findFamilyByNormalisedName(store, normaliseFamilyName(name)) ?? null;
|
||||
|
||||
export const mockGetFamilyByOwner = (store: MockStore, owner: string): NodeFamily | null =>
|
||||
findFamilyByOwner(store, owner) ?? null;
|
||||
|
||||
export const mockGetFamilyMembership = (store: MockStore, nodeId: NodeId): NodeFamilyMembershipResponse => ({
|
||||
node_id: nodeId,
|
||||
family_id: store.members.get(nodeId)?.family_id ?? null,
|
||||
});
|
||||
|
||||
export const mockGetFamilyMembersPaged = (
|
||||
store: MockStore,
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
): FamilyPagedResponse<{ node_id: NodeId; joined_at: number }> => {
|
||||
const all = [...store.members.entries()]
|
||||
.filter(([, m]) => m.family_id === familyId)
|
||||
.map(([node_id, m]) => ({ cursor: node_id, value: { node_id, joined_at: m.joined_at } }));
|
||||
return paginate(all, startAfter, limit);
|
||||
};
|
||||
|
||||
const withExpiry = (store: MockStore, invitation: FamilyInvitation): PendingFamilyInvitationDetails => ({
|
||||
invitation,
|
||||
expired: store.nowSecs >= invitation.expires_at,
|
||||
});
|
||||
|
||||
export const mockGetPendingInvitationsForFamilyPaged = (
|
||||
store: MockStore,
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
): FamilyPagedResponse<PendingFamilyInvitationDetails> => {
|
||||
const all = [...store.pending.values()]
|
||||
.filter((inv) => inv.family_id === familyId)
|
||||
.map((inv) => ({ cursor: inv.node_id, value: withExpiry(store, inv) }));
|
||||
return paginate(all, startAfter, limit);
|
||||
};
|
||||
|
||||
export const mockGetPendingInvitationsForNodePaged = (
|
||||
store: MockStore,
|
||||
nodeId: NodeId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
): FamilyPagedResponse<PendingFamilyInvitationDetails> => {
|
||||
const all = [...store.pending.values()]
|
||||
.filter((inv) => inv.node_id === nodeId)
|
||||
.map((inv) => ({ cursor: inv.family_id, value: withExpiry(store, inv) }));
|
||||
return paginate(all, startAfter, limit);
|
||||
};
|
||||
|
||||
export const mockGetPastInvitationsForFamilyPaged = (
|
||||
store: MockStore,
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
): FamilyPagedResponse<PastFamilyInvitation> => {
|
||||
const all = store.pastInvitations
|
||||
.filter((a) => a.invitation.family_id === familyId)
|
||||
.map((a) => ({ cursor: a.seq, value: { invitation: a.invitation, status: a.status } }));
|
||||
return paginate(all, startAfter, limit);
|
||||
};
|
||||
|
||||
export const mockGetPastMembersForFamilyPaged = (
|
||||
store: MockStore,
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
): FamilyPagedResponse<PastFamilyMember> => {
|
||||
const all = store.pastMembers
|
||||
.filter((a) => a.record.family_id === familyId)
|
||||
.map((a) => ({ cursor: a.seq, value: a.record }));
|
||||
return paginate(all, startAfter, limit);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { within, screen, userEvent, waitFor, expect } from 'storybook/test';
|
||||
import { withFamiliesMock } from 'src/components/Families/withFamiliesMock';
|
||||
import {
|
||||
buildOperatorFlowStore,
|
||||
buildOwnerFlowStore,
|
||||
MOCK_OPERATOR_ADDRESS,
|
||||
MOCK_OPERATOR_FLOW_ACCEPT_NODE,
|
||||
MOCK_OPERATOR_FLOW_REJECT_NODE,
|
||||
MOCK_OWNER_ADDRESS,
|
||||
MOCK_OWNER_FLOW_NODE,
|
||||
} from 'src/context/mocks/families.fixtures';
|
||||
import { FamilyPage } from './FamilyPage';
|
||||
|
||||
/**
|
||||
* End-to-end flow stories driven by play functions against the mock contract.
|
||||
* Confirmation dialogs portal to document.body, so confirm buttons are queried via
|
||||
* `screen` while in-canvas elements use `within(canvasElement)`.
|
||||
* (These are exercised by the Storybook interaction + Playwright runs in §8/§9.)
|
||||
*/
|
||||
const meta: Meta<typeof FamilyPage> = {
|
||||
title: 'Families/Flows',
|
||||
component: FamilyPage,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FamilyPage>;
|
||||
|
||||
const NODE = MOCK_OWNER_FLOW_NODE;
|
||||
|
||||
/** Owner lifecycle: create → invite → accept → kick → disband (single self-controlled account). */
|
||||
export const OwnerLifecycle: Story = {
|
||||
decorators: [withFamiliesMock({ sender: MOCK_OWNER_ADDRESS, makeStore: buildOwnerFlowStore, latencyMs: 0 })],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// create
|
||||
await userEvent.type(await canvas.findByTestId('create-family-name'), 'Flow Family');
|
||||
await userEvent.type(await canvas.findByTestId('create-family-description'), 'A family created in a flow test.');
|
||||
await userEvent.click(canvas.getByTestId('create-family-submit'));
|
||||
await canvas.findByTestId('owner-management-page');
|
||||
|
||||
// invite the self-controlled node
|
||||
await userEvent.type(await canvas.findByTestId('invite-node-id'), String(NODE));
|
||||
await userEvent.click(canvas.getByTestId('invite-node-submit'));
|
||||
await userEvent.click(await screen.findByTestId('invite-node-confirm'));
|
||||
await canvas.findByTestId(`pending-invite-${NODE}`);
|
||||
|
||||
// accept it from the operator tab (same account controls the node)
|
||||
await userEvent.click(canvas.getByTestId('family-tab-operator'));
|
||||
const group = await canvas.findByTestId(`node-invite-group-${NODE}`);
|
||||
await userEvent.click(await within(group).findByTestId('invite-card-1-accept'));
|
||||
await userEvent.click(await screen.findByTestId('invite-card-1-accept-confirm'));
|
||||
await canvas.findByTestId(`operator-node-${NODE}-family`);
|
||||
|
||||
// kick it from the owner tab
|
||||
await userEvent.click(canvas.getByTestId('family-tab-owner'));
|
||||
await userEvent.click(await canvas.findByTestId(`member-joined-${NODE}-kick`));
|
||||
await userEvent.click(await screen.findByTestId(`member-joined-${NODE}-kick-confirm`));
|
||||
await waitFor(() => expect(canvas.queryByTestId(`member-joined-${NODE}`)).toBeNull());
|
||||
|
||||
// disband the now-empty family
|
||||
await userEvent.click(await canvas.findByTestId('delete-family-button'));
|
||||
await userEvent.click(await screen.findByTestId('delete-family-button-confirm'));
|
||||
await canvas.findByTestId('create-family-name');
|
||||
},
|
||||
};
|
||||
|
||||
/** Operator lifecycle: receive → accept (then leave) on one node, reject on another. */
|
||||
export const OperatorLifecycle: Story = {
|
||||
decorators: [withFamiliesMock({ sender: MOCK_OPERATOR_ADDRESS, makeStore: buildOperatorFlowStore, latencyMs: 0 })],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByTestId('family-tab-operator'));
|
||||
|
||||
// accept the invite on the accept-node
|
||||
const acceptGroup = await canvas.findByTestId(`node-invite-group-${MOCK_OPERATOR_FLOW_ACCEPT_NODE}`);
|
||||
await userEvent.click(await within(acceptGroup).findByTestId('invite-card-1-accept'));
|
||||
await userEvent.click(await screen.findByTestId('invite-card-1-accept-confirm'));
|
||||
await canvas.findByTestId(`operator-node-${MOCK_OPERATOR_FLOW_ACCEPT_NODE}-family`);
|
||||
|
||||
// leave the family
|
||||
await userEvent.click(await canvas.findByTestId('leave-family-button'));
|
||||
await userEvent.click(await screen.findByTestId('leave-family-button-confirm'));
|
||||
|
||||
// reject the invite on the reject-node
|
||||
const rejectGroup = await canvas.findByTestId(`node-invite-group-${MOCK_OPERATOR_FLOW_REJECT_NODE}`);
|
||||
await userEvent.click(await within(rejectGroup).findByTestId('invite-card-1-reject'));
|
||||
await userEvent.click(await screen.findByTestId('invite-card-1-reject-confirm'));
|
||||
await canvas.findByTestId(`node-invite-group-${MOCK_OPERATOR_FLOW_REJECT_NODE}-empty`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { within, userEvent } from 'storybook/test';
|
||||
import { withFamiliesMock } from 'src/components/Families/withFamiliesMock';
|
||||
import { buildSeededStore, MOCK_OPERATOR_ADDRESS, MOCK_OWNER_ADDRESS } from 'src/context/mocks/families.fixtures';
|
||||
import { FamilyPage } from './FamilyPage';
|
||||
|
||||
const FRESH_ADDRESS = 'n1fresh00000000000000000000000000000fresh';
|
||||
|
||||
const meta: Meta<typeof FamilyPage> = {
|
||||
title: 'Families/Pages/FamilyPage',
|
||||
component: FamilyPage,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FamilyPage>;
|
||||
|
||||
/** Account owns a family → management surface. */
|
||||
export const OwnerWithFamily: Story = {
|
||||
decorators: [withFamiliesMock({ sender: MOCK_OWNER_ADDRESS, makeStore: buildSeededStore })],
|
||||
};
|
||||
|
||||
/** Account owns no family → create entry point. */
|
||||
export const OwnerNoFamily: Story = {
|
||||
decorators: [withFamiliesMock({ sender: FRESH_ADDRESS, makeStore: buildSeededStore })],
|
||||
};
|
||||
|
||||
/** Operator persona → switch to the Node invites tab. */
|
||||
export const Operator: Story = {
|
||||
decorators: [withFamiliesMock({ sender: MOCK_OPERATOR_ADDRESS, makeStore: buildSeededStore })],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByTestId('family-tab-operator'));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React, { useState } from 'react';
|
||||
import { Box, CircularProgress, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { useFamiliesContext, useFamilyByOwner } from 'src/context/families';
|
||||
import { CreateFamilyEntry, OwnerManagementPage } from './OwnerManagementPage';
|
||||
import { OperatorInvitesPage } from './OperatorInvitesPage';
|
||||
|
||||
const OwnerTab = () => {
|
||||
const familyByOwner = useFamilyByOwner();
|
||||
|
||||
if (familyByOwner.isPending) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }} data-testid="family-owner-loading">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return familyByOwner.data ? <OwnerManagementPage family={familyByOwner.data} /> : <CreateFamilyEntry />;
|
||||
};
|
||||
|
||||
/** The Family tab content — always visible; adapts to ownership and exposes operator invites. */
|
||||
export const FamilyPage = () => {
|
||||
const [tab, setTab] = useState(0);
|
||||
// touch the context so the tab is meaningful even before reads resolve
|
||||
useFamiliesContext();
|
||||
|
||||
return (
|
||||
<Stack spacing={3} sx={{ p: 4 }} data-testid="family-page">
|
||||
<Typography variant="h5">Family</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Coordinate your nodes under a family wallet. Operators stay sovereign; owners delegate, never seize.
|
||||
</Typography>
|
||||
|
||||
<Tabs value={tab} onChange={(_e, v) => setTab(v)} aria-label="family tabs">
|
||||
<Tab label="My family" data-testid="family-tab-owner" />
|
||||
<Tab label="Node invites" data-testid="family-tab-operator" />
|
||||
</Tabs>
|
||||
|
||||
<Box hidden={tab !== 0}>{tab === 0 && <OwnerTab />}</Box>
|
||||
<Box hidden={tab !== 1}>{tab === 1 && <OperatorInvitesPage />}</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { FamiliesContextProvider } from 'src/context/FamiliesContextProvider';
|
||||
import { FamilyPage } from './FamilyPage';
|
||||
|
||||
/**
|
||||
* Route-level entry: wraps the page in the real (Tauri-backed) FamiliesContext
|
||||
* provider. Kept separate from `FamilyPage` so Storybook can render the page with
|
||||
* the mock provider without pulling in real Tauri code.
|
||||
*/
|
||||
export const FamilyPageWithProvider = () => (
|
||||
<FamiliesContextProvider>
|
||||
<FamilyPage />
|
||||
</FamiliesContextProvider>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { withFamiliesMock } from 'src/components/Families/withFamiliesMock';
|
||||
import { buildSeededStore, MOCK_OPERATOR_ADDRESS } from 'src/context/mocks/families.fixtures';
|
||||
import { OperatorInvitesPage } from './OperatorInvitesPage';
|
||||
|
||||
const meta: Meta<typeof OperatorInvitesPage> = {
|
||||
title: 'Families/Pages/OperatorInvitesPage',
|
||||
component: OperatorInvitesPage,
|
||||
decorators: [withFamiliesMock({ sender: MOCK_OPERATOR_ADDRESS, makeStore: buildSeededStore })],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof OperatorInvitesPage>;
|
||||
|
||||
/** Multi-node operator: active invite, expired invite, and a node with none. */
|
||||
export const MultiNode: Story = {};
|
||||
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { useFamiliesContext, useFamilyById, useFamilyMembership, useOperatorNodeInvites } from 'src/context/families';
|
||||
import { LeaveFamilyButton, NodeInviteGroup, familyErrorMessage } from 'src/components/Families';
|
||||
import { NymCard } from 'src/components/NymCard';
|
||||
|
||||
const OperatorNodeSection = ({ nodeId }: { nodeId: number }) => {
|
||||
const ctx = useFamiliesContext();
|
||||
const invites = useOperatorNodeInvites(nodeId);
|
||||
const membership = useFamilyMembership(nodeId);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const familyId = membership.data?.family_id ?? undefined;
|
||||
const family = useFamilyById(familyId);
|
||||
|
||||
const handleAccept = async (fid: number) => {
|
||||
try {
|
||||
await ctx.acceptFamilyInvitation({ family_id: fid, node_id: nodeId });
|
||||
enqueueSnackbar('Invite accepted', { variant: 'success' });
|
||||
} catch (e) {
|
||||
enqueueSnackbar(familyErrorMessage(e), { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (fid: number) => {
|
||||
try {
|
||||
await ctx.rejectFamilyInvitation({ family_id: fid, node_id: nodeId });
|
||||
enqueueSnackbar('Invite rejected', { variant: 'success' });
|
||||
} catch (e) {
|
||||
enqueueSnackbar(familyErrorMessage(e), { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
try {
|
||||
await ctx.leaveFamily({ node_id: nodeId });
|
||||
enqueueSnackbar('Left family', { variant: 'success' });
|
||||
} catch (e) {
|
||||
enqueueSnackbar(familyErrorMessage(e), { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2} data-testid={`operator-node-${nodeId}`}>
|
||||
{familyId !== undefined && family.data && (
|
||||
<NymCard title="Current family" data-testid={`operator-node-${nodeId}-family`}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2">
|
||||
Node {nodeId} is a member of <strong>{family.data.name}</strong>.
|
||||
</Typography>
|
||||
<LeaveFamilyButton familyName={family.data.name} isBusy={ctx.isExecuting} onLeave={handleLeave} />
|
||||
</Stack>
|
||||
</NymCard>
|
||||
)}
|
||||
<NodeInviteGroup
|
||||
nodeId={nodeId}
|
||||
invites={invites.data ?? []}
|
||||
nowSecs={ctx.nowSecs}
|
||||
isBusy={ctx.isExecuting}
|
||||
onAccept={handleAccept}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** Operator surface — pending invites per controlled node, plus leave for member nodes. */
|
||||
export const OperatorInvitesPage = () => {
|
||||
const { controlledNodeIds } = useFamiliesContext();
|
||||
|
||||
if (controlledNodeIds.length === 0) {
|
||||
return (
|
||||
<NymCard title="Node invites" data-testid="operator-invites-empty">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
You do not control any bonded nodes, so there are no family invites to show.
|
||||
</Typography>
|
||||
</NymCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={3} data-testid="operator-invites-page">
|
||||
{controlledNodeIds.map((nodeId) => (
|
||||
<OperatorNodeSection key={nodeId} nodeId={nodeId} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Grid, Stack, Typography } from '@mui/material';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { NodeFamily, PendingMemberRow } from 'src/types/families';
|
||||
import {
|
||||
useFamiliesContext,
|
||||
useFamilyConfig,
|
||||
useFamilyMemberList,
|
||||
usePendingInvitationsForFamily,
|
||||
} from 'src/context/families';
|
||||
import {
|
||||
CreateFamilyForm,
|
||||
DeleteFamilyButton,
|
||||
EditFamilyForm,
|
||||
familyErrorMessage,
|
||||
InviteNodeForm,
|
||||
InviteWarning,
|
||||
inviteWarningFromError,
|
||||
MemberList,
|
||||
PendingInvitesList,
|
||||
} from 'src/components/Families';
|
||||
import { NymCard } from 'src/components/NymCard';
|
||||
import { formatCoin } from 'src/components/Families/helpers';
|
||||
|
||||
export interface OwnerManagementPageProps {
|
||||
family: NodeFamily;
|
||||
}
|
||||
|
||||
/** Composed owner management surface (Family Detail) — shown when the account owns a family. */
|
||||
export const OwnerManagementPage = ({ family }: OwnerManagementPageProps) => {
|
||||
const ctx = useFamiliesContext();
|
||||
const config = useFamilyConfig();
|
||||
const memberList = useFamilyMemberList(family.id);
|
||||
const pending = usePendingInvitationsForFamily(family.id);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [editError, setEditError] = useState<string>();
|
||||
const [inviteWarning, setInviteWarning] = useState<InviteWarning>();
|
||||
const [inviteError, setInviteError] = useState<string>();
|
||||
const [deleteError, setDeleteError] = useState<string>();
|
||||
|
||||
const nameLimit = config.data?.family_name_length_limit ?? 30;
|
||||
const descLimit = config.data?.family_description_length_limit ?? 120;
|
||||
|
||||
const pendingRows: PendingMemberRow[] = (pending.data ?? []).map((d) => ({
|
||||
section: 'pending',
|
||||
node_id: d.invitation.node_id,
|
||||
expires_at: d.invitation.expires_at,
|
||||
expired: d.expired,
|
||||
}));
|
||||
|
||||
const handleEdit = async (updatedName: string | null, updatedDescription: string | null) => {
|
||||
setEditError(undefined);
|
||||
try {
|
||||
await ctx.updateFamily({ updated_name: updatedName, updated_description: updatedDescription });
|
||||
enqueueSnackbar('Family updated', { variant: 'success' });
|
||||
} catch (e) {
|
||||
setEditError(familyErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = async (nodeId: number) => {
|
||||
setInviteWarning(undefined);
|
||||
setInviteError(undefined);
|
||||
try {
|
||||
await ctx.inviteToFamily({ node_id: nodeId });
|
||||
enqueueSnackbar(`Invite sent to node ${nodeId}`, { variant: 'success' });
|
||||
} catch (e) {
|
||||
const warning = inviteWarningFromError(e);
|
||||
if (warning) setInviteWarning(warning);
|
||||
else setInviteError(familyErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (nodeId: number) => {
|
||||
try {
|
||||
await ctx.revokeFamilyInvitation({ node_id: nodeId });
|
||||
enqueueSnackbar('Invite withdrawn', { variant: 'success' });
|
||||
} catch (e) {
|
||||
enqueueSnackbar(familyErrorMessage(e), { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleKick = async (nodeId: number) => {
|
||||
try {
|
||||
await ctx.kickFromFamily({ node_id: nodeId });
|
||||
enqueueSnackbar(`Removed node ${nodeId}`, { variant: 'success' });
|
||||
} catch (e) {
|
||||
enqueueSnackbar(familyErrorMessage(e), { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleteError(undefined);
|
||||
try {
|
||||
await ctx.disbandFamily();
|
||||
enqueueSnackbar('Family dissolved', { variant: 'success' });
|
||||
} catch (e) {
|
||||
setDeleteError(familyErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={3} data-testid="owner-management-page">
|
||||
<NymCard title={family.name} subheader={family.description} data-testid="family-summary">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{family.members} member{family.members === 1 ? '' : 's'} · bond {formatCoin(family.paid_fee)}
|
||||
</Typography>
|
||||
</NymCard>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditFamilyForm
|
||||
initialName={family.name}
|
||||
initialDescription={family.description}
|
||||
nameLimit={nameLimit}
|
||||
descriptionLimit={descLimit}
|
||||
isSubmitting={ctx.isExecuting}
|
||||
errorMessage={editError}
|
||||
onSubmit={handleEdit}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<InviteNodeForm
|
||||
isSubmitting={ctx.isExecuting}
|
||||
warning={inviteWarning}
|
||||
errorMessage={inviteError}
|
||||
onSubmit={handleInvite}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<PendingInvitesList
|
||||
invites={pendingRows}
|
||||
nowSecs={ctx.nowSecs}
|
||||
isBusy={ctx.isExecuting}
|
||||
onRevoke={handleRevoke}
|
||||
onClearExpired={handleRevoke}
|
||||
/>
|
||||
|
||||
<MemberList
|
||||
sections={memberList.sections}
|
||||
nowSecs={ctx.nowSecs}
|
||||
isLoading={memberList.isLoading}
|
||||
isError={memberList.isError}
|
||||
isBusy={ctx.isExecuting}
|
||||
onKick={handleKick}
|
||||
onRefresh={memberList.refetch}
|
||||
/>
|
||||
|
||||
<NymCard title="Dissolve family" data-testid="dissolve-family-card">
|
||||
<DeleteFamilyButton
|
||||
memberCount={family.members}
|
||||
isBusy={ctx.isExecuting}
|
||||
errorMessage={deleteError}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</NymCard>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** Create entry point — shown when the account owns no family. */
|
||||
export const CreateFamilyEntry = () => {
|
||||
const ctx = useFamiliesContext();
|
||||
const config = useFamilyConfig();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const handleCreate = async (name: string, description: string) => {
|
||||
setError(undefined);
|
||||
if (!config.data) return;
|
||||
try {
|
||||
await ctx.createFamily({ name, description, fee: config.data.create_family_fee });
|
||||
enqueueSnackbar('Family created', { variant: 'success' });
|
||||
} catch (e) {
|
||||
setError(familyErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (!config.data) {
|
||||
return (
|
||||
<Box data-testid="create-family-loading">
|
||||
<Typography color="text.secondary">Loading…</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateFamilyForm
|
||||
fee={config.data.create_family_fee}
|
||||
nameLimit={config.data.family_name_length_limit}
|
||||
descriptionLimit={config.data.family_description_length_limit}
|
||||
isSubmitting={ctx.isExecuting}
|
||||
errorMessage={error}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './FamilyPage';
|
||||
export * from './FamilyPageRoute';
|
||||
export * from './OwnerManagementPage';
|
||||
export * from './OperatorInvitesPage';
|
||||
@@ -2,6 +2,7 @@ export * from './Admin';
|
||||
export * from './balance';
|
||||
export * from './bonding';
|
||||
export * from './delegation';
|
||||
export * from './families';
|
||||
export * from './internal-docs';
|
||||
export * from './buy';
|
||||
export * from './settings';
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import {
|
||||
AcceptFamilyInvitationArgs,
|
||||
CreateFamilyArgs,
|
||||
FamilyConfig,
|
||||
FamilyCursor,
|
||||
FamilyPagedResponse,
|
||||
FamilyTxResult,
|
||||
InviteToFamilyArgs,
|
||||
KickFromFamilyArgs,
|
||||
LeaveFamilyArgs,
|
||||
NodeFamily,
|
||||
NodeFamilyId,
|
||||
NodeFamilyMembershipResponse,
|
||||
NodeId,
|
||||
PastFamilyInvitation,
|
||||
PastFamilyMember,
|
||||
PendingFamilyInvitationDetails,
|
||||
RejectFamilyInvitationArgs,
|
||||
RevokeFamilyInvitationArgs,
|
||||
UpdateFamilyArgs,
|
||||
} from 'src/types/families';
|
||||
import { invokeWrapper } from './wrapper';
|
||||
|
||||
/**
|
||||
* Tauri IPC bindings for the node-families contract.
|
||||
*
|
||||
* Command names are the assumed Rust handler names; the Rust side lands with the
|
||||
* wiring task (tasks.md §9) and is verified on rebase (§9.5). Until then these
|
||||
* are exercised exclusively through the mock provider.
|
||||
*/
|
||||
|
||||
// --- Execute messages -------------------------------------------------------
|
||||
|
||||
export const createFamily = async (args: CreateFamilyArgs) => invokeWrapper<FamilyTxResult>('create_family', args);
|
||||
|
||||
export const updateFamily = async (args: UpdateFamilyArgs) => invokeWrapper<FamilyTxResult>('update_family', args);
|
||||
|
||||
export const disbandFamily = async () => invokeWrapper<FamilyTxResult>('disband_family');
|
||||
|
||||
export const inviteToFamily = async (args: InviteToFamilyArgs) =>
|
||||
invokeWrapper<FamilyTxResult>('invite_to_family', args);
|
||||
|
||||
export const revokeFamilyInvitation = async (args: RevokeFamilyInvitationArgs) =>
|
||||
invokeWrapper<FamilyTxResult>('revoke_family_invitation', args);
|
||||
|
||||
export const kickFromFamily = async (args: KickFromFamilyArgs) =>
|
||||
invokeWrapper<FamilyTxResult>('kick_from_family', args);
|
||||
|
||||
export const acceptFamilyInvitation = async (args: AcceptFamilyInvitationArgs) =>
|
||||
invokeWrapper<FamilyTxResult>('accept_family_invitation', args);
|
||||
|
||||
export const rejectFamilyInvitation = async (args: RejectFamilyInvitationArgs) =>
|
||||
invokeWrapper<FamilyTxResult>('reject_family_invitation', args);
|
||||
|
||||
export const leaveFamily = async (args: LeaveFamilyArgs) => invokeWrapper<FamilyTxResult>('leave_family', args);
|
||||
|
||||
// --- Single-entity queries --------------------------------------------------
|
||||
|
||||
export const getFamilyById = async (familyId: NodeFamilyId) =>
|
||||
invokeWrapper<NodeFamily | null>('get_family_by_id', { familyId });
|
||||
|
||||
export const getFamilyByOwner = async (owner: string) =>
|
||||
invokeWrapper<NodeFamily | null>('get_family_by_owner', { owner });
|
||||
|
||||
export const getFamilyMembership = async (nodeId: NodeId) =>
|
||||
invokeWrapper<NodeFamilyMembershipResponse>('get_family_membership', { nodeId });
|
||||
|
||||
export const getFamilyConfig = async () => invokeWrapper<FamilyConfig>('get_family_config');
|
||||
|
||||
// --- Paginated queries ------------------------------------------------------
|
||||
|
||||
export const getFamilyMembersPaged = async (familyId: NodeFamilyId, startAfter?: FamilyCursor, limit?: number) =>
|
||||
invokeWrapper<FamilyPagedResponse<{ node_id: NodeId; joined_at: number }>>('get_family_members_paged', {
|
||||
familyId,
|
||||
startAfter,
|
||||
limit,
|
||||
});
|
||||
|
||||
export const getPendingInvitationsForFamilyPaged = async (
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
) =>
|
||||
invokeWrapper<FamilyPagedResponse<PendingFamilyInvitationDetails>>('get_pending_invitations_for_family_paged', {
|
||||
familyId,
|
||||
startAfter,
|
||||
limit,
|
||||
});
|
||||
|
||||
export const getPendingInvitationsForNodePaged = async (nodeId: NodeId, startAfter?: FamilyCursor, limit?: number) =>
|
||||
invokeWrapper<FamilyPagedResponse<PendingFamilyInvitationDetails>>('get_pending_invitations_for_node_paged', {
|
||||
nodeId,
|
||||
startAfter,
|
||||
limit,
|
||||
});
|
||||
|
||||
export const getPastInvitationsForFamilyPaged = async (
|
||||
familyId: NodeFamilyId,
|
||||
startAfter?: FamilyCursor,
|
||||
limit?: number,
|
||||
) =>
|
||||
invokeWrapper<FamilyPagedResponse<PastFamilyInvitation>>('get_past_invitations_for_family_paged', {
|
||||
familyId,
|
||||
startAfter,
|
||||
limit,
|
||||
});
|
||||
|
||||
export const getPastMembersForFamilyPaged = async (familyId: NodeFamilyId, startAfter?: FamilyCursor, limit?: number) =>
|
||||
invokeWrapper<FamilyPagedResponse<PastFamilyMember>>('get_past_members_for_family_paged', {
|
||||
familyId,
|
||||
startAfter,
|
||||
limit,
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export * from './bond';
|
||||
export * from './actions';
|
||||
export * from './contract';
|
||||
export * from './delegation';
|
||||
export * from './families';
|
||||
export * from './logging';
|
||||
export * from './network';
|
||||
export * from './queries';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
NodeSettingsPage,
|
||||
BuyPage,
|
||||
Settings,
|
||||
FamilyPageWithProvider,
|
||||
} from '../pages';
|
||||
|
||||
export const AppRoutes = () => (
|
||||
@@ -28,6 +29,7 @@ export const AppRoutes = () => (
|
||||
<Route path="/bonding" element={<BondingPage />} />
|
||||
<Route path="/bonding/node-settings" element={<NodeSettingsPage />} />
|
||||
<Route path="/delegation" element={<DelegationPage />} />
|
||||
<Route path="/family" element={<FamilyPageWithProvider />} />
|
||||
{config.INTERNAL_DOCS_ENABLED && <Route path="/docs" element={<InternalDocs />} />}
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/buy" element={<BuyPage />} />
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { DecCoin, TransactionExecuteResult } from '@nymproject/types';
|
||||
|
||||
/**
|
||||
* Wallet-facing types for the `node-families-contract` capability.
|
||||
*
|
||||
* These mirror the contract data types defined in
|
||||
* `openspec/specs/node-families-contract/spec.md`. Field names are kept in the
|
||||
* contract's snake_case so request/IPC payloads map 1:1. Status unions and the
|
||||
* error set are flattened into wallet-friendly discriminated unions; the request
|
||||
* layer is responsible for translating the contract's serde envelope into these
|
||||
* shapes (verified on rebase per tasks.md 9.5).
|
||||
*/
|
||||
|
||||
/** u64 on-chain; safe as a JS number for wallet-scale ids. `0` is the "no family" sentinel. */
|
||||
export type NodeFamilyId = number;
|
||||
/** Mixnet node id (u32 on-chain). */
|
||||
export type NodeId = number;
|
||||
/** Unix timestamp in seconds (block time). */
|
||||
export type UnixSeconds = number;
|
||||
|
||||
/** Runtime config, read from chain — never hardcode the fee or limits. */
|
||||
export interface FamilyConfig {
|
||||
create_family_fee: DecCoin;
|
||||
/** Byte-length limit (String::len) on the family name. */
|
||||
family_name_length_limit: number;
|
||||
/** Byte-length limit on the family description. */
|
||||
family_description_length_limit: number;
|
||||
default_invitation_validity_secs: number;
|
||||
}
|
||||
|
||||
export interface NodeFamily {
|
||||
id: NodeFamilyId;
|
||||
name: string;
|
||||
description: string;
|
||||
/** ASCII-normalised canonical name (globally unique among live families). */
|
||||
normalised_name: string;
|
||||
members: number;
|
||||
created_at: UnixSeconds;
|
||||
paid_fee: DecCoin;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export interface FamilyMembership {
|
||||
family_id: NodeFamilyId;
|
||||
joined_at: UnixSeconds;
|
||||
}
|
||||
|
||||
export interface FamilyInvitation {
|
||||
family_id: NodeFamilyId;
|
||||
node_id: NodeId;
|
||||
expires_at: UnixSeconds;
|
||||
}
|
||||
|
||||
/** A pending invitation stamped with the contract's live `expired` flag (`now >= expires_at`). */
|
||||
export interface PendingFamilyInvitationDetails {
|
||||
invitation: FamilyInvitation;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
export type PastInvitationStatusKind = 'Accepted' | 'Rejected' | 'Revoked';
|
||||
|
||||
/** Terminal status of an archived invitation. `Revoked` is owner-side only. */
|
||||
export interface PastInvitationStatus {
|
||||
kind: PastInvitationStatusKind;
|
||||
at: UnixSeconds;
|
||||
}
|
||||
|
||||
export interface PastFamilyInvitation {
|
||||
invitation: FamilyInvitation;
|
||||
status: PastInvitationStatus;
|
||||
}
|
||||
|
||||
export interface PastFamilyMember {
|
||||
family_id: NodeFamilyId;
|
||||
node_id: NodeId;
|
||||
removed_at: UnixSeconds;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute message args
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateFamilyArgs {
|
||||
name: string;
|
||||
description: string;
|
||||
/** The configured `create_family_fee`, attached as funds. */
|
||||
fee: DecCoin;
|
||||
}
|
||||
|
||||
/** `None`/`undefined` means "leave unchanged"; a string sets the field. */
|
||||
export interface UpdateFamilyArgs {
|
||||
updated_name?: string | null;
|
||||
updated_description?: string | null;
|
||||
}
|
||||
|
||||
export interface InviteToFamilyArgs {
|
||||
node_id: NodeId;
|
||||
/** Falls back to `Config::default_invitation_validity_secs` when omitted. */
|
||||
validity_secs?: number | null;
|
||||
}
|
||||
|
||||
export interface RevokeFamilyInvitationArgs {
|
||||
node_id: NodeId;
|
||||
}
|
||||
|
||||
export interface KickFromFamilyArgs {
|
||||
node_id: NodeId;
|
||||
}
|
||||
|
||||
export interface AcceptFamilyInvitationArgs {
|
||||
family_id: NodeFamilyId;
|
||||
node_id: NodeId;
|
||||
}
|
||||
|
||||
export interface RejectFamilyInvitationArgs {
|
||||
family_id: NodeFamilyId;
|
||||
node_id: NodeId;
|
||||
}
|
||||
|
||||
export interface LeaveFamilyArgs {
|
||||
node_id: NodeId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Cursor for the contract's exclusive `start_after` pagination. */
|
||||
export type FamilyCursor = number | [number, number] | null;
|
||||
|
||||
/** Generic shape for a paginated contract query response. */
|
||||
export interface FamilyPagedResponse<T> {
|
||||
items: T[];
|
||||
/** Cursor of the last entry; `null` ends the list. */
|
||||
start_next_after: FamilyCursor;
|
||||
}
|
||||
|
||||
export interface NodeFamilyMembershipResponse {
|
||||
node_id: NodeId;
|
||||
family_id: NodeFamilyId | null;
|
||||
}
|
||||
|
||||
export const FAMILY_PAGE_DEFAULT_LIMIT = 50;
|
||||
export const FAMILY_PAGE_MAX_LIMIT = 100;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Member-list sections (D4: each maps 1:1 to a contract query, one row per record)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MemberListSectionKey = 'pending' | 'joined' | 'rejected' | 'removed';
|
||||
|
||||
export interface PendingMemberRow {
|
||||
section: 'pending';
|
||||
node_id: NodeId;
|
||||
expires_at: UnixSeconds;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
export interface JoinedMemberRow {
|
||||
section: 'joined';
|
||||
node_id: NodeId;
|
||||
joined_at: UnixSeconds;
|
||||
}
|
||||
|
||||
export interface RejectedMemberRow {
|
||||
section: 'rejected';
|
||||
node_id: NodeId;
|
||||
rejected_at: UnixSeconds;
|
||||
}
|
||||
|
||||
export interface RemovedMemberRow {
|
||||
section: 'removed';
|
||||
node_id: NodeId;
|
||||
removed_at: UnixSeconds;
|
||||
}
|
||||
|
||||
export type MemberRow = PendingMemberRow | JoinedMemberRow | RejectedMemberRow | RemovedMemberRow;
|
||||
|
||||
/** A pending invitation addressed to one of the operator's nodes, resolved with its family details. */
|
||||
export interface OperatorInviteView {
|
||||
family_id: NodeFamilyId;
|
||||
family_name: string;
|
||||
owner_address: string;
|
||||
expires_at: UnixSeconds;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
export interface FamilyMemberSections {
|
||||
pending: PendingMemberRow[];
|
||||
joined: JoinedMemberRow[];
|
||||
rejected: RejectedMemberRow[];
|
||||
removed: RemovedMemberRow[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed error set (mirrors NodeFamiliesContractError)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FamilyErrorKind =
|
||||
| 'InvalidFamilyCreationFee'
|
||||
| 'InvalidDeposit'
|
||||
| 'FamilyNameAlreadyTaken'
|
||||
| 'FamilyNameTooLong'
|
||||
| 'EmptyFamilyName'
|
||||
| 'FamilyDescriptionTooLong'
|
||||
| 'SenderAlreadyOwnsAFamily'
|
||||
| 'SenderDoesntOwnAFamily'
|
||||
| 'NodeAlreadyInFamily'
|
||||
| 'AlreadyInFamily'
|
||||
| 'NodeDoesntExist'
|
||||
| 'PendingInvitationAlreadyExists'
|
||||
| 'ZeroInvitationValidity'
|
||||
| 'InvitationExpired'
|
||||
| 'InvitationNotFound'
|
||||
| 'FamilyNotEmpty'
|
||||
| 'FamilyNotFound'
|
||||
| 'SenderDoesntControlNode'
|
||||
| 'NodeNotMemberOfFamily'
|
||||
| 'NodeNotInFamily'
|
||||
| 'UnauthorisedMixnetCallback';
|
||||
|
||||
/** Error thrown by the mock (and surfaced from the real IPC error string) so the UI can branch on `kind`. */
|
||||
export class FamilyError extends Error {
|
||||
constructor(public kind: FamilyErrorKind, message?: string, public context?: Record<string, unknown>) {
|
||||
super(message ?? kind);
|
||||
this.name = 'FamilyError';
|
||||
}
|
||||
}
|
||||
|
||||
export const isFamilyError = (e: unknown): e is FamilyError => e instanceof FamilyError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events (stable public surface; mock execute returns carry these)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FamilyEventName =
|
||||
| 'family_creation'
|
||||
| 'family_update'
|
||||
| 'family_disband'
|
||||
| 'family_invitation'
|
||||
| 'family_invitation_revoked'
|
||||
| 'family_invitation_accepted'
|
||||
| 'family_invitation_rejected'
|
||||
| 'family_member_left'
|
||||
| 'family_member_kicked'
|
||||
| 'family_node_unbond_cleanup';
|
||||
|
||||
export interface FamilyEvent {
|
||||
ty: FamilyEventName;
|
||||
attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
/** TransactionExecuteResult augmented with the family event(s) the call emitted. */
|
||||
export type FamilyTxResult = TransactionExecuteResult & { family_events?: FamilyEvent[] };
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './global';
|
||||
export * from './families';
|
||||
export * from './rust/AppEnv';
|
||||
export * from './rust/Interval';
|
||||
export * from './rust/Network';
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@assets/*": ["../assets/*"],
|
||||
"@tauri-apps/plugin-process": ["../node_modules/@tauri-apps/plugin-process"]
|
||||
"@tauri-apps/plugin-process": ["../node_modules/@tauri-apps/plugin-process"],
|
||||
"@storybook/react-webpack5": ["node_modules/@storybook/react-webpack5/dist/index.d.ts"],
|
||||
"storybook/test": ["node_modules/storybook/dist/test/index.d.ts"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +30,8 @@
|
||||
"webpack.config.js",
|
||||
"webpack.prod.js",
|
||||
"webpack.common.js",
|
||||
"target"
|
||||
"target",
|
||||
"e2e",
|
||||
"playwright.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+1055
-11
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user