Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec51855500 | |||
| fcc4cbceea | |||
| 56cafe2370 | |||
| fcf24f0c33 | |||
| 645b8c7abf | |||
| 3a77eff16b | |||
| b0dbb174cb | |||
| 50382553c2 | |||
| 1bb7b1f68b | |||
| 44a87e79de | |||
| 2baf9aca95 | |||
| c9e2d18ef3 | |||
| 2007894ee6 | |||
| f25bc99e5c | |||
| 0e386b3b92 | |||
| 7b061caf46 | |||
| 919ea1e981 | |||
| cdb88cc08f | |||
| 1d9215a0c0 | |||
| eb774da916 | |||
| 84fd7fe57f | |||
| 5723a828fa | |||
| e63ed3fb99 | |||
| da69934be1 | |||
| ce1739fe5a | |||
| 7d72526405 | |||
| 5650bfbd4e | |||
| 5baccc4a97 | |||
| c0a1c77709 | |||
| 4f51e50fb1 | |||
| 41fadee392 | |||
| 87b5dcfd05 |
@@ -8,7 +8,7 @@ on:
|
||||
- '.github/workflows/ci-nym-wallet-frontend.yml'
|
||||
|
||||
jobs:
|
||||
types-lint:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -44,3 +44,110 @@ jobs:
|
||||
|
||||
- name: Unit tests (nym-wallet)
|
||||
run: pnpm --filter @nymproject/nym-wallet-app test
|
||||
|
||||
- name: Install Playwright browser (chromium)
|
||||
run: pnpm --filter @nymproject/nym-wallet-app exec playwright install --with-deps chromium
|
||||
|
||||
- name: e2e — Playwright vs mock-wired dev server (primary, design D1)
|
||||
# Launches the mock-wired dev server (webpack:dev:mock, WALLET_MOCK_FAMILIES=on)
|
||||
# via Playwright's webServer and replays the owner/operator journeys. Fails the job.
|
||||
run: pnpm --filter @nymproject/nym-wallet-app test:e2e
|
||||
|
||||
- name: Prepare build output directory
|
||||
shell: bash
|
||||
env:
|
||||
OUTPUT_DIR: ci-builds/${{ github.ref_name }}/nym-wallet/storybook/
|
||||
run: |
|
||||
rm -rf ci-builds || true
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
echo "$OUTPUT_DIR"
|
||||
|
||||
- name: Build storybook
|
||||
run: pnpm --filter @nymproject/nym-wallet-app build-storybook
|
||||
|
||||
- name: Copy storybook outputs
|
||||
shell: bash
|
||||
env:
|
||||
OUTPUT_DIR: ci-builds/${{ github.ref_name }}/nym-wallet/storybook/
|
||||
run: |
|
||||
cp -R nym-wallet/storybook-static "$OUTPUT_DIR"
|
||||
|
||||
- name: Stage e2e visual flow report
|
||||
# Generated by the Playwright run (globalTeardown) regardless of pass/fail; `always()`
|
||||
# so a failing run still uploads the filmstrip for inspection. mkdir covers the
|
||||
# e2e-failure path where "Prepare build output directory" was skipped.
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
REPORT_DIR: ci-builds/${{ github.ref_name }}/nym-wallet/e2e-report/
|
||||
run: |
|
||||
if [ -d nym-wallet/e2e-report ]; then
|
||||
mkdir -p "$REPORT_DIR"
|
||||
cp -R nym-wallet/e2e-report/. "$REPORT_DIR"
|
||||
else
|
||||
echo "no e2e-report produced"
|
||||
fi
|
||||
|
||||
- name: Upload CI artifacts (storybook + e2e visual report)
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: easingthemes/ssh-deploy@main
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
|
||||
ARGS: "-avz"
|
||||
SOURCE: "ci-builds/"
|
||||
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
|
||||
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
|
||||
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/builds/
|
||||
EXCLUDE: "/dist/, /node_modules/"
|
||||
|
||||
# Optional native-webview validation (design D4/D8): drives the packaged Tauri binary
|
||||
# through tauri-driver + WebdriverIO under xvfb. Linux-only; non-blocking until stabilised.
|
||||
# NOTE: the final wiring (Tauri window → main.mock.html in the mock build) is a documented
|
||||
# TODO in wdio.conf.ts; this job is the scaffold that runs once that lands.
|
||||
e2e-tauri:
|
||||
runs-on: ubuntu-22.04
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Tauri + WebDriver system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev webkit2gtk-driver xvfb \
|
||||
libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
build-essential curl wget file libssl-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5.0.0
|
||||
with:
|
||||
version: 11.1.2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: nym-wallet/.nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: nym-wallet/src-tauri
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build TypeScript packages
|
||||
run: pnpm build:types && pnpm build:packages
|
||||
|
||||
- name: Install tauri-driver
|
||||
run: cargo install tauri-driver --locked
|
||||
|
||||
- name: Build mock-wired Tauri binary
|
||||
# TODO (native-leg wiring): build the Tauri app from the WALLET_MOCK_FAMILIES=on
|
||||
# frontend with the window pointed at main.mock.html. See wdio.conf.ts.
|
||||
run: pnpm --filter @nymproject/nym-wallet-app tauri:build:mock
|
||||
|
||||
- name: Run WebdriverIO native-webview suite (under xvfb)
|
||||
run: xvfb-run -a pnpm --filter @nymproject/nym-wallet-app test:e2e:tauri
|
||||
|
||||
@@ -83,6 +83,11 @@ test-tutorials/
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
<<<<<<< HEAD
|
||||
tmp/
|
||||
# operator tools
|
||||
scripts/nym-node-setup/auto-bond/nodes.csv
|
||||
=======
|
||||
|
||||
*storybook.log
|
||||
>>>>>>> dea01ef63 (NYM-1199: integrate OpenSpec AI for Node Families UI)
|
||||
|
||||
@@ -7709,6 +7709,7 @@ dependencies = [
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11482,7 +11483,7 @@ dependencies = [
|
||||
"nym-sdk",
|
||||
"reqwest 0.13.4",
|
||||
"rustls 0.23.40",
|
||||
"smoltcp",
|
||||
"smoltcp 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
@@ -13090,6 +13091,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"nym-api-requests",
|
||||
"nym-mixnet-contract-common",
|
||||
"nym-node-families-contract-common",
|
||||
"nym-types",
|
||||
"nym-validator-client",
|
||||
"nym-vesting-contract-common",
|
||||
|
||||
@@ -73,6 +73,8 @@ Nym Node Operators and Validators Terms and Conditions can be found [here](https
|
||||
|
||||
## Getting Started
|
||||
|
||||
Requires [Node.js](https://nodejs.org) version 24.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
@@ -24,9 +24,11 @@ cw-utils = { workspace = true }
|
||||
|
||||
nym-contracts-common = { workspace = true }
|
||||
nym-mixnet-contract-common = { workspace = true }
|
||||
ts-rs = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
schema = []
|
||||
generate-ts = ['ts-rs']
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -13,8 +13,21 @@ pub type NodeFamilyId = u32;
|
||||
|
||||
/// Runtime configuration of the node families contract.
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/FamilyConfig.ts",
|
||||
rename = "FamilyConfig"
|
||||
)
|
||||
)]
|
||||
pub struct Config {
|
||||
/// Fee charged on each successful `create_family` execution.
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(type = "{ denom: string, amount: string }")
|
||||
)]
|
||||
pub create_family_fee: Coin,
|
||||
|
||||
/// Maximum allowed length, in characters, of a family name.
|
||||
@@ -26,11 +39,17 @@ pub struct Config {
|
||||
/// Default lifetime, in seconds, used by `invite_to_family` when the
|
||||
/// sender doesn't supply an explicit value. Senders may override this
|
||||
/// per-invitation via the optional `validity_secs` argument.
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
pub default_invitation_validity_secs: u64,
|
||||
}
|
||||
|
||||
/// On-chain representation of a node family.
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(export, export_to = "ts-packages/types/src/types/rust/NodeFamily.ts")
|
||||
)]
|
||||
pub struct NodeFamily {
|
||||
/// The id of the node family
|
||||
pub id: NodeFamilyId,
|
||||
@@ -45,17 +64,24 @@ pub struct NodeFamily {
|
||||
pub description: String,
|
||||
|
||||
/// The owner of the node family
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "string"))]
|
||||
pub owner: Addr,
|
||||
|
||||
/// Records the fee paid when the family was created,
|
||||
/// so that the appropriate amount could be returned upon it getting disbanded.
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(type = "{ denom: string, amount: string }")
|
||||
)]
|
||||
pub paid_fee: Coin,
|
||||
|
||||
/// Memoized value of the current number of members in the node family
|
||||
/// Used to detect if the family is empty
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
pub members: u64,
|
||||
|
||||
/// Timestamp of the creation of the node family
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
@@ -68,6 +94,14 @@ pub struct NodeFamily {
|
||||
/// issues a fresh invitation for the same node, which archives the stale one as
|
||||
/// `Expired` and supersedes it.
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/FamilyInvitation.ts"
|
||||
)
|
||||
)]
|
||||
pub struct FamilyInvitation {
|
||||
/// The family that issued the invitation.
|
||||
pub family_id: NodeFamilyId,
|
||||
@@ -76,6 +110,7 @@ pub struct FamilyInvitation {
|
||||
pub node_id: NodeId,
|
||||
|
||||
/// Block timestamp (unix seconds) after which the invitation is no longer valid.
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
@@ -85,18 +120,35 @@ pub struct FamilyInvitation {
|
||||
/// `NodeId` alone — `family_id` is carried in the value to support reverse
|
||||
/// lookups (all nodes in a given family) via a secondary index.
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/FamilyMembership.ts"
|
||||
)
|
||||
)]
|
||||
pub struct FamilyMembership {
|
||||
/// The family the node is currently a member of.
|
||||
pub family_id: NodeFamilyId,
|
||||
|
||||
/// Block timestamp (unix seconds) at which the node accepted its
|
||||
/// invitation and joined the family.
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
pub joined_at: u64,
|
||||
}
|
||||
|
||||
/// Historical record of a node that used to be part of a family but has since been
|
||||
/// removed (kicked, left voluntarily, or because the family was disbanded).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PastFamilyMember.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PastFamilyMember {
|
||||
/// The family the node used to belong to.
|
||||
pub family_id: NodeFamilyId,
|
||||
@@ -105,6 +157,7 @@ pub struct PastFamilyMember {
|
||||
pub node_id: NodeId,
|
||||
|
||||
/// Block timestamp (unix seconds) at which the membership was terminated.
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
pub removed_at: u64,
|
||||
}
|
||||
|
||||
@@ -115,21 +168,37 @@ pub struct PastFamilyMember {
|
||||
/// reaches `Expired` if the family issues a fresh invitation for the same node, which
|
||||
/// supersedes and archives the stale one.
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/FamilyInvitationStatus.ts"
|
||||
)
|
||||
)]
|
||||
pub enum FamilyInvitationStatus {
|
||||
/// Still awaiting a response. Recorded with a timestamp for completeness even
|
||||
/// though pending invitations live in a separate map.
|
||||
Pending { at: u64 },
|
||||
Pending {
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
at: u64,
|
||||
},
|
||||
/// The invitee accepted and joined the family at the given timestamp.
|
||||
Accepted { at: u64 },
|
||||
Accepted {
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
at: u64,
|
||||
},
|
||||
/// The invitee explicitly rejected the invitation at the given timestamp.
|
||||
Rejected { at: u64 },
|
||||
Rejected {
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
at: u64,
|
||||
},
|
||||
/// The family revoked the invitation at the given timestamp before it could
|
||||
/// be accepted or rejected.
|
||||
Revoked { at: u64 },
|
||||
/// The invitation had already expired and was superseded by a fresh invitation
|
||||
/// for the same node from the same family, issued at the given timestamp. This is
|
||||
/// the only path that archives a timed-out invitation.
|
||||
Expired { at: u64 },
|
||||
Revoked {
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "number"))]
|
||||
at: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Historical record of an invitation that has reached a terminal state
|
||||
@@ -137,6 +206,14 @@ pub enum FamilyInvitationStatus {
|
||||
/// archived here only when a fresh invitation for the same node supersedes it
|
||||
/// (status `Expired`); otherwise it stays in the pending map until explicitly cleared.
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PastFamilyInvitation.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PastFamilyInvitation {
|
||||
/// The original invitation as it was issued.
|
||||
pub invitation: FamilyInvitation,
|
||||
@@ -147,6 +224,14 @@ pub struct PastFamilyInvitation {
|
||||
|
||||
/// Response to [`QueryMsg::GetFamilyById`](crate::QueryMsg::GetFamilyById).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/NodeFamilyResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct NodeFamilyResponse {
|
||||
/// The id that was queried, echoed back so paginated callers can correlate.
|
||||
pub family_id: NodeFamilyId,
|
||||
@@ -157,9 +242,18 @@ pub struct NodeFamilyResponse {
|
||||
|
||||
/// Response to [`QueryMsg::GetFamilyByOwner`](crate::QueryMsg::GetFamilyByOwner).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/NodeFamilyByOwnerResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct NodeFamilyByOwnerResponse {
|
||||
/// The (validated) owner address that was queried, echoed back so callers
|
||||
/// can correlate.
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "string"))]
|
||||
pub owner: Addr,
|
||||
|
||||
/// The matching family, or `None` if `owner` does not currently own one.
|
||||
@@ -178,6 +272,14 @@ pub struct NodeFamilyByNameResponse {
|
||||
|
||||
/// Response to [`QueryMsg::GetFamilyMembership`](crate::QueryMsg::GetFamilyMembership).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/NodeFamilyMembershipResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct NodeFamilyMembershipResponse {
|
||||
/// The node that was queried.
|
||||
pub node_id: NodeId,
|
||||
@@ -190,6 +292,14 @@ pub struct NodeFamilyMembershipResponse {
|
||||
/// A pending [`FamilyInvitation`] paired with whether it has already timed
|
||||
/// out at the time the query was served.
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PendingFamilyInvitationDetails.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PendingFamilyInvitationDetails {
|
||||
/// The stored invitation as it was issued.
|
||||
pub invitation: FamilyInvitation,
|
||||
@@ -201,6 +311,14 @@ pub struct PendingFamilyInvitationDetails {
|
||||
|
||||
/// Response to [`QueryMsg::GetPendingInvitation`](crate::QueryMsg::GetPendingInvitation).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PendingFamilyInvitationResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PendingFamilyInvitationResponse {
|
||||
/// The family component of the queried `(family_id, node_id)` key.
|
||||
pub family_id: NodeFamilyId,
|
||||
@@ -216,6 +334,14 @@ pub struct PendingFamilyInvitationResponse {
|
||||
/// One entry in a [`FamilyMembersPagedResponse`] page — pairs a node id with
|
||||
/// its [`FamilyMembership`] record (notably its `joined_at` timestamp).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/FamilyMemberRecord.ts"
|
||||
)
|
||||
)]
|
||||
pub struct FamilyMemberRecord {
|
||||
/// The node currently in the family.
|
||||
pub node_id: NodeId,
|
||||
@@ -226,6 +352,14 @@ pub struct FamilyMemberRecord {
|
||||
|
||||
/// Response to [`QueryMsg::GetFamilyMembersPaged`](crate::QueryMsg::GetFamilyMembersPaged).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/FamilyMembersPagedResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct FamilyMembersPagedResponse {
|
||||
/// The family whose members were queried, echoed back so paginated
|
||||
/// callers can correlate.
|
||||
@@ -253,6 +387,14 @@ pub struct AllFamilyMembersPagedResponse {
|
||||
|
||||
/// Response to [`QueryMsg::GetPendingInvitationsForFamilyPaged`](crate::QueryMsg::GetPendingInvitationsForFamilyPaged).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PendingFamilyInvitationsPagedResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PendingFamilyInvitationsPagedResponse {
|
||||
/// The family whose pending invitations were queried, echoed back so
|
||||
/// paginated callers can correlate.
|
||||
@@ -270,6 +412,14 @@ pub struct PendingFamilyInvitationsPagedResponse {
|
||||
|
||||
/// Response to [`QueryMsg::GetPendingInvitationsForNodePaged`](crate::QueryMsg::GetPendingInvitationsForNodePaged).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PendingInvitationsForNodePagedResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PendingInvitationsForNodePagedResponse {
|
||||
/// The node whose pending invitations were queried, echoed back so
|
||||
/// paginated callers can correlate.
|
||||
@@ -316,6 +466,14 @@ pub type GlobalPastFamilyInvitationCursor = ((NodeFamilyId, NodeId), u64);
|
||||
|
||||
/// Response to [`QueryMsg::GetPastInvitationsForFamilyPaged`](crate::QueryMsg::GetPastInvitationsForFamilyPaged).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PastFamilyInvitationsPagedResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PastFamilyInvitationsPagedResponse {
|
||||
/// The family whose archived invitations were queried, echoed back so
|
||||
/// paginated callers can correlate.
|
||||
@@ -327,6 +485,7 @@ pub struct PastFamilyInvitationsPagedResponse {
|
||||
|
||||
/// Cursor to pass as `start_after` on the next call, or `None` if this
|
||||
/// page is empty (treat as end-of-list).
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "[number, number] | null"))]
|
||||
pub start_next_after: Option<PastFamilyInvitationCursor>,
|
||||
}
|
||||
|
||||
@@ -371,6 +530,14 @@ pub type PastFamilyMemberForNodeCursor = (NodeFamilyId, u64);
|
||||
|
||||
/// Response to [`QueryMsg::GetPastMembersForFamilyPaged`](crate::QueryMsg::GetPastMembersForFamilyPaged).
|
||||
#[cw_serde]
|
||||
#[cfg_attr(feature = "generate-ts", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "generate-ts",
|
||||
ts(
|
||||
export,
|
||||
export_to = "ts-packages/types/src/types/rust/PastFamilyMembersPagedResponse.ts"
|
||||
)
|
||||
)]
|
||||
pub struct PastFamilyMembersPagedResponse {
|
||||
/// The family whose archived memberships were queried, echoed back so
|
||||
/// paginated callers can correlate.
|
||||
@@ -382,6 +549,7 @@ pub struct PastFamilyMembersPagedResponse {
|
||||
|
||||
/// Cursor to pass as `start_after` on the next call, or `None` if this
|
||||
/// page is empty (treat as end-of-list).
|
||||
#[cfg_attr(feature = "generate-ts", ts(type = "[number, number] | null"))]
|
||||
pub start_next_after: Option<PastFamilyMemberCursor>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/opsx:archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
description: Archive a completed change in the experimental workflow
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -1,14 +1,20 @@
|
||||
{
|
||||
"extends": [
|
||||
"@nymproject/eslint-config-react-typescript"
|
||||
"@nymproject/eslint-config-react-typescript",
|
||||
"plugin:storybook/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.eslint.json"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.ts", "**/*.test.tsx"],
|
||||
"env": { "jest": true }
|
||||
"files": [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx"
|
||||
],
|
||||
"env": {
|
||||
"jest": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
@@ -19,4 +25,4 @@
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/quotes": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
test-results/
|
||||
.tsbuild/
|
||||
e2e-report/
|
||||
playwright-report/
|
||||
@@ -0,0 +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)'],
|
||||
addons: [
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-mcp',
|
||||
],
|
||||
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;
|
||||
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<CacheProvider value={muiEmotionCache}>
|
||||
<ThemeProvider theme={storybookTheme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
|
||||
<Story />
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
"nym-contracts-common",
|
||||
"nym-crypto",
|
||||
"nym-mixnet-contract-common",
|
||||
"nym-node-families-contract-common",
|
||||
"nym-node-requests",
|
||||
"nym-store-cipher",
|
||||
"nym-types",
|
||||
@@ -4927,7 +4928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-api-requests"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"celes",
|
||||
@@ -4967,7 +4968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-bin-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"log",
|
||||
@@ -4998,7 +4999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-coconut-dkg-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cosmwasm-schema",
|
||||
"cosmwasm-std",
|
||||
@@ -5011,7 +5012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-compact-ecash"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"bs58",
|
||||
@@ -5033,7 +5034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-config"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"handlebars",
|
||||
@@ -5047,7 +5048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-contracts-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"cosmwasm-schema",
|
||||
@@ -5061,7 +5062,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-credentials-interface"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"nym-bls12_381-fork",
|
||||
"nym-compact-ecash",
|
||||
@@ -5079,7 +5080,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-crypto"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
@@ -5101,7 +5102,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-ecash-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"cosmwasm-schema",
|
||||
@@ -5114,7 +5115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-ecash-signer-check-types"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"nym-coconut-dkg-common",
|
||||
"nym-crypto",
|
||||
@@ -5129,14 +5130,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-ecash-time"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nym-exit-policy"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5147,7 +5148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-group-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cosmwasm-schema",
|
||||
"cw-controllers",
|
||||
@@ -5158,7 +5159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-http-api-client"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bincode",
|
||||
@@ -5190,7 +5191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-http-api-client-macro"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.3.0",
|
||||
"proc-macro2",
|
||||
@@ -5201,7 +5202,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-http-api-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"serde",
|
||||
@@ -5211,7 +5212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-kkt-ciphersuite"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"num_enum",
|
||||
"semver",
|
||||
@@ -5222,7 +5223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-mixnet-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"bs58",
|
||||
"cosmwasm-schema",
|
||||
@@ -5243,7 +5244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-multisig-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cosmwasm-schema",
|
||||
"cosmwasm-std",
|
||||
@@ -5258,7 +5259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-network-defaults"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cargo_metadata 0.19.2",
|
||||
"dotenvy",
|
||||
@@ -5273,7 +5274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-network-monitors-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cosmwasm-schema",
|
||||
"cosmwasm-std",
|
||||
@@ -5285,7 +5286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-node-families-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cosmwasm-schema",
|
||||
"cosmwasm-std",
|
||||
@@ -5300,7 +5301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-node-requests"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"celes",
|
||||
@@ -5327,7 +5328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-noise-keys"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"nym-crypto",
|
||||
"schemars",
|
||||
@@ -5337,7 +5338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-pemstore"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"tracing",
|
||||
@@ -5346,7 +5347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-performance-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cosmwasm-schema",
|
||||
"cosmwasm-std",
|
||||
@@ -5359,7 +5360,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-serde-helpers"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
@@ -5370,7 +5371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-store-cipher"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -5385,7 +5386,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-ticketbooks-merkle"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"nym-credentials-interface",
|
||||
"nym-serde-helpers",
|
||||
@@ -5399,7 +5400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-types"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cosmrs",
|
||||
@@ -5429,7 +5430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-upgrade-mode-check"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"jwt-simple",
|
||||
"nym-crypto",
|
||||
@@ -5445,7 +5446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-validator-client"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -5496,7 +5497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-vesting-contract-common"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"cosmwasm-schema",
|
||||
"cosmwasm-std",
|
||||
@@ -5542,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-wireguard-types"
|
||||
version = "1.21.0"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"nym-crypto",
|
||||
|
||||
@@ -68,11 +68,11 @@ It is intended to be used during development and for troubleshooting.
|
||||
You can compile the wallet in development mode by running the following command inside the `nym-wallet` directory:
|
||||
|
||||
```
|
||||
yarn dev
|
||||
pnpm run dev
|
||||
```
|
||||
This will produce a binary in - `nym-wallet/target/debug/` named `nym-wallet`
|
||||
This will produce a binary in - `nym-wallet/target/debug/` named `NymWallet`
|
||||
|
||||
To launch the wallet, navigate to the directory and run the following command: `./nym-wallet`
|
||||
To launch the wallet, navigate to the directory and run the following command: `./NymWallet`
|
||||
|
||||
## Production mode
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Node Families e2e — visual flow report</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; background: #0a0a0a; color: #eee; }
|
||||
h1 { font-size: 20px; }
|
||||
section { margin: 32px 0; }
|
||||
h2 { font-size: 16px; color: #5bf0a0; text-transform: capitalize; border-bottom: 1px solid #333; padding-bottom: 6px; }
|
||||
.strip { display: flex; gap: 16px; overflow-x: auto; padding: 12px 0; }
|
||||
figure { margin: 0; flex: 0 0 auto; width: 320px; }
|
||||
img { width: 320px; border: 1px solid #333; border-radius: 6px; background: #1a1a1c; cursor: zoom-in; }
|
||||
img:target, img:active { transform: scale(2.4); transform-origin: top left; position: relative; z-index: 10; }
|
||||
figcaption { font-size: 12px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Node Families e2e — visual flow report</h1>
|
||||
<p style="color:#888">Per-step screenshots from the mock-wired Playwright journeys (design D1/D2). Hover/scroll each filmstrip; click an image to zoom.</p>
|
||||
<section><h2>operator lifecycle accept leave then reject</h2><div class="strip"><figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/01-pending-node-invites.png" alt="pending node invites"><figcaption>pending node invites</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/02-invite-accepted.png" alt="invite accepted"><figcaption>invite accepted</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/03-family-left.png" alt="family left"><figcaption>family left</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/operator-lifecycle-accept-leave-then-reject/04-invite-rejected.png" alt="invite rejected"><figcaption>invite rejected</figcaption></figure></div></section>
|
||||
<section><h2>operator page shows multi node invite states</h2><div class="strip"><figure><img loading="lazy" src="screenshots/operator-page-shows-multi-node-invite-states/01-multi-node-invite-states.png" alt="multi node invite states"><figcaption>multi node invite states</figcaption></figure></div></section>
|
||||
<section><h2>owner lifecycle create invite accept kick disband</h2><div class="strip"><figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/01-create-family-entry.png" alt="create family entry"><figcaption>create family entry</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/02-family-created.png" alt="family created"><figcaption>family created</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/03-node-invited-pending.png" alt="node invited pending"><figcaption>node invited pending</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/04-member-joined.png" alt="member joined"><figcaption>member joined</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/05-member-kicked.png" alt="member kicked"><figcaption>member kicked</figcaption></figure>
|
||||
<figure><img loading="lazy" src="screenshots/owner-lifecycle-create-invite-accept-kick-disband/06-family-disbanded.png" alt="family disbanded"><figcaption>family disbanded</figcaption></figure></div></section>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,67 @@
|
||||
import { FAMILY_IDS, FAMILY_NODES, TID } from '../e2e/shared/families';
|
||||
|
||||
/**
|
||||
* Native-webview replay of the owner + operator journeys (design D4), sharing selectors +
|
||||
* fixtures with the primary Playwright suite via `e2e/shared/families` (parity requirement).
|
||||
*
|
||||
* The mock binary boots into `main.mock.html` (owner persona) — so the owner journey runs on
|
||||
* launch with no navigation. The operator journey navigates the webview to the operator persona
|
||||
* first. On Linux/WebKitGTK the Tauri asset scheme is `tauri://localhost/` (the mock config sets
|
||||
* `useHttpsScheme: false`); adjust `appUrl` if a platform serves a different scheme.
|
||||
*/
|
||||
|
||||
const appUrl = (persona: 'owner' | 'operator' | 'operator-seeded') =>
|
||||
`tauri://localhost/main.mock.html?persona=${persona}`;
|
||||
|
||||
const byId = (id: string) => $(`[data-testid="${id}"]`);
|
||||
const inSection = (node: number, id: string) => byId(TID.operatorNodeSection(node)).$(`[data-testid="${id}"]`);
|
||||
|
||||
const { ownerFlow, operatorAccept, operatorReject } = FAMILY_NODES;
|
||||
|
||||
describe('Families flows — native webview', () => {
|
||||
it('owner lifecycle: create → invite → accept → kick → disband', async () => {
|
||||
const fid = FAMILY_IDS.ownerFlow;
|
||||
|
||||
await byId(TID.createFamilyName).waitForDisplayed({ timeout: 30_000 });
|
||||
await byId(TID.createFamilyName).setValue('Flow Family');
|
||||
await byId(TID.createFamilyDescription).setValue('A family created in a flow test.');
|
||||
await byId(TID.createFamilySubmit).click();
|
||||
await byId(TID.ownerManagementPage).waitForDisplayed();
|
||||
|
||||
await byId(TID.inviteNodeId).setValue(String(ownerFlow));
|
||||
await byId(TID.inviteNodeSubmit).click();
|
||||
await byId(TID.inviteNodeConfirm).click();
|
||||
await byId(TID.pendingInvite(ownerFlow)).waitForDisplayed();
|
||||
|
||||
await byId(TID.tabOperator).click();
|
||||
await inSection(ownerFlow, TID.acceptCard(fid)).click();
|
||||
await byId(TID.acceptConfirm(fid)).click();
|
||||
|
||||
await byId(TID.tabOwner).click();
|
||||
await byId(TID.memberJoined(ownerFlow)).waitForDisplayed();
|
||||
await byId(TID.memberJoinedKick(ownerFlow)).click();
|
||||
await byId(TID.memberJoinedKickConfirm(ownerFlow)).click();
|
||||
await byId(TID.memberJoined(ownerFlow)).waitForExist({ reverse: true });
|
||||
|
||||
await byId(TID.deleteButton).click();
|
||||
await byId(TID.deleteConfirm).click();
|
||||
await byId(TID.createFamilyName).waitForDisplayed();
|
||||
});
|
||||
|
||||
it('operator lifecycle: accept → leave, then reject', async () => {
|
||||
const fid = FAMILY_IDS.operatorFlow;
|
||||
await browser.url(appUrl('operator'));
|
||||
|
||||
await byId(TID.tabOperator).click();
|
||||
await inSection(operatorAccept, TID.acceptCard(fid)).click();
|
||||
await byId(TID.acceptConfirm(fid)).click();
|
||||
await inSection(operatorAccept, TID.leaveButton).waitForDisplayed();
|
||||
|
||||
await inSection(operatorAccept, TID.leaveButton).click();
|
||||
await byId(TID.leaveConfirm).click();
|
||||
|
||||
await inSection(operatorReject, TID.rejectCard(fid)).click();
|
||||
await byId(TID.rejectConfirm(fid)).click();
|
||||
await byId(TID.inviteGroupEmpty(operatorReject)).waitForDisplayed();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Skip-not-fail launcher for the optional native-webview leg (design D5).
|
||||
// tauri-driver only works on Linux/Windows; on macOS (no WKWebView driver) or when the
|
||||
// required drivers are missing, this exits 0 with a clear message instead of failing.
|
||||
import { execSync } from 'node:child_process';
|
||||
import { platform } from 'node:os';
|
||||
|
||||
const skip = (why) => {
|
||||
console.log(`[e2e:tauri] skipped — ${why}`);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const has = (bin) => {
|
||||
try {
|
||||
execSync(`command -v ${bin}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (platform() === 'darwin') skip('macOS has no WKWebView driver (tauri-driver unsupported) — use the Playwright suite locally.');
|
||||
if (platform() === 'win32' ? !has('msedgedriver') : !(has('WebKitWebDriver') || has('webkit2gtk-driver')))
|
||||
skip('platform webdriver not found (install webkit2gtk-driver on Linux / msedgedriver on Windows).');
|
||||
if (!has('tauri-driver')) skip('tauri-driver not found — run `cargo install tauri-driver --locked`.');
|
||||
|
||||
execSync('wdio run ./wdio.conf.ts', { stdio: 'inherit' });
|
||||
@@ -0,0 +1,150 @@
|
||||
# Node Families e2e
|
||||
|
||||
Three tiers validate the Family page journeys. They share selectors + fixtures via
|
||||
[`e2e/shared/families.ts`](./shared/families.ts) so they cannot drift apart.
|
||||
|
||||
## Mock-wired build (`WALLET_MOCK_FAMILIES`)
|
||||
|
||||
The real wallet bootstrap (`AppProvider`) is Tauri-coupled and login-gated, so it can't run
|
||||
in a plain browser. A dedicated, flag-gated mock entry solves this (design D2):
|
||||
|
||||
- `WALLET_MOCK_FAMILIES=on` adds a `mainMock` webpack entry + `main.mock.html` that mounts
|
||||
the real router + `ApplicationLayout` + Family page, but with the Storybook mocks
|
||||
(`MockMainContextProvider` + `MockFamiliesContextProvider`) — no Tauri, no login.
|
||||
- Off (default, and always in production) the mock entry/HTML are never built.
|
||||
- Persona is chosen at runtime: `main.mock.html?persona=owner|operator|operator-seeded`.
|
||||
|
||||
Run it: `pnpm webpack:dev:mock` → http://localhost:9000/main.mock.html?persona=owner#/family
|
||||
|
||||
## Tier 1 — Playwright vs dev server (primary, cross-platform)
|
||||
|
||||
The main suite. Runs everywhere incl. macOS; this is what gates CI.
|
||||
|
||||
```
|
||||
pnpm --dir .. run build # once: build workspace packages (@nymproject/types, etc.)
|
||||
npx playwright install chromium # once
|
||||
pnpm test:e2e # launches webpack:dev:mock + replays the journeys
|
||||
```
|
||||
|
||||
Config: [`playwright.config.ts`](../playwright.config.ts). Specs: [`families.spec.ts`](./families.spec.ts).
|
||||
|
||||
**Visual flow report:** each journey step captures a captioned screenshot (`shot()` in
|
||||
[`shared/report.ts`](./shared/report.ts)), stitched into a static `e2e-report/index.html`
|
||||
filmstrip (via global setup/teardown) for smoke inspection. The `build` CI job stages + uploads
|
||||
`e2e-report/` alongside Storybook (`if: always()`, so a failing run still publishes it).
|
||||
|
||||
## Tier 2 — WebdriverIO + tauri-driver (optional, Linux CI only)
|
||||
|
||||
Drives the **packaged Tauri binary** in the native WebKitGTK webview. macOS is unsupported
|
||||
(no WKWebView driver), so `pnpm test:e2e:tauri` **skips** there (design D5). Runs in the
|
||||
non-blocking `e2e-tauri` CI job under `xvfb`.
|
||||
|
||||
- Config: [`../wdio.conf.ts`](../wdio.conf.ts) · Spec: [`../e2e-tauri/families.tauri.ts`](../e2e-tauri/families.tauri.ts)
|
||||
- Prereqs (Linux): `webkit2gtk-driver`, `cargo install tauri-driver --locked`.
|
||||
- Binary wiring: `pnpm tauri:build:mock` builds the frontend with `WALLET_MOCK_FAMILIES=on`
|
||||
and a Tauri binary whose window boots `main.mock.html` (owner persona) via
|
||||
[`../src-tauri/tauri.mock.conf.json`](../src-tauri/tauri.mock.conf.json); the spec navigates to
|
||||
other personas in-webview. `wdio.conf.ts` points at `src-tauri/target/release/NymWallet`.
|
||||
- The run itself is Linux-CI-only (`tauri-driver`; macOS skips via `e2e-tauri/run.mjs`).
|
||||
|
||||
Note: `tauri:build:mock` runs `webpack:prod`, which was previously broken repo-wide — the
|
||||
shared `ForkTsCheckerWebpackPlugin` (`write-references` emit mode) + `allowJs` emitted `.js`
|
||||
into `src` (polluting it / breaking Jest) and type-checked test files. Fixed in the wallet via
|
||||
`outDir: ./.tsbuild` (redirects the emit), `declare module '*.css'` (`src/typings/css.d.ts`),
|
||||
and excluding `**/*.test.*` from the type-check program.
|
||||
|
||||
## Tier 3 — Sandbox real-IPC read smoke (optional, manual)
|
||||
|
||||
The node-families contract is deployed to **sandbox** (currently one family, one member).
|
||||
This smoke validates the *real* `FamiliesContextProvider` + `requests/families.ts` wiring
|
||||
that the mock stands in for — separate from, and non-blocking relative to, the mock tiers.
|
||||
|
||||
Manual procedure (design D9):
|
||||
|
||||
1. Launch the real wallet (`pnpm dev`) and sign into a **sandbox** account (network `SANDBOX`).
|
||||
2. Open the Family page and confirm it renders the known sandbox family/member via real IPC.
|
||||
3. **Read-only** — do not create/invite/kick/disband against the shared sandbox.
|
||||
4. Assert render/shape (or pin the known family id), not exact contents, so a contract
|
||||
redeploy doesn't hard-fail.
|
||||
|
||||
Promote to a non-blocking CI job only once a sandbox test account can be provisioned
|
||||
headlessly (mnemonic in CI secrets) — a follow-up, not a blocker.
|
||||
|
||||
### Headless equivalent (`sandbox_families_smoke` example)
|
||||
|
||||
GUI automation of the real wallet can't run on macOS (tauri-driver is Linux/Windows-only;
|
||||
Playwright can't drive the native webview), so the read smoke above also exists as a
|
||||
headless Rust harness that exercises the exact `validator-client` calls the Tauri commands
|
||||
in `src-tauri/src/operations/families/` wrap. It reads the funded sandbox account mnemonic
|
||||
from `.env` (`TAURI-WALLET-MNEMONIC`, gitignored — never printed or committed). Run from
|
||||
the `nym-wallet/` directory:
|
||||
|
||||
```sh
|
||||
# read-only smoke (no state change) — lists the live families/members/config via real IPC
|
||||
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke
|
||||
|
||||
# owner subset (create → rename → disband, with cleanup) on the funded account
|
||||
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --write
|
||||
|
||||
# full member/operator journey against FAMILY_OWNER's existing family, using
|
||||
# ACCOUNT_WITH_BONDED_NODE as the node operator (kick/invite/reject/revoke/accept/leave),
|
||||
# restoring the node's membership at the end
|
||||
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --member
|
||||
|
||||
# read-only diagnostics
|
||||
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --accounts
|
||||
cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --bond-check <n1-address>
|
||||
```
|
||||
|
||||
`--write` uses `TAURI-WALLET-MNEMONIC` (refuses to start if that account already owns a
|
||||
family; disbands its throwaway family at the end). `--member` uses `FAMILY_OWNER_MNEMONIC`
|
||||
(owner) + `ACCOUNT_WITH_BONDED_NODE_MNEMONIC` (operator) to drive all 6 member commands
|
||||
against the owner's existing family and restore the node's original membership, so a real
|
||||
family is left unchanged. All mnemonics are read from `.env` (gitignored) and never printed.
|
||||
Together the two runs cover all 9 execute commands end-to-end on sandbox.
|
||||
|
||||
## Real IPC layer (what the mock stands in for)
|
||||
|
||||
The 18 commands `requests/families.ts` invokes are implemented in
|
||||
[`../src-tauri/src/operations/families/`](../src-tauri/src/operations/families/) and registered
|
||||
in [`../src-tauri/src/main.rs`](../src-tauri/src/main.rs):
|
||||
|
||||
- `execute.rs` — 9 state-changing txs over `NodeFamiliesSigningClient` (auto/simulated gas;
|
||||
`create_family` attaches the `create_family_fee` as base-denom funds). Returns
|
||||
`TransactionExecuteResult` (the optional `family_events` is omitted — the provider
|
||||
`refreshAll()`s queries after every execute, so the UI re-derives state from reads).
|
||||
- `query.rs` — 9 reads over `NodeFamiliesQueryClient`, **normalised at the IPC boundary** to the
|
||||
wallet shapes in `src/types/families.ts`: base-denom `Coin` → display `DecCoin`
|
||||
(`paid_fee`, `create_family_fee`), per-page contract envelopes → `{ items, start_next_after }`,
|
||||
the cw_serde tagged `FamilyInvitationStatus` → `{ kind, at }`. `get_family_config` has no smart
|
||||
query, so it reads raw contract state at the `"config"` key.
|
||||
|
||||
**Tauri arg casing:** JS passes camelCase keys, Tauri maps them to the commands' snake_case
|
||||
params — so the `requests/families.ts` execute bindings send `nodeId`/`familyId`/`validitySecs`/
|
||||
`updatedName`/`updatedDescription`.
|
||||
|
||||
**Mock → real switch.** `FamiliesContext.defaultQueries` points at the real `requests/families.ts`;
|
||||
`MockFamiliesContextProvider` swaps in an in-memory engine. Production `/family`
|
||||
([`../src/pages/families/FamilyPageRoute.tsx`](../src/pages/families/FamilyPageRoute.tsx)) mounts
|
||||
`BondingContextProvider` → real `FamiliesContextProvider` → `FamilyPage`; the mock entry
|
||||
(`main.mock.html`) is offline/e2e-only. `controlledNodeIds` derives from the account's bonded node
|
||||
(≤1 on chain).
|
||||
|
||||
**Contract drift guard.** [`../src/types/families.contract-guard.ts`](../src/types/families.contract-guard.ts)
|
||||
holds `tsc`-checked assertions between `src/types/families.ts` and the ts-rs-generated contract
|
||||
types; regenerate (`tools/ts-rs-cli`) on a contract change and the guard flags any divergence.
|
||||
|
||||
## Sandbox write flows (guarded, manual)
|
||||
|
||||
Mutating journeys (create → invite → accept → kick → disband; accept/leave/reject) need a
|
||||
**dedicated funded sandbox account** — never a shared/real one:
|
||||
|
||||
- Account `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv` (sandbox, ~101k NYM). Mnemonic lives **only**
|
||||
in vault secret `TAURI-WALLET-MNEMONIC` — inject via CI secret, never commit it.
|
||||
- Check state with `nym-cli -c sandbox.env account balance n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv`.
|
||||
- Tolerate on-chain latency (poll/refresh after each execute). Target **only** this account's
|
||||
family/nodes and **clean up at the end** (disband / leave) so it stays reusable.
|
||||
- An account bonds ≤1 node, so the multi-node operator persona can't be reproduced here — that
|
||||
journey stays mock/Storybook-only.
|
||||
|
||||
Keep this tier manual/non-blocking until headless account provisioning exists.
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { FAMILY_IDS, FAMILY_NODES, familyMockUrl, TID } from './shared/families';
|
||||
import { shot } from './shared/report';
|
||||
|
||||
/**
|
||||
* Primary e2e of the owner + operator Node Families journeys (design D1), driven against
|
||||
* the mock-wired app shell (`main.mock.html`, design D2) on the dev server. Selectors are
|
||||
* shared with the optional WebdriverIO leg via `./shared/families` (parity requirement)
|
||||
* and target the ids that actually render (see the note in that file).
|
||||
*
|
||||
* Each step also captures a screenshot via `shot()` → assembled into a static visual flow
|
||||
* report (`e2e-report/index.html`) for smoke inspection, uploaded by the CI build job.
|
||||
*
|
||||
* Confirmation dialogs portal to the document body; in a single browser DOM they're reached
|
||||
* at page scope. Per-node invite content is scoped via the `operator-node-<n>` wrapper, and
|
||||
* invite buttons are keyed by family id.
|
||||
*/
|
||||
|
||||
const { ownerFlow, operatorAccept, operatorReject, operatorNone } = FAMILY_NODES;
|
||||
|
||||
const openOperatorTab = (page: Page) => page.getByTestId(TID.tabOperator).click();
|
||||
|
||||
test.describe('Families flows (mock-wired app shell)', () => {
|
||||
test('owner lifecycle: create → invite → accept → kick → disband', async ({ page }, testInfo) => {
|
||||
const fid = FAMILY_IDS.ownerFlow;
|
||||
await page.goto(familyMockUrl('owner'));
|
||||
|
||||
// create
|
||||
await expect(page.getByTestId(TID.createFamilyName)).toBeVisible({ timeout: 30_000 });
|
||||
await shot(page, testInfo, 'create family entry');
|
||||
await page.getByTestId(TID.createFamilyName).fill('Flow Family');
|
||||
await page.getByTestId(TID.createFamilyDescription).fill('A family created in a flow test.');
|
||||
await page.getByTestId(TID.createFamilySubmit).click();
|
||||
await expect(page.getByTestId(TID.ownerManagementPage)).toBeVisible();
|
||||
await shot(page, testInfo, 'family created');
|
||||
|
||||
// invite the self-controlled node
|
||||
await page.getByTestId(TID.inviteNodeId).fill(String(ownerFlow));
|
||||
await page.getByTestId(TID.inviteNodeSubmit).click();
|
||||
await page.getByTestId(TID.inviteNodeConfirm).click();
|
||||
await expect(page.getByTestId(TID.pendingInvite(ownerFlow))).toBeVisible();
|
||||
await shot(page, testInfo, 'node invited (pending)');
|
||||
|
||||
// accept it from the operator tab (same account controls the node)
|
||||
await openOperatorTab(page);
|
||||
await page.getByTestId(TID.operatorNodeSection(ownerFlow)).getByTestId(TID.acceptCard(fid)).click();
|
||||
await page.getByTestId(TID.acceptConfirm(fid)).click();
|
||||
|
||||
// kick it from the owner tab — the joined member appears, then is removed
|
||||
await page.getByTestId(TID.tabOwner).click();
|
||||
await expect(page.getByTestId(TID.memberJoined(ownerFlow))).toBeVisible();
|
||||
await shot(page, testInfo, 'member joined');
|
||||
await page.getByTestId(TID.memberJoinedKick(ownerFlow)).click();
|
||||
await page.getByTestId(TID.memberJoinedKickConfirm(ownerFlow)).click();
|
||||
await expect(page.getByTestId(TID.memberJoined(ownerFlow))).toHaveCount(0);
|
||||
await shot(page, testInfo, 'member kicked');
|
||||
|
||||
// disband the now-empty family
|
||||
await page.getByTestId(TID.deleteButton).click();
|
||||
await page.getByTestId(TID.deleteConfirm).click();
|
||||
await expect(page.getByTestId(TID.createFamilyName)).toBeVisible();
|
||||
await shot(page, testInfo, 'family disbanded');
|
||||
});
|
||||
|
||||
test('operator lifecycle: accept → leave, then reject', async ({ page }, testInfo) => {
|
||||
const fid = FAMILY_IDS.operatorFlow;
|
||||
await page.goto(familyMockUrl('operator'));
|
||||
await openOperatorTab(page);
|
||||
await shot(page, testInfo, 'pending node invites');
|
||||
|
||||
// accept the invite on the accept-node (scope by node; invite buttons are keyed by family id)
|
||||
const acceptSection = page.getByTestId(TID.operatorNodeSection(operatorAccept));
|
||||
await acceptSection.getByTestId(TID.acceptCard(fid)).click();
|
||||
await page.getByTestId(TID.acceptConfirm(fid)).click();
|
||||
// joined → a Leave action appears for that node
|
||||
await expect(acceptSection.getByTestId(TID.leaveButton)).toBeVisible();
|
||||
await shot(page, testInfo, 'invite accepted');
|
||||
|
||||
// leave the family
|
||||
await acceptSection.getByTestId(TID.leaveButton).click();
|
||||
await page.getByTestId(TID.leaveConfirm).click();
|
||||
await shot(page, testInfo, 'family left');
|
||||
|
||||
// reject the invite on the reject-node → its group ends empty
|
||||
await page.getByTestId(TID.operatorNodeSection(operatorReject)).getByTestId(TID.rejectCard(fid)).click();
|
||||
await page.getByTestId(TID.rejectConfirm(fid)).click();
|
||||
await expect(page.getByTestId(TID.inviteGroupEmpty(operatorReject))).toBeVisible();
|
||||
await shot(page, testInfo, 'invite rejected');
|
||||
});
|
||||
|
||||
test('operator page shows multi-node invite states', async ({ page }, testInfo) => {
|
||||
await page.goto(familyMockUrl('operator-seeded'));
|
||||
await openOperatorTab(page);
|
||||
// node with an active invite renders its section; node with none shows the empty state
|
||||
await expect(page.getByTestId(TID.operatorNodeSection(operatorAccept))).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(TID.inviteGroupEmpty(operatorNone))).toBeVisible();
|
||||
await shot(page, testInfo, 'multi-node invite states');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { resetReport } from './shared/report';
|
||||
|
||||
// Clears the previous visual report before a run so the uploaded artifact is current.
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
resetReport();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buildGallery } from './shared/report';
|
||||
|
||||
// Stitches the per-step screenshots into e2e-report/index.html after the run.
|
||||
export default async function globalTeardown(): Promise<void> {
|
||||
buildGallery();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Shared journey constants for the Node Families e2e suites (parity requirement,
|
||||
* design D3). Both the primary Playwright suite (`e2e/families.spec.ts`) and the
|
||||
* optional WebdriverIO native leg (`e2e-tauri/families.tauri.ts`) import these.
|
||||
*
|
||||
* These are the test ids that render reliably in the DOM. We scope by the `operator-node-<n>`
|
||||
* Stack wrapper / table rows and key invite buttons by family id (the ids emitted by
|
||||
* `ConfirmActionButton`, e.g. `invite-card-<familyId>-accept`). Historically `NymCard` dropped
|
||||
* `data-testid` (its prop was `dataTestid`), so `node-invite-group-<n>` / `invite-card-<fid>`
|
||||
* didn't render and the original Storybook play-function selectors were invalid; that was fixed
|
||||
* in the `fix-nymcard-data-testid` change. These selectors remain valid and are kept as-is.
|
||||
*/
|
||||
|
||||
export type FamilyPersona = 'owner' | 'operator' | 'operator-seeded';
|
||||
|
||||
/** Fixture node ids (mirror src/context/mocks/families.fixtures.ts). */
|
||||
export const FAMILY_NODES = {
|
||||
ownerFlow: 301, // MOCK_OWNER_FLOW_NODE
|
||||
operatorAccept: 201, // MOCK_OPERATOR_FLOW_ACCEPT_NODE / MOCK_OPERATOR_NODE_ACTIVE
|
||||
operatorReject: 204, // MOCK_OPERATOR_FLOW_REJECT_NODE
|
||||
operatorNone: 203, // MOCK_OPERATOR_NODE_NONE
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The family_id that issued each persona's invites — invite-card test ids are keyed by
|
||||
* family_id, not node id. Owner-flow + operator-flow each have a single family (id 1);
|
||||
* the seeded store's operator invites come from the second family (id 2).
|
||||
*/
|
||||
export const FAMILY_IDS = {
|
||||
ownerFlow: 1,
|
||||
operatorFlow: 1,
|
||||
seeded: 2,
|
||||
} as const;
|
||||
|
||||
/** Route into the mock-wired app shell for a given persona (see PERSONAS in src/main.mock.tsx). */
|
||||
export const familyMockUrl = (persona: FamilyPersona) => `/main.mock.html?persona=${persona}#/family`;
|
||||
|
||||
/** Test ids that actually render (parameterised by node id or family id where noted). */
|
||||
export const TID = {
|
||||
// owner: create + manage
|
||||
createFamilyName: 'create-family-name',
|
||||
createFamilyDescription: 'create-family-description',
|
||||
createFamilySubmit: 'create-family-submit',
|
||||
ownerManagementPage: 'owner-management-page',
|
||||
inviteNodeId: 'invite-node-id',
|
||||
inviteNodeSubmit: 'invite-node-submit',
|
||||
inviteNodeConfirm: 'invite-node-confirm',
|
||||
deleteButton: 'delete-family-button',
|
||||
deleteConfirm: 'delete-family-button-confirm',
|
||||
pendingInvite: (node: number) => `pending-invite-${node}`,
|
||||
memberJoined: (node: number) => `member-joined-${node}`,
|
||||
memberJoinedKick: (node: number) => `member-joined-${node}-kick`,
|
||||
memberJoinedKickConfirm: (node: number) => `member-joined-${node}-kick-confirm`,
|
||||
// tabs
|
||||
tabOwner: 'family-tab-owner',
|
||||
tabOperator: 'family-tab-operator',
|
||||
// operator: per-node section wrapper (Stack — renders) used for scoping
|
||||
operatorNodeSection: (node: number) => `operator-node-${node}`,
|
||||
inviteGroupEmpty: (node: number) => `node-invite-group-${node}-empty`,
|
||||
leaveButton: 'leave-family-button',
|
||||
leaveConfirm: 'leave-family-button-confirm',
|
||||
// invite cards: keyed by FAMILY id (ConfirmActionButton dataTestid — renders)
|
||||
acceptCard: (familyId: number) => `invite-card-${familyId}-accept`,
|
||||
acceptConfirm: (familyId: number) => `invite-card-${familyId}-accept-confirm`,
|
||||
rejectCard: (familyId: number) => `invite-card-${familyId}-reject`,
|
||||
rejectConfirm: (familyId: number) => `invite-card-${familyId}-reject-confirm`,
|
||||
} as const;
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Visual flow report for the Node Families e2e journeys. Each `shot()` writes a numbered,
|
||||
* captioned PNG into `e2e-report/screenshots/<test>/` (and attaches it to the Playwright HTML
|
||||
* report). `buildGallery()` (run from globalTeardown) stitches them into a static
|
||||
* `e2e-report/index.html` filmstrip for visual inspection / smoke checks — uploaded by CI.
|
||||
*/
|
||||
|
||||
export const REPORT_DIR = path.resolve(__dirname, '..', '..', 'e2e-report');
|
||||
const SCREENSHOTS_DIR = path.join(REPORT_DIR, 'screenshots');
|
||||
|
||||
const slug = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[^\w]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
// Per-test step counters (workers run one test each, so this stays consistent per test).
|
||||
const counters = new Map<string, number>();
|
||||
|
||||
/** Clear any previous report so the upload reflects only the latest run. */
|
||||
export function resetReport(): void {
|
||||
fs.rmSync(REPORT_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||||
counters.clear();
|
||||
}
|
||||
|
||||
/** Capture a full-page, captioned step screenshot into the report (and the Playwright report). */
|
||||
export async function shot(page: Page, testInfo: TestInfo, label: string): Promise<void> {
|
||||
const testSlug = slug(testInfo.title);
|
||||
const dir = path.join(SCREENSHOTS_DIR, testSlug);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const n = (counters.get(testSlug) ?? 0) + 1;
|
||||
counters.set(testSlug, n);
|
||||
const file = `${String(n).padStart(2, '0')}-${slug(label)}.png`;
|
||||
const body = await page.screenshot({ fullPage: true });
|
||||
fs.writeFileSync(path.join(dir, file), body);
|
||||
await testInfo.attach(label, { body, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
/** Stitch the captured screenshots into a static index.html filmstrip. */
|
||||
export function buildGallery(): void {
|
||||
if (!fs.existsSync(SCREENSHOTS_DIR)) return;
|
||||
const tests = fs
|
||||
.readdirSync(SCREENSHOTS_DIR)
|
||||
.filter((d) => fs.statSync(path.join(SCREENSHOTS_DIR, d)).isDirectory())
|
||||
.sort();
|
||||
|
||||
const sections = tests
|
||||
.map((t) => {
|
||||
const imgs = fs
|
||||
.readdirSync(path.join(SCREENSHOTS_DIR, t))
|
||||
.filter((f) => f.endsWith('.png'))
|
||||
.sort();
|
||||
const frames = imgs
|
||||
.map((f) => {
|
||||
const caption = f.replace(/^\d+-/, '').replace(/\.png$/, '').replace(/-/g, ' ');
|
||||
return `<figure><img loading="lazy" src="screenshots/${t}/${f}" alt="${caption}"><figcaption>${caption}</figcaption></figure>`;
|
||||
})
|
||||
.join('\n');
|
||||
return `<section><h2>${t.replace(/-/g, ' ')}</h2><div class="strip">${frames}</div></section>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Node Families e2e — visual flow report</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 24px; font: 14px/1.5 system-ui, sans-serif; background: #0a0a0a; color: #eee; }
|
||||
h1 { font-size: 20px; }
|
||||
section { margin: 32px 0; }
|
||||
h2 { font-size: 16px; color: #5bf0a0; text-transform: capitalize; border-bottom: 1px solid #333; padding-bottom: 6px; }
|
||||
.strip { display: flex; gap: 16px; overflow-x: auto; padding: 12px 0; }
|
||||
figure { margin: 0; flex: 0 0 auto; width: 320px; }
|
||||
img { width: 320px; border: 1px solid #333; border-radius: 6px; background: #1a1a1c; cursor: zoom-in; }
|
||||
img:target, img:active { transform: scale(2.4); transform-origin: top left; position: relative; z-index: 10; }
|
||||
figcaption { font-size: 12px; color: #aaa; margin-top: 6px; text-transform: capitalize; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Node Families e2e — visual flow report</h1>
|
||||
<p style="color:#888">Per-step screenshots from the mock-wired Playwright journeys (design D1/D2). Hover/scroll each filmstrip; click an image to zoom.</p>
|
||||
${sections}
|
||||
</body>
|
||||
</html>`;
|
||||
fs.writeFileSync(path.join(REPORT_DIR, 'index.html'), html);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"codeConnect": {
|
||||
"include": ["src/**/*.figma.tsx"],
|
||||
"exclude": ["**/*.test.*", "**/*.stories.*"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-08
|
||||
@@ -0,0 +1,43 @@
|
||||
## Context
|
||||
|
||||
`src/components/NymCard.tsx` currently:
|
||||
|
||||
```tsx
|
||||
<Card data-testid={hideHeader ? dataTestid : undefined} ...>
|
||||
{!hideHeader && title !== undefined && (
|
||||
<CardHeader data-testid={dataTestid || (typeof title === 'string' ? title : 'nym-card')} ... />
|
||||
)}
|
||||
...children...
|
||||
</Card>
|
||||
```
|
||||
|
||||
Problems: (1) only reads `dataTestid` (camelCase) — the 17 call sites passing `data-testid` (kebab) are ignored; (2) when a header is shown the id lands on the `CardHeader`, not the content-wrapping root, so it can't scope children; (3) the `|| title` fallback emits misleading ids like `Node 201` / `Current family`.
|
||||
|
||||
Discovered while building `node-families-tauri-webdriver-e2e`: the e2e had to scope around this (using `operator-node-<n>` wrappers and family-id-keyed button ids) because `node-invite-group-<n>` / `invite-card-<n>` never rendered.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** accept `data-testid`; put the resolved id on the card root (content-wrapping, scope-able); single element only; drop title-derived ids; keep `dataTestid` working.
|
||||
|
||||
**Non-Goals:** changing call sites from `dataTestid` to `data-testid` en masse; any visual/behavioural change; touching `ConfirmActionButton` (its `dataTestid` already renders correctly).
|
||||
|
||||
## Decisions
|
||||
|
||||
**D1 — Resolve `data-testid ?? dataTestid` and place it on the `<Card>` root, always.**
|
||||
Destructure both props (`'data-testid': dataTestidAttr`, `dataTestid`) and compute `const testId = dataTestidAttr ?? dataTestid`. Apply `data-testid={testId}` to the outer `<Card>` unconditionally (header or not), so it wraps content and scoping works. *Alternative considered:* keep it on the header when a header exists — rejected (can't scope children, and is the current broken behaviour).
|
||||
|
||||
**D2 — Remove the `|| title` / `'nym-card'` fallback; render no attribute when unset.**
|
||||
Emit `data-testid` only when `testId` is defined. Verified no test queries by a title-derived id, so this only removes noise. Avoids duplicate ids (root + header) that would break Playwright strict locators.
|
||||
|
||||
**D3 — Revert the e2e scope-arounds where the intended ids now render.**
|
||||
With the fix, `node-invite-group-<n>` and `invite-card-<familyId>` render on real elements. Optionally simplify `e2e/shared/families.ts` back toward the intended ids. Keep the suite green either way — this is cleanup, not required for correctness.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **17 previously-absent `data-testid`s start appearing** → could affect DOM snapshot tests. There are none today (Jest is node-env); mitigation: run the wallet build + the Node Families Playwright suite + `tsc`/eslint after the change.
|
||||
- **A consumer relied on the title-derived id** → verified none do; if one surfaces, it can pass an explicit `data-testid`.
|
||||
- **Type prop name** → React/TS allows `'data-testid'` as a prop key; type it explicitly in `NymCard`'s props so call sites keep type-checking.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single-component change; additive (new ids appear, none removed except the unused title fallback). No rollback concerns. Verify by: build wallet, run `pnpm test`, `pnpm tsc`, and the Node Families Playwright suite (`pnpm test:e2e`).
|
||||
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
`NymCard`'s test-id prop is `dataTestid` (camelCase), but **17** call sites across the wallet pass `data-testid` (kebab-case), which `NymCard` silently ignores. Those intended test ids (`member-list`, `create-family-form`, `invite-card-<id>`, `operator-node-<id>-family`, `dissolve-family-card`, `family-summary`, `balance-usd-approx`, error/gateway cards, …) never reach the DOM. Worse, when a `data-testid` is dropped `NymCard` stamps the header with the **title text** as a test id (e.g. `Node 201`, `Current family`), producing misleading ids.
|
||||
|
||||
This silently breaks UI test selectors: the Node Families Storybook `play` functions and the first Playwright pass both targeted ids that don't render (discovered during the `node-families-tauri-webdriver-e2e` work, which had to scope around it). Fixing the component restores the intended, consistent test-id contract for the whole app.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `NymCard` SHALL accept a standard `data-testid` prop (in addition to the existing `dataTestid`) and apply the resolved id to the card **root** element, so it wraps the card's content and is usable as a scope container.
|
||||
- Remove the title-as-test-id fallback (`data-testid={dataTestid || title}`) so cards no longer emit misleading ids derived from their title; emit a test id only when one is explicitly provided.
|
||||
- The resolved id MUST appear on exactly one element (no duplicate root/header ids that would break strict locators).
|
||||
- Follow-up cleanup: revert the scope-around workarounds in the Node Families e2e selectors (`e2e/shared/families.ts`) back to the now-rendering intended ids where it simplifies them.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `nymcard-testid-contract`: The `NymCard` component's contract for exposing a DOM test id (accept `data-testid`, single deterministic element, no title-derived ids).
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None — no existing capability spec covers NymCard. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Component**: `src/components/NymCard.tsx` (the fix). ~27 `NymCard` call sites; **17** currently-dropped `data-testid`s will start rendering (families, balance, gateway, error cards). No call-site edits required.
|
||||
- **Tests**: `nym-wallet` Jest is node-env (no DOM render), so unit tests are unaffected; verify the wallet builds, `tsc`/eslint stay clean, the Node Families Playwright suite stays green, and (bonus) the Storybook `play` functions now resolve their original ids.
|
||||
- **Risk**: low — no test currently queries by the title-fallback id (verified); the change only adds/relocates ids. Snapshot-style DOM tests (none today) would see new `data-testid` attributes appear.
|
||||
- **Out of scope**: broad re-write of call sites to switch `dataTestid` → `data-testid` (both remain supported); behavioural/visual changes to `NymCard`.
|
||||
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: NymCard exposes a DOM test id from a standard prop
|
||||
|
||||
`NymCard` SHALL apply a caller-provided test id to its root DOM element. It MUST accept the standard `data-testid` prop, and SHALL continue to accept the legacy `dataTestid` prop; when both are provided, one resolved value is used. The resolved test id MUST appear on exactly one element (the card root), never duplicated across the root and the header.
|
||||
|
||||
#### Scenario: data-testid reaches the DOM
|
||||
|
||||
- **WHEN** a `NymCard` is rendered with `data-testid="member-list"`
|
||||
- **THEN** the rendered card root element has `data-testid="member-list"`
|
||||
- **AND** a scoped query within that element can find the card's content
|
||||
|
||||
#### Scenario: legacy dataTestid still works
|
||||
|
||||
- **WHEN** a `NymCard` is rendered with `dataTestid="foo"` and no `data-testid`
|
||||
- **THEN** the rendered card root element has `data-testid="foo"`
|
||||
|
||||
#### Scenario: no duplicate ids
|
||||
|
||||
- **WHEN** a `NymCard` with a header and a provided test id is rendered
|
||||
- **THEN** exactly one element carries that test id (strict locators match a single node)
|
||||
|
||||
### Requirement: No title-derived test ids
|
||||
|
||||
`NymCard` SHALL NOT derive a test id from its `title` (or emit a `nym-card` fallback). When no test id is provided, the card SHALL render without a `data-testid` attribute.
|
||||
|
||||
#### Scenario: untagged card emits no test id
|
||||
|
||||
- **WHEN** a `NymCard` is rendered with a `title` but no `data-testid`/`dataTestid`
|
||||
- **THEN** neither the card root nor its header carries a `data-testid` derived from the title
|
||||
@@ -0,0 +1,17 @@
|
||||
## 1. Fix NymCard
|
||||
|
||||
- [x] 1.1 In `src/components/NymCard.tsx`, accept a `'data-testid'?: string` prop alongside `dataTestid`; resolve `dataTestidAttr ?? dataTestid`.
|
||||
- [x] 1.2 Apply `data-testid` to the root `<Card>` element unconditionally (header or not); rendered only when defined.
|
||||
- [x] 1.3 Remove the `dataTestid || title`/`'nym-card'` fallback on `CardHeader` so no title-derived id is emitted and the id is not duplicated.
|
||||
|
||||
## 2. Verify
|
||||
|
||||
- [x] 2.1 `pnpm tsc` clean ("No errors found"); eslint clean on `NymCard.tsx`; mock dev build compiles.
|
||||
- [x] 2.2 `pnpm test` (Jest) green — **85/85** (after removing 328 stray compiled `.js` artifacts that were shadowing the `.ts` sources and breaking Jest; unrelated to this fix).
|
||||
- [x] 2.3 Node Families Playwright suite green (3/3). Spot-checked in the mock app: `node-invite-group-201` and `invite-card-2` now render (were dropped before), and the misleading title ids (`Node 201`, `Current family`) are gone.
|
||||
- [ ] 2.4 (Bonus) Confirm the Storybook Node Families `play` functions resolve their original ids — not run (would need a Storybook run); the ids they target now render in the DOM.
|
||||
|
||||
## 3. Cleanup (optional)
|
||||
|
||||
- [ ] 3.1 Simplify `e2e/shared/families.ts` — **skipped intentionally**: the current selectors work and remain correct (invite buttons are family-id keyed via `ConfirmActionButton` regardless of this fix; `operator-node-<n>` is a valid scope). No change needed.
|
||||
- [x] 3.2 Updated the `NymCard` caveat note in `e2e/README.md` to record that the prop is fixed.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-09
|
||||
@@ -0,0 +1,57 @@
|
||||
## Context
|
||||
|
||||
Three layers already exist; only the middle one is missing:
|
||||
|
||||
- **Frontend** — `src/requests/families.ts` defines all 18 IPC bindings (9 execute + 9 query) with command names + arg shapes; the real `FamiliesContextProvider` already calls them (and `refreshAll()`s queries after each execute). The only stub is `controlledNodeIds = []`.
|
||||
- **On-chain client** — `validator-client` exposes `NodeFamiliesSigningClient` (create/update/disband/invite/revoke/accept/reject/leave/kick) and `NodeFamiliesQueryClient` (by-id/by-owner/membership/members-paged/pending/past/…). The `node-families-contract` is **deployed to sandbox** (one family, one member).
|
||||
- **Wallet Tauri layer (MISSING)** — `src-tauri/src/operations/` has `mixnet/`, `vesting/`, etc. but **no `families/`**, so the 18 invoked commands have no handler. This is the entire gap.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** implement the 18 Tauri commands over the existing validator-client traits; return frontend-typed results; switch the running app to real data (drop the `controlledNodeIds` stub); verify journeys against sandbox (read smoke + guarded writes).
|
||||
|
||||
**Non-Goals:** contract changes; mainnet; UI/`data-testid` changes; replacing the mock entry (it stays for offline e2e).
|
||||
|
||||
## Decisions
|
||||
|
||||
**D1 — Mirror `operations/mixnet/` for the families command module.**
|
||||
Add `src-tauri/src/operations/families/{mod,query,execute}.rs`. Each `#[tauri::command]` acquires the account's client from the wallet `State` (as mixnet ops do), calls the corresponding `NodeFamilies{Signing,Query}Client` method, and returns the mapped type. Register all 18 in `main.rs` `invoke_handler`. *Alternative:* one mega-file — rejected for clarity; split execute vs query.
|
||||
|
||||
**D2 — `family_events`: rely on post-execute query refresh, parse events best-effort.**
|
||||
The mock fabricates `FamilyTxResult.family_events`; on chain they'd come from parsing the tx's wasm events. The provider already `refreshAll()`s all family queries after every execute, so the UI re-derives state from queries, not from `family_events`. Decision: return the real `TransactionExecuteResult` fields and populate `family_events` best-effort (parse wasm events if cheap; otherwise empty) — the UI does not depend on it for correctness. Revisit if a view reads `family_events` directly. *Alternative:* full event parsing up front — deferred as unnecessary for the journeys.
|
||||
|
||||
**D3 — Derive `controlledNodeIds` from the account's bonded nodes.**
|
||||
Reuse the existing bonding/account node info the wallet already fetches (the operator persona needs "nodes I control"). Replace the `useMemo(() => [], [])` stub in `FamiliesContextProvider` with that derivation. Keep it resilient when the account controls no nodes.
|
||||
*Implemented:* consume `useBondingContext().bondedNode` (`[nodeId]` nym-node / `[mixId]` legacy mixnode / `[]` gateway-or-none); the `/family` route is now wrapped in `BondingContextProvider` (it is mounted per-route, not globally). **Reality check:** an account bonds **at most one** node on chain, so `controlledNodeIds` is 0–1 long. The mock's 3-node operator persona is therefore **not reproducible** from a single sandbox account — the multi-node operator journey (§5.3) remains a mock/Storybook-only scenario, and the sandbox operator check is limited to that one node's invites.
|
||||
|
||||
**D4 — Generate contract types via ts-rs (`nym-wallet-types`) and reconcile with `src/types/families.ts`.**
|
||||
`src/types/families.ts` was hand-written for the mock. Prefer generating the canonical shapes from the contract Rust types (ts-rs, as other wallet types are) and reconciling field-by-field (cursors, paged envelope, membership, `FamilyTxResult`). Where the hand-written type and generated type diverge, the generated (contract-truth) shape wins; update the mock/types accordingly so mock and real stay parity.
|
||||
|
||||
**D5 — `get_family_config`: no smart query exists → read raw contract state.**
|
||||
*Confirmed from the contract:* `QueryMsg` has **no** `GetConfig` variant (only `UpdateConfig { config }` execute + `config` in instantiate), and the validator-client query trait has no config getter. So `get_family_config` can't be a normal smart query. Options: (a) **raw contract-state read** of the `Config` `Item` via `query_contract_raw` at its storage key (no contract change; preferred); (b) hardcode/derive client-side (brittle); (c) add a `GetConfig` query to the contract (out of scope here). Plan: (a). The UI only uses `FamilyConfig` for cosmetic fee display, so if (a) proves awkward, `get_family_config` can degrade gracefully without blocking the journeys.
|
||||
|
||||
**D6 — Sandbox e2e in two stages; writes are guarded and may stay partly manual.**
|
||||
(1) **Read smoke** (non-mutating): point a build at the real provider + sandbox, assert the Family page renders the known family/member — automatable and safe. (2) **Write flows**: require a *dedicated funded sandbox test account* (mnemonic via CI secret) and tolerate on-chain latency/fees; they mutate real state, so they must target only that account's family/nodes and clean up (disband/leave) at the end. Iterate until the owner/operator journeys pass. If headless account provisioning isn't available, the write tier stays a documented manual run while the read smoke gates CI. *This is the riskiest part and the one most likely to need iteration.*
|
||||
|
||||
**D7 — Fees: follow the wallet's existing execute convention.**
|
||||
Reuse however `operations/mixnet/` supplies `Option<Fee>` (auto/simulated default) so the families execute commands behave consistently with the rest of the wallet.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Type drift mock↔chain** → D4 reconciliation against ts-rs-generated types; a contract-shape test guards it.
|
||||
- **`family_events` shape mismatch** → D2 leans on query refresh; UI doesn't depend on the fabricated events.
|
||||
- **Shared sandbox mutation / flakiness / fees** → D6 dedicated funded test account, target-only-self, cleanup; read smoke gates CI, writes non-blocking/manual until provisioning exists.
|
||||
- **`UpdateFamily` contract shape** (parent §9.5) → verify `update_family` args (`updated_name`/`updated_description: Option`) against the deployed contract on rebase.
|
||||
- **Sandbox availability/endpoint** → the read smoke must fail soft (skip/non-blocking) if sandbox is down, to avoid false CI reds.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Additive: the commands don't exist today, so adding them only enables the already-present provider. Roll out: (1) Rust commands + registration (compiles, app boots), (2) type reconciliation, (3) `controlledNodeIds` derivation, (4) read smoke vs sandbox, (5) guarded write flows + iterate. Rollback = the mock entry still runs the UI offline regardless.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- ~~Is there a dedicated funded sandbox test account we can use headlessly?~~ **RESOLVED:** yes — a dedicated **sandbox** account is provisioned. Address `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv`, funded with ~101,000 NYM; mnemonic stored in vault as secret **`TAURI-WALLET-MNEMONIC`** (vault.nymte.ch item `95d3d842-90ad-4b6f-8b0c-10f5febce1c3`) — inject via CI secret, never commit it. Inspect with `nym-cli -c sandbox.env account balance <addr>`. Write flows (§5) target only this account; clean up (disband/leave) so it stays reusable.
|
||||
- ~~Does any view read `FamilyTxResult.family_events`?~~ **RESOLVED:** no — only the type def + the mock/provider *produce* it; nothing reads it, so query-refresh is sufficient (confirms D2, no event parsing needed).
|
||||
- ~~`get_family_config` mapping?~~ **RESOLVED:** the contract has no config query → read raw contract state (design D5); degrades gracefully (fee display is cosmetic).
|
||||
- ~~`update_family` arg shape?~~ **RESOLVED:** contract is `UpdateFamily { updated_name, updated_description: Option<String> }` — matches the frontend args + parent §9.5.
|
||||
- **Still a decision (not a blocker):** replace `src/types/families.ts` wholesale with ts-rs output vs reconcile selectively. Default: reconcile selectively, contract-truth shape wins (design D4).
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
The Node Families UI is complete and proven against the in-memory mock, but it has never run on real chain data: the frontend invokes 18 Tauri commands (`src/requests/families.ts`) that **don't exist on the Rust side yet**, so the real `FamiliesContextProvider` can't resolve anything. The on-chain pieces are already in place — the `node-families-contract` is deployed to **sandbox**, and `validator-client` already exposes full `NodeFamiliesSigningClient` + `NodeFamiliesQueryClient` traits. The only missing link is the wallet's Tauri command layer that bridges the two. This change adds it and switches the running app from mock to real IPC.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a wallet Tauri command module (`src-tauri/src/operations/families/`) implementing the **18 commands** the frontend already calls (9 execute + 9 query), each delegating to the existing `validator-client` node-families traits via the wallet's signing/query client (mirroring `operations/mixnet/`). Register them in the `invoke_handler` (`main.rs`).
|
||||
- Map chain results to the frontend's TS contract: execute commands return `FamilyTxResult` (incl. `family_events` parsed from the tx, which the mock currently fabricates); query commands return the existing `NodeFamily` / paged / membership shapes. Confirm `nym-wallet-types` (ts-rs) generation matches `src/types/families.ts`, or reconcile.
|
||||
- Finish the **real provider**: `FamiliesContextProvider` already wires the 18 requests; remove its `controlledNodeIds` stub (currently `[]`) by deriving controlled node ids from the connected account's bonded nodes, so owner/operator personas work on real data. No change to `FamilyPage`.
|
||||
- The real provider is **already** the default `/family` route; the mock entry (`main.mock.tsx`) stays for the offline Tier-1 suite. This change makes the *production* app show live family data.
|
||||
- **Iterate to green against sandbox**: a real-IPC tier — first a read-only smoke (queries render the sandbox family/member), then guarded execute flows against a funded sandbox test account — run until the journeys pass. Replaces the parent change's manual task 9.4 with an automated path where feasible.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `families-real-ipc`: The wallet's Tauri command layer for the node-families contract (queries + execute via the nyxd/validator client) and the real-data provider wiring that replaces the mock in the running app.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None: families-app-mock-build / families-app-e2e (from node-families-tauri-webdriver-e2e) are reused, not changed in requirement. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Rust (`nym-wallet/src-tauri`)**: new `operations/families/{mod.rs,queries.rs,execute.rs}` (or similar); `main.rs` `invoke_handler` additions; uses `nym_validator_client` node-families traits + wallet `State`/signing client; tx-event parsing for `family_events`. Possibly new error variants.
|
||||
- **Types**: `nym-wallet-types` ts-rs exports for the contract types; reconcile with `src/types/families.ts` (esp. `FamilyTxResult.family_events`, cursors, paged shapes).
|
||||
- **Frontend (`src`)**: `FamiliesContextProvider` `controlledNodeIds` derivation (drop the `[]` stub); no UI/`data-testid` changes.
|
||||
- **e2e**: a real-IPC tier (sandbox) — read smoke + guarded write flows; needs a sandbox test account (mnemonic via secrets) and a network/account that tolerates lifecycle mutations.
|
||||
- **Dependency / sequencing**: builds on `node-families-tauri-webdriver-e2e` (mock app, Tier-1/2 harness) and parent `node-families-wallet` (§9.4/9.5 — real IPC + `UpdateFamily` contract shape). The sandbox contract being live satisfies the external prerequisite.
|
||||
- **Out of scope**: contract changes (the contract is deployed as-is); mainnet wiring; UI redesign.
|
||||
@@ -0,0 +1,51 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tauri command layer for the node-families contract
|
||||
|
||||
The wallet SHALL implement the Tauri commands the frontend invokes for node families — the 9 execute commands (`create_family`, `update_family`, `disband_family`, `invite_to_family`, `revoke_family_invitation`, `kick_from_family`, `accept_family_invitation`, `reject_family_invitation`, `leave_family`) and the query commands (`get_family_by_id`, `get_family_by_owner`, `get_family_membership`, `get_family_config`, `get_family_members_paged`, `get_pending_invitations_for_family_paged`, `get_pending_invitations_for_node_paged`, `get_past_invitations_for_family_paged`, `get_past_members_for_family_paged`). Each command MUST delegate to the existing `validator-client` node-families traits using the connected account's client, and MUST be registered in the Tauri `invoke_handler`. Command names and argument shapes MUST match `src/requests/families.ts`.
|
||||
|
||||
#### Scenario: Every frontend command resolves on the Rust side
|
||||
|
||||
- **WHEN** the frontend invokes any of the 18 node-families commands
|
||||
- **THEN** a registered Rust handler executes it against the node-families contract via the validator client
|
||||
- **AND** no command falls through to "command not found"
|
||||
|
||||
#### Scenario: Execute returns a parsed family transaction result
|
||||
|
||||
- **WHEN** an execute command (e.g. `create_family`) succeeds on chain
|
||||
- **THEN** the command returns a `FamilyTxResult` whose shape matches the frontend type, including `family_events` derived from the transaction (not fabricated)
|
||||
|
||||
#### Scenario: Queries return the frontend-typed shapes
|
||||
|
||||
- **WHEN** a query command runs against the contract
|
||||
- **THEN** it returns data matching the corresponding `src/types/families.ts` shape (`NodeFamily`, membership, paged response with cursor)
|
||||
|
||||
### Requirement: Real provider replaces the mock in the running app
|
||||
|
||||
The production `/family` route SHALL render `FamilyPage` backed by the real `FamiliesContextProvider` (Tauri IPC), not the mock. The provider SHALL derive `controlledNodeIds` from the connected account's bonded nodes (removing the current empty-stub), so the operator view reflects nodes the account actually controls. The mock entry SHALL remain available for the offline e2e suite but MUST NOT back the production route.
|
||||
|
||||
#### Scenario: Production app shows live family data
|
||||
|
||||
- **WHEN** a signed-in account opens the Family page in the production app connected to a network with the contract
|
||||
- **THEN** the page renders that account's family / invites from on-chain queries via the real provider
|
||||
- **AND** the operator tab lists the account's controlled bonded nodes
|
||||
|
||||
#### Scenario: Mock stays isolated to e2e
|
||||
|
||||
- **WHEN** the production build is produced
|
||||
- **THEN** the `/family` route uses `FamiliesContextProvider` and the mock provider/entry is excluded (per the mock-build gate)
|
||||
|
||||
### Requirement: Journeys pass against the sandbox contract
|
||||
|
||||
The owner and operator journeys SHALL be verifiable against the node-families contract on **sandbox** through the real provider. A read-only smoke MUST confirm queries render the known sandbox family/member via real IPC. Execute flows, when exercised, MUST run against a dedicated funded sandbox test account and MUST NOT depend on or corrupt unrelated shared state; the suite SHALL be iterated until the targeted journeys pass.
|
||||
|
||||
#### Scenario: Sandbox read smoke
|
||||
|
||||
- **WHEN** the app is connected to sandbox and the Family page loads
|
||||
- **THEN** the real queries return and render the sandbox family/member without any state-changing transaction
|
||||
|
||||
#### Scenario: Sandbox execute flow (guarded)
|
||||
|
||||
- **WHEN** an execute journey runs against a funded sandbox test account
|
||||
- **THEN** the on-chain state change is reflected back through the real queries in the UI
|
||||
- **AND** the flow targets only that test account's family/nodes
|
||||
@@ -0,0 +1,41 @@
|
||||
## 1. Rust Tauri command layer (operations/families)
|
||||
|
||||
- [x] 1.1 Scaffold `src-tauri/src/operations/families/{mod.rs,query.rs,execute.rs}` mirroring `operations/mixnet/` (state access via `WalletState`, `current_client()?.nyxd`, `BackendError`, auto/simulated gas per D7). Added `nym-node-families-contract-common` path dep to `src-tauri/Cargo.toml`.
|
||||
- [x] 1.2 Implemented the 9 **execute** commands over `NodeFamiliesSigningClient`; each returns `TransactionExecuteResult` (a subset of the frontend `FamilyTxResult` — `family_events` omitted per D2, UI re-derives via `refreshAll()`). `create_family` attaches the `create_family_fee` as base-denom funds; all use auto gas (`None`).
|
||||
- [x] 1.3 Implemented the **query** commands over `NodeFamiliesQueryClient`. Each paged command flattens the contract envelope into the frontend's `{ items, start_next_after }` (`FamilyPagedResponse<T,C>`); members → `{ node_id, joined_at }`; past-invitation status normalised from the cw_serde tagged enum to `{ kind, at }`.
|
||||
- [x] 1.4 Implemented `get_family_config` via raw contract-state read (design D5 — no `GetConfig` smart query): `query_contract_raw(addr, b"config")` → deserialize `Config` → `create_family_fee` converted base→display `DecCoin` so the UI can round-trip it into `create_family`.
|
||||
- [x] 1.5 Registered all 18 commands in `main.rs` `invoke_handler`. Confirmed Tauri maps JS camelCase → Rust snake_case (verified against `update_mixnode_cost_params`/`new_costs`); fixed `src/requests/families.ts` execute bindings to pass camelCase keys (`nodeId`/`familyId`/`validitySecs`/`updatedName`/`updatedDescription`) — query bindings already correct.
|
||||
- [x] 1.6 `cargo build` + `cargo clippy` clean (only pre-existing `delegate.rs` warning remains; none from `families/`).
|
||||
|
||||
## 2. Types reconciliation
|
||||
|
||||
- [x] 2.1 **Generated ts-rs exports** for the node-families contract types. Added a `generate-ts` feature + optional `ts-rs` dep to `nym-node-families-contract-common`, annotated 18 types in `types.rs` (`derive(ts_rs::TS)` + `export_to` under `ts-packages/types/src/types/rust/`, with overrides: `Coin → { denom, amount }`, `Addr → string`, `u64 → number`, tuple cursors `→ [number, number] | null`, `Config` renamed to `FamilyConfig`), wired them into `tools/ts-rs-cli` (dep + `use` + `do_export!`), and ran it → 18 `*.ts` files emitted (FamilyConfig, NodeFamily, NodeFamilyResponse, paged/membership/pending/past responses, etc.). Verified shapes (e.g. `NodeFamily.members: number`, `paid_fee: { denom, amount }`).
|
||||
- [x] 2.2 Reconciled field-by-field. Outcome: `src/types/families.ts` is the wallet-facing (IPC-boundary) shape and the Rust command layer translates contract-truth into exactly it — no `families.ts`/mock field changes were needed. Divergences are all handled in `operations/families/query.rs`: base-denom `Coin` → display `DecCoin` (`paid_fee`, `create_family_fee`), per-page contract envelopes → uniform `{ items, start_next_after }`, cw_serde tagged `FamilyInvitationStatus` → `{ kind, at }`. Also fixed the generated `FamilyInvitationStatus` `at` field (`bigint` → `number`, `ts(type="number")` override) and exported all 18 generated family types from the `@nymproject/types` package barrel (`ts-packages/types/src/types/rust/index.ts`).
|
||||
- [x] 2.3 Added a compile-time contract-shape guard `src/types/families.contract-guard.ts`: `tsc`/ForkTsChecker-checked `Equal<>` assertions between the wallet types and the committed ts-rs-generated contract types (imported by relative path). 1:1 types asserted whole; the IPC-normalised fields (`paid_fee`/`create_family_fee`, paged envelopes, status union) excluded with a documented reason. Drift on any other field breaks the build. Guard passes; families Jest suite green (43).
|
||||
|
||||
## 3. Real provider wiring (replace mock in the running app)
|
||||
|
||||
- [x] 3.1 Replaced the `controlledNodeIds = []` stub in `FamiliesContextProvider` with a derivation from `useBondingContext().bondedNode`: `[nodeId]` for a nym-node, `[mixId]` for a legacy mixnode, `[]` for a gateway / no bond (an account bonds ≤1 node — design D3, see §note below). Wrapped the `/family` route (`FamilyPageRoute`) in `BondingContextProvider` so the data is available (it's per-route, not global).
|
||||
- [x] 3.2 Verified structurally: `/family` → `FamilyPageWithProvider` now mounts `BondingContextProvider` → `FamiliesContextProvider` → `FamilyPage`; queries point at `defaultQueries` (real Tauri requests); no `data-testid`/UI change; the mock entry (`main.mock.tsx` → `MockFamiliesContextProvider`) is untouched and e2e-only. tsc shows no families/bonding errors; full render-vs-chain confirmation is part of 3.3.
|
||||
- [ ] 3.3 **(manual — needs running wallet + dev/sandbox network)** Sign in, open Family, confirm queries resolve and an execute reflects back after refresh. Overlaps the §4/§5 sandbox runs.
|
||||
|
||||
> **Note (D3 / §5 reality):** on the real chain an account controls **at most one** bonded node, so `controlledNodeIds` is 0–1 long. The mock's multi-node operator persona (3 nodes) cannot be reproduced from a single sandbox account; the multi-node operator journey stays a mock/Storybook-only scenario.
|
||||
|
||||
## 4. Sandbox read smoke (safe, automatable)
|
||||
|
||||
- [x] 4.1 **Read smoke passing against live sandbox.** Implemented as a headless Rust harness (`src-tauri/examples/sandbox_families_smoke.rs`) that connects via the same `validator-client` calls the Tauri commands wrap (GUI automation can't run on macOS — tauri-driver is Linux/Windows-only). Run output: resolved the bundled sandbox families contract address, read `Config` from raw state (`create_family_fee=50 NYM`, limits 30/50, 600s), and listed the 2 live families with members/pending invites via real IPC — no state change. Validates the query envelope deserialization + raw-config read (1.4/D5) end-to-end.
|
||||
- [x] 4.2 Documented run rather than a CI gate (the harness needs the funded-account mnemonic via `.env`, not yet a CI secret): invocation documented in the example's header + `e2e/README.md`. Promote to a non-blocking CI job once the mnemonic is wired as a CI secret.
|
||||
|
||||
## 5. Sandbox execute flows (guarded) + iterate to green
|
||||
|
||||
- [x] 5.1 **Sandbox test account provisioned + funded:** `n13jtj2unhhtryxllnuc8zkng3nl4xnnjvxe0tzv` (~101,000 NYM — ample for fees/repeat runs); mnemonic in vault secret `TAURI-WALLET-MNEMONIC` (item `95d3d842-90ad-4b6f-8b0c-10f5febce1c3`). Inject via CI secret only — never commit. Verify/inspect with `nym-cli -c sandbox.env account balance <addr>` (also handy for pre/post-run state checks + cleanup). Tests target only this account and clean up after.
|
||||
- [x] 5.2 **Owner journey green on sandbox** (`sandbox_families_smoke.rs --write`): `create_family` (50 NYM funds attached + converted), `update_family` (rename), `disband_family` — all executed + confirmed on chain (real tx hashes), on-chain-latency polling, full cleanup. The owner-side member ops (`invite`/`revoke`/`kick`) are covered by the §5.3 run below.
|
||||
- [x] 5.3 **Operator journey green on sandbox** (`--member`, two-account flow): owner=`FAMILY_OWNER` (`n18cuqlr…`, owns family 1), operator=`ACCOUNT_WITH_BONDED_NODE` (`n1nu7zg8…`, controls node_id 31). Exercised all 6 member commands against family 1 with per-step state assertions — `kick`, `invite`(×4), `reject`, `revoke`, `accept`(×2), `leave` — then **restored** node 31's membership (verified via `--accounts`: family 1 back to 1 member). A real pre-existing family was left exactly as found.
|
||||
- [x] 5.4 **Both journeys pass against sandbox.** Across the two runs all 9 execute commands are confirmed end-to-end (funds, base-coin conversion, signing, two-account owner/operator separation, on-chain latency). Write tier stays manual (not a CI gate) until the mnemonics are wired as CI secrets; `.env` (gitignored) holds them locally and they are never printed/committed.
|
||||
|
||||
## 6. Verify & docs
|
||||
|
||||
- [~] 6.1 **Families work is regression-free; types-package generation gap fixed.** `cargo build` + `cargo clippy` clean; families code has no tsc errors; the contract-shape guard passes. Fixed the `@nymproject/types` build: `NodeAnnotationV1/V2` generated imports for `DetailedNodePerformanceV1/V2`, `DisplayRole`, `RoutingScore`, `ConfigScore`, `StressTestingScore` dangled because those (TS-deriving) types weren't in `tools/ts-rs-cli`; added them + regenerated → `tsc` builds the package with **0 errors**. Full wallet Jest now **101 pass, 0 fail** (was 84 + 4 suites unable to run); the two recovered suites are `delegationIdentity` + `unbondedDelegation.acceptance`. **Remaining (separate, out-of-scope, pre-existing):** `api/nodeStatus.test.ts` + `api/networkOverview.test.ts` fail to compile because `src/api/{nodeStatus,networkOverview}.ts` + their tests reference `'QA'`, which the source-of-truth `Network` enum (`nym-wallet-types`, → `SANDBOX | MAINNET`) doesn't include — an app-code drift unrelated to families or type generation; needs a product call (drop `QA` from the app code vs re-add it to `Network`). Mock Tier-1 Playwright unaffected (mock path untouched).
|
||||
- [x] 6.2 Confirmed against the deployed contract source: `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` (`node-families-contract/src/msg.rs`) — matches the `update_family` command + frontend `UpdateFamilyArgs` + parent §9.5.
|
||||
- [x] 6.3 Documented the real-IPC path in `e2e/README.md`: a "Real IPC layer (what the mock stands in for)" section (the 18 commands, the contract→wallet IPC-boundary translations, the mock→real provider switch) plus "Tier 3 — Sandbox real-IPC read smoke" and the guarded write-flow procedure (dedicated funded sandbox account, mnemonic via secret, target-only-self + cleanup).
|
||||
- [x] 6.4 Updated parent change `node-families-wallet` §9.4 to `[~]` "Realised by the `node-families-real-ipc` change" with a pointer to this change's Rust command layer + provider wiring.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-05
|
||||
@@ -0,0 +1,87 @@
|
||||
## Context
|
||||
|
||||
The Node Families feature (parent change `node-families-wallet`) is complete and verified in Storybook. The page component is already cleanly decoupled from Tauri:
|
||||
|
||||
- `src/pages/families/FamilyPage.tsx` — pure page, consumes `useFamiliesContext()`, no Tauri imports.
|
||||
- `src/context/FamiliesContextProvider.tsx` — the **real** provider; the only families file importing `./main` (Tauri runtime).
|
||||
- `src/context/mocks/families.tsx` (`MockFamiliesContextProvider`) — drives the page from an in-memory contract engine (`familiesMockState.ts`) seeded by `families.fixtures.ts`.
|
||||
- Storybook flow stories (`FamilyFlows.stories.tsx`) and `e2e/families.spec.ts` (Playwright → Storybook on :6006) already encode the journeys via `data-testid`s.
|
||||
|
||||
Environment facts that shape the design:
|
||||
- The wallet dev server is **webpack-dev-server on `http://localhost:9000`** (`historyApiFallback: true`, `hot: true`) — the same `devUrl` Tauri loads. Pointing a browser-based runner here exercises the real app shell + router.
|
||||
- **Playwright cannot drive Tauri's WebDriver.** `tauri-driver` exposes the classic W3C WebDriver protocol; Playwright's only WebDriver story is experimental **BiDi**, a different protocol. The Tauri-documented native clients are WebdriverIO and Selenium.
|
||||
- **`tauri-driver` has no macOS support** (no WKWebView driver). Native-webview e2e runs only on Linux/Windows — in CI.
|
||||
- The node-families **contract is now deployed to sandbox** (one family, one member), enabling an optional real-IPC read smoke.
|
||||
|
||||
This design supersedes an earlier framing that made WebdriverIO + `tauri-driver` the primary suite. Per the latest direction, the primary suite is **Playwright against the mock-wired dev server** (cross-platform, runs on macOS), and the native-webview run is an **optional CI validation leg**.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Render the existing Family page inside the real wallet app shell, backed by the Storybook mock providers, with zero chain/IPC dependency.
|
||||
- A primary e2e suite that runs **everywhere including the developer's Mac**, reusing the existing Playwright dependency, selectors, and journeys.
|
||||
- Keep all mock code out of the production bundle (compile-time elimination).
|
||||
- Provide an optional native-binary validation leg (WebdriverIO + `tauri-driver`) in Linux CI for higher fidelity.
|
||||
- Make use of the sandbox contract deployment via an optional real-IPC read smoke.
|
||||
|
||||
**Non-Goals:**
|
||||
- No real IPC/chain *write* wiring (remains parent-change tasks 9.4/9.5); the sandbox tier is read-only.
|
||||
- No macOS native-webview e2e (unsupported by Tauri).
|
||||
- No destructive lifecycle mutations against the shared sandbox.
|
||||
- No change to the Family feature's behavior or its specs.
|
||||
|
||||
## Decisions
|
||||
|
||||
**D1 — Playwright against the mock-wired dev server is the PRIMARY suite.**
|
||||
Point Playwright at `http://localhost:9000` with the mock flag on, and replay the journeys against the real app shell + router. Rationale: runs on every OS (incl. the developer's Mac), reuses the already-present `@playwright/test` dep and the existing selectors/specs, and tests the actual app chrome rather than Storybook iframes. *Alternative considered:* keep driving Storybook stories — lower fidelity (no router/app shell) and no closer to the real app. *Alternative considered:* Cypress — no advantage over the Playwright suite already in the repo.
|
||||
|
||||
**D2 — Dedicated mock ENTRY (gated by the build flag); persona chosen at runtime within it.**
|
||||
*Revised during apply:* the original "swap the families provider inside the prod `main` entry" cannot work in a browser — `main.tsx` → `AppCommon` → `AppProvider` is Tauri-coupled at bootstrap (imports `tauri-forage`, `@tauri-apps/api/app`, ~9 `requests/*`, fires `invoke` effects on mount) and the main routes are login-gated, so a plain browser never reaches `/family`. Instead, add a **dedicated mock entry** (`src/main.mock.tsx` + generated `main.mock.html`) that mounts the real `HashRouter` + real `ApplicationLayout` + the Family page, but with the existing Storybook mocks (`MockMainContextProvider` for the app bootstrap + `MockFamiliesContextProvider` for families) — no Tauri, no login gate, browser-safe. The webpack flag (`WALLET_MOCK_FAMILIES=on|off`, default `off`, also added to `EnvironmentPlugin`) **gates whether the mock entry + its HTML are built at all**, so production builds never include it (cleaner than tree-shaking a swapped import — the mock code is simply never an entry). The **persona** (`buildOwnerFlowStore` / `buildOperatorFlowStore` + sender) is read at *runtime* from `?persona=owner|operator` (default `owner`) on `window.location.search`, so a **single** dev server serves both personas and Playwright just navigates to different URLs. The real `/family` route (`FamilyPageRoute.tsx` → `FamiliesContextProvider`) and `FamilyPage.tsx` are left untouched, keeping the merged Code Connect mapping valid. *Alternatives considered:* in-place provider swap (rejected — bootstrap can't boot in a browser); thin shell without `ApplicationLayout` (rejected — little fidelity over the Storybook suite being replaced); native-only via Tier 2 (rejected — never exercises the shell on macOS).
|
||||
|
||||
**D3 — Reuse the existing fixtures and selectors verbatim.**
|
||||
The mock-wired dev server seeds the same fixtures as the Storybook flows, and the page already exposes the journey `data-testid`s, so the Playwright journeys mirror `FamilyFlows.stories.tsx` step-for-step. The same selectors then carry over to the optional WebdriverIO leg, keeping all suites observably equivalent.
|
||||
|
||||
**D4 — Optional native-webview validation: WebdriverIO + `tauri-driver` in CI (not Playwright).**
|
||||
For "how far can we get against the actual binary," follow the Tauri WebDriver-in-CI flow: Ubuntu runner, `xvfb-run` for a headless display, `libwebkit2gtk-4.1-dev` + `webkit2gtk-driver` + `tauri-driver` (cargo, `--locked`), WebdriverIO driving the packaged app's native WebKitGTK webview. Playwright is not an option here (protocol mismatch, D-context). This leg is optional and starts non-blocking.
|
||||
|
||||
**D5 — Skip-not-fail on unsupported platforms / missing tools.**
|
||||
The WebdriverIO leg detects macOS (or a missing `tauri-driver`/`webkit2gtk-driver`) and skips with a clear message, so invoking it locally on a Mac is a no-op rather than a red failure. The Playwright suite is the local source of truth; the native leg is CI-only.
|
||||
|
||||
**D6 — CI: primary step in `build`, native leg as a separate job.**
|
||||
The merged `ci-nym-wallet-frontend.yml` has one `build` job (ubuntu-22.04: install → tsc → lint → unit tests → build-storybook → upload). Add the **primary Playwright e2e as a step in `build`** (the Playwright suite is not yet in CI — only unit tests + Storybook build are). Add the **native-webview run as a separate job** (`needs: build` or independent) because it adds a Rust/Tauri compile + system webdriver deps that would slow and couple the main job; start it `continue-on-error`.
|
||||
|
||||
**D7 — Sandbox real-IPC smoke is a separate, optional, READ-only tier.**
|
||||
The sandbox contract (one family/one member) lets us smoke the real `FamiliesContextProvider` + `src/requests/families.ts` against a live chain — validating the IPC wiring that the mock deliberately stands in for (parent-change 9.4). Keep it read-only and separate from the deterministic mock e2e: a shared sandbox is a poor place to run create/kick/disband lifecycles, and live-network reads are inherently flakier. *Alternative considered:* fold sandbox into the main e2e — rejected (non-determinism + shared-state mutation).
|
||||
|
||||
**D8 — Native leg is Linux-only initially (no Windows leg yet).**
|
||||
Tier 2 ships as a single Ubuntu job. Adding the Windows/WebView2 leg (also supported by `tauri-driver`) doubles CI cost and maintenance for a tier that is already optional/`continue-on-error`; WebKitGTK on Linux is the higher-value target since it is closest to the Linux desktop builds. Revisit Windows only if a WebView2-specific regression surfaces.
|
||||
|
||||
**D9 — Sandbox smoke ships as a documented MANUAL step first, pinning the known family id.**
|
||||
A live sandbox read needs a connected, funded wallet account, which is not provisionable non-interactively in CI today (mnemonic in secrets + network + chain availability). So D7's smoke starts as a documented manual procedure that pins the known sandbox family id and asserts render/shape, not exact contents. It graduates to a CI job only once a sandbox test account can be provisioned headlessly — tracked as a follow-up, not a blocker.
|
||||
|
||||
**D10 — One Playwright suite: repoint to the dev server, retire the Storybook-iframe specs.**
|
||||
Rather than maintain two Playwright suites, repoint the single `e2e/families.spec.ts` at the mock-wired dev server (`:9000`). The Storybook `play` functions remain as Storybook-level interaction coverage (runnable via the test-runner), but we do not keep a parallel Playwright-against-Storybook suite — it would be lower fidelity and a drift source for no added coverage.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Browser fidelity ≠ packaged app** → The primary Playwright suite tests the web frontend, not the native binary or real `invoke`; the optional WebdriverIO leg covers the binary, and D7 covers real IPC. The three tiers together close the gap the mock leaves.
|
||||
- **WebKitGTK rendering/timing differs from Chromium (native leg)** → Journeys wait on `data-testid`s (as the Storybook flows do) with low mock latency and generous CI timeouts.
|
||||
- **Build-flag branch could ship mock code** → Default off; bundle/guard check + the spec scenario "Production build excludes mock code" lock it in.
|
||||
- **Provider seam breaks the merged Figma Code Connect mapping** (`FamilyPage.figma.tsx` → `example: () => <FamilyPage />`) → Keep mock/real selection in a *separate* module (D2); never make `FamilyPage`'s module depend on the flag or on Tauri.
|
||||
- **Theme swap (Nym 2.0) under test** → Confirmed color-only (families components untouched by the merge); journeys assert visibility/test ids, not pixels.
|
||||
- **`tauri-driver`/`webkit2gtk-driver` version drift in CI** → Pin `tauri-driver` (`cargo install --locked`), install a known WebKitGTK driver in the runner, cache cargo bin; the leg is `continue-on-error` until stable.
|
||||
- **Sandbox state drifts / contract redeploys** → The read smoke asserts shape/render, not exact contents (or pins to a known family id) so a changed fixture doesn't hard-fail; keep it non-blocking.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Additive only. Rollout: (1) build-time flag + provider seam; (2) repoint Playwright at the mock-wired dev server and add it to the `build` CI job; (3) add the optional WebdriverIO native job (`continue-on-error`); (4) add the optional sandbox read smoke. Any optional leg can be dropped/gated without affecting the app or the primary suite.
|
||||
|
||||
## Open Questions
|
||||
|
||||
All four prior open questions are now resolved:
|
||||
- **Persona handling** → single dev server; persona via runtime `?persona=` URL param inside the build-gated mock branch (**D2**).
|
||||
- **Windows native leg** → no; Linux-only initially (**D8**).
|
||||
- **Sandbox smoke placement / family id** → documented manual step first, pinning the known sandbox family id; CI only once a headless test account exists (**D9**).
|
||||
- **Retire Storybook-iframe Playwright specs?** → yes; one suite, repointed at the dev server (**D10**).
|
||||
|
||||
Residual (a follow-up, not a blocker): provisioning a headless sandbox test account would let D9's smoke graduate from manual to CI. **Update:** that account now exists (sandbox `n13jtj2...e0tzv`, mnemonic in vault secret `TAURI-WALLET-MNEMONIC`) — the graduation is now actionable, tracked in the `node-families-real-ipc` change (§4–5).
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
The Node Families feature is fully built and exercised in Storybook, but its end-to-end journeys have only ever run against Storybook iframes. We want to validate the page and the owner/operator flows inside the **real wallet app shell** (the production router and chrome), backed by the existing Storybook mock providers so no live chain or Rust IPC is required — and we want that primary suite to run **everywhere, including the developer's Mac**, with a heavier native-binary check available in CI.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Mount the existing Family page via a **dedicated, build-flag-gated mock entry** (`main.mock.tsx` + `main.mock.html`): it renders the real router + layout but with the Storybook mocks (`MockMainContextProvider` + `MockFamiliesContextProvider`), so it runs in a plain browser with no Tauri runtime or login. The real `main` entry and `/family` route are untouched; production builds never include the mock entry. (Revised during apply — the real app bootstrap is Tauri-coupled and can't boot in a browser, so an in-place provider swap was not viable.)
|
||||
- **Primary e2e — Playwright against the dev server (mock-wired):** point Playwright at the running webpack dev server (`http://localhost:9000`, the same `devUrl` Tauri loads) with the mock flag enabled, and replay the same journeys currently covered by the Storybook flow stories. This drives the real app shell + router in a real browser (Chromium/WebKit), is cross-platform (runs locally on macOS), and reuses the existing `@playwright/test` dependency and `data-testid` selectors. It supersedes the current Playwright→Storybook-iframe suite by pointing at the app shell instead.
|
||||
- **Optional validation — WebdriverIO + `tauri-driver` in CI:** as a "how far can we get against the actual binary" leg, add the Tauri WebDriver CI flow (Ubuntu + `xvfb-run`, `webkit2gtk-driver` + `tauri-driver`, WebdriverIO) to drive the **packaged app in the native WebKitGTK webview**. Linux/Windows only (macOS has no WKWebView driver), so it lives in CI, not local dev, and starts non-blocking.
|
||||
- **Optional higher-fidelity tier — sandbox real-IPC smoke:** the node-families contract is now deployed to **sandbox** (currently one family, one member). This unlocks a read-only smoke against the real `FamiliesContextProvider` + `src/requests/families.ts` wiring (parent-change task 9.4), separate from the deterministic mock e2e.
|
||||
- **Not** adopting Playwright for the Tauri runtime: Playwright can't speak the classic W3C WebDriver protocol `tauri-driver` exposes (its experimental WebDriver **BiDi** support is a different protocol); WebdriverIO remains the client for the native leg.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `families-app-mock-build`: A build-time flag that mounts the Family page inside the wallet app shell with the Storybook mock providers, seeded by deterministic fixtures, served by the dev server, while keeping mock code out of production builds.
|
||||
- `families-app-e2e`: End-to-end journey coverage of the owner and operator Node Families flows — primarily Playwright against the mock-wired dev server (cross-platform), with an optional WebdriverIO + `tauri-driver` native-webview validation leg in CI.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None: the existing node-families-owner / node-families-operator specs describe behavior this change exercises but does not alter. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Frontend (`nym-wallet/src`)**: provider-selection seam (build-time flag) around `FamiliesContextProvider` vs `MockFamiliesContextProvider`; a Family route reachable in the mock-wired dev server; webpack config gains a mock-flag-driven `DefinePlugin` constant + persona seed.
|
||||
- **Tests (`nym-wallet/e2e`)**: repoint/extend the Playwright config from Storybook (:6006) to the mock-wired dev server (:9000); journeys reuse existing selectors. Add an optional WebdriverIO config + `tauri-driver` (cargo) for the native leg.
|
||||
- **CI (`.github/workflows/ci-nym-wallet-frontend.yml`)**: the existing single `build` job (ubuntu-22.04: install → tsc → lint → unit tests → build-storybook → upload) gains the **primary Playwright e2e step** (currently the Playwright suite is NOT in CI), and a **separate optional** native-webview job (xvfb-run + `webkit2gtk-driver` + `tauri-driver` + Tauri/Rust build, starts `continue-on-error`).
|
||||
- **Figma Code Connect (recently merged)**: `src/pages/families/FamilyPage.figma.tsx` maps `FamilyPage` via `example: () => <FamilyPage />`. The provider seam MUST keep `FamilyPage` itself provider-agnostic and importable in isolation so this mapping (and the `src/**/*.figma.tsx` config) keeps resolving.
|
||||
- **Theme (recently merged Nym 2.0 swap)**: orthogonal — color-only, no DOM/`data-testid` change, so journeys/selectors are unaffected; the mock-wired build simply renders the new dark palette.
|
||||
- **Dependencies**: no new dep for the primary suite (`@playwright/test` already present); `webdriverio` (+ runner) and `tauri-driver` (cargo) only for the optional native leg.
|
||||
- **Out of scope**: full destructive lifecycle mutations against shared sandbox (read smoke only); macOS native-webview e2e (unsupported by Tauri); Figma Code Connect publish (Tier-1/Hux-gated); real IPC wiring beyond the read smoke (parent-change 9.4/9.5).
|
||||
@@ -0,0 +1,71 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Primary e2e against the mock-wired dev server
|
||||
|
||||
The project SHALL provide a Playwright suite that drives the wallet app served by the dev server (`http://localhost:9000`) with the families mock flag enabled, exercising the Family page within the real app shell and router (not a Storybook iframe). The suite MUST run cross-platform (including macOS) and MUST NOT require a live chain or Rust IPC. It SHALL reuse the existing journey `data-testid` selectors.
|
||||
|
||||
#### Scenario: Suite drives the app shell, not Storybook
|
||||
|
||||
- **WHEN** the Playwright suite starts
|
||||
- **THEN** it launches (or reuses) the mock-wired dev server on `:9000` and navigates to the Family page within the app router
|
||||
- **AND** the page renders backed by `MockFamiliesContextProvider` with no IPC call
|
||||
|
||||
#### Scenario: Runs on the developer's platform
|
||||
|
||||
- **WHEN** the suite is run locally on macOS
|
||||
- **THEN** it executes the journeys in a real browser and reports pass/fail (no platform skip)
|
||||
|
||||
### Requirement: Owner lifecycle journey
|
||||
|
||||
The primary suite SHALL replay the owner lifecycle end to end against the app shell: create a family, invite the self-controlled node, accept the invite from the operator tab, kick the member, and disband the family, asserting the same post-step DOM transitions as the Storybook owner-lifecycle flow.
|
||||
|
||||
#### Scenario: Owner create-to-disband completes
|
||||
|
||||
- **WHEN** the suite runs the owner lifecycle against the owner-persona mock build
|
||||
- **THEN** creating a family reveals the owner management page
|
||||
- **AND** the invited node appears as a pending invite, then as a joined member after acceptance
|
||||
- **AND** kicking the member removes it, and disbanding returns the create-family entry point
|
||||
|
||||
### Requirement: Operator lifecycle journey
|
||||
|
||||
The primary suite SHALL replay the operator lifecycle end to end: accept an invite on one controlled node, leave that family, then reject an invite on another controlled node, asserting the same post-step DOM transitions as the Storybook operator-lifecycle flow, including that the reject-node invite group ends empty.
|
||||
|
||||
#### Scenario: Operator accept-leave-reject completes
|
||||
|
||||
- **WHEN** the suite runs the operator lifecycle against the operator-persona mock build
|
||||
- **THEN** accepting the invite shows the current-family card with a leave action
|
||||
- **AND** after leaving and rejecting the other node's invite, that node's invite group is empty
|
||||
|
||||
### Requirement: Selector and journey parity across suites
|
||||
|
||||
All e2e suites (primary Playwright, and the optional native-webview leg) SHALL target the same `data-testid` selectors and assert the same observable outcomes as the existing Storybook flow stories, so they verify equivalent behavior across environments. Confirmation dialogs that portal outside the page canvas SHALL be located by their global test ids, mirroring the Storybook `screen`-scoped queries.
|
||||
|
||||
#### Scenario: Equivalent assertions across environments
|
||||
|
||||
- **WHEN** a journey step is asserted in more than one suite
|
||||
- **THEN** every suite queries the same `data-testid` and expects the same visible/absent outcome
|
||||
|
||||
### Requirement: Optional native-webview validation leg
|
||||
|
||||
The project SHALL provide an optional WebdriverIO + `tauri-driver` leg that launches the packaged Tauri binary and replays the owner and operator journeys against the platform native webview, following the Tauri WebDriver-in-CI flow (Ubuntu runner, `xvfb-run` headless display, `webkit2gtk-driver` + `tauri-driver`). The leg MUST run in CI on a supported platform (Linux) and MUST skip — not fail — on unsupported platforms (macOS) or when `tauri-driver`/`webkit2gtk-driver` is absent.
|
||||
|
||||
#### Scenario: Native leg validates the binary in CI
|
||||
|
||||
- **WHEN** the native leg runs on the Linux CI runner
|
||||
- **THEN** it installs `webkit2gtk-driver` and `tauri-driver`, builds the mock-wired binary, and replays the owner and operator journeys under `xvfb-run`
|
||||
- **AND** a journey failure is reported (non-blocking while the leg is stabilizing)
|
||||
|
||||
#### Scenario: Skip-not-fail off-platform
|
||||
|
||||
- **WHEN** the native leg is invoked on macOS or without the required drivers
|
||||
- **THEN** it is skipped with a clear message rather than reported as a failure
|
||||
|
||||
### Requirement: Optional sandbox real-IPC read smoke
|
||||
|
||||
The project MAY provide a read-only smoke that exercises the real `FamiliesContextProvider` + `src/requests/families.ts` against the node-families contract deployed to sandbox, validating the IPC wiring the mock stands in for. The smoke SHALL be read-only (no create/invite/kick/disband against shared sandbox) and SHALL assert rendered shape rather than exact contents (or pin a known family id), and MUST be separate from and non-blocking relative to the deterministic mock suites.
|
||||
|
||||
#### Scenario: Sandbox read smoke renders real data
|
||||
|
||||
- **WHEN** the read smoke runs against the sandbox-connected app
|
||||
- **THEN** the Family page renders the sandbox family/member via real IPC without performing any state-changing transaction
|
||||
- **AND** a failure does not block the primary mock suites
|
||||
@@ -0,0 +1,42 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Dedicated mock entry, gated by a build flag
|
||||
|
||||
The wallet app SHALL provide a dedicated mock entry (a separate webpack entry + generated HTML) that mounts the Family page with the mock app bootstrap (`MockMainContextProvider`) and mock families provider (`MockFamiliesContextProvider`), requiring no Tauri runtime and no login. A build flag (`WALLET_MOCK_FAMILIES`, default off) SHALL gate whether the mock entry and its HTML are built at all, so a production build never includes the mock entry or any families mock-engine code. The real `/family` route and `FamilyPage` component SHALL be left unchanged so the page stays backed by `FamiliesContextProvider` in production and remains importable in isolation.
|
||||
|
||||
#### Scenario: Mock build wires the mock providers
|
||||
|
||||
- **WHEN** the app is built with the families mock flag enabled
|
||||
- **THEN** the mock entry renders `FamilyPage` wrapped in `MockFamiliesContextProvider` (and the app shell in `MockMainContextProvider`)
|
||||
- **AND** the page resolves family reads from the in-memory mock engine without any Tauri IPC call or login
|
||||
|
||||
#### Scenario: Production build excludes the mock entry
|
||||
|
||||
- **WHEN** the app is built with the families mock flag disabled (default)
|
||||
- **THEN** no mock entry or `main.mock.html` is produced and the bundle contains no families mock-engine code
|
||||
- **AND** the real `/family` route still renders `FamilyPage` wrapped in `FamiliesContextProvider`
|
||||
|
||||
### Requirement: Deterministic seeded fixtures in the mock build
|
||||
|
||||
The mock-wired build SHALL seed the families mock store from the same deterministic fixtures used by the Storybook flow stories, so that a given launch presents a reproducible starting state for each persona. The build SHALL expose, without a live chain or Rust handlers, an owner-persona entry state and an operator-persona entry state equivalent to `buildOwnerFlowStore` and `buildOperatorFlowStore`.
|
||||
|
||||
#### Scenario: Owner persona entry state
|
||||
|
||||
- **WHEN** the mock build launches in the owner-persona configuration
|
||||
- **THEN** the Family page opens with no existing family and the create-family entry point is reachable
|
||||
- **AND** the self-controlled flow node is available to invite
|
||||
|
||||
#### Scenario: Operator persona entry state
|
||||
|
||||
- **WHEN** the mock build launches in the operator-persona configuration
|
||||
- **THEN** the Node invites tab presents the seeded pending invitations on the operator's controlled nodes
|
||||
|
||||
### Requirement: Family page reachable in the Tauri app shell
|
||||
|
||||
The mock-wired build SHALL mount the Family page within the production app shell (the real router and chrome), not a Storybook iframe, and the page SHALL be reachable through normal in-app navigation. The page and its interactive elements SHALL expose the same `data-testid` selectors as the Storybook stories so a single set of journey selectors works in both environments.
|
||||
|
||||
#### Scenario: Navigating to the Family page in the app
|
||||
|
||||
- **WHEN** the mock-wired app is loaded and the user navigates to the Family section
|
||||
- **THEN** the `family-page` element is rendered inside the app shell
|
||||
- **AND** the owner and operator tabs (`family-tab-owner`, `family-tab-operator`) are present with the same test ids used in Storybook
|
||||
@@ -0,0 +1,54 @@
|
||||
## 1. Dedicated mock entry (D2)
|
||||
|
||||
- [x] 1.1 Add `WALLET_MOCK_FAMILIES` (default `off`) to webpack `EnvironmentPlugin`, and conditionally register a `mainMock` entry + a `main.mock.html` HtmlWebpackPlugin output **only when the flag is `on`**, so production builds never include the mock entry.
|
||||
- [x] 1.2 Create `src/main.mock.tsx`: mount real `HashRouter` + real `ApplicationLayout` + a `/family` route, wrapped in `MockMainContextProvider` (mock app bootstrap) + `MockFamiliesContextProvider` (mock families) + `QueryClientProvider` + `NymWalletTheme` — no Tauri, no login gate. Leave `FamilyPage.tsx` and the real `FamilyPageRoute.tsx` untouched (keeps Code Connect valid).
|
||||
- [x] 1.3 In `main.mock.tsx`, read the persona at runtime from `?persona=owner|operator` (default `owner`) on `window.location.search` and seed `buildOwnerFlowStore` / `buildOperatorFlowStore` + the matching sender (reuse `families.fixtures.ts`); seed the hash to `#/family`. (Added a third `operator-seeded` persona for the multi-node assertion.)
|
||||
- [x] 1.4 Confirm the Family page is reachable at `/main.mock.html?persona=...#/family` on the dev server (`:9000`), rendering inside the real layout/router (not a Storybook iframe), keeping the existing `data-testid`s. (Layout chrome `Nav`/`AppBar` verified Tauri-free; full browser render pending a live run — see 6.1.)
|
||||
- [x] 1.5 Confirm a default (flag `off`) production build is unchanged — no `mainMock` entry, no `main.mock.html`, no mock-engine code; the real `/family` route still wires `FamiliesContextProvider`. (Verified: config builds entry `auth,main,log` with flag off; `+mainMock` only with flag on.)
|
||||
- [x] 1.6 Add an npm script to launch the single mock-wired dev server (`webpack:dev:mock` = `WALLET_MOCK_FAMILIES=on webpack serve --config webpack.dev.js`); both personas are reached on the one server via `?persona=`.
|
||||
|
||||
## 2. Primary e2e — Playwright against the mock-wired dev server
|
||||
|
||||
- [x] 2.1 Repoint `playwright.config.ts` from Storybook (:6006) to the mock-wired dev server (`baseURL http://localhost:9000`), with a single `webServer` (`webpack:dev:mock`) and `reuseExistingServer` locally; tests pick persona via the `?persona=` URL.
|
||||
- [x] 2.2 Port the owner lifecycle journey (create → invite → accept → kick → disband) against `main.mock.html?persona=owner`, reusing the existing `data-testid` selectors and the Storybook flow steps.
|
||||
- [x] 2.3 Port the operator lifecycle journey against `?persona=operator` (accept → leave, then reject), asserting the reject-node invite group ends empty.
|
||||
- [x] 2.4 Port the multi-node operator invite-states assertion (`node-invite-group-201` present, `node-invite-group-203-empty`) against `?persona=operator-seeded`.
|
||||
- [x] 2.5 Handle portalled confirmation dialogs via their global test ids (page-scoped, mirroring the Storybook `screen` queries).
|
||||
- [x] 2.6 Retire the old Storybook-iframe specs (D10): replaced `e2e/families.spec.ts` with the dev-server journeys and factored shared selectors into `e2e/shared/families.ts` for parity (Storybook `play` functions stay as Storybook-level coverage).
|
||||
- [x] 2.7 Confirm the suite runs green locally on macOS — **DONE: 3/3 passing** (clean cold run, Chromium). Required selector corrections: the original Storybook-derived ids on `NymCard` don't render (its prop is `dataTestid`, not `data-testid`), so journeys scope by the `operator-node-<n>` wrapper and key invite buttons by family id.
|
||||
|
||||
## 3. CI — wire the primary suite in
|
||||
|
||||
- [x] 3.1 Add a Playwright e2e step to the existing `build` job in `.github/workflows/ci-nym-wallet-frontend.yml` (install `--with-deps chromium` + run `test:e2e`).
|
||||
- [x] 3.2 The step launches the mock-wired dev server via Playwright's `webServer` and fails the job on any journey failure (no `continue-on-error`).
|
||||
|
||||
## 4. Optional — native-webview validation leg (WebdriverIO + tauri-driver)
|
||||
|
||||
- [x] 4.1 Add `webdriverio` + `@wdio/{cli,local-runner,mocha-framework,spec-reporter}` + `tsx` to dev deps; documented `cargo install tauri-driver --locked` (README + CI).
|
||||
- [x] 4.2 Create `wdio.conf.ts` (starts `tauri-driver`, `tauri:options.application` → release binary, mocha timeouts) + `test:e2e:tauri` script. **Binary wiring DONE:** `src-tauri/tauri.mock.conf.json` overrides the window to boot `main.mock.html` (owner persona); `tauri:build:mock` = `WALLET_MOCK_FAMILIES=on webpack:prod` + `tauri build --no-bundle --config tauri.mock.conf.json`. Operator persona reached via in-webview `browser.url('tauri://localhost/main.mock.html?persona=operator')` in the spec.
|
||||
- [x] 4.3 Implement the skip-not-fail guard (`e2e-tauri/run.mjs`): macOS / missing `tauri-driver` / missing `webkit2gtk-driver` → exit 0 with a clear message (design D5).
|
||||
- [x] 4.4 Reuse the journey selectors from §2 via `e2e/shared/families.ts` (`e2e-tauri/families.tauri.ts`), so the native leg asserts identical outcomes.
|
||||
- [x] 4.5 Add a **separate** `e2e-tauri` CI job (ubuntu-22.04, `continue-on-error`) following the Tauri WebDriver-in-CI flow: `libwebkit2gtk-4.1-dev` + `webkit2gtk-driver` + `xvfb`, Rust + cache, `cargo install tauri-driver --locked`, build mock binary, run under `xvfb-run`.
|
||||
|
||||
## 5. Optional — sandbox real-IPC read smoke (manual first, D9)
|
||||
|
||||
- [x] 5.1 Documented (e2e/README.md) a manual read-only smoke: connect to a sandbox account, open the Family page, confirm it renders the known sandbox family/member via real `FamiliesContextProvider` + `requests/families.ts` (no state-changing tx).
|
||||
- [x] 5.2 Documented: pin the known sandbox family id and assert render/shape, not exact contents; kept separate + non-blocking vs the mock suites.
|
||||
- [x] 5.3 Documented the follow-up: promote to a non-blocking CI job once a sandbox test account can be provisioned headlessly.
|
||||
|
||||
## 6. Verification & docs
|
||||
|
||||
- [x] 6.1 Run the primary Playwright suite locally (macOS) — **DONE: 3/3 green** against the mock-wired app shell. (CI execution still pending the first push.) Fixes needed to get a clean browser render: skip React Refresh/HMR + the dev-server live-reload client in the mock build (`webpack.dev.js`, avoids missing `core-js-pure`/`ansi-html-community`); add the relative `node_modules` walk for the mock build (pnpm strict + absolute `resolve.modules` dropped `object-assign`); make `src/utils/common.ts` resolve `getCurrentWebviewWindow()` lazily (was crashing at import outside Tauri).
|
||||
- [x] 6.2a **Fixed the pre-existing `webpack:prod` failure** that blocked the mock binary (and `pnpm build` generally). Root cause: the shared `ForkTsCheckerWebpackPlugin` runs in `mode: 'write-references'` (emit) and, with the wallet's `allowJs: true` + no `outDir`, emitted `.js` next to sources — polluting `src` (broke Jest), erroring "would overwrite input file" on `.test.js`/`.test.ts` pairs, and type-checking test files (jest globals). Contained wallet fix: add `declare module '*.css'` (`src/typings/css.d.ts`), set `outDir: ./.tsbuild` (redirects the emit out of `src`; `tsc --noEmit`/ts-loader/ts-jest ignore it), and exclude `**/*.test.*` from the type-check program (Jest still type-checks tests via ts-jest). Result: `webpack:prod` exits 0, emits `dist/main.mock.html`, no `src` pollution.
|
||||
- [x] 6.2b Built the mock binary locally (`tauri:build:mock`, ~3m48s) and **visually confirmed** it boots straight into the Family page in the real app shell (owner persona / "Create a family" entry, network "Testnet Sandbox", sidebar "Version mock" — i.e. `MockMainContextProvider` + `MockFamiliesContextProvider`, no Tauri IPC/login). Fixed the binary path in `wdio.conf.ts` (`target/release/NymWallet` — the Cargo workspace target is at the wallet root, not `src-tauri/target`).
|
||||
- [ ] 6.2c Run the WebdriverIO suite against the binary — **Linux/Windows-only** (`tauri-driver`), so it executes in the `e2e-tauri` CI job (skips on macOS via `run.mjs`). This is the one remaining unrun step.
|
||||
- [x] 6.3 Confirm `tsc` + eslint stay clean and the production build is unaffected — verified: after `pnpm install`, `tsc` is fully clean (exit 0); `main.mock.tsx` + `utils/common.ts` lint clean; webpack prod-safe (no `mainMock` entry with flag off).
|
||||
- [x] 6.4 Confirm the provider seam didn't break Code Connect (seam is a separate entry; `FamilyPage.tsx` + `FamilyPageRoute.tsx` untouched; `FamilyPage.figma.tsx` now type-checks once `@figma/code-connect` is installed) and the Nym 2.0 theme swap left journey `data-testid`s intact (color-only).
|
||||
- [x] 6.5 Document the tiered setup + mock-flag usage (`e2e/README.md`).
|
||||
|
||||
## 7. Visual flow report (bonus)
|
||||
|
||||
- [x] 7.1 Capture a full-page, captioned screenshot at each journey step (`shot()` in `e2e/shared/report.ts`) — written to `e2e-report/screenshots/<test>/NN-label.png` and attached to the Playwright report.
|
||||
- [x] 7.2 Assemble a static `e2e-report/index.html` filmstrip (per-test, ordered, captioned) via Playwright `globalSetup`/`globalTeardown` (`report.globalSetup.ts` resets, `report.globalTeardown.ts` builds).
|
||||
- [x] 7.3 Stage + upload `e2e-report/` in the CI `build` job alongside Storybook, with `if: always()` so a failing run still publishes the filmstrip for inspection; gitignore `e2e-report/` + `playwright-report/`.
|
||||
- [x] 7.4 Verified locally: `pnpm test:e2e` green (3/3), report renders 11 frames across the owner/operator/multi-node journeys.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-27
|
||||
@@ -0,0 +1,102 @@
|
||||
## Context
|
||||
|
||||
Node Families is a new on-chain capability defined in the `node-families-contract` spec (root openspec). The Nym Wallet is a Tauri + React app. State for each domain is exposed through a React Context provider that calls Tauri IPC `requests` and is mirrored by a mock provider under `src/context/mocks/` (see `bonding.tsx` ↔ `mocks/bonding.tsx`, wired in `src/context/index.tsx`). Reads in newer code use TanStack Query (see `delegationQuery.ts`, `delegationQueryKeys.ts`). Storybook (`@storybook/react-webpack5`, addons: a11y, docs, mcp) is configured but currently only has the default example stories — there is no established pattern yet for rendering domain UI against mock providers. Playwright is not yet a dependency.
|
||||
|
||||
This design covers the wallet UI, the hooks/mocks layer, Storybook structure, and the test strategy. UI visuals come from Figma via Figma MCP during apply.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- A `families` context + hooks that expose the full owner and operator contract surface, each with a mock counterpart.
|
||||
- Mocked smart-contract fixtures sufficient to drive every story and test without a chain.
|
||||
- Storybook coverage on three levels: component states, composed pages, full user-flow stories with simulated actions.
|
||||
- Storybook interaction tests, Playwright e2e flows, and hook/integration tests against mocks.
|
||||
- UI built from Figma designs.
|
||||
|
||||
**Non-Goals:**
|
||||
- Implementing the contract-side `UpdateFamily` handler - it lands in a separate contract change that this branch rebases onto before merge.
|
||||
- Owner-acts-for-node behaviour (V2 per NYM-1217): the future capability for the family owner to perform actions on member nodes, plus any reward/redemption flows tied to it. V1 acceptance is a pure membership record.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: One `FamiliesContext` covering both personas, reads via TanStack Query
|
||||
A single `src/context/families.tsx` (exported from `index.tsx`) exposes owner operations (create, edit, disband, invite, revoke, kick) and operator operations (accept, reject, leave), plus loading/error/refresh. Reads (family-by-owner, members, pending/past invitations, per-node invitations, config) are TanStack Query hooks with a `familyQueryKeys` module mirroring the `delegationQueryKeys` pattern. *Alternative considered:* two separate contexts (owner/operator) — rejected because a single connected account can be both, and the member list needs owner + archive reads together; one context avoids cross-provider coordination.
|
||||
|
||||
### D2: Tauri requests in `src/requests/families.ts`, types in `src/types`
|
||||
Add IPC bindings mirroring `requests/bond.ts` (one function per execute msg + per query), typed against new TS types for `NodeFamily`, `FamilyMembership`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation`, `PastFamilyMember`, and `Config`. Execute calls return `TransactionExecuteResult` like the existing bonding calls.
|
||||
|
||||
### D3: A faithful `node-families-contract` mock, derived from the root spec
|
||||
Following the existing `src/context/mocks` convention (module-level fixtures, a provider that mirrors the real context, execute methods that `setIsLoading` → `mockSleep` → mutate in-memory fixtures → return `TxResultMock`, reads as "fake tauri request" functions), the mock models the **entire** `node-families-contract` surface from `openspec/specs/node-families-contract/spec.md` — not just the happy path. A `withFamiliesMock` Storybook decorator wraps stories in this provider; the same provider backs RTL integration tests. *Alternative considered:* MSW/network mocking — rejected because the data crosses Tauri IPC, not HTTP, so provider-level mocking is the faithful seam.
|
||||
|
||||
The mock lives in `src/context/mocks/families.tsx` with fixtures in a co-located `src/context/mocks/families.fixtures.ts`, and reproduces:
|
||||
|
||||
- **Config**: `create_family_fee` (DecCoin), `family_name_length_limit`, `family_description_length_limit`, `default_invitation_validity_secs`.
|
||||
- **Data types**: `NodeFamily { id, name, description, normalised_name, members, created_at, paid_fee, owner }`; `FamilyMembership { family_id, joined_at }`; `FamilyInvitation { family_id, node_id, expires_at }`; `PendingFamilyInvitationDetails { invitation, expired }`; `PastFamilyInvitation { invitation, status: Accepted{at} | Rejected{at} | Revoked{at} }`; `PastFamilyMember { family_id, node_id, removed_at }`.
|
||||
- **Execute msgs** (mutating fixtures, honoring invariants): `CreateFamily`, `DisbandFamily`, `InviteToFamily`, `RevokeFamilyInvitation`, `KickFromFamily`, `AcceptFamilyInvitation`, `RejectFamilyInvitation`, `LeaveFamily`, plus an `OnNymNodeUnbond` test helper to simulate the mixnet cleanup callback. (`UpdateConfig` is admin-only and out of the wallet's scope.)
|
||||
- **Queries**: `GetFamilyById`, `GetFamilyByName` (normalised lookup), `GetFamilyByOwner`, `GetFamilyMembership`, and the paginated `GetFamiliesPaged`, `GetFamilyMembersPaged`, `GetAllFamilyMembersPaged`, `GetPendingInvitation(s)ForFamilyPaged`, `GetPendingInvitationsForNodePaged`, `GetAllPendingInvitationsPaged`, `GetPastInvitationsForFamily/NodePaged`, `GetAllPastInvitationsPaged`, `GetPastMembersForFamily/NodePaged` — all with exclusive `start_after`, default limit 50, max 100, and `start_next_after`.
|
||||
- **Invariants enforced in the mock** so stories/tests exercise real edge cases: one family per owner, one family per node, monotonic never-recycled family ids starting at 1, ASCII normalisation + global uniqueness of names, byte-length limits on name/description, `expired = now >= expires_at` computed live, per-`(family, node)` archive counters starting at 0, and the disband/leave/kick/unbond archival transitions.
|
||||
- **Errors**: a typed error set mirroring the contract (`InvalidFamilyCreationFee`, `FamilyNameAlreadyTaken`, `FamilyNameTooLong`, `EmptyFamilyName`, `SenderAlreadyOwnsAFamily`, `NodeAlreadyInFamily`, `NodeDoesntExist`, `PendingInvitationAlreadyExists`, `ZeroInvitationValidity`, `InvitationExpired`, `InvitationNotFound`, `FamilyNotEmpty`, `SenderDoesntControlNode`, `NodeNotMemberOfFamily`, etc.) so warning/error states are reachable from mocked calls.
|
||||
- **Events**: 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`) so any UI/indexer assertions can verify them.
|
||||
|
||||
### D4: Member-list sections map 1:1 to contract queries (one row per record)
|
||||
The four UI sections each correspond to a distinct contract query, paginating independently via its `start_after` cursor. **Pending**: rows from `GetPendingInvitationsForFamilyPaged`, each carrying the `expired` flag. **Joined**: rows from `GetFamilyMembersPaged`. **Rejected**: rows from `GetPastInvitationsForFamilyPaged` filtered to `Rejected` status. **Removed**: rows from `GetPastMembersForFamilyPaged` (covers both left and kicked). `Revoked` past invitations are owner-side actions and are NOT shown in the member list. Because the contract stores per-`(family, node)` archive records that accumulate (a node may be invited, kicked, re-invited, etc. arbitrarily many times), a single node MAY legitimately appear in more than one section - each row represents a record, not a node. The aggregator hook is therefore a thin pass-through (queries → named sections), not a priority-cascade derivation; UI clarity comes from per-section headings + record timestamps, not from collapsing history.
|
||||
|
||||
### D5: Family tab is always visible; UI identifies families by name only
|
||||
The Family tab renders for **every** wallet account (not gated on owning a family or controlling a node), so any account can start a family: it shows the create entry point when the account owns no family and the management surface when it does. `family_id` is internal only - the UI identifies families by name (globally unique among live families after normalisation) and shows the owner address as supplementary trust context wherever invites are displayed. Names are released for reuse when a family is disbanded, so a past archived record's name may not match the family currently holding that name.
|
||||
|
||||
### D6: Large lists are paginated via the contract's exclusive `start_after` cursor
|
||||
Member lists and invitation archives use the contract's cursor pagination: each page passes `start_after` (exclusive) and reads `start_next_after` from the response to fetch the next page, with the contract's default limit of 50 (max 100). The TanStack Query read hooks expose this as incremental/infinite pagination; the mock honours the same cursor semantics so paging is exercised without a chain.
|
||||
|
||||
### D7: Storybook three-level structure
|
||||
- **Components** (`src/components/families/*.stories.tsx`): each component with explicit state args (empty, loading, error, expired, over-limit, success).
|
||||
- **Pages** (`src/pages/families/*.stories.tsx`): composed surfaces (owner management page, operator invites page) backed by the mock provider.
|
||||
- **Flows** (`*.flow.stories.tsx`): play functions (`@storybook/test`) that perform the user actions end to end (create → invite → accept → kick → disband; operator: receive → accept/reject → leave).
|
||||
|
||||
### D8: Playwright runs against the static Storybook build
|
||||
Because the production app is Tauri (not a plain web target), Playwright e2e specs run against `build-storybook` served statically, exercising the flow stories as real browser sessions. This gives deterministic, chain-free e2e without packaging Tauri. *Alternative considered:* `tauri-driver`/WebDriver against the native app — heavier, flaky in CI, and unnecessary since the contract layer is mocked anyway.
|
||||
|
||||
### 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.
|
||||
- **[Same node may appear in multiple sections]** (e.g., currently Joined and previously Removed) → record timestamps and clear section headings must make the overlap read as history rather than as a duplicate row; aggregator hook is a pass-through, so the risk is purely UX, not data correctness.
|
||||
- **[Playwright-vs-Tauri divergence]** → Storybook flows test UI logic against mocks, not the real IPC bridge; a thin set of manual/native smoke checks should still cover Tauri wiring before release.
|
||||
- **[Fresh Storybook conventions]** → Establish the `withFamiliesMock` decorator and naming once, up front, to avoid per-story drift.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Additive only — new tab, context, requests, types, stories, tests. No existing wallet behavior changes. This branch rebases onto the `UpdateFamily` contract change before merging, so the edit path is real (not feature-flagged) at ship time. Rollback is removal of the tab entry point.
|
||||
|
||||
## Open Questions
|
||||
|
||||
_(none open)_
|
||||
|
||||
_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.
|
||||
@@ -0,0 +1,50 @@
|
||||
## Why
|
||||
|
||||
Node Families is a new on-chain capability (see the `node-families-contract` spec) that lets a family owner group nodes under a single family and lets node operators join, leave, and respond to invites. The Nym Wallet currently has no surface for any of this. This change introduces a **Family Tab** in the wallet so the two personas — family owners and node operators — can drive the full family lifecycle from the UI, end to end. It delivers tickets NYM-1210 through NYM-1219.
|
||||
|
||||
## What Changes
|
||||
|
||||
**Family owner flows (NYM-1210–1215)**
|
||||
- New **Family Tab**, always visible to any wallet account: shows a create-family entry point when the account owns no family, and the family management surface when it does.
|
||||
- Create a family: attach the configured creation fee (`Config::create_family_fee`), set name + description, surface insufficient-balance and fee errors.
|
||||
- Add/edit family **name** and **description**, with byte-length limits and input sanitisation, inline over-limit errors.
|
||||
- Invite a node by **node ID**: triggers the contract invite (nonce/TTL via `validity_secs`), with confirmation; warns and does not send if the node is already in a family, does not exist, or already has a pending invite from this family.
|
||||
- Manage **pending invites**: withdraw an active invite (confirmation prompt), dismiss/clear expired invites (confirmation), with list + contract state kept in sync.
|
||||
- View the **member list** grouped by status: Pending / Joined / Rejected / Removed, with per-status empty states and refresh.
|
||||
- **Kick** a member (confirmation prompt → contract `KickFromFamily`), moving the node to Removed.
|
||||
- **Delete** an empty family (`DisbandFamily`); blocked with a clear error when members remain.
|
||||
|
||||
**Node operator flows (NYM-1216–1219)**
|
||||
- View incoming **invites per node** (multi-node aware): family name, inviting owner, expiry/TTL; expired invites shown as non-actionable.
|
||||
- **Accept** an invite (`AcceptFamilyInvitation`) → node moves to Joined. V1 acceptance is a pure membership record; owner-acts-for-node behaviour (where the family owner could perform actions on member nodes) is V2 per NYM-1217 and out of scope here.
|
||||
- **Reject** an invite (`RejectFamilyInvitation`, confirmation) → no longer shown, node reflected as Rejected.
|
||||
- **Leave** a family (`LeaveFamily`, confirmation) → removed from member list; can subsequently receive/accept new invites.
|
||||
|
||||
**Engineering scope**
|
||||
- React **hooks** wrapping the contract surface (queries + execute msgs), each with a **mock** counterpart following the existing `src/context/<x>.tsx` ↔ `src/context/mocks/<x>.tsx` pattern.
|
||||
- **Storybook** coverage on three levels: component states, composed pages, and full user-flow stories with simulated actions, all driven by the mocked hooks/contract data.
|
||||
- **Tests**: Storybook interaction tests, Playwright end-to-end flows, and hook/integration tests against the mocks.
|
||||
- UI implemented from **Figma** (designs supplied via Figma MCP during apply).
|
||||
|
||||
**Contract dependencies** (landing in a separate contract change; this branch rebases onto it before merge):
|
||||
- **Edit name/description after creation** (NYM-1211): the contract spec has `CreateFamily` (carries name/description) and `UpdateConfig` (admin) but no `UpdateFamily` edit handler yet. The contract team will add it; the wallet builds against an assumed message shape and verifies it on rebase (see design.md Open Questions).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `node-families-owner`: Wallet behavior for the family-owner persona — Family Tab visibility, create/edit/delete family, invite/withdraw/clear invitations, and the status-grouped member list.
|
||||
- `node-families-operator`: Wallet behavior for the node-operator persona — per-node invite viewing (with TTL/expiry), accept, reject, and leave.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None in the wallet's own spec set. The two contract gaps above belong to the
|
||||
`node-families-contract` capability in the root openspec project and must be
|
||||
resolved there (separate change); they are tracked here as dependencies, not deltas. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**: new Family Tab pages/components under `src/pages` + `src/components`; new context/hooks in `src/context` (`families.tsx` owner, `familyInvites.tsx` operator or a combined `families.tsx`) with mocks in `src/context/mocks`; new types in `src/types`; new request/IPC bindings (Tauri) for the contract execute/query methods.
|
||||
- **Mocks**: a faithful `node-families-contract` mock under `src/context/mocks` (provider + `families.fixtures.ts`) derived from `openspec/specs/node-families-contract/spec.md`, covering its full surface — Config, all data types, every execute msg and query (with pagination), enforced invariants, the typed error set, and emitted events. Follows the existing `mocks/bonding.tsx` convention.
|
||||
- **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 — Nym 2.0 file `moIK1E6AaXhFz8lI1pZVrI`, board "Nym_Wallet – Node families added" (`1859:981`); per-frame mapping in design.md "Design Source (Figma)".
|
||||
@@ -0,0 +1,63 @@
|
||||
## 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
|
||||
- **WHEN** a controlled node has a pending, not-yet-expired invitation
|
||||
- **THEN** the wallet shows the family name, inviting owner, and expiry, and offers accept/reject actions
|
||||
|
||||
#### Scenario: Expired invite is shown as non-actionable
|
||||
- **WHEN** a pending invitation's `expired` flag is true
|
||||
- **THEN** the wallet shows it as expired and offers no accept/reject actions
|
||||
|
||||
#### Scenario: Multiple nodes show their invites separately
|
||||
- **WHEN** the operator controls more than one node, each with different invitations
|
||||
- **THEN** the wallet groups invitations under their respective node and shows each node's distinct invite state
|
||||
|
||||
### 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
|
||||
- **WHEN** the operator accepts a not-yet-expired invitation for a node they control
|
||||
- **THEN** `AcceptFamilyInvitation` is triggered, a confirmation is shown, and the node is reflected as Joined
|
||||
|
||||
#### Scenario: Expired invitation cannot be accepted
|
||||
- **WHEN** the operator attempts to accept an invitation whose `expired` flag is true
|
||||
- **THEN** the wallet prevents acceptance and surfaces an expired error
|
||||
|
||||
### 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
|
||||
- **WHEN** the operator rejects a pending invitation and confirms the prompt
|
||||
- **THEN** `RejectFamilyInvitation` is triggered and the invitation is removed from the pending list
|
||||
|
||||
#### Scenario: Rejected invite is no longer shown
|
||||
- **WHEN** an invitation has been rejected
|
||||
- **THEN** it is not shown again in the operator's pending invite list and the node shows as Rejected in the family member list
|
||||
|
||||
### 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
|
||||
- **WHEN** the operator leaves a family and confirms the prompt
|
||||
- **THEN** `LeaveFamily` is triggered and the node is removed from the family member list
|
||||
|
||||
#### Scenario: Node can join another family after leaving
|
||||
- **WHEN** a node has left a family
|
||||
- **THEN** the operator can receive and accept invitations from other families for that node
|
||||
@@ -0,0 +1,155 @@
|
||||
## 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
|
||||
- **WHEN** any connected account opens the Family tab and its address owns no family
|
||||
- **THEN** the tab is shown and renders a "Create family" entry point
|
||||
|
||||
#### Scenario: Owner sees management surface instead of create
|
||||
- **WHEN** the connected address already owns a family
|
||||
- **THEN** the Family tab renders the family management surface (member list, invite, edit, delete) and not the create entry point
|
||||
|
||||
### 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
|
||||
- **WHEN** a user with sufficient balance submits a valid name and description with the correct fee attached
|
||||
- **THEN** the family is created, the fee is deducted, and a success confirmation referencing the new family is shown
|
||||
|
||||
#### Scenario: Insufficient balance is surfaced before submission
|
||||
- **WHEN** the connected account balance is below the creation fee plus estimated gas
|
||||
- **THEN** the wallet shows a clear insufficient-balance error and does not submit the transaction
|
||||
|
||||
#### Scenario: Contract fee error is surfaced
|
||||
- **WHEN** creation fails with `InvalidFamilyCreationFee` or `InvalidDeposit`
|
||||
- **THEN** the wallet shows a clear fee error and the family is not created
|
||||
|
||||
### 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
|
||||
- **WHEN** the owner enters a name and description within the byte limits
|
||||
- **THEN** the input passes validation and is submitted
|
||||
|
||||
#### Scenario: Over-limit input is blocked with an inline error
|
||||
- **WHEN** the owner enters a name or description whose byte length exceeds its configured limit (e.g. a multi-byte emoji pushing the byte count over)
|
||||
- **THEN** the wallet shows an inline over-limit error and does not submit
|
||||
|
||||
#### Scenario: Special characters and scripts are sanitised
|
||||
- **WHEN** the owner enters input containing HTML/script tags or injection attempts
|
||||
- **THEN** the wallet neutralises the input and never renders it as executable markup
|
||||
|
||||
#### Scenario: Owner edits name and description after creation
|
||||
- **WHEN** the owner edits the name and/or description of an existing family with valid input
|
||||
- **THEN** the updated values are persisted and reflected in the family management surface
|
||||
|
||||
### 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
|
||||
- **WHEN** the owner enters a valid node ID for an existing, family-free node
|
||||
- **THEN** the invite is sent and a confirmation is shown
|
||||
|
||||
#### Scenario: Node already in a family is warned, not invited
|
||||
- **WHEN** the entered node is already a member of a family
|
||||
- **THEN** the wallet shows an "already in family" warning and does not send the invite
|
||||
|
||||
#### Scenario: Non-existent node is warned, not invited
|
||||
- **WHEN** the entered node does not exist or is unbonding
|
||||
- **THEN** the wallet shows a "node does not exist" warning and does not send the invite
|
||||
|
||||
#### Scenario: Invalid node ID is rejected
|
||||
- **WHEN** the owner enters a malformed node ID
|
||||
- **THEN** the wallet shows a clear validation error and does not submit
|
||||
|
||||
### 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
|
||||
- **WHEN** the owner withdraws a pending, not-yet-expired invite and confirms the prompt
|
||||
- **THEN** the invite is revoked on-chain, removed from the pending list, and the state refreshes
|
||||
|
||||
#### Scenario: Expired invite shows as expired with a clear option
|
||||
- **WHEN** a pending invite's `expired` flag is true
|
||||
- **THEN** the wallet displays it as expired and offers a dismiss/clear action
|
||||
|
||||
#### Scenario: Clearing an expired invite requires confirmation
|
||||
- **WHEN** the owner clears an expired invite
|
||||
- **THEN** a confirmation prompt is shown, and on confirm the invite is removed from the pending list and the state refreshes
|
||||
|
||||
### 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
|
||||
- **WHEN** a section has more entries than one page
|
||||
- **THEN** the wallet fetches additional pages using `start_after`/`start_next_after` rather than loading the whole section at once
|
||||
|
||||
#### Scenario: Records are grouped into sections
|
||||
- **WHEN** the owner opens the member list
|
||||
- **THEN** records appear under Pending, Joined, Rejected, and Removed according to which contract query produced them
|
||||
|
||||
#### Scenario: Node appears in multiple sections when history justifies it
|
||||
- **WHEN** a node is currently a member of the family AND has been kicked or has left at some earlier point
|
||||
- **THEN** it appears as a row in Joined for the current membership AND as a separate row in Removed for the past kick/leave
|
||||
|
||||
#### Scenario: Revoked invitations are not shown in the member list
|
||||
- **WHEN** a node has only past `Revoked` invitations from this family (no current membership, no pending invite, no past membership, no past Rejected invitation)
|
||||
- **THEN** the node does not appear in the member list
|
||||
|
||||
#### Scenario: Empty section shows an empty state
|
||||
- **WHEN** a section has no entries (e.g. no pending invites)
|
||||
- **THEN** the wallet renders an empty state for that section
|
||||
|
||||
#### Scenario: List reflects state after an action
|
||||
- **WHEN** the underlying contract state changes (invite accepted, member kicked, etc.) and the list refreshes
|
||||
- **THEN** the new record appears in its corresponding section, while any pre-existing records for the same node remain in their own sections
|
||||
|
||||
### 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
|
||||
- **WHEN** the owner kicks a member and confirms the prompt
|
||||
- **THEN** `KickFromFamily` is triggered and the node moves to Removed in the member list
|
||||
|
||||
#### Scenario: Cancellation makes no change
|
||||
- **WHEN** the owner opens the removal confirmation prompt and cancels
|
||||
- **THEN** no contract call is made and the member remains Joined
|
||||
|
||||
### 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
|
||||
- **WHEN** the owner deletes a family with zero members and confirms the prompt
|
||||
- **THEN** `DisbandFamily` is triggered, the family is removed, and the creation fee refund is reflected
|
||||
|
||||
#### Scenario: Deleting a non-empty family is blocked
|
||||
- **WHEN** the owner attempts to delete a family that still has members
|
||||
- **THEN** the wallet shows a clear `FamilyNotEmpty` error and the family is not removed
|
||||
@@ -0,0 +1,72 @@
|
||||
## 1. Types & request bindings
|
||||
|
||||
- [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
|
||||
|
||||
- [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`)
|
||||
|
||||
- [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)
|
||||
|
||||
- [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)
|
||||
|
||||
- [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
|
||||
|
||||
- [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)
|
||||
|
||||
- [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
|
||||
|
||||
- [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
|
||||
|
||||
- [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 **Realised by the `node-families-real-ipc` change.** The Rust Tauri command handlers (the missing layer this task waited on) are now implemented in `src-tauri/src/operations/families/` and registered in `main.rs`; the real `FamiliesContextProvider` is wired to them and `controlledNodeIds` derives from the bonded node. The remaining manual IPC smoke on a wired build (create + invite + accept against sandbox) is tracked there as §3.3 / §4 / §5 (needs the native app + sandbox + the funded test account).
|
||||
- [x] 9.5 **RESOLVED:** `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` is confirmed in the `node-families-contract` source (`src/msg.rs`), matching the wallet's `requests/families.ts`, mock execute, and TS types. Verified in `node-families-real-ipc` §6.2.
|
||||
@@ -0,0 +1,20 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@@ -19,11 +19,19 @@
|
||||
"tauri:build:adhoc": "APPLE_SIGNING_IDENTITY=- tauri build -b app",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:buildx86": "tauri build --target x86_64-apple-darwin",
|
||||
"tauri:build:mock": "WALLET_MOCK_FAMILIES=on run-s webpack:prod tauri:build:mock:bin",
|
||||
"tauri:build:mock:bin": "tauri build --no-bundle --config src-tauri/tauri.mock.conf.json",
|
||||
"test": "jest --config jest.config.cjs",
|
||||
"tsc": "tsc --noEmit true",
|
||||
"tsc:watch": "tsc --noEmit true --watch",
|
||||
"webpack:dev": "webpack serve --config webpack.dev.js",
|
||||
"webpack:prod": "webpack --progress --config webpack.prod.js"
|
||||
"webpack:dev:mock": "WALLET_MOCK_FAMILIES=on webpack serve --config webpack.dev.js",
|
||||
"webpack:prod": "webpack --progress --config webpack.prod.js",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:tauri": "node e2e-tauri/run.mjs",
|
||||
"test:storybook": "test-storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-simple-access": "catalog:",
|
||||
@@ -41,21 +49,22 @@
|
||||
"@emotion/use-insertion-effect-with-fallbacks": "catalog:",
|
||||
"@emotion/utils": "catalog:",
|
||||
"@emotion/weak-memoize": "catalog:",
|
||||
"@fission-ai/openspec": "^1.3.1",
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@mui/base": "catalog:",
|
||||
"@mui/icons-material": "catalog:",
|
||||
"@mui/lab": "catalog:",
|
||||
"@mui/material": "catalog:",
|
||||
"@mui/private-theming": "catalog:",
|
||||
"@mui/styles": "catalog:",
|
||||
"@mui/system": "catalog:",
|
||||
"@mui/icons-material": "catalog:",
|
||||
"@mui/utils": "catalog:",
|
||||
"@nymproject/react": "workspace:*",
|
||||
"@nymproject/types": "workspace:*",
|
||||
"@popperjs/core": "catalog:",
|
||||
"@remix-run/router": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/query-core": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tauri-apps/api": "catalog:",
|
||||
"@tauri-apps/plugin-clipboard-manager": "catalog:",
|
||||
"@tauri-apps/plugin-opener": "catalog:",
|
||||
@@ -69,8 +78,8 @@
|
||||
"bn.js": "catalog:",
|
||||
"bs58": "catalog:",
|
||||
"buffer": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"clipboard-copy": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"colornames": "catalog:",
|
||||
"d3-array": "catalog:",
|
||||
"d3-color": "catalog:",
|
||||
@@ -137,12 +146,26 @@
|
||||
"@babel/preset-env": "catalog:",
|
||||
"@babel/preset-react": "catalog:",
|
||||
"@babel/preset-typescript": "catalog:",
|
||||
"@nymproject/webpack": "workspace:*",
|
||||
"@figma/code-connect": "^1.4.7",
|
||||
"@nymproject/eslint-config-react-typescript": "workspace:*",
|
||||
"@nymproject/webpack": "workspace:*",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "catalog:",
|
||||
"@storybook/addon-a11y": "^10.4.1",
|
||||
"@storybook/addon-docs": "^10.4.1",
|
||||
"@storybook/addon-mcp": "^0.6.0",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/react-webpack5": "^10.4.1",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@svgr/webpack": "catalog:",
|
||||
"@tauri-apps/cli": "catalog:",
|
||||
"@testing-library/dom": "catalog:",
|
||||
"@wdio/cli": "^9.12.0",
|
||||
"@wdio/local-runner": "^9.12.0",
|
||||
"@wdio/mocha-framework": "^9.12.0",
|
||||
"@wdio/spec-reporter": "^9.12.0",
|
||||
"webdriverio": "^9.12.0",
|
||||
"tsx": "^4.19.2",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@types/big.js": "catalog:",
|
||||
@@ -176,6 +199,7 @@
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint-plugin-react": "catalog:",
|
||||
"eslint-plugin-react-hooks": "catalog:",
|
||||
"eslint-plugin-storybook": "^10.4.1",
|
||||
"favicons": "catalog:",
|
||||
"favicons-webpack-plugin": "catalog:",
|
||||
"file-loader": "catalog:",
|
||||
@@ -187,6 +211,7 @@
|
||||
"prettier": "catalog:",
|
||||
"react-refresh": "catalog:",
|
||||
"react-refresh-typescript": "catalog:",
|
||||
"storybook": "^10.4.1",
|
||||
"style-loader": "catalog:",
|
||||
"thread-loader": "catalog:",
|
||||
"ts-jest": "^29.4.9",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Primary e2e config (design D1/D10). Drives the Family page inside the real app shell +
|
||||
* router via the mock-wired dev server (`main.mock.html`, design D2) — a real browser
|
||||
* session with no Tauri runtime or chain. Cross-platform, runs locally on macOS.
|
||||
*
|
||||
* Requires the workspace packages to be built (`pnpm --dir .. run build`) plus
|
||||
* `npx playwright install chromium`. The webServer launches `webpack:dev:mock` on :9000
|
||||
* with `WALLET_MOCK_FAMILIES=on` (reused if already running locally).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: 'list',
|
||||
// Build the static visual flow report (e2e-report/) from per-step screenshots.
|
||||
globalSetup: './e2e/report.globalSetup.ts',
|
||||
globalTeardown: './e2e/report.globalTeardown.ts',
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:9000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run webpack:dev:mock',
|
||||
// The mock entry's generated page — only exists when WALLET_MOCK_FAMILIES=on.
|
||||
url: 'http://localhost:9000/main.mock.html',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 300_000,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
});
|
||||
@@ -60,6 +60,7 @@ nym-validator-client = { path = "../../common/client-libs/validator-client" }
|
||||
nym-crypto = { path = "../../common/crypto", features = ["asymmetric"] }
|
||||
nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" }
|
||||
nym-mixnet-contract-common = { path = "../../common/cosmwasm-smart-contracts/mixnet-contract" }
|
||||
nym-node-families-contract-common = { path = "../../common/cosmwasm-smart-contracts/node-families-contract" }
|
||||
nym-vesting-contract-common = { path = "../../common/cosmwasm-smart-contracts/vesting-contract" }
|
||||
nym-config = { path = "../../common/config" }
|
||||
nym-types = { path = "../../common/types" }
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Headless **sandbox** smoke for the node-families real-IPC layer
|
||||
//! (node-families-real-ipc tasks 4.1 read smoke / 5.2-5.3 guarded writes).
|
||||
//!
|
||||
//! GUI automation of the real wallet can't run on macOS (tauri-driver is
|
||||
//! Linux/Windows-only; Playwright can't drive the native webview), so this
|
||||
//! exercises the exact `validator-client` calls the Tauri commands in
|
||||
//! `operations/families/` wrap, against the contract deployed to sandbox
|
||||
//! (address bundled in `nym-wallet-types/src/network/sandbox.rs`).
|
||||
//!
|
||||
//! The funded sandbox account mnemonic is read from `.env` at runtime
|
||||
//! (`TAURI-WALLET-MNEMONIC`); it is **never** printed, logged, or written
|
||||
//! anywhere. Run from the `nym-wallet/` directory so `.env` is found:
|
||||
//!
|
||||
//! # read-only smoke (safe, no state change) — task 4.1
|
||||
//! cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke
|
||||
//!
|
||||
//! # + guarded write journey (create → rename → disband, with cleanup) — task 5.2
|
||||
//! cargo run --manifest-path src-tauri/Cargo.toml --example sandbox_families_smoke -- --write
|
||||
//!
|
||||
//! The write journey only touches a throwaway family this account creates and
|
||||
//! disbands within the run; it refuses to start if the account already owns a
|
||||
//! family (so it never clobbers pre-existing state).
|
||||
|
||||
use std::error::Error;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bip39::Mnemonic;
|
||||
use nym_config::defaults::NymNetworkDetails;
|
||||
use nym_mixnet_contract_common::NodeId;
|
||||
use nym_node_families_contract_common::{Config as FamilyConfig, NodeFamilyId};
|
||||
use nym_validator_client::nyxd::contract_traits::{
|
||||
MixnetQueryClient, NodeFamiliesQueryClient, NodeFamiliesSigningClient, NymContractsProvider,
|
||||
PagedNodeFamiliesQueryClient,
|
||||
};
|
||||
use nym_validator_client::nyxd::cosmwasm_client::types::ExecuteResult;
|
||||
use nym_validator_client::nyxd::{AccountId, Coin, CosmWasmClient};
|
||||
use nym_wallet_types::network::Network as WalletNetwork;
|
||||
|
||||
type Smoke = Result<(), Box<dyn Error>>;
|
||||
type Client = nym_validator_client::DirectSigningHttpRpcValidatorClient;
|
||||
|
||||
fn tx(label: &str, res: ExecuteResult) {
|
||||
println!(" {label} → tx {}", res.transaction_hash);
|
||||
}
|
||||
|
||||
/// Read a value for `key` from `.env` (handles hyphenated keys, which not every
|
||||
/// dotenv loader exports into the process env) or fall back to the process env.
|
||||
/// Secret values are returned for use but never printed by this harness.
|
||||
fn read_env(key: &str) -> Option<String> {
|
||||
[".env", "nym-wallet/.env", "../.env"]
|
||||
.iter()
|
||||
.find_map(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|contents| {
|
||||
contents.lines().find_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
line.strip_prefix(&format!("{key}="))
|
||||
.map(|v| v.trim().trim_matches(['"', '\'']).to_string())
|
||||
})
|
||||
})
|
||||
.or_else(|| std::env::var(key).ok())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Load a mnemonic from the first of `keys` present in `.env`.
|
||||
fn mnemonic_from(keys: &[&str]) -> Result<Mnemonic, Box<dyn Error>> {
|
||||
let phrase = keys
|
||||
.iter()
|
||||
.find_map(|k| read_env(k))
|
||||
.ok_or_else(|| format!("none of {keys:?} found in .env (run from the nym-wallet/ dir)"))?;
|
||||
Ok(Mnemonic::from_str(phrase.trim())?)
|
||||
}
|
||||
|
||||
fn build_client(mnemonic: Mnemonic) -> Result<Client, Box<dyn Error>> {
|
||||
let network: NymNetworkDetails = WalletNetwork::SANDBOX.into();
|
||||
let config = nym_validator_client::Config::try_from_nym_network_details(&network)?;
|
||||
Ok(nym_validator_client::Client::new_signing(config, mnemonic)?)
|
||||
}
|
||||
|
||||
/// The numeric node_id an account controls (nym-node, else legacy mixnode), if any.
|
||||
async fn controlled_node(client: &Client) -> Result<Option<NodeId>, Box<dyn Error>> {
|
||||
let me = client.nyxd.address();
|
||||
if let Some(d) = client.nyxd.get_owned_nymnode(&me).await?.details {
|
||||
return Ok(Some(d.bond_information.node_id));
|
||||
}
|
||||
if let Some(m) = client.nyxd.get_owned_mixnode(&me).await?.mixnode_details {
|
||||
return Ok(Some(m.bond_information.mix_id));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Print the on-chain family/node state of the owner + operator accounts so we
|
||||
/// can pick the right write flow before mutating anything.
|
||||
async fn accounts_state() -> Smoke {
|
||||
let owner = build_client(mnemonic_from(&["FAMILY_OWNER_MNEMONIC"])?)?;
|
||||
let operator = build_client(mnemonic_from(&["ACCOUNT_WITH_BONDED_NODE_MNEMONIC"])?)?;
|
||||
|
||||
let o_addr = owner.nyxd.address();
|
||||
let o_family = owner.nyxd.get_family_by_owner(&o_addr).await?.family;
|
||||
println!("\n=== ACCOUNTS STATE ===");
|
||||
println!("FAMILY_OWNER = {o_addr}");
|
||||
match &o_family {
|
||||
Some(f) => println!(
|
||||
" owns family id={} name={:?} members={}",
|
||||
f.id, f.name, f.members
|
||||
),
|
||||
None => println!(" owns no family"),
|
||||
}
|
||||
|
||||
let p_addr = operator.nyxd.address();
|
||||
let node = controlled_node(&operator).await?;
|
||||
println!("ACCOUNT_WITH_BONDED_NODE = {p_addr}");
|
||||
match node {
|
||||
Some(id) => {
|
||||
let membership = operator.nyxd.get_family_membership(id).await?.family_id;
|
||||
println!(" controls node_id={id}, current family membership = {membership:?}");
|
||||
}
|
||||
None => println!(" controls no node"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the contract `Config` straight from raw state (the same path
|
||||
/// `get_family_config` uses — there is no `GetConfig` smart query).
|
||||
async fn read_config(
|
||||
client: &nym_validator_client::DirectSigningHttpRpcValidatorClient,
|
||||
) -> Result<FamilyConfig, Box<dyn Error>> {
|
||||
let contract = client
|
||||
.nyxd
|
||||
.node_families_contract_address()
|
||||
.ok_or("node_families_contract_address is not set for SANDBOX")?
|
||||
.clone();
|
||||
let raw = client
|
||||
.nyxd
|
||||
.query_contract_raw(&contract, b"config".to_vec())
|
||||
.await?;
|
||||
Ok(serde_json::from_slice(&raw)?)
|
||||
}
|
||||
|
||||
async fn read_smoke(client: &nym_validator_client::DirectSigningHttpRpcValidatorClient) -> Smoke {
|
||||
println!("\n=== READ SMOKE (task 4.1) ===");
|
||||
|
||||
let config = read_config(client).await?;
|
||||
println!(
|
||||
"config: create_family_fee={} {}, name_limit={}, desc_limit={}, default_invite_validity={}s",
|
||||
config.create_family_fee.amount,
|
||||
config.create_family_fee.denom,
|
||||
config.family_name_length_limit,
|
||||
config.family_description_length_limit,
|
||||
config.default_invitation_validity_secs,
|
||||
);
|
||||
|
||||
let families = client.nyxd.get_all_families().await?;
|
||||
println!(
|
||||
"get_all_families → {} family/families on sandbox",
|
||||
families.len()
|
||||
);
|
||||
for f in &families {
|
||||
println!(
|
||||
" • id={} name={:?} owner={} members={} created_at={}",
|
||||
f.id, f.name, f.owner, f.members, f.created_at
|
||||
);
|
||||
let members = client.nyxd.get_all_family_members_for_family(f.id).await?;
|
||||
for m in &members {
|
||||
println!(
|
||||
" member node_id={} joined_at={}",
|
||||
m.node_id, m.membership.joined_at
|
||||
);
|
||||
}
|
||||
let pending = client
|
||||
.nyxd
|
||||
.get_all_pending_invitations_for_family(f.id)
|
||||
.await?;
|
||||
for p in &pending {
|
||||
println!(
|
||||
" pending invite node_id={} expires_at={} expired={}",
|
||||
p.invitation.node_id, p.invitation.expires_at, p.expired
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let me = client.nyxd.address();
|
||||
let owned = client.nyxd.get_family_by_owner(&me).await?;
|
||||
match owned.family {
|
||||
Some(f) => println!("this account owns family id={} ({:?})", f.id, f.name),
|
||||
None => println!("this account does not currently own a family"),
|
||||
}
|
||||
|
||||
println!("read smoke OK ✅");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_journey(client: &Client) -> Smoke {
|
||||
println!("\n=== GUARDED WRITE JOURNEY (tasks 5.2 + 5.3: full owner + operator lifecycle) ===");
|
||||
|
||||
let me = client.nyxd.address();
|
||||
|
||||
// Guard: never clobber a pre-existing family owned by this account.
|
||||
if let Some(existing) = client.nyxd.get_family_by_owner(&me).await?.family {
|
||||
return Err(format!(
|
||||
"account already owns family id={} ({:?}); refusing to run the write journey — \
|
||||
disband it manually first if this is the throwaway test account",
|
||||
existing.id, existing.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Resolve the numeric node_id this account controls. The account acts as
|
||||
// BOTH family owner (invite/kick/revoke/disband) and node operator
|
||||
// (accept/reject/leave), so a single funded account drives the whole
|
||||
// lifecycle — covering all 9 execute commands.
|
||||
// The member-management commands (invite/accept/reject/kick/revoke/leave)
|
||||
// need a numeric node_id this account controls; the account is both family
|
||||
// owner and node operator, so one account can drive the whole lifecycle.
|
||||
// If no node is bonded we still run the owner-only subset (create/update/
|
||||
// disband) and skip the member steps with a clear notice.
|
||||
let nym_node = client.nyxd.get_owned_nymnode(&me).await?.details;
|
||||
let legacy_mixnode = client.nyxd.get_owned_mixnode(&me).await?.mixnode_details;
|
||||
let controlled: Option<NodeId> = match (&nym_node, &legacy_mixnode) {
|
||||
(Some(d), _) => Some(d.bond_information.node_id),
|
||||
(_, Some(m)) => Some(m.bond_information.mix_id),
|
||||
(None, None) => None,
|
||||
};
|
||||
match controlled {
|
||||
Some(id) => println!(
|
||||
"controlled node_id = {id} ({})",
|
||||
if nym_node.is_some() {
|
||||
"nym-node"
|
||||
} else {
|
||||
"legacy mixnode"
|
||||
}
|
||||
),
|
||||
None => println!(
|
||||
"⚠️ account controls no node on the sandbox mixnet contract — running the owner-only \
|
||||
subset (create/update/disband); skipping invite/accept/reject/kick/revoke/leave"
|
||||
),
|
||||
}
|
||||
|
||||
let config = read_config(client).await?;
|
||||
let creation_fee: Vec<Coin> = vec![config.create_family_fee.into()];
|
||||
|
||||
println!("\n[1] create_family attaching {} ...", creation_fee[0]);
|
||||
tx(
|
||||
"create_family",
|
||||
client
|
||||
.nyxd
|
||||
.create_family(
|
||||
"smoke-test-family".to_string(),
|
||||
"throwaway family created by sandbox_families_smoke".to_string(),
|
||||
None,
|
||||
creation_fee,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let fid = poll_for_owned_family(client, &me).await?.id;
|
||||
println!(" → family id={fid}");
|
||||
|
||||
println!("\n[2] update_family (rename) ...");
|
||||
tx(
|
||||
"update_family",
|
||||
client
|
||||
.nyxd
|
||||
.update_family(Some("smoke-test-renamed".to_string()), None, None)
|
||||
.await?,
|
||||
);
|
||||
|
||||
if let Some(node_id) = controlled {
|
||||
println!("\n[3] invite_to_family → revoke_family_invitation (owner revokes a pending invite) ...");
|
||||
tx(
|
||||
"invite_to_family",
|
||||
client.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(client, fid, node_id, true).await?;
|
||||
tx(
|
||||
"revoke_family_invitation",
|
||||
client.nyxd.revoke_family_invitation(node_id, None).await?,
|
||||
);
|
||||
poll_pending(client, fid, node_id, false).await?;
|
||||
|
||||
println!("\n[4] invite_to_family → reject_family_invitation (operator rejects) ...");
|
||||
tx(
|
||||
"invite_to_family",
|
||||
client.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(client, fid, node_id, true).await?;
|
||||
tx(
|
||||
"reject_family_invitation",
|
||||
client
|
||||
.nyxd
|
||||
.reject_family_invitation(fid, node_id, None)
|
||||
.await?,
|
||||
);
|
||||
poll_pending(client, fid, node_id, false).await?;
|
||||
|
||||
println!("\n[5] invite → accept_family_invitation → leave_family (operator joins then leaves) ...");
|
||||
tx(
|
||||
"invite_to_family",
|
||||
client.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(client, fid, node_id, true).await?;
|
||||
tx(
|
||||
"accept_family_invitation",
|
||||
client
|
||||
.nyxd
|
||||
.accept_family_invitation(fid, node_id, None)
|
||||
.await?,
|
||||
);
|
||||
poll_membership(client, node_id, Some(fid)).await?;
|
||||
tx(
|
||||
"leave_family",
|
||||
client.nyxd.leave_family(node_id, None).await?,
|
||||
);
|
||||
poll_membership(client, node_id, None).await?;
|
||||
|
||||
println!("\n[6] invite → accept → kick_from_family (owner kicks the member) ...");
|
||||
tx(
|
||||
"invite_to_family",
|
||||
client.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(client, fid, node_id, true).await?;
|
||||
tx(
|
||||
"accept_family_invitation",
|
||||
client
|
||||
.nyxd
|
||||
.accept_family_invitation(fid, node_id, None)
|
||||
.await?,
|
||||
);
|
||||
poll_membership(client, node_id, Some(fid)).await?;
|
||||
tx(
|
||||
"kick_from_family",
|
||||
client.nyxd.kick_from_family(node_id, None).await?,
|
||||
);
|
||||
poll_membership(client, node_id, None).await?;
|
||||
}
|
||||
|
||||
println!("\n[7] disband_family (cleanup) ...");
|
||||
tx("disband_family", client.nyxd.disband_family(None).await?);
|
||||
poll_until_no_owned_family(client, &me).await?;
|
||||
|
||||
if controlled.is_some() {
|
||||
println!("\nwrite journey OK ✅ — all 9 execute commands exercised; state cleaned up");
|
||||
} else {
|
||||
println!(
|
||||
"\nwrite journey OK ✅ — owner subset (create/update/disband) exercised; \
|
||||
state cleaned up. Bond a node to this account to also cover the 6 member commands"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On-chain state isn't readable until the block commits; poll briefly.
|
||||
async fn poll_for_owned_family(
|
||||
client: &Client,
|
||||
owner: &AccountId,
|
||||
) -> Result<nym_node_families_contract_common::NodeFamily, Box<dyn Error>> {
|
||||
for _ in 0..10 {
|
||||
if let Some(f) = client.nyxd.get_family_by_owner(owner).await?.family {
|
||||
return Ok(f);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err("timed out waiting for the created family to appear on chain".into())
|
||||
}
|
||||
|
||||
async fn poll_until_no_owned_family(client: &Client, owner: &AccountId) -> Smoke {
|
||||
for _ in 0..10 {
|
||||
if client
|
||||
.nyxd
|
||||
.get_family_by_owner(owner)
|
||||
.await?
|
||||
.family
|
||||
.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err("timed out waiting for the family to be disbanded".into())
|
||||
}
|
||||
|
||||
/// Wait until a pending invitation for `(fid, node_id)` is present/absent.
|
||||
async fn poll_pending(client: &Client, fid: NodeFamilyId, node_id: NodeId, want: bool) -> Smoke {
|
||||
for _ in 0..10 {
|
||||
let present = client
|
||||
.nyxd
|
||||
.get_pending_invitation(fid, node_id)
|
||||
.await?
|
||||
.invitation
|
||||
.is_some();
|
||||
if present == want {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(format!("timed out waiting for pending invitation present={want}").into())
|
||||
}
|
||||
|
||||
/// Wait until `node_id`'s membership equals `want` (`Some(fid)` joined / `None` not a member).
|
||||
async fn poll_membership(client: &Client, node_id: NodeId, want: Option<NodeFamilyId>) -> Smoke {
|
||||
for _ in 0..10 {
|
||||
if client.nyxd.get_family_membership(node_id).await?.family_id == want {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(format!("timed out waiting for membership = {want:?}").into())
|
||||
}
|
||||
|
||||
/// Two-account member/operator journey (task 5.3) against the owner's EXISTING
|
||||
/// family — owner = `FAMILY_OWNER` (invite/revoke/kick), operator =
|
||||
/// `ACCOUNT_WITH_BONDED_NODE` (accept/reject/leave). Exercises all 6 member
|
||||
/// commands and restores the node's original membership at the end, so a
|
||||
/// real pre-existing family is left exactly as it was found.
|
||||
async fn member_journey(owner: &Client, operator: &Client) -> Smoke {
|
||||
println!("\n=== MEMBER / OPERATOR JOURNEY (task 5.3) ===");
|
||||
|
||||
let owner_addr = owner.nyxd.address();
|
||||
let fid = owner
|
||||
.nyxd
|
||||
.get_family_by_owner(&owner_addr)
|
||||
.await?
|
||||
.family
|
||||
.ok_or("FAMILY_OWNER owns no family to run the member journey against")?
|
||||
.id;
|
||||
let node_id = controlled_node(operator)
|
||||
.await?
|
||||
.ok_or("ACCOUNT_WITH_BONDED_NODE controls no node")?;
|
||||
let initial = operator
|
||||
.nyxd
|
||||
.get_family_membership(node_id)
|
||||
.await?
|
||||
.family_id;
|
||||
println!("owner={owner_addr}\noperator node_id={node_id}, family={fid}, initial membership={initial:?} (restored at end)");
|
||||
|
||||
// Baseline: clear any stray pending invite, then make node a clean member of `fid`.
|
||||
if owner
|
||||
.nyxd
|
||||
.get_pending_invitation(fid, node_id)
|
||||
.await?
|
||||
.invitation
|
||||
.is_some()
|
||||
{
|
||||
tx(
|
||||
"revoke (baseline)",
|
||||
owner.nyxd.revoke_family_invitation(node_id, None).await?,
|
||||
);
|
||||
poll_pending(owner, fid, node_id, false).await?;
|
||||
}
|
||||
if operator
|
||||
.nyxd
|
||||
.get_family_membership(node_id)
|
||||
.await?
|
||||
.family_id
|
||||
!= Some(fid)
|
||||
{
|
||||
tx(
|
||||
"invite (baseline)",
|
||||
owner.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(owner, fid, node_id, true).await?;
|
||||
tx(
|
||||
"accept (baseline)",
|
||||
operator
|
||||
.nyxd
|
||||
.accept_family_invitation(fid, node_id, None)
|
||||
.await?,
|
||||
);
|
||||
poll_membership(operator, node_id, Some(fid)).await?;
|
||||
}
|
||||
|
||||
println!("\n[a] kick_from_family (owner removes the member) ...");
|
||||
tx(
|
||||
"kick_from_family",
|
||||
owner.nyxd.kick_from_family(node_id, None).await?,
|
||||
);
|
||||
poll_membership(operator, node_id, None).await?;
|
||||
|
||||
println!("\n[b] invite_to_family → reject_family_invitation (operator rejects) ...");
|
||||
tx(
|
||||
"invite_to_family",
|
||||
owner.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(owner, fid, node_id, true).await?;
|
||||
tx(
|
||||
"reject_family_invitation",
|
||||
operator
|
||||
.nyxd
|
||||
.reject_family_invitation(fid, node_id, None)
|
||||
.await?,
|
||||
);
|
||||
poll_pending(owner, fid, node_id, false).await?;
|
||||
|
||||
println!("\n[c] invite_to_family → revoke_family_invitation (owner revokes) ...");
|
||||
tx(
|
||||
"invite_to_family",
|
||||
owner.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(owner, fid, node_id, true).await?;
|
||||
tx(
|
||||
"revoke_family_invitation",
|
||||
owner.nyxd.revoke_family_invitation(node_id, None).await?,
|
||||
);
|
||||
poll_pending(owner, fid, node_id, false).await?;
|
||||
|
||||
println!(
|
||||
"\n[d] invite → accept_family_invitation → leave_family (operator joins then leaves) ..."
|
||||
);
|
||||
tx(
|
||||
"invite_to_family",
|
||||
owner.nyxd.invite_to_family(node_id, None, None).await?,
|
||||
);
|
||||
poll_pending(owner, fid, node_id, true).await?;
|
||||
tx(
|
||||
"accept_family_invitation",
|
||||
operator
|
||||
.nyxd
|
||||
.accept_family_invitation(fid, node_id, None)
|
||||
.await?,
|
||||
);
|
||||
poll_membership(operator, node_id, Some(fid)).await?;
|
||||
tx(
|
||||
"leave_family",
|
||||
operator.nyxd.leave_family(node_id, None).await?,
|
||||
);
|
||||
poll_membership(operator, node_id, None).await?;
|
||||
|
||||
// Restore the node's original membership so a real family is left unchanged.
|
||||
println!("\n[restore] returning node {node_id} to its initial membership {initial:?} ...");
|
||||
match initial {
|
||||
Some(f) if f == fid => {
|
||||
tx("invite_to_family", owner.nyxd.invite_to_family(node_id, None, None).await?);
|
||||
poll_pending(owner, fid, node_id, true).await?;
|
||||
tx("accept_family_invitation", operator.nyxd.accept_family_invitation(fid, node_id, None).await?);
|
||||
poll_membership(operator, node_id, Some(fid)).await?;
|
||||
}
|
||||
None => poll_membership(operator, node_id, None).await?,
|
||||
Some(other) => println!(
|
||||
" ⚠️ node was originally in family {other} (not {fid}); leaving it free — re-add manually if needed"
|
||||
),
|
||||
}
|
||||
|
||||
println!("\nmember journey OK ✅ — all 6 member commands exercised; node membership restored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only: report which (if any) node an arbitrary account controls on the
|
||||
/// sandbox mixnet contract. Signing identity is irrelevant — these are queries.
|
||||
async fn bond_check(client: &Client, addr: &str) -> Smoke {
|
||||
let account = AccountId::from_str(addr)?;
|
||||
let nym_node = client.nyxd.get_owned_nymnode(&account).await?.details;
|
||||
let legacy = client
|
||||
.nyxd
|
||||
.get_owned_mixnode(&account)
|
||||
.await?
|
||||
.mixnode_details;
|
||||
let gateway = client.nyxd.get_owned_gateway(&account).await?.gateway;
|
||||
|
||||
println!("\n=== BOND CHECK: {addr} ===");
|
||||
println!(
|
||||
"mixnet contract: {}",
|
||||
client
|
||||
.nyxd
|
||||
.mixnet_contract_address()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
match (&nym_node, &legacy) {
|
||||
(Some(d), _) => println!(
|
||||
"✅ controls nym-node → node_id = {}",
|
||||
d.bond_information.node_id
|
||||
),
|
||||
(_, Some(m)) => println!(
|
||||
"✅ controls legacy mixnode → node_id (mix_id) = {}",
|
||||
m.bond_information.mix_id
|
||||
),
|
||||
(None, None) => println!("❌ controls no nym-node and no mixnode"),
|
||||
}
|
||||
println!(
|
||||
" gateway: {}",
|
||||
if gateway.is_some() {
|
||||
"yes (gateways can't be family members)"
|
||||
} else {
|
||||
"none"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Smoke {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let do_write = args.iter().any(|a| a == "--write");
|
||||
let do_accounts = args.iter().any(|a| a == "--accounts");
|
||||
let do_member = args.iter().any(|a| a == "--member");
|
||||
let bond_check_addr = args
|
||||
.iter()
|
||||
.position(|a| a == "--bond-check")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned();
|
||||
|
||||
// These only need the owner/operator keys, not the primary account.
|
||||
if do_accounts {
|
||||
return accounts_state().await;
|
||||
}
|
||||
if do_member {
|
||||
let owner = build_client(mnemonic_from(&["FAMILY_OWNER_MNEMONIC"])?)?;
|
||||
let operator = build_client(mnemonic_from(&["ACCOUNT_WITH_BONDED_NODE_MNEMONIC"])?)?;
|
||||
return member_journey(&owner, &operator).await;
|
||||
}
|
||||
|
||||
let client = build_client(mnemonic_from(&[
|
||||
"TAURI-WALLET-MNEMONIC",
|
||||
"TAURI_WALLET_MNEMONIC",
|
||||
])?)?;
|
||||
|
||||
println!("connected to SANDBOX as {}", client.nyxd.address());
|
||||
println!(
|
||||
"node_families_contract_address = {:?}",
|
||||
client.nyxd.node_families_contract_address()
|
||||
);
|
||||
|
||||
if let Some(addr) = bond_check_addr {
|
||||
bond_check(&client, &addr).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
read_smoke(&client).await?;
|
||||
|
||||
if do_write {
|
||||
write_journey(&client).await?;
|
||||
} else {
|
||||
println!("\n(skipping write journey — pass `-- --write` to run create → rename → disband)");
|
||||
}
|
||||
|
||||
println!("\nDone.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use tauri_plugin_updater::Builder as UpdaterBuilder;
|
||||
|
||||
use crate::menu::SHOW_LOG_WINDOW;
|
||||
use crate::operations::app;
|
||||
use crate::operations::families;
|
||||
use crate::operations::help;
|
||||
use crate::operations::mixnet;
|
||||
use crate::operations::nym_api;
|
||||
@@ -109,6 +110,24 @@ fn main() {
|
||||
mixnet::rewards::get_current_rewarding_parameters,
|
||||
mixnet::send::send,
|
||||
mixnet::bond::get_mixnode_uptime,
|
||||
families::execute::create_family,
|
||||
families::execute::update_family,
|
||||
families::execute::disband_family,
|
||||
families::execute::invite_to_family,
|
||||
families::execute::revoke_family_invitation,
|
||||
families::execute::kick_from_family,
|
||||
families::execute::accept_family_invitation,
|
||||
families::execute::reject_family_invitation,
|
||||
families::execute::leave_family,
|
||||
families::query::get_family_by_id,
|
||||
families::query::get_family_by_owner,
|
||||
families::query::get_family_membership,
|
||||
families::query::get_family_config,
|
||||
families::query::get_family_members_paged,
|
||||
families::query::get_pending_invitations_for_family_paged,
|
||||
families::query::get_pending_invitations_for_node_paged,
|
||||
families::query::get_past_invitations_for_family_paged,
|
||||
families::query::get_past_members_for_family_paged,
|
||||
network_config::add_validator,
|
||||
network_config::get_nym_api_urls,
|
||||
network_config::get_nyxd_urls,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! State-changing node-families commands over [`NodeFamiliesSigningClient`].
|
||||
//!
|
||||
//! Every command uses the wallet's standard auto/simulated gas fee convention
|
||||
//! (design D7): the frontend bindings don't supply an explicit gas `Fee`, so we
|
||||
//! pass `None` and let the client simulate. `create_family` additionally attaches
|
||||
//! the configured `create_family_fee` (a display [`DecCoin`] from
|
||||
//! `get_family_config`) as funds, converted back to its base denomination.
|
||||
//!
|
||||
//! The returned [`TransactionExecuteResult`] is a subset of the frontend's
|
||||
//! `FamilyTxResult` (which adds an optional `family_events`). Per design D2 we
|
||||
//! omit `family_events` — the provider re-derives state via `refreshAll()` after
|
||||
//! every execute, and nothing reads the fabricated events.
|
||||
|
||||
use crate::error::BackendError;
|
||||
use crate::state::WalletState;
|
||||
use nym_mixnet_contract_common::NodeId;
|
||||
use nym_node_families_contract_common::NodeFamilyId;
|
||||
use nym_types::currency::DecCoin;
|
||||
use nym_types::transaction::TransactionExecuteResult;
|
||||
use nym_validator_client::nyxd::contract_traits::NodeFamiliesSigningClient;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_family(
|
||||
name: String,
|
||||
description: String,
|
||||
fee: DecCoin,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Create family: name = {name}, creation_fee = {fee}");
|
||||
let guard = state.read().await;
|
||||
// `fee` here is the contract's `create_family_fee` (attached as funds), not a gas fee.
|
||||
let creation_fee = vec![guard.attempt_convert_to_base_coin(fee)?];
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.create_family(name, description, None, creation_fee)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
log::trace!("<<< {res:?}");
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_family(
|
||||
updated_name: Option<String>,
|
||||
updated_description: Option<String>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Update family: name = {updated_name:?}, description = {updated_description:?}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.update_family(updated_name, updated_description, None)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disband_family(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Disband family");
|
||||
let guard = state.read().await;
|
||||
let res = guard.current_client()?.nyxd.disband_family(None).await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn invite_to_family(
|
||||
node_id: NodeId,
|
||||
validity_secs: Option<u64>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Invite to family: node_id = {node_id}, validity_secs = {validity_secs:?}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.invite_to_family(node_id, validity_secs, None)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn revoke_family_invitation(
|
||||
node_id: NodeId,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Revoke family invitation: node_id = {node_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.revoke_family_invitation(node_id, None)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn kick_from_family(
|
||||
node_id: NodeId,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Kick from family: node_id = {node_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.kick_from_family(node_id, None)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn accept_family_invitation(
|
||||
family_id: NodeFamilyId,
|
||||
node_id: NodeId,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Accept family invitation: family_id = {family_id}, node_id = {node_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.accept_family_invitation(family_id, node_id, None)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reject_family_invitation(
|
||||
family_id: NodeFamilyId,
|
||||
node_id: NodeId,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Reject family invitation: family_id = {family_id}, node_id = {node_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.reject_family_invitation(family_id, node_id, None)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn leave_family(
|
||||
node_id: NodeId,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<TransactionExecuteResult, BackendError> {
|
||||
log::info!(">>> Leave family: node_id = {node_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.leave_family(node_id, None)
|
||||
.await?;
|
||||
log::info!("<<< tx hash = {}", res.transaction_hash);
|
||||
Ok(TransactionExecuteResult::from_execute_result(res, None)?)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Tauri command layer for the `node-families-contract`.
|
||||
//!
|
||||
//! Mirrors `operations/mixnet/`: each `#[tauri::command]` acquires the active
|
||||
//! account's signing client from the wallet [`WalletState`](crate::state::WalletState)
|
||||
//! and delegates to the existing `validator-client` `NodeFamilies{Signing,Query}Client`
|
||||
//! traits, returning the shapes the `src/requests/families.ts` bindings expect.
|
||||
//!
|
||||
//! Split into `execute` (state-changing txs) and `query` (read-only) to match the
|
||||
//! rest of the wallet (design D1).
|
||||
|
||||
pub mod execute;
|
||||
pub mod query;
|
||||
@@ -0,0 +1,319 @@
|
||||
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Read-only node-families commands over [`NodeFamiliesQueryClient`].
|
||||
//!
|
||||
//! The contract's paged responses each carry an echoed key (`family_id` /
|
||||
//! `node_id`) plus a named items field (`members` / `invitations`) and a
|
||||
//! `start_next_after` cursor. The frontend bindings expect a uniform
|
||||
//! `{ items, start_next_after }` envelope ([`FamilyPagedResponse`]), so each
|
||||
//! command flattens the contract response into it. Per the `src/types/families.ts`
|
||||
//! contract — "the request layer is responsible for translating the contract's
|
||||
//! serde envelope into these shapes" — we also normalise the cw_serde tagged
|
||||
//! `FamilyInvitationStatus` enum into the wallet's `{ kind, at }` union.
|
||||
|
||||
use crate::error::BackendError;
|
||||
use crate::state::WalletState;
|
||||
use crate::state::WalletStateInner;
|
||||
use nym_mixnet_contract_common::NodeId;
|
||||
use nym_node_families_contract_common::{
|
||||
Config, FamilyInvitation, FamilyInvitationStatus, NodeFamily, NodeFamilyId,
|
||||
NodeFamilyMembershipResponse, PastFamilyInvitation, PastFamilyInvitationCursor,
|
||||
PastFamilyMember, PastFamilyMemberCursor, PendingFamilyInvitationDetails,
|
||||
};
|
||||
use nym_types::currency::DecCoin;
|
||||
use nym_validator_client::nyxd::contract_traits::{NodeFamiliesQueryClient, NymContractsProvider};
|
||||
use nym_validator_client::nyxd::error::NyxdError;
|
||||
use nym_validator_client::nyxd::{AccountId, Coin, CosmWasmClient};
|
||||
use serde::Serialize;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Uniform `{ items, start_next_after }` envelope the frontend's
|
||||
/// `FamilyPagedResponse<T>` expects. `C` is the (page-specific) cursor type:
|
||||
/// a `NodeId`/`NodeFamilyId` for single-key pages or a `(node_id, counter)`
|
||||
/// tuple for the archived listings.
|
||||
#[derive(Serialize)]
|
||||
pub struct FamilyPagedResponse<T, C> {
|
||||
pub items: Vec<T>,
|
||||
pub start_next_after: Option<C>,
|
||||
}
|
||||
|
||||
/// One current-member row: `{ node_id, joined_at }` (drops the contract's
|
||||
/// nested `membership` envelope the UI doesn't use here).
|
||||
#[derive(Serialize)]
|
||||
pub struct FamilyMemberItem {
|
||||
pub node_id: NodeId,
|
||||
pub joined_at: u64,
|
||||
}
|
||||
|
||||
/// Wallet-friendly `{ kind, at }` form of the contract's tagged
|
||||
/// `FamilyInvitationStatus` enum.
|
||||
#[derive(Serialize)]
|
||||
pub struct PastInvitationStatus {
|
||||
pub kind: String,
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
/// `PastFamilyInvitation` with its status normalised for the frontend.
|
||||
#[derive(Serialize)]
|
||||
pub struct PastFamilyInvitationItem {
|
||||
pub invitation: FamilyInvitation,
|
||||
pub status: PastInvitationStatus,
|
||||
}
|
||||
|
||||
/// Frontend `NodeFamily`: the contract's `paid_fee` (stored in the base
|
||||
/// denomination) is converted to a display [`DecCoin`] and `owner` to a plain
|
||||
/// string, so the UI can `formatCoin(paid_fee)` directly.
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeFamilyView {
|
||||
pub id: NodeFamilyId,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub normalised_name: String,
|
||||
pub members: u64,
|
||||
pub created_at: u64,
|
||||
pub paid_fee: DecCoin,
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// Frontend `FamilyConfig`: `create_family_fee` returned as a display
|
||||
/// [`DecCoin`] (converted from the contract's base-denom `Coin`) so the UI can
|
||||
/// round-trip it straight back into `create_family`.
|
||||
#[derive(Serialize)]
|
||||
pub struct FamilyConfigResponse {
|
||||
pub create_family_fee: DecCoin,
|
||||
pub family_name_length_limit: usize,
|
||||
pub family_description_length_limit: usize,
|
||||
pub default_invitation_validity_secs: u64,
|
||||
}
|
||||
|
||||
fn normalise_status(status: FamilyInvitationStatus) -> PastInvitationStatus {
|
||||
let (kind, at) = match status {
|
||||
FamilyInvitationStatus::Pending { at } => ("Pending", at),
|
||||
FamilyInvitationStatus::Accepted { at } => ("Accepted", at),
|
||||
FamilyInvitationStatus::Rejected { at } => ("Rejected", at),
|
||||
FamilyInvitationStatus::Revoked { at } => ("Revoked", at),
|
||||
};
|
||||
PastInvitationStatus {
|
||||
kind: kind.to_string(),
|
||||
at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_past_invitation(past: PastFamilyInvitation) -> PastFamilyInvitationItem {
|
||||
PastFamilyInvitationItem {
|
||||
invitation: past.invitation,
|
||||
status: normalise_status(past.status),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_family(
|
||||
state: &WalletStateInner,
|
||||
family: NodeFamily,
|
||||
) -> Result<NodeFamilyView, BackendError> {
|
||||
Ok(NodeFamilyView {
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
description: family.description,
|
||||
normalised_name: family.normalised_name,
|
||||
members: family.members,
|
||||
created_at: family.created_at,
|
||||
paid_fee: state.attempt_convert_to_display_dec_coin(Coin::from(family.paid_fee))?,
|
||||
owner: family.owner.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Single-entity queries --------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_family_by_id(
|
||||
family_id: NodeFamilyId,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<Option<NodeFamilyView>, BackendError> {
|
||||
log::trace!(">>> Get family by id: family_id = {family_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_family_by_id(family_id)
|
||||
.await?;
|
||||
res.family.map(|f| map_family(&guard, f)).transpose()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_family_by_owner(
|
||||
owner: String,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<Option<NodeFamilyView>, BackendError> {
|
||||
log::trace!(">>> Get family by owner: owner = {owner}");
|
||||
let owner_addr = AccountId::from_str(&owner)?;
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_family_by_owner(&owner_addr)
|
||||
.await?;
|
||||
res.family.map(|f| map_family(&guard, f)).transpose()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_family_membership(
|
||||
node_id: NodeId,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<NodeFamilyMembershipResponse, BackendError> {
|
||||
log::trace!(">>> Get family membership: node_id = {node_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_family_membership(node_id)
|
||||
.await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// The contract exposes no `GetConfig` smart query (design D5), so we read the
|
||||
/// `Config` `Item` from raw contract state at its storage key (`"config"`).
|
||||
/// `create_family_fee` is stored in the base denomination; we convert it to a
|
||||
/// display `DecCoin` for the UI.
|
||||
#[tauri::command]
|
||||
pub async fn get_family_config(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<FamilyConfigResponse, BackendError> {
|
||||
log::trace!(">>> Get family config");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
let contract = client
|
||||
.nyxd
|
||||
.node_families_contract_address()
|
||||
.ok_or_else(|| NyxdError::unavailable_contract_address("node families contract"))?
|
||||
.clone();
|
||||
let raw = client
|
||||
.nyxd
|
||||
.query_contract_raw(&contract, b"config".to_vec())
|
||||
.await?;
|
||||
let config: Config = serde_json::from_slice(&raw)?;
|
||||
let create_family_fee =
|
||||
guard.attempt_convert_to_display_dec_coin(Coin::from(config.create_family_fee))?;
|
||||
Ok(FamilyConfigResponse {
|
||||
create_family_fee,
|
||||
family_name_length_limit: config.family_name_length_limit,
|
||||
family_description_length_limit: config.family_description_length_limit,
|
||||
default_invitation_validity_secs: config.default_invitation_validity_secs,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Paginated queries ------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_family_members_paged(
|
||||
family_id: NodeFamilyId,
|
||||
start_after: Option<NodeId>,
|
||||
limit: Option<u32>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<FamilyPagedResponse<FamilyMemberItem, NodeId>, BackendError> {
|
||||
log::trace!(">>> Get family members paged: family_id = {family_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_family_members_paged(family_id, start_after, limit)
|
||||
.await?;
|
||||
let items = res
|
||||
.members
|
||||
.into_iter()
|
||||
.map(|m| FamilyMemberItem {
|
||||
node_id: m.node_id,
|
||||
joined_at: m.membership.joined_at,
|
||||
})
|
||||
.collect();
|
||||
Ok(FamilyPagedResponse {
|
||||
items,
|
||||
start_next_after: res.start_next_after,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_pending_invitations_for_family_paged(
|
||||
family_id: NodeFamilyId,
|
||||
start_after: Option<NodeId>,
|
||||
limit: Option<u32>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<FamilyPagedResponse<PendingFamilyInvitationDetails, NodeId>, BackendError> {
|
||||
log::trace!(">>> Get pending invitations for family paged: family_id = {family_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_pending_invitations_for_family_paged(family_id, start_after, limit)
|
||||
.await?;
|
||||
Ok(FamilyPagedResponse {
|
||||
items: res.invitations,
|
||||
start_next_after: res.start_next_after,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_pending_invitations_for_node_paged(
|
||||
node_id: NodeId,
|
||||
start_after: Option<NodeFamilyId>,
|
||||
limit: Option<u32>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<FamilyPagedResponse<PendingFamilyInvitationDetails, NodeFamilyId>, BackendError> {
|
||||
log::trace!(">>> Get pending invitations for node paged: node_id = {node_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_pending_invitations_for_node_paged(node_id, start_after, limit)
|
||||
.await?;
|
||||
Ok(FamilyPagedResponse {
|
||||
items: res.invitations,
|
||||
start_next_after: res.start_next_after,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_past_invitations_for_family_paged(
|
||||
family_id: NodeFamilyId,
|
||||
start_after: Option<PastFamilyInvitationCursor>,
|
||||
limit: Option<u32>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<FamilyPagedResponse<PastFamilyInvitationItem, PastFamilyInvitationCursor>, BackendError>
|
||||
{
|
||||
log::trace!(">>> Get past invitations for family paged: family_id = {family_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_past_invitations_for_family_paged(family_id, start_after, limit)
|
||||
.await?;
|
||||
let items = res
|
||||
.invitations
|
||||
.into_iter()
|
||||
.map(map_past_invitation)
|
||||
.collect();
|
||||
Ok(FamilyPagedResponse {
|
||||
items,
|
||||
start_next_after: res.start_next_after,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_past_members_for_family_paged(
|
||||
family_id: NodeFamilyId,
|
||||
start_after: Option<PastFamilyMemberCursor>,
|
||||
limit: Option<u32>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<FamilyPagedResponse<PastFamilyMember, PastFamilyMemberCursor>, BackendError> {
|
||||
log::trace!(">>> Get past members for family paged: family_id = {family_id}");
|
||||
let guard = state.read().await;
|
||||
let res = guard
|
||||
.current_client()?
|
||||
.nyxd
|
||||
.get_past_members_for_family_paged(family_id, start_after, limit)
|
||||
.await?;
|
||||
Ok(FamilyPagedResponse {
|
||||
items: res.members,
|
||||
start_next_after: res.start_next_after,
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
pub mod app;
|
||||
pub mod families;
|
||||
pub mod help;
|
||||
pub(crate) mod helpers;
|
||||
pub mod mixnet;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Nym Wallet (mock e2e)",
|
||||
"url": "main.mock.html",
|
||||
"width": 1268,
|
||||
"height": 768,
|
||||
"minWidth": 1024,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"useHttpsScheme": false,
|
||||
"backgroundColor": "#242b2d"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
@@ -19,14 +19,30 @@ export const NymCard: FCWithChildren<{
|
||||
borderless?: boolean;
|
||||
/** Omit the card header row (use for fully custom headers inside children). */
|
||||
hideHeader?: boolean;
|
||||
/** Preferred: standard DOM test id. `dataTestid` (camelCase) is kept for back-compat. */
|
||||
'data-testid'?: string;
|
||||
dataTestid?: string;
|
||||
sx?: SxProps;
|
||||
sxTitle?: SxProps;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ title, subheader, Action, Icon, noPadding, borderless, hideHeader, children, dataTestid, sx, sxTitle }) => (
|
||||
}> = ({
|
||||
title,
|
||||
subheader,
|
||||
Action,
|
||||
Icon,
|
||||
noPadding,
|
||||
borderless,
|
||||
hideHeader,
|
||||
children,
|
||||
dataTestid,
|
||||
'data-testid': dataTestidAttr,
|
||||
sx,
|
||||
sxTitle,
|
||||
}) => (
|
||||
<Card
|
||||
variant="outlined"
|
||||
data-testid={hideHeader ? dataTestid : undefined}
|
||||
// Resolved id on the root element (wraps content, so it can scope children).
|
||||
data-testid={dataTestidAttr ?? dataTestid}
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
borderRadius: 4,
|
||||
@@ -49,7 +65,6 @@ export const NymCard: FCWithChildren<{
|
||||
}}
|
||||
title={<Title title={title} Icon={Icon} sx={sxTitle} />}
|
||||
subheader={subheader}
|
||||
data-testid={dataTestid || (typeof title === 'string' ? title : 'nym-card')}
|
||||
subheaderTypographyProps={{ variant: 'subtitle1' }}
|
||||
action={Action}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/* 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 { isMixnode, isNymNode } from 'src/types/global';
|
||||
import { AppContext } from './main';
|
||||
import { FamiliesContext, TFamiliesContext, defaultQueries } from './families';
|
||||
import { useBondingContext } from './bonding';
|
||||
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>();
|
||||
|
||||
// The operator persona is "nodes I control". An account bonds at most one node,
|
||||
// so this is the bonded node's id (the unified mixnet node id — `nodeId` for a
|
||||
// nym-node, `mixId` for a legacy mixnode), or none for a gateway / no bond
|
||||
// (design D3). Sourced from the `BondingContext` the families route now wraps.
|
||||
const { bondedNode } = useBondingContext();
|
||||
const controlledNodeIds = useMemo<NodeId[]>(() => {
|
||||
if (!bondedNode) return [];
|
||||
if (isNymNode(bondedNode)) return [bondedNode.nodeId];
|
||||
if (isMixnode(bondedNode)) return [bondedNode.mixId];
|
||||
return [];
|
||||
}, [bondedNode]);
|
||||
|
||||
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>;
|
||||
};
|
||||