From 105da76c6560ba6984aa58f67627532ed461ddbb Mon Sep 17 00:00:00 2001 From: greatest0fallt1me <1nonlygem@gmail.com> Date: Tue, 30 Jun 2026 01:02:54 +0530 Subject: [PATCH] feat: Add a duplicate-from-existing-commitment prefill action to the create flow - Add `usePrefillFromCommitment` hook that reads `?sourceId` from the URL, fetches the source commitment's configurable parameters, and returns a typed `PrefillData` object (identity-bound fields excluded). - Add `onDuplicate` prop to `CommitmentDetailActions` with a Duplicate button that routes to `/create?sourceId=`. - Update `CreateCommitment` page to apply prefill on mount (skips step 1, prefills step 2) and show an accessible status banner in duplicate mode. - Add `DuplicateCommitment.test.tsx` covering hook happy path, missing source, identity-field exclusion, clamping, and UI integration. - Add `docs/DUPLICATE_COMMITMENT.md` with API, accessibility, usage, and edge-case docs. Co-Authored-By: Claude Sonnet 4.6 --- docs/DUPLICATE_COMMITMENT.md | 141 +++++++++ src/app/create/DuplicateCommitment.test.tsx | 308 ++++++++++++++++++++ src/app/create/page.tsx | 31 ++ src/components/CommitmentDetailActions.tsx | 32 +- src/hooks/usePrefillFromCommitment.ts | 88 ++++++ 5 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 docs/DUPLICATE_COMMITMENT.md create mode 100644 src/app/create/DuplicateCommitment.test.tsx create mode 100644 src/hooks/usePrefillFromCommitment.ts diff --git a/docs/DUPLICATE_COMMITMENT.md b/docs/DUPLICATE_COMMITMENT.md new file mode 100644 index 00000000..ecd5db60 --- /dev/null +++ b/docs/DUPLICATE_COMMITMENT.md @@ -0,0 +1,141 @@ +# Duplicate Commitment — Prefill Create Flow + +This document describes the **Duplicate** action that lets users open the commitment +create wizard pre-filled with the configurable parameters of an existing commitment. + +--- + +## Overview + +When a user wants a new commitment similar to one they already own, they no longer +need to re-enter every field from scratch. A **Duplicate Commitment** button on the +commitment detail page routes them to `/create?sourceId=`, where the wizard +loads the source commitment's parameters and pre-populates step 2 (Configure). + +Identity-bound fields — `id`, `ownerAddress`, on-chain state — are intentionally +**not** copied. Only the configurable parameters listed below are prefilled. + +### Prefilled fields + +| Field | Type | Notes | +|---|---|---| +| `commitmentType` | `"safe" \| "balanced" \| "aggressive"` | Falls back to `"balanced"` if invalid | +| `amount` | `string` | Converted to string; user may edit freely | +| `asset` | `string` | e.g. `"XLM"`, `"USDC"` | +| `durationDays` | `number` | Clamped to `[1, 365]` | +| `maxLossPercent` | `number` | Clamped to `[0, 100]` | + +--- + +## Component / hook API + +### `usePrefillFromCommitment(): PrefillData | null` + +**File:** `src/hooks/usePrefillFromCommitment.ts` + +Reads the `sourceId` query parameter from the current URL, fetches +`/api/commitments/`, and returns a `PrefillData` object. + +Returns `null` while loading, when no `sourceId` is present, or when the source +commitment cannot be loaded (network error, 404, etc.). + +```ts +interface PrefillData { + commitmentType: "safe" | "balanced" | "aggressive"; + amount: string; + asset: string; + durationDays: number; + maxLossPercent: number; +} +``` + +### `CommitmentDetailActions` — `onDuplicate` prop + +**File:** `src/components/CommitmentDetailActions.tsx` + +The existing `CommitmentDetailActions` component accepts an optional +`onDuplicate?: (commitmentId: string) => void` prop. When provided alongside a +`commitmentId`, a **Duplicate Commitment** button is rendered in the +"Additional Actions" section. + +```tsx + router.push(`/create?sourceId=${id}`)} +/> +``` + +### `CreateCommitment` page — prefill banner + +**File:** `src/app/create/page.tsx` + +When the `?sourceId` query parameter is present and a source commitment is loaded +successfully, the page: + +1. Skips step 1 (type selection) and opens directly on step 2 (configure). +2. Pre-fills all configurable fields from the source commitment. +3. Shows a status banner informing the user they are in duplicate mode. + +All pre-filled fields are fully editable. Submitting creates a **new** commitment; +the source is unaffected. + +--- + +## Accessibility + +- The Duplicate button has an explicit `aria-label` describing the action and its + effect: `"Duplicate Commitment - create a new commitment prefilled with these parameters"`. +- The prefill banner uses `role="status"` and `aria-live="polite"` so screen + readers announce it without interrupting the current focus. +- Keyboard focus lands on the configure-step heading (existing behaviour via + `headingRef`) when the wizard opens at step 2. + +--- + +## Usage example + +```tsx +// On a commitment detail page: +import { useRouter } from "next/navigation"; +import { CommitmentDetailActions } from "@/components/CommitmentDetailActions"; + +function CommitmentDetailPage({ commitmentId }: { commitmentId: string }) { + const router = useRouter(); + + return ( + {}} + onViewAttestations={() => {}} + onExportData={() => {}} + onReportIssue={() => {}} + commitmentId={commitmentId} + onDuplicate={(id) => router.push(`/create?sourceId=${encodeURIComponent(id)}`)} + /> + ); +} +``` + +--- + +## Edge cases + +| Scenario | Behaviour | +|---|---| +| `sourceId` not in URL | `usePrefillFromCommitment` returns `null`; wizard starts normally | +| Source commitment returns 404 | Returns `null`; wizard starts normally; no error thrown | +| Network failure during fetch | Returns `null`; wizard starts normally | +| `commitmentType` value unknown | Falls back to `"balanced"` | +| `durationDays` out of range | Clamped to `[1, 365]` | +| `maxLossPercent` out of range | Clamped to `[0, 100]` | +| Draft exists + sourceId present | Prefill wins; resume prompt is dismissed | + +--- + +## Related files + +- `src/hooks/usePrefillFromCommitment.ts` — hook (new) +- `src/components/CommitmentDetailActions.tsx` — Duplicate button (updated) +- `src/app/create/page.tsx` — prefill integration + banner (updated) +- `src/app/create/DuplicateCommitment.test.tsx` — tests (new) diff --git a/src/app/create/DuplicateCommitment.test.tsx b/src/app/create/DuplicateCommitment.test.tsx new file mode 100644 index 00000000..f31392cc --- /dev/null +++ b/src/app/create/DuplicateCommitment.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment happy-dom +/** + * Tests for the duplicate-from-existing-commitment prefill feature. + * + * Covers: + * - usePrefillFromCommitment hook: happy path, missing source, identity fields excluded + * - CommitmentDetailActions: Duplicate button renders and calls onDuplicate + * - CreateCommitment page: banner shown and fields editable when prefill arrives + */ + +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// --------------------------------------------------------------------------- +// Minimal mocks +// --------------------------------------------------------------------------- + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams('sourceId=CMT-42'), +})); + +vi.mock('@/hooks/useWallet', () => ({ useWallet: () => ({ address: '0xABCD' }) })); +vi.mock('@/hooks/useDraftPersistence', () => ({ + useDraftPersistence: () => ({ draft: null, saveDraft: vi.fn(), clearDraft: vi.fn() }), +})); +vi.mock('@/hooks/useGuidedTour', () => ({ + useGuidedTour: () => ({ + isActive: false, currentStepIndex: 0, currentStepConfig: null, + totalSteps: 0, nextStep: vi.fn(), prevStep: vi.fn(), + skipTour: vi.fn(), startTour: vi.fn(), + }), +})); +vi.mock('@/components/shell/AppShellLayout', () => ({ + AppShellLayout: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'app-shell' }, children), +})); +vi.mock('@/components/create/ResumeDraftPrompt', () => ({ + default: () => React.createElement('div', { 'data-testid': 'resume-prompt' }), +})); +vi.mock('@/components/onboarding/GuidedTour', () => ({ + GuidedTour: () => null, +})); +vi.mock('@/components/CreateCommitmentStepSelectType', () => ({ + default: ({ onSelectType, onNext }: { onSelectType: (t: string) => void; onNext: () => void }) => + React.createElement('div', { 'data-testid': 'step-select-type' }, + React.createElement('button', { onClick: () => { onSelectType('balanced'); onNext(); } }, 'Select balanced') + ), +})); +vi.mock('@/components/CreateCommitmentStepConfigure', () => ({ + default: ({ amount, asset, durationDays, maxLossPercent }: { + amount: string; asset: string; durationDays: number; maxLossPercent: number; + }) => + React.createElement('div', { 'data-testid': 'step-configure' }, + React.createElement('span', { 'data-testid': 'prefill-amount' }, amount), + React.createElement('span', { 'data-testid': 'prefill-asset' }, asset), + React.createElement('span', { 'data-testid': 'prefill-duration' }, String(durationDays)), + React.createElement('span', { 'data-testid': 'prefill-maxloss' }, String(maxLossPercent)), + ), +})); +vi.mock('@/components/CreateCommitmentStepReview', () => ({ default: () => null })); +vi.mock('@/components/modals/CommitmentCreatedModal', () => ({ default: () => null })); +vi.mock('@/utils/explorerLinks', () => ({ + buildExplorerUrl: () => 'https://explorer.example.com', + openExplorerUrl: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// usePrefillFromCommitment — unit tests +// --------------------------------------------------------------------------- + +describe('usePrefillFromCommitment', () => { + const SOURCE_URL = '/api/commitments/CMT-42'; + + afterEach(() => { vi.restoreAllMocks(); }); + + it('returns prefill data when source commitment is found', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + commitmentType: 'aggressive', + amount: '500', + asset: 'USDC', + durationDays: 180, + maxLossPercent: 60, + // Identity-bound fields that MUST NOT appear in PrefillData + id: 'CMT-42', + ownerAddress: '0xDEAD', + onChainState: 'active', + }, + }), + }); + + const { usePrefillFromCommitment } = await import('@/hooks/usePrefillFromCommitment'); + const { result } = renderHook(() => usePrefillFromCommitment()); + + await waitFor(() => expect(result.current).not.toBeNull()); + + const prefill = result.current!; + expect(prefill.commitmentType).toBe('aggressive'); + expect(prefill.amount).toBe('500'); + expect(prefill.asset).toBe('USDC'); + expect(prefill.durationDays).toBe(180); + expect(prefill.maxLossPercent).toBe(60); + + // Identity-bound fields must NOT be present + expect((prefill as Record).id).toBeUndefined(); + expect((prefill as Record).ownerAddress).toBeUndefined(); + expect((prefill as Record).onChainState).toBeUndefined(); + }); + + it('returns null when source commitment is not found (404)', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ ok: false, json: async () => ({}) }); + + const { usePrefillFromCommitment } = await import('@/hooks/usePrefillFromCommitment'); + const { result } = renderHook(() => usePrefillFromCommitment()); + + // Give the async effect a chance to run + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + expect(result.current).toBeNull(); + }); + + it('returns null and does not throw when fetch rejects (network error)', async () => { + global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error')); + + const { usePrefillFromCommitment } = await import('@/hooks/usePrefillFromCommitment'); + const { result } = renderHook(() => usePrefillFromCommitment()); + + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + expect(result.current).toBeNull(); + }); + + it('clamps durationDays to valid range [1, 365]', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { commitmentType: 'safe', amount: '10', asset: 'XLM', durationDays: 9999, maxLossPercent: 50 } }), + }); + + const { usePrefillFromCommitment } = await import('@/hooks/usePrefillFromCommitment'); + const { result } = renderHook(() => usePrefillFromCommitment()); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current!.durationDays).toBe(365); + }); + + it('clamps maxLossPercent to [0, 100]', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { commitmentType: 'balanced', amount: '10', asset: 'XLM', durationDays: 30, maxLossPercent: 200 } }), + }); + + const { usePrefillFromCommitment } = await import('@/hooks/usePrefillFromCommitment'); + const { result } = renderHook(() => usePrefillFromCommitment()); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current!.maxLossPercent).toBe(100); + }); + + it('falls back to "balanced" when commitmentType is invalid', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { commitmentType: 'UNKNOWN_TYPE', amount: '10', asset: 'XLM', durationDays: 30, maxLossPercent: 50 } }), + }); + + const { usePrefillFromCommitment } = await import('@/hooks/usePrefillFromCommitment'); + const { result } = renderHook(() => usePrefillFromCommitment()); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current!.commitmentType).toBe('balanced'); + }); +}); + +// --------------------------------------------------------------------------- +// CommitmentDetailActions — Duplicate button +// --------------------------------------------------------------------------- + +describe('CommitmentDetailActions – Duplicate action', () => { + it('renders the Duplicate button when commitmentId and onDuplicate are provided', async () => { + const { CommitmentDetailActions } = await import('@/components/CommitmentDetailActions'); + + render( + React.createElement(CommitmentDetailActions, { + canEarlyExit: false, + onEarlyExit: vi.fn(), + onViewAttestations: vi.fn(), + onExportData: vi.fn(), + onReportIssue: vi.fn(), + onDuplicate: vi.fn(), + commitmentId: 'CMT-42', + }), + ); + + expect(screen.getByTestId('duplicate-commitment-btn')).toBeTruthy(); + expect(screen.getByLabelText(/Duplicate Commitment/i)).toBeTruthy(); + }); + + it('does not render the Duplicate button when onDuplicate is not provided', async () => { + const { CommitmentDetailActions } = await import('@/components/CommitmentDetailActions'); + + render( + React.createElement(CommitmentDetailActions, { + canEarlyExit: false, + onEarlyExit: vi.fn(), + onViewAttestations: vi.fn(), + onExportData: vi.fn(), + onReportIssue: vi.fn(), + commitmentId: 'CMT-42', + }), + ); + + expect(screen.queryByTestId('duplicate-commitment-btn')).toBeNull(); + }); + + it('calls onDuplicate with the commitmentId when clicked', async () => { + const onDuplicate = vi.fn(); + const { CommitmentDetailActions } = await import('@/components/CommitmentDetailActions'); + + render( + React.createElement(CommitmentDetailActions, { + canEarlyExit: false, + onEarlyExit: vi.fn(), + onViewAttestations: vi.fn(), + onExportData: vi.fn(), + onReportIssue: vi.fn(), + onDuplicate, + commitmentId: 'CMT-42', + }), + ); + + fireEvent.click(screen.getByTestId('duplicate-commitment-btn')); + expect(onDuplicate).toHaveBeenCalledWith('CMT-42'); + }); +}); + +// --------------------------------------------------------------------------- +// CreateCommitment page — prefill integration +// --------------------------------------------------------------------------- + +describe('CreateCommitment page – prefill integration', () => { + beforeEach(() => { + // Simulate a source commitment response for the page's usePrefillFromCommitment call + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + commitmentType: 'aggressive', + amount: '750', + asset: 'USDC', + durationDays: 120, + maxLossPercent: 70, + }, + }), + }); + }); + + afterEach(() => { vi.restoreAllMocks(); }); + + it('shows the duplicate prefill banner when a sourceId is in the URL', async () => { + const CreateCommitment = (await import('@/app/create/page')).default; + render(React.createElement(CreateCommitment)); + + await waitFor(() => + expect(screen.getByTestId('duplicate-prefill-banner')).toBeTruthy() + ); + }); + + it('prefills configure step with source commitment parameters', async () => { + const CreateCommitment = (await import('@/app/create/page')).default; + render(React.createElement(CreateCommitment)); + + await waitFor(() => + expect(screen.getByTestId('step-configure')).toBeTruthy() + ); + + expect(screen.getByTestId('prefill-amount').textContent).toBe('750'); + expect(screen.getByTestId('prefill-asset').textContent).toBe('USDC'); + expect(screen.getByTestId('prefill-duration').textContent).toBe('120'); + expect(screen.getByTestId('prefill-maxloss').textContent).toBe('70'); + }); + + it('skips the type-selection step and lands on step 2 when prefill is available', async () => { + const CreateCommitment = (await import('@/app/create/page')).default; + render(React.createElement(CreateCommitment)); + + await waitFor(() => + expect(screen.getByTestId('step-configure')).toBeTruthy() + ); + + // Step 1 (type selection) must NOT be visible + expect(screen.queryByTestId('step-select-type')).toBeNull(); + }); + + it('prefilled fields remain editable (component renders with mutable props)', async () => { + const CreateCommitment = (await import('@/app/create/page')).default; + render(React.createElement(CreateCommitment)); + + await waitFor(() => expect(screen.getByTestId('step-configure')).toBeTruthy()); + + // The configure step renders with the source values — user can edit from here. + // Verify the amount is the prefilled value (not the default empty string). + expect(screen.getByTestId('prefill-amount').textContent).not.toBe(''); + }); +}); diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index c09997b3..131ea85f 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -14,6 +14,7 @@ import ResumeDraftPrompt from "@/components/create/ResumeDraftPrompt"; import { useGuidedTour } from "@/hooks/useGuidedTour"; import { GuidedTour } from "@/components/onboarding/GuidedTour"; import { HelpCircle } from "lucide-react"; +import { usePrefillFromCommitment } from "@/hooks/usePrefillFromCommitment"; type CommitmentType = "safe" | "balanced" | "aggressive"; @@ -31,6 +32,7 @@ export default function CreateCommitment() { const router = useRouter(); const { address: ownerAddress } = useWallet(); const { draft, saveDraft, clearDraft } = useDraftPersistence(); + const prefill = usePrefillFromCommitment(); const [showResumePrompt, setShowResumePrompt] = useState(false); const [step, setStep] = useState(1); const [initialFocusField, setInitialFocusField] = useState(null); @@ -77,6 +79,23 @@ export default function CreateCommitment() { } }, [draft]); + // When a source commitment is loaded via ?sourceId=, prefill the wizard fields + // and skip straight to step 2 so the user can review / adjust the copied parameters. + // Identity-bound fields (id, ownership, on-chain state) are NOT copied — only + // configurable parameters that the user is free to edit. + useEffect(() => { + if (!prefill) return; + setSelectedType(prefill.commitmentType); + setCommitmentType(prefill.commitmentType); + setAmount(prefill.amount); + setAsset(prefill.asset); + setDurationDays(prefill.durationDays); + setMaxLossPercent(prefill.maxLossPercent); + // Skip type-selection step — type is already chosen from the source. + setStep(2); + setShowResumePrompt(false); + }, [prefill]); + const handleResumeDraft = () => { if (draft) { setStep(draft.step); @@ -255,6 +274,18 @@ export default function CreateCommitment() { return ( + {/* Duplicate-mode banner: shown when the wizard was opened from an existing commitment */} + {prefill && ( +
+ Duplicating from an existing commitment — all fields are pre-filled and fully editable. +
+ )} + {showResumePrompt && draft && ( void; onExportData: () => void; onReportIssue: () => void; + /** Called when the user clicks Duplicate; receives the source commitment id. */ + onDuplicate?: (commitmentId: string) => void; earlyExitDisabledReason?: string; commitmentId?: string; onSettle?: () => void; @@ -21,6 +23,7 @@ export function CommitmentDetailActions ({ onViewAttestations, onExportData, onReportIssue, + onDuplicate, earlyExitDisabledReason = 'Early exit is only available before maturity', commitmentId, onSettle, @@ -125,6 +128,31 @@ export function CommitmentDetailActions ({ + {/* Duplicate Commitment */} + {commitmentId && onDuplicate && ( + + )} + {/* Report an Issue */}