diff --git a/src/lib/components/progress-bar/progress-bar.svelte b/src/lib/components/progress-bar/progress-bar.svelte index 98a6488f0..ea4d4de29 100644 --- a/src/lib/components/progress-bar/progress-bar.svelte +++ b/src/lib/components/progress-bar/progress-bar.svelte @@ -14,9 +14,15 @@ progressFn: ProgressFn; updateFrequencyMs?: number; errorMessage?: string | undefined; + centeredProgressText?: boolean; } - let { progressFn, updateFrequencyMs = 10, errorMessage = undefined }: Props = $props(); + let { + progressFn, + updateFrequencyMs = 10, + errorMessage = undefined, + centeredProgressText = true, + }: Props = $props(); let interval: ReturnType | undefined = $state(); @@ -86,13 +92,18 @@ {/if} + {#if remainingText || done}

.progress-bar-wrapper { + min-width: 16rem; color: var(--color-foreground-level-6); + display: flex; + flex-direction: column; + justify-content: center; } .progress-bar-container { diff --git a/src/lib/components/stepper/components/await-step.svelte b/src/lib/components/stepper/components/await-step.svelte index bfb1abd14..531704082 100644 --- a/src/lib/components/stepper/components/await-step.svelte +++ b/src/lib/components/stepper/components/await-step.svelte @@ -16,6 +16,8 @@ import { createEventDispatcher, onMount, type Component } from 'svelte'; import type { UpdateAwaitStepFn } from '../types'; import { isHttpError } from '@sveltejs/kit'; + import type { ProgressFn } from '$lib/components/progress-bar/progress-bar.svelte'; + import ProgressBar from '$lib/components/progress-bar/progress-bar.svelte'; const dispatch = createEventDispatcher<{ result: Result }>(); @@ -25,6 +27,12 @@ link?: { url: string; label: string } | undefined; icon?: { component: Component; props: Record } | undefined; promise: (updateFn: UpdateAwaitStepFn) => Promise; + progressBar?: + | { + progressFn: ProgressFn; + centeredProgressText?: boolean; + } + | undefined; } let { @@ -33,6 +41,7 @@ link = $bindable(), icon = $bindable(), promise, + progressBar = $bindable(), }: Props = $props(); const updateFn: UpdateAwaitStepFn = (params) => { @@ -87,7 +96,13 @@ {/if}

{message}

{#if subtitle}

{subtitle}

{/if} + + {#if progressBar} + + {/if} + {#if link} + {link.label} {/if} diff --git a/src/lib/components/stepper/types.ts b/src/lib/components/stepper/types.ts index d0171b65c..9d5ba1eb5 100644 --- a/src/lib/components/stepper/types.ts +++ b/src/lib/components/stepper/types.ts @@ -3,6 +3,7 @@ import type { SendTransactionsResponse } from '@safe-global/safe-apps-sdk'; import type { TransactionLike, TypedDataDomain, TypedDataField } from 'ethers'; import type { TransactionReceipt } from 'ethers'; import type { Component, ComponentProps } from 'svelte'; +import type { ProgressFn } from '../progress-bar/progress-bar.svelte'; export type TransactionWrapper = { title: string; @@ -93,6 +94,14 @@ export interface AwaitPendingPayload extends UpdateAwaitStepParams { * and text displayed on the await step before the promise resolves. */ promise: (updateFn: UpdateAwaitStepFn) => Promise; + /** + * Optional function to report progress of the awaited promise. + * If provided, a progress bar is shown below the message. + */ + progressBar?: { + progressFn: ProgressFn; + centeredProgressText?: boolean; + }; } export interface MovePayload { diff --git a/src/lib/flows/create-drip-list-flow/create-drip-list-flow.ts b/src/lib/flows/create-drip-list-flow/create-drip-list-flow.ts index bc9d53c58..9eca3acb2 100644 --- a/src/lib/flows/create-drip-list-flow/create-drip-list-flow.ts +++ b/src/lib/flows/create-drip-list-flow/create-drip-list-flow.ts @@ -13,6 +13,8 @@ import type { AddItemError } from '$lib/components/list-editor/errors'; import walletStore from '$lib/stores/wallet/wallet.store'; import dismissablesStore from '$lib/stores/dismissables/dismissables.store'; import DripList from '$lib/components/illustrations/drip-list.svelte'; +import type { BlueprintOrBlueprintError } from '../../utils/blueprints/schemas'; +import PopulateBlueprint from './steps/populate-blueprint/populate-blueprint.svelte'; export interface State { dripList: DripListConfig; @@ -53,11 +55,27 @@ const staticHeaderComponent = { }, }; -export const steps = (state: Writable, skipWalletConnect = false, isModal = false) => [ +export const steps = ( + state: Writable, + skipWalletConnect = false, + isModal = false, + blueprintOrBlueprintError: BlueprintOrBlueprintError | undefined, +) => [ + ...(blueprintOrBlueprintError + ? [ + makeStep({ + component: PopulateBlueprint, + props: { + blueprintOrBlueprintError, + }, + }), + ] + : []), makeStep({ component: ChooseCreationMode, props: { canCancel: isModal, + blueprintMode: !!blueprintOrBlueprintError, }, staticHeaderComponent, }), diff --git a/src/lib/flows/create-drip-list-flow/create-drip-list-stepper.svelte b/src/lib/flows/create-drip-list-flow/create-drip-list-stepper.svelte index 8088b0cee..04e6d56c6 100644 --- a/src/lib/flows/create-drip-list-flow/create-drip-list-stepper.svelte +++ b/src/lib/flows/create-drip-list-flow/create-drip-list-stepper.svelte @@ -1,13 +1,16 @@ diff --git a/src/lib/utils/blueprints/schemas.ts b/src/lib/utils/blueprints/schemas.ts new file mode 100644 index 000000000..f4af20414 --- /dev/null +++ b/src/lib/utils/blueprints/schemas.ts @@ -0,0 +1,53 @@ +import z from 'zod'; + +const MAX_SPLITS_WEIGHT = 1000000; + +const addressReceiver = z.object({ + type: z.literal('address'), + ethAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/), + weight: z.number().min(0).max(MAX_SPLITS_WEIGHT), +}); + +const projectReceiver = z.object({ + type: z.literal('project'), + // string with repoOwner/repoName + repoName: z.string().regex(/^[^/]+\/[^/]+$/), + weight: z.number().min(0).max(MAX_SPLITS_WEIGHT), +}); + +const dripListReceiver = z.object({ + type: z.literal('drip-list'), + accountId: z.string().regex(/^0x[a-fA-F0-9]{40}$/), + weight: z.number().min(0).max(MAX_SPLITS_WEIGHT), +}); + +const orcidIdReceiver = z.object({ + type: z.literal('orcid-id'), + orcidId: z.string().regex(/^(\d{4}-){3}\d{3}(\d|X)$/), + weight: z.number().min(0).max(MAX_SPLITS_WEIGHT), +}); + +const splitsSchema = z.union([addressReceiver, projectReceiver, dripListReceiver, orcidIdReceiver]); + +const blueprintSchema = z.object({ + listName: z.string().min(1).max(200), + listDescription: z.string().max(1000).optional(), + splits: z.array(splitsSchema).min(1).max(200), +}); + +export type Split = z.infer; +export type AddressReceiver = z.infer; +export type ProjectReceiver = z.infer; +export type DripListReceiver = z.infer; +export type Blueprint = z.infer; +export type BlueprintError = 'not-found' | 'unknown' | 'invalid'; + +export type BlueprintOrBlueprintError = + | { + blueprintError: BlueprintError | undefined; + } + | { + blueprint: Blueprint; + }; + +export { blueprintSchema }; diff --git a/src/lib/utils/launch-create-drip-list.ts b/src/lib/utils/launch-create-drip-list.ts index 720fb6f81..05c9ea0bf 100644 --- a/src/lib/utils/launch-create-drip-list.ts +++ b/src/lib/utils/launch-create-drip-list.ts @@ -9,6 +9,7 @@ export default function launchCreateDripList() { modal.show(CreateDripListStepper, undefined, { skipWalletConnect: true, isModal: true, + blueprintOrBlueprintError: undefined, }); return; } diff --git a/src/routes/(pages)/app/(flows)/funder-onboarding/+page.server.ts b/src/routes/(pages)/app/(flows)/funder-onboarding/+page.server.ts new file mode 100644 index 000000000..08cb4e76b --- /dev/null +++ b/src/routes/(pages)/app/(flows)/funder-onboarding/+page.server.ts @@ -0,0 +1,34 @@ +import { blueprintSchema } from '../../../../../lib/utils/blueprints/schemas.js'; +import type { Blueprint, BlueprintError } from '../../../../../lib/utils/blueprints/schemas.js'; + +export const load = async ({ fetch, url }) => { + const blueprintIdParam = url.searchParams.get('blueprintId'); + + let blueprint: Blueprint | undefined = undefined; + let blueprintError: BlueprintError | undefined = undefined; + + if (blueprintIdParam) { + const blueprintResponse = await fetch(`/api/list-blueprints/${blueprintIdParam}`); + + if (!blueprintResponse.ok) { + blueprintError = blueprintResponse.status === 404 ? 'not-found' : 'unknown'; + } else { + const asJson = await blueprintResponse.json().catch(() => null); + const parsedBlueprint = blueprintSchema.safeParse(asJson); + + if (!parsedBlueprint.success) { + blueprintError = 'invalid'; + } else { + blueprint = parsedBlueprint.data; + } + } + } + + return { + blueprintOrBlueprintError: blueprintError + ? { blueprintError: blueprintError } + : blueprint + ? { blueprint } + : undefined, + }; +}; diff --git a/src/routes/(pages)/app/(flows)/funder-onboarding/+page.svelte b/src/routes/(pages)/app/(flows)/funder-onboarding/+page.svelte index c84b71670..7e9507a4a 100644 --- a/src/routes/(pages)/app/(flows)/funder-onboarding/+page.svelte +++ b/src/routes/(pages)/app/(flows)/funder-onboarding/+page.svelte @@ -4,10 +4,12 @@ import HeadMeta from '$lib/components/head-meta/head-meta.svelte'; import CreateDripListStepper from '$lib/flows/create-drip-list-flow/create-drip-list-stepper.svelte'; + export let data; + onMount(() => browser && (window.onbeforeunload = () => true)); onDestroy(() => browser && (window.onbeforeunload = null)); - + diff --git a/src/routes/api/list-blueprints/+server.ts b/src/routes/api/list-blueprints/+server.ts new file mode 100644 index 000000000..189ef5e0b --- /dev/null +++ b/src/routes/api/list-blueprints/+server.ts @@ -0,0 +1,33 @@ +/** + * "List Blueprints" allow submitting a list of splits in return for a "blueprint ID". + * Then, the user can be sent off to /app/funder-onboarding?blueprintId=XYZ, which will retrieve + * the blueprint and pre-fill the splits in the onboarding flow. + * + * We store blueprints on Redis with a short TTL of 6 hours, since they are meant to be + * short-lived and temporary. + */ + +import { error } from '@sveltejs/kit'; +import { redis } from '../redis'; +import network from '$lib/stores/wallet/network'; +import { blueprintSchema } from '../../../lib/utils/blueprints/schemas'; + +export const PUT = async ({ request }) => { + if (!redis) return error(503, 'Redis not available'); + + const body = await request.json(); + + const parsed = blueprintSchema.safeParse(body); + + if (!parsed.success) { + throw error(400, parsed.error); + } + + const id = crypto.randomUUID(); + + await redis.set(`list-blueprint:${network.chainId}:${id}`, JSON.stringify(parsed.data), { + EX: 60 * 60 * 6, // 6 hours + }); + + return new Response(JSON.stringify({ id, expiresAt: Date.now() + 60 * 60 * 6 * 1000 })); +}; diff --git a/src/routes/api/list-blueprints/[blueprintId]/+server.ts b/src/routes/api/list-blueprints/[blueprintId]/+server.ts new file mode 100644 index 000000000..de9094a46 --- /dev/null +++ b/src/routes/api/list-blueprints/[blueprintId]/+server.ts @@ -0,0 +1,30 @@ +import { error } from '@sveltejs/kit'; +import { redis } from '../../redis'; +import network from '$lib/stores/wallet/network'; +import { blueprintSchema } from '../../../../lib/utils/blueprints/schemas'; + +export const GET = async ({ params }) => { + if (!redis) return error(503, 'Redis not available'); + + const { blueprintId: id } = params; + + const data = await redis.get(`list-blueprint:${network.chainId}:${id}`); + if (!data) { + throw error(404, 'Blueprint not found or expired'); + } + + let asJson: unknown; + try { + asJson = JSON.parse(data); + } catch { + throw error(500, 'Failed to parse blueprint'); + } + + const parsed = blueprintSchema.safeParse(asJson); + + if (!parsed.success) { + throw error(500, 'Invalid blueprint stored'); + } + + return new Response(JSON.stringify(parsed.data)); +}; diff --git a/tests/create-drip-list.spec.ts b/tests/create-drip-list.spec.ts index 649b03efe..c6e437ca3 100644 --- a/tests/create-drip-list.spec.ts +++ b/tests/create-drip-list.spec.ts @@ -1,5 +1,6 @@ import { test as base, expect } from '@playwright/test'; import { ConnectedSession, TEST_ADDRESSES } from './fixtures/ConnectedSession'; +import createBlueprintPayload from './payloads/create-blueprint-payload.json' with { type: 'json' }; const test = base.extend<{ connectedSession: ConnectedSession }>({ connectedSession: async ({ page }, use) => { @@ -160,3 +161,46 @@ test('create collaborative drip list', async ({ page, connectedSession }) => { await expect(page.getByText('Test collaborative list').nth(0)).toBeVisible(); await expect(page.getByText('This is a test for a collaborative drip list').nth(0)).toBeVisible(); }); + +test('create drip list with blueprint', async ({ page, request }) => { + test.setTimeout(240_000); + + // First, create the blueprint + const blueprintCreatedResponse = await request.put('http://localhost:5173/api/list-blueprints', { + data: { + ...createBlueprintPayload, + }, + }); + expect(blueprintCreatedResponse.ok()).toBeTruthy(); + const { id: blueprintId } = await blueprintCreatedResponse.json(); + + await page.goto('http://localhost:5173/app/funder-onboarding?blueprintId=' + blueprintId); + + // Move past the confirmation that you're using a blueprint + await page.getByRole('button', { name: 'Continue' }).nth(0).click(); + // There's one invalid recipient, so we should see a warning + await expect(page.getByText('Some of your blueprint recipients were invalid').nth(0)).toBeVisible(); + // Move past the configuration of your splits + await page.getByRole('button', { name: 'Continue' }).nth(0).click(); + // Connect wallet + await page.getByRole('button', { name: 'Connect wallet' }).nth(0).click(); + await page.getByRole('button', { name: 'Continue' }).nth(0).click(); + // Begin the transaction + await page.getByRole('button', { name: 'Confirm in wallet' }).click(); + // Wait for the transaction to be confirmed + await page.getByRole('button', { name: 'Continue' }).nth(0).click({ timeout: 120_000 }); + // Move to the drip list page + await page.getByRole('link', { name: 'View your Drip List' }).click(); + // Wait for the drip list page to load + await page.waitForURL('http://localhost:5173/app/drip-lists/*'); + + // Verify that the drip list is created as expected + await expect(page.getByText(createBlueprintPayload.listName).nth(0)).toBeVisible(); + await expect(page.getByText(createBlueprintPayload.listDescription).nth(0)).toBeVisible(); + await expect( + page.getByText(createBlueprintPayload.splits.at(1)?.repoName as string).nth(0), + ).toBeVisible(); + const fullWeight: number = createBlueprintPayload.splits.at(1)?.weight as number; + const percentage = ((fullWeight / 1000000) * 100).toFixed(2); + await expect(page.getByText(`${percentage}%`).nth(0)).toBeVisible(); +}); diff --git a/tests/ecosystems.spec.ts b/tests/ecosystems.spec.ts index 55b4c26bd..920c1d7e2 100644 --- a/tests/ecosystems.spec.ts +++ b/tests/ecosystems.spec.ts @@ -1,5 +1,5 @@ import { test as base, expect } from '@playwright/test'; -import createEcosystemPayload from './create-ecosystem-payload.json' with { type: 'json' }; +import createEcosystemPayload from './payloads/create-ecosystem-payload.json' with { type: 'json' }; import { ConnectedSession, TEST_ADDRESSES } from './fixtures/ConnectedSession'; import workerUniqueString from './utils/worker-unique-string'; diff --git a/tests/payloads/create-blueprint-payload.json b/tests/payloads/create-blueprint-payload.json new file mode 100644 index 000000000..e403e1e79 --- /dev/null +++ b/tests/payloads/create-blueprint-payload.json @@ -0,0 +1,26 @@ +{ + "listName": "Test Blueprint", + "listDescription": "This is a test blueprint", + "splits": [ + { + "type": "orcid-id", + "orcidId": "0009-0007-1106-8413", + "weight": 333333 + }, + { + "type": "project", + "repoName": "mhgbrown/drips.test", + "weight": 333333 + }, + { + "type": "address", + "ethAddress": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "weight": 333333 + }, + { + "type": "project", + "repoName": "drips-network/drips-network", + "weight": 333333 + } + ] +} diff --git a/tests/create-ecosystem-payload.json b/tests/payloads/create-ecosystem-payload.json similarity index 100% rename from tests/create-ecosystem-payload.json rename to tests/payloads/create-ecosystem-payload.json