diff --git a/.changeset/vite-plugin-weft-testing-cookies.md b/.changeset/vite-plugin-weft-testing-cookies.md new file mode 100644 index 0000000..3b38da4 --- /dev/null +++ b/.changeset/vite-plugin-weft-testing-cookies.md @@ -0,0 +1,8 @@ +--- +'@e-cline/weft_vite_plugin': patch +--- + +The test harness keeps cookies, as a browser does for the app's origin: what the server's +`Set-Cookie` headers set is sent with every request after, until it expires (`cookies`, +`clearCookies`). Opening a page closes the one before it (`closePage`): its root is unmounted, so +its intervals, subscriptions and history listener stop, and can't act in the next test. diff --git a/.changeset/vite-plugin-weft-testing-redirects.md b/.changeset/vite-plugin-weft-testing-redirects.md new file mode 100644 index 0000000..096e751 --- /dev/null +++ b/.changeset/vite-plugin-weft-testing-redirects.md @@ -0,0 +1,9 @@ +--- +'@e-cline/weft_vite_plugin': patch +--- + +The test harness's `fetch` follows redirects on the app's origin as a browser's does (a 303, or a +POST's 301 or 302, with a `GET`; at most 20; `redirect: 'manual'` and `'error'` respected), and its +responses say where they ended up (`url`, `redirected`); a redirect to another origin fails. A page +opened at a URL that redirects is at the URL it led to (`WeftTesting.url`), and payloads report it +to `Navigation`, as in a browser. diff --git a/.changeset/weft-core-dehydrated-retry.md b/.changeset/weft-core-dehydrated-retry.md new file mode 100644 index 0000000..3be7c66 --- /dev/null +++ b/.changeset/weft-core-dehydrated-retry.md @@ -0,0 +1,8 @@ +--- +'@e-cline/weft': patch +--- + +Fixes a pending `Suspense` boundary rendered twice: when its content arrived after hydration had +started, and suspended as it hydrated (an entrypoint's chunk still loading, say), the boundary +rendered its content on the client after the server's, which stayed, unhydrated. It now stays as the +server sent it until what it waits on settles, then hydrates. diff --git a/.changeset/weft-dom-hydration-and-props.md b/.changeset/weft-dom-hydration-and-props.md new file mode 100644 index 0000000..5351300 --- /dev/null +++ b/.changeset/weft-dom-hydration-and-props.md @@ -0,0 +1,11 @@ +--- +'@e-cline/weft_dom': patch +--- + +- `autoFocus` focuses an element rendered after the page has loaded (a dialog's field, say), once + the commit has put it in the page: the attribute alone only works for elements there as the page + loads. +- Hydration compares `style` values as the browser reads them back, so the same style written + another way (`hsl(...)`, read back as `rgb(...)`; `-0%` as `0%`) isn't reported as a difference. +- New props: `ariaExpanded`, `ariaSelected`, `ariaControls`, `ariaCurrent`, `ariaHidden`, + `ariaLive`, `ariaModal`, `ariaBusy`, `required`, `autoComplete`, `inputMode` and `maxLength`. diff --git a/.changeset/weft-server-navigation-commit.md b/.changeset/weft-server-navigation-commit.md new file mode 100644 index 0000000..9bd54bc --- /dev/null +++ b/.changeset/weft-server-navigation-commit.md @@ -0,0 +1,9 @@ +--- +'@e-cline/weft_server': patch +--- + +- `Navigation.navigate`, `load` and `refresh` resolve once the new page is on screen (its transition + has committed), as documented, rather than as soon as it starts rendering: code awaiting them (to + clear a pending state, say) no longer runs while the old page is still showing. +- `Bootstrap.start` takes a `~signal`: aborting it stops the page (its root unmounted, the history + no longer followed), for tests that open one page after another in a document. diff --git a/.changeset/weft-server-redirects.md b/.changeset/weft-server-redirects.md new file mode 100644 index 0000000..e920195 --- /dev/null +++ b/.changeset/weft-server-redirects.md @@ -0,0 +1,11 @@ +--- +'@e-cline/weft_server': patch +--- + +A navigation the server redirects lands where the redirect led. `Decoder.fetch` now resolves with +`Decoder.fetched` (the payload's `stream`, and the `url` a redirect led to, if one did) instead of +the bare stream; `webFetch` reports where the browser's `fetch` ended up. `Navigation.url` is that +URL, `navigate` calls `onNavigate` with it (so the browser's history gets it, not the URL asked +for), and a `load` or `refresh` that was redirected calls the new `~onRedirect` with it (the +browser's replaces the history's entry). `Decoder.Latest.fetchElement` resolves with the element and +its URL (`Decoder.Latest.fetched`). diff --git a/.changeset/weft-signals-server.md b/.changeset/weft-signals-server.md new file mode 100644 index 0000000..8a11a8f --- /dev/null +++ b/.changeset/weft-signals-server.md @@ -0,0 +1,7 @@ +--- +'@e-cline/weft_signals': patch +--- + +The hooks work in an entrypoint rendered on the server (to HTML): they give the signal's value +there, where they failed before (`useSyncExternalStore` without a server snapshot, error 15), and +the page fell back to rendering on the client. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c1d2ad..47e6515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,9 @@ jobs: - run: vp run test e2e: - # The browser tests (examples/weft_notes/e2e): a few, against the built app in Chromium, on one - # Node version. Everything a browser isn't needed for is tested in the job above. + # The browser tests (examples/weft_notes/e2e and examples/weft_shop/e2e), against the built apps + # in Chromium, on one Node version. Everything a browser isn't needed for is tested in the job + # above. name: Browser tests runs-on: ubuntu-latest steps: @@ -80,3 +81,4 @@ jobs: pnpm --filter @e-cline/weft_notes_example exec playwright install --with-deps chromium - run: vp run --filter @e-cline/weft_notes_example e2e + - run: vp run --filter @e-cline/weft_shop_example e2e diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47f93ce..89f2413 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,8 +70,9 @@ pnpm run build pnpm run test ``` -Run the browser tests (a few, in [`examples/weft_notes/e2e`](examples/weft_notes/e2e), against the -built app), which need Playwright's Chromium installed once: +Run the browser tests (in [`examples/weft_notes/e2e`](examples/weft_notes/e2e) and +[`examples/weft_shop/e2e`](examples/weft_shop/e2e), against the built apps), which need Playwright's +Chromium installed once: ```bash pnpm --filter @e-cline/weft_notes_example exec playwright install chromium diff --git a/README.md b/README.md index 9f03be7..2040479 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ WARNING: None of this is intended for real production use. Weft is the UI framework in here: `weft` (components, hooks and a fiber reconciler), `weft_dom` (the DOM renderer) and `weft_signals` (hooks for reading `signals`), plus the `weft_app` example. Server -components for it are under way: `weft_server` and `weft_vite_plugin`, with the `weft_notes` and -`weft_split` examples. +components for it are under way: `weft_server` and `weft_vite_plugin`, with the `weft_notes`, +`weft_split` and `weft_shop` examples. ## Packages diff --git a/examples/weft_shop/README.md b/examples/weft_shop/README.md new file mode 100644 index 0000000..76d0fe0 --- /dev/null +++ b/examples/weft_shop/README.md @@ -0,0 +1,130 @@ +# weft_shop + +A storefront, "Loom & Co.", built with [Weft's server components](../../packages/weft_server): the +largest of the examples, and a test bed for how Weft's features work together, with the latency of +real services in between. Its home and category pages are server-driven: what they're made of is +data the server decides on for each request, and each part of them streams in by itself. + +- **Server-driven pages** ([`src/server/Cms.res`](src/server/Cms.res), + [`src/server/Blocks.res`](src/server/Blocks.res)): the home page and each category's banner are a + tree of blocks (heroes, rails of products, promotions, tabs, columns, live deals, an experiment) + kept as data and changed at runtime, and personalised: signed-in shoppers are welcomed, get + recommendations and a rail to buy again, and each half of the experiment sees its own promotion. + Every block is a server component waiting on its own service, in a `Suspense` boundary of its own, + inside a `BlockFrame` entrypoint: an error boundary (a block that fails says so, with Retry) that + fetches the block again from its own route (`useRefetchable`), on Retry or, for live deals, every + few seconds. Containers render their blocks the same way, so a rail in a tab streams, fails and + refetches by itself too, and blocks keep their state when a refresh brings a layout that moved + them. +- **Streaming everywhere**: the shell first, then the layout, then each block, then each card's + stock, each under its own boundary; a product's offer, reviews (a promise, read on the client with + `Hooks.use`), rails and questions; an order's delivery estimate. +- **The catalog** ([`src/server/Pages.res`](src/server/Pages.res)): filters the server decides on + per category (`FacetPanel`), each change a navigation to the page's new URL (showing what was + picked until it's rendered), with the results in a grid (`ProductGrid`) keyed by the filters, a + quick filter over the cards already there (`useDeferredValue`), and "Load more" from the + `catalog.more` RPC, whose answer is the cards themselves, rendered on the server, their stock + still streaming. +- **Search**: suggestions as you type (`search.suggest`, through `Rpc.call` with an `AbortSignal`, + so each query cancels the one before it), results on Enter, and the box in the header keeping what + was typed as the shopper navigates. +- **A product's page**: variants whose offer (price, stock, the add to cart button) is fetched again + by itself (`ProductOffer`), keeping the quantity chosen; a gallery; reviews sorted in a + transition. +- **The cart, client and server** ([`src/client/Store.res`](src/client/Store.res), + [`src/client/ShopContext.res`](src/client/ShopContext.res)): the cart an RPC answers with goes to + a `signals` atom, which `ShopShell` (the entrypoint around every page, providing the page's + context) reads and hands down, newest of its and the server's, so the header's count, the drawer + (a portal) and the cart page follow at once; the cart page's quantities change before the server + answers (a reducer), and its totals, rendered on the server, are fetched again whenever the cart's + version goes up. +- **Context and currency**: every price is a `Price` entrypoint reading the currency from + `ShopContext`, so picking one re-renders every price on the page with nothing fetched, and the + `prefs.setCurrency` RPC makes it the session's, for the next pages the server renders. +- **Sessions and auth** ([`src/server/Session.res`](src/server/Session.res)): an `sid` cookie that + `@e-cline/servy` middleware sets on the first response without one, naming what the server keeps + for the shopper; signing in (a name, no password: a demo) from a dialog rendered into `` (a + portal) that takes the focus (`autoFocus`), or from the sign-in page the account page redirects a + signed-out shopper to (a navigation lands at the URL a redirect leads to); RPC handlers finding + the session from the HTTP request, even on a visitor's first request. +- **Checkout**: a three-step form (a reducer, labels linked by `useId`; an empty cart is redirected + back to the cart), the address checked as the RPC will check it before moving on, and the order + refused, saying which item, if someone else bought the last of it first. The order's page + refreshes itself until it's delivered. +- **RPCs** ([`src/shared/Api.res`](src/shared/Api.res), handled in + [`src/server/ApiServer.res`](src/server/ApiServer.res)): eleven, with object, variant, option and + custom codecs, `prepare` steps that send nothing for an empty promo code or name, `check`s on both + sides, and errors the shopper is told about. +- **The rest**: an offline banner (`useSyncExternalStore` with a server snapshot), a countdown that + renders the server's time first so hydration matches, toasts, and a stylesheet in each page's + head. + +The "database" ([`src/server/Db.res`](src/server/Db.res)) is in memory and the same every time. +Every slow call waits on [`src/server/Latency.res`](src/server/Latency.res): in `vp dev` and +`vp run start`, a random time from a seeded generator, with the recommendations and questions +services failing now and then; in the tests, nothing opens until the test says. `WEFT_SHOP_LATENCY` +(`random`, `fixed`, `off` or `manual`), `WEFT_SHOP_SEED` and `WEFT_SHOP_FAIL_RATE` set it. + +Run it: + +```bash +pnpm --filter @e-cline/weft_shop_example exec vp run dev +``` + +or build it and serve the build: + +```bash +pnpm --filter @e-cline/weft_shop_example exec vp run start +``` + +## Tests + +[`tests/`](tests) runs the app's scenarios through +[the plugin's harness](../../tools/weft_vite_plugin#testing-an-app): the server in a worker thread +(with its controls, [`tests/ShopControl.res`](tests/ShopControl.res), called there), each page in +jsdom, cookies kept as a browser keeps them. Each test starts with every latency gate closed, and +opens them in the order it's about: + +- [`Home_test.res`](tests/Home_test.res): the home page streaming block by block in any order, and + in chunks of any size; a boundary whose content arrives while an entrypoint's chunk is still + loading hydrated once it has, with nothing rendered twice; navigating away from a page still + streaming, which stops its services; blocks working once hydrated; a refresh bringing a layout + with blocks moved, added and removed, each keeping its state; the experiment; a block that fails, + and Retry; live deals refetching themselves; personalisation after signing in, and recommendations + failing; the currency changing every price at once; a page closed when the next opens. +- [`Catalog_test.res`](tests/Catalog_test.res): a category page's blocks, filters and results + streaming by themselves; "Load more" (server-rendered cards from the RPC, their stock streaming + after); the quick filter, with nothing fetched; filters and sort as navigations, the grid starting + afresh, the results staying until the new ones are ready, the range reset when the filters are + cleared; search suggestions, a newer query cancelling an older one's request; search results. +- [`Product_test.res`](tests/Product_test.res): the offer, reviews, rail and questions streaming by + themselves; hydration in chunks of any size; another variant's offer fetched by itself, keeping + the quantity (clamped to the stock); adding to the cart, refused past the stock; the drawer; a + sold out variant; reviews sorted in a transition; the questions failing, and Retry; the wishlist, + signed out and in, and put back when the server says no. +- [`Cart_test.res`](tests/Cart_test.res): the cart's lines and server-rendered totals; quantities + changed at once, the totals following by themselves; promo codes (an empty one never sent, unknown + and expired ones refused); checking out, the address checked on the client, the order placed, and + its page refreshing itself until it's delivered; an item that ran out first; the session's cookie, + even for an RPC that's a visitor's first request; the account page redirecting to sign in, and + back after, and out again; an empty cart's checkout redirecting to the cart; names refused on both + sides. + +[`e2e/`](e2e) checks, in Chromium against the build, what only a browser can, served twice: + +- [`shop.e2e.ts`](e2e/shop.e2e.ts), with the latency gated (`WEFT_SHOP_TEST_ROUTES=1` adds routes + that open the gates): the home page streaming; blocks arriving while a chunk is held back, + hydrated once it's loaded, not rendered twice; a whole purchase, filter to order; the sign-in + dialog's focus and Escape; links to pages that redirect, landing at the redirect's URL; the + currency kept across a reload; the back and forward buttons through filtered pages; a failed block + retried; and the pages readable with JavaScript off. +- [`random.e2e.ts`](e2e/random.e2e.ts), with seeded random latency and failures, as `vp run start` + serves it: every page streaming in completely, once, and hydrating; navigating quickly through + pages still streaming; and a purchase, retrying whatever fails. + +Each with nothing in the console. Run them (they need Chromium: +`pnpm --filter @e-cline/weft_shop_example exec playwright install chromium`): + +```bash +pnpm --filter @e-cline/weft_shop_example exec vp run e2e +``` diff --git a/examples/weft_shop/e2e/playwright.config.ts b/examples/weft_shop/e2e/playwright.config.ts new file mode 100644 index 0000000..3d8773c --- /dev/null +++ b/examples/weft_shop/e2e/playwright.config.ts @@ -0,0 +1,59 @@ +import {fileURLToPath} from 'node:url'; + +import {defineConfig, devices} from '@playwright/test'; + +// The browser tests, against the build (`vp run e2e` builds it first), served twice by `serve.mjs`: +// once with its latency gated (`manual`, opened by the tests through the test-only routes), for +// tests that choose the order things arrive in; and once with seeded random latency and failures, +// as `vp run start` serves it, for tests that only say what must happen eventually. +const gated = Number(process.env.WEFT_SHOP_E2E_PORT ?? 4417); +const random = gated + 1; +const cwd = fileURLToPath(new URL('..', import.meta.url)); + +export default defineConfig({ + testDir: '.', + fullyParallel: false, + // One server (and one database) each: the tests take turns. + workers: 1, + reporter: process.env.CI ? 'github' : 'list', + projects: [ + { + name: 'gated', + testMatch: 'shop.e2e.ts', + use: {...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${gated}`}, + }, + { + name: 'random latency', + testMatch: 'random.e2e.ts', + use: { + ...devices['Desktop Chrome'], + baseURL: `http://127.0.0.1:${random}`, + }, + }, + ], + webServer: [ + { + command: 'node serve.mjs', + cwd, + url: `http://127.0.0.1:${gated}/test/ready`, + env: { + PORT: String(gated), + WEFT_SHOP_TEST_ROUTES: '1', + WEFT_SHOP_LATENCY: 'manual', + }, + reuseExistingServer: false, + }, + { + command: 'node serve.mjs', + cwd, + url: `http://127.0.0.1:${random}/c/shoes`, + env: { + PORT: String(random), + WEFT_SHOP_LATENCY: 'random', + WEFT_SHOP_SEED: '7', + WEFT_SHOP_FAIL_RATE: '0.3', + }, + reuseExistingServer: false, + }, + ], +}); diff --git a/examples/weft_shop/e2e/random.e2e.ts b/examples/weft_shop/e2e/random.e2e.ts new file mode 100644 index 0000000..40914da --- /dev/null +++ b/examples/weft_shop/e2e/random.e2e.ts @@ -0,0 +1,124 @@ +// The build as `vp run start` serves it: every service waits a random time (seeded, so a run can be +// repeated), and the recommendations and questions fail now and then. These tests say only what +// must happen eventually, whatever order things arrive in: every page streams in completely, with +// one copy of everything and nothing in the console, and a shopper can buy something, retrying +// whatever fails. + +import {expect, test, type Page} from '@playwright/test'; + +/** What the console says that it shouldn't: errors and warnings. */ +let complaints: string[] = []; + +test.beforeEach(({page}) => { + complaints = []; + page.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + complaints.push(message.text()); + } + }); + page.on('pageerror', (error) => complaints.push(String(error))); +}); + +test.afterEach(() => { + expect(complaints).toEqual([]); +}); + +/** Waits until nothing on the page is still loading (no fallback, skeleton or pending stock). */ +const streamedIn = (page: Page) => + expect(page.locator('.loading, .skeleton, .stock.loading')).toHaveCount(0, { + timeout: 15_000, + }); + +/** Clicks Retry on every block that failed, until none has. */ +const retryFailures = async (page: Page) => { + for (let tries = 0; tries < 20; tries++) { + const failed = page.locator('.block-error button'); + if ((await failed.count()) === 0) return; + await failed.first().click(); + await page.waitForTimeout(300); + await streamedIn(page); + } + throw new Error('a block kept failing'); +}; + +for (const url of [ + '/', + '/c/shoes', + '/c/home?sort=price-asc', + '/search?q=wool', + '/p/down-parka', + '/cart', +]) { + test(`${url} streams in completely, once, and hydrates`, async ({page}) => { + await page.goto(url, {waitUntil: 'commit'}); + await streamedIn(page); + await retryFailures(page); + await expect(page.locator('#main')).toHaveCount(1); + await expect(page.locator('#header')).toHaveCount(1); + // Hydrated: the header's search box answers. + await page.waitForFunction( + () => '$$resFiberProps' in document.querySelector('#search-input')!, + ); + }); +} + +test('navigating quickly through pages still streaming lands on the last, complete', async ({ + page, +}) => { + await page.goto('/'); + await page.waitForFunction( + () => '$$resFiberProps' in document.querySelector('#nav-shoes')!, + ); + // Each click before the page before has finished streaming. + await page.locator('#nav-shoes').click(); + await page.locator('#nav-outerwear').click(); + await page.locator('#nav-bags').click(); + await expect(page).toHaveURL('/c/bags'); + await expect(page.locator('#hero-bags-hero h1')).toHaveText('Bags'); + await streamedIn(page); + await expect(page.locator('#grid .card-name').first()).toHaveText( + 'Weekender', + ); + await expect(page.locator('#blocks-c-bags')).toHaveCount(1); +}); + +test('a shopper can find something, buy it, and see the order, whatever fails along the way', async ({ + page, +}) => { + await page.goto('/'); + await page.waitForFunction( + () => '$$resFiberProps' in document.querySelector('#search-input')!, + ); + await page.locator('#search-input').fill('blanket'); + await page.locator('#suggestion-wool-blanket').click(); + await expect(page.locator('#product-name')).toHaveText('Wool Blanket'); + await streamedIn(page); + await retryFailures(page); + await page.waitForFunction( + () => '$$resFiberProps' in document.querySelector('#variant-p26-lg')!, + ); + await page.locator('#variant-p26-lg').click(); + await expect(page.locator('#stock')).toContainText('Large'); + await expect(page.locator('#offer-price')).toHaveText('$159.00'); + await page.locator('#add').click(); + await expect(page.locator('#cart-button')).toHaveText('Cart (1)'); + await page.locator('#cart-button').click(); + await page.locator('#drawer-cart-link').click(); + await expect(page.locator('#summary-total')).toHaveText('$159.00', { + timeout: 15_000, + }); + await page.locator('#checkout-link').click(); + await page.locator('input[name=name]').fill('Grace'); + await page.locator('input[name=street]').fill('2 Loop Lane'); + await page.locator('input[name=city]').fill('Testville'); + await page.locator('input[name=postcode]').fill('54321'); + await page.locator('#checkout-next').click(); + await page.locator('#checkout-next').click(); + await page.locator('#checkout-next').click(); + await expect(page.locator('#order-title')).toContainText('Order o'); + await expect(page.locator('#cart-button')).toHaveText('Cart (0)'); + // The order moves on by itself (by the clock, here), and the page follows. + await expect(page.locator('#order-status')).toHaveText('Status: Packed', { + timeout: 15_000, + }); +}); diff --git a/examples/weft_shop/e2e/shop.e2e.ts b/examples/weft_shop/e2e/shop.e2e.ts new file mode 100644 index 0000000..2d4e3f0 --- /dev/null +++ b/examples/weft_shop/e2e/shop.e2e.ts @@ -0,0 +1,342 @@ +// What only a browser can show, against the build, with the latency gated (the tests open the +// gates through the server's test-only routes): real HTML streaming and parsing, real chunk +// loading, portals, focus, cookies, the history, and the page with JavaScript off. Everything that +// can be checked without a browser is, in `tests/` (through the plugin's harness). + +import { + expect, + test, + type APIRequestContext, + type Page, +} from '@playwright/test'; + +const control = (request: APIRequestContext, ...path: string[]) => + request.post(`/test/${path.map(encodeURIComponent).join('/')}`); +const release = (request: APIRequestContext, gate: string) => + control(request, 'release', gate); +const releaseAll = (request: APIRequestContext) => + control(request, 'release-all'); + +/** + * Waits until the bootstrap has hydrated the element `selector` matches (it has its props): a + * click before then does nothing. + */ +const hydrated = (page: Page, selector: string) => + page.waitForFunction((selector) => { + const element = document.querySelector(selector); + return element !== null && '$$resFiberProps' in element; + }, selector); + +/** Holds the requests for the chunk whose file name starts with `name` until the function it returns is called. */ +const holdChunk = async (page: Page, name: string) => { + let releaseChunk!: () => void; + const held = new Promise((resolve) => { + releaseChunk = resolve; + }); + await page.route( + new RegExp(`/assets/${name}\\.res-[^/]*\\.js$`), + async (route) => { + await held; + await route.continue(); + }, + ); + return releaseChunk; +}; + +/** What the console says that it shouldn't: errors and warnings. */ +let complaints: string[] = []; + +test.beforeEach(async ({page, request}) => { + // Every gate closed, and the store as it started. + await control(request, 'reset'); + complaints = []; + page.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + complaints.push(message.text()); + } + }); + page.on('pageerror', (error) => complaints.push(String(error))); +}); + +test.afterEach(() => { + expect(complaints).toEqual([]); +}); + +test('the home page streams: the shell, then the layout, then each block as its service answers', async ({ + page, + request, +}) => { + // Not waiting for `load`: the page is still streaming, so it hasn't loaded. + await page.goto('/', {waitUntil: 'commit'}); + await expect(page.locator('#home-loading')).toBeVisible(); + await expect(page.locator('#cart-button')).toHaveText('Cart (0)'); + await release(request, 'layout:home'); + await expect(page.locator('#loading-featured')).toBeVisible(); + await expect(page.locator('#loading-hero')).toBeVisible(); + await release(request, 'rail:featured'); + await expect(page.locator('#carousel-featured')).toBeVisible(); + await expect(page.locator('#stock-loading-p1')).toHaveText('Checking stock…'); + await expect(page.locator('#loading-hero')).toBeVisible(); + await release(request, 'stock:p1'); + await expect(page.locator('#stock-p1')).toHaveText('In stock'); + await release(request, 'block:hero'); + await expect(page.locator('#hero-hero h1')).toHaveText('Made to last'); + await releaseAll(request); + await expect(page.locator('#newsletter')).toBeVisible(); + // One copy of everything, hydrated. + await hydrated(page, '#next-featured'); + await expect(page.locator('#blocks-home')).toHaveCount(1); + await page.locator('#next-featured').click(); + await expect(page.locator('#position-featured')).toHaveText('5–6 of 6'); +}); + +test("blocks arriving while an entrypoint's chunk is still loading are hydrated once it has, not rendered twice", async ({ + page, + request, +}) => { + const releaseTabs = await holdChunk(page, 'Tabs'); + await page.goto('/', {waitUntil: 'commit'}); + await hydrated(page, '#cart-button'); + await releaseAll(request); + await expect(page.locator('#newsletter')).toBeVisible(); + await expect(page.locator('#blocks-home')).toHaveCount(1); + releaseTabs(); + await hydrated(page, '#tabs-tabs-tab-1'); + await page.locator('#tabs-tabs-tab-1').click(); + await expect(page.locator('#tabs-tabs-panel-1')).toBeVisible(); + await expect(page.locator('#tabs-tabs-panel-0')).toBeHidden(); + await expect(page.locator('#blocks-home')).toHaveCount(1); + await expect(page.locator('#tabs-tabs')).toHaveCount(1); +}); + +test('shopping, start to finish: a filter, a product, a variant, the cart, a promo code, checkout and the order', async ({ + page, + request, +}) => { + await releaseAll(request); + await control(request, 'poll', '200'); + await page.goto('/c/shoes'); + await hydrated(page, '#color-green'); + await page.locator('#color-green').check(); + await expect(page).toHaveURL('/c/shoes?color=green'); + await expect(page.locator('#result-count')).toHaveText('Showing 2 of 2'); + await page.locator('#card-p8 .card-link').click(); + await expect(page.locator('#product-name')).toHaveText('Hiking Boot'); + await expect(page).toHaveURL('/p/hiking-boot'); + await hydrated(page, '#variant-p8-9'); + await page.locator('#variant-p8-9').click(); + await expect(page.locator('#stock')).toHaveText('Size 9: 5 in stock'); + await page.locator('#add-to-cart select').selectOption('2'); + await page.locator('#add').click(); + await expect(page.locator('#cart-button')).toHaveText('Cart (2)'); + await expect(page.locator('#toasts')).toContainText( + 'Added 2 × Hiking Boot to your cart', + ); + // The drawer, in a portal at the end of the body. + await page.locator('#cart-button').click(); + await expect(page.locator('body > #cart-drawer')).toContainText( + 'Hiking Boot (Size 9) × 2: $338.00', + ); + await page.locator('#drawer-cart-link').click(); + await expect(page.locator('#cart-title')).toBeVisible(); + await expect(page.locator('#cart-drawer')).toHaveCount(0); + await page.locator('.promo-code').fill('loom20'); + await page.locator('#apply-promo').click(); + await expect(page.locator('#summary-discount')).toHaveText('-$67.60'); + await expect(page.locator('#summary-total')).toHaveText('$270.40'); + await page.locator('#checkout-link').click(); + await page.locator('input[name=name]').fill('Ada'); + await page.locator('input[name=street]').fill('1 Loop Lane'); + await page.locator('input[name=city]').fill('Testville'); + await page.locator('input[name=postcode]').fill('12345'); + await page.locator('#checkout-next').click(); + await page.locator('#delivery-express').check(); + await page.locator('#checkout-next').click(); + await expect(page.locator('#review-total')).toHaveText('Total: $285.40'); + await page.locator('#checkout-next').click(); + await expect(page).toHaveURL('/orders/o1001'); + await expect(page.locator('#order-status')).toHaveText('Status: Placed'); + await expect(page.locator('#cart-button')).toHaveText('Cart (0)'); + // The order page refreshes itself: the server's newer status comes in. + await control(request, 'advance', 'o1001'); + await expect(page.locator('#order-status')).toHaveText('Status: Packed'); + await control(request, 'advance', 'o1001'); + await control(request, 'advance', 'o1001'); + await expect(page.locator('#order-status')).toHaveText('Status: Delivered'); + await expect(page.locator('#estimate')).toHaveText('Delivered.'); +}); + +test('the sign-in dialog takes focus, closes on Escape, and signing in personalises the home page', async ({ + page, + request, +}) => { + await releaseAll(request); + await page.goto('/'); + await hydrated(page, '#sign-in'); + await page.locator('#sign-in').click(); + await expect( + page.locator('body > #signin-overlay #signin-dialog'), + ).toBeVisible(); + await expect(page.locator('#signin-dialog .signin-name')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.locator('#signin-dialog')).toHaveCount(0); + await page.locator('#sign-in').click(); + await page.locator('#signin-dialog .signin-name').fill('Ada'); + await page.keyboard.press('Enter'); + await expect(page.locator('#account-menu')).toContainText('Hi, Ada'); + await expect(page.locator('#hero-welcome h1')).toHaveText( + 'Welcome back, Ada', + ); + await expect(page.locator('#carousel-for-you')).toBeVisible(); + // The session is the cookie's: a new page load is still Ada's. + await page.reload(); + await expect(page.locator('#hero-welcome h1')).toHaveText( + 'Welcome back, Ada', + ); + expect((await page.context().cookies()).map((cookie) => cookie.name)).toEqual( + ['sid'], + ); +}); + +test('a link to a page that redirects lands at the URL the redirect leads to, in place', async ({ + page, + request, +}) => { + await releaseAll(request); + const where = () => { + const url = new URL(page.url()); + return url.pathname + url.search; + }; + await page.goto('/'); + await hydrated(page, '#sign-in'); + await page.locator('#sign-in').click(); + await page.locator('#signin-dialog .signin-name').fill('Ada'); + await page.keyboard.press('Enter'); + await hydrated(page, '#account-link'); + // Signed out behind the page's back: its account link now redirects to sign in. + await page.context().clearCookies(); + // Gone if the page loads again: the link should navigate in place. + await page.evaluate(() => { + (window as {stayed?: boolean}).stayed = true; + }); + await page.locator('#account-link').click(); + await expect(page.locator('#signin-page')).toBeVisible(); + expect(where()).toBe('/signin?next=/account'); + // The history's entry is the one it led to: back is the page before, forward that one again. + await page.goBack(); + await expect(page.locator('#signin-page')).toHaveCount(0); + expect(where()).toBe('/'); + await page.goForward(); + await expect(page.locator('#signin-page')).toBeVisible(); + expect(where()).toBe('/signin?next=/account'); + // Signing in there goes on to the account. + await hydrated(page, '#signin-page .signin-name'); + await page.locator('#signin-page .signin-name').fill('Grace'); + await page.keyboard.press('Enter'); + await expect(page.locator('#account-title')).toHaveText("Grace's account"); + expect(where()).toBe('/account'); + // With nothing in the cart, checking out redirects back to it. + await page.locator('#cart-button').click(); + await page.locator('#drawer-cart-link').click(); + await hydrated(page, '#checkout-link'); + await page.locator('#checkout-link').click(); + await expect(page.locator('#cart-empty')).toBeVisible(); + await expect(page.locator('#checkout-title')).toHaveCount(0); + expect(where()).toBe('/cart'); + expect(await page.evaluate(() => (window as {stayed?: boolean}).stayed)).toBe( + true, + ); +}); + +test('the currency picked changes every price at once, and is the session’s for the next page load', async ({ + page, + request, +}) => { + await releaseAll(request); + await page.goto('/c/outerwear'); + await hydrated(page, '#currency'); + await expect(page.locator('#card-p10 .price')).toHaveText('$149.00'); + // On sale: the price it was, too. + await expect(page.locator('#card-p9 .price')).toHaveText('$373.75 $299.00'); + const saved = page.waitForResponse((response) => + response.url().endsWith('/_weft/rpc/prefs.setCurrency'), + ); + await page.locator('#currency').selectOption('EUR'); + await expect(page.locator('#card-p10 .price')).toHaveText('€137.08'); + await expect(page.locator('#card-p9 .price')).toHaveText('€343.85 €275.08'); + await saved; + await page.reload(); + await expect(page.locator('#card-p10 .price')).toHaveText('€137.08'); + await expect(page.locator('#currency')).toHaveValue('EUR'); +}); + +test('the back and forward buttons go through filtered pages, and the filters follow', async ({ + page, + request, +}) => { + await releaseAll(request); + await page.goto('/c/bags'); + await hydrated(page, '#color-black'); + await page.locator('#color-black').check(); + await expect(page).toHaveURL('/c/bags?color=black'); + await expect(page.locator('#result-count')).toHaveText('Showing 2 of 2'); + await page.locator('#sort').selectOption('price-desc'); + await expect(page).toHaveURL('/c/bags?color=black&sort=price-desc'); + await page.goBack(); + await expect(page).toHaveURL('/c/bags?color=black'); + await expect(page.locator('#sort')).toHaveValue('featured'); + await page.goBack(); + await expect(page).toHaveURL('/c/bags'); + await expect(page.locator('#result-count')).toHaveText('Showing 4 of 8'); + await expect(page.locator('#color-black')).not.toBeChecked(); + await page.goForward(); + await expect(page.locator('#color-black')).toBeChecked(); + await expect(page.locator('#result-count')).toHaveText('Showing 2 of 2'); +}); + +test('a block whose service fails says so, and Retry fetches it by itself once it’s back', async ({ + page, + request, +}) => { + await control(request, 'fail', 'rail:featured'); + await releaseAll(request); + await page.goto('/'); + // The build sends no error messages: only that the server failed. + await expect(page.locator('#block-error-featured')).toContainText( + "Couldn't load this: WeftServer: the server failed to render this", + ); + await expect(page.locator('#hero-hero')).toBeVisible(); + await control(request, 'heal', 'rail:featured'); + await release(request, 'rail:featured'); + await hydrated(page, '#retry-featured'); + const refetched = page.waitForRequest((sent) => + sent.url().endsWith('/blocks/home/featured'), + ); + await page.locator('#retry-featured').click(); + expect((await refetched).headers()['accept']).toBe('text/x-weft-payload'); + await expect(page.locator('#carousel-featured')).toBeVisible(); + await expect(page.locator('#block-error-featured')).toHaveCount(0); +}); + +test.describe('with JavaScript off', () => { + test.use({javaScriptEnabled: false}); + + test('the pages are readable: everything the server rendered', async ({ + page, + request, + }) => { + await releaseAll(request); + await page.goto('/'); + await expect(page.locator('#hero-hero h1')).toHaveText('Made to last'); + await expect( + page.locator('#carousel-featured #card-p1 .card-name'), + ).toHaveText('Trail Runner'); + await expect(page.locator('#carousel-featured #card-p1 .price')).toHaveText( + '$129.00', + ); + await page.goto('/p/trail-runner'); + await expect(page.locator('#product-name')).toHaveText('Trail Runner'); + await expect(page.locator('#stock')).toHaveText('Size 7: only 1 left'); + await expect(page.locator('#card-p2')).toBeVisible(); + }); +}); diff --git a/examples/weft_shop/package.json b/examples/weft_shop/package.json new file mode 100644 index 0000000..f335c1f --- /dev/null +++ b/examples/weft_shop/package.json @@ -0,0 +1,31 @@ +{ + "name": "@e-cline/weft_shop_example", + "version": "0.0.0", + "private": true, + "description": "A storefront built with Weft: server-driven, streamed home and catalog pages, a cart, checkout and accounts, and a test bed for how Weft's features work together.", + "type": "module", + "dependencies": { + "@e-cline/servy": "workspace:*", + "@e-cline/signals": "workspace:*", + "@e-cline/weft": "workspace:*", + "@e-cline/weft_dom": "workspace:*", + "@e-cline/weft_server": "workspace:*", + "@e-cline/weft_signals": "workspace:*", + "@rescript/runtime": "catalog:", + "@rescript/webapi": "catalog:" + }, + "devDependencies": { + "@e-cline/ppx_weft": "workspace:*", + "@e-cline/weft_vite_plugin": "workspace:*", + "@playwright/test": "catalog:", + "@types/node": "catalog:", + "jsdom": "catalog:", + "rescript": "catalog:", + "rescript-vitest": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "engines": { + "node": ">=22" + } +} diff --git a/examples/weft_shop/rescript.json b/examples/weft_shop/rescript.json new file mode 100644 index 0000000..3b19dd4 --- /dev/null +++ b/examples/weft_shop/rescript.json @@ -0,0 +1,26 @@ +{ + "name": "@e-cline/weft_shop_example", + "sources": [ + {"dir": "src", "subdirs": true}, + {"dir": "tests", "subdirs": true, "type": "dev"} + ], + "package-specs": [ + { + "module": "esmodule", + "in-source": true + } + ], + "suffix": ".res.js", + "namespace": "WeftShop", + "dev-dependencies": ["rescript-vitest", "@e-cline/weft_vite_plugin"], + "dependencies": [ + "@e-cline/servy", + "@e-cline/signals", + "@e-cline/weft", + "@e-cline/weft_dom", + "@e-cline/weft_server", + "@e-cline/weft_signals", + "@rescript/webapi" + ], + "ppx-flags": ["@e-cline/ppx_weft/ppx"] +} diff --git a/examples/weft_shop/serve.mjs b/examples/weft_shop/serve.mjs new file mode 100644 index 0000000..3377d5e --- /dev/null +++ b/examples/weft_shop/serve.mjs @@ -0,0 +1,13 @@ +// Serves the build (`vp run build` first): the client's files from `dist/client`, and every other +// request answered by the server's handler. Run with `vp run start`. +import {serve} from '@e-cline/weft_vite_plugin/node'; + +const port = Number(process.env.PORT ?? 3000); +// This machine only, unless told otherwise. +const hostname = process.env.HOST ?? '127.0.0.1'; + +const server = await serve({dir: 'dist', port, hostname}); +const address = server.address(); +console.log( + `Listening on http://${hostname}:${typeof address === 'object' ? address.port : port}`, +); diff --git a/examples/weft_shop/src/client/AccountMenu.res b/examples/weft_shop/src/client/AccountMenu.res new file mode 100644 index 0000000..f9915a6 --- /dev/null +++ b/examples/weft_shop/src/client/AccountMenu.res @@ -0,0 +1,35 @@ +/*** Who's signed in, and signing in (the dialog) or out (the `auth.signOut` RPC, then a refresh). */ + +@weft.component({kind: Entrypoint}) +let make = () => { + let {user} = ShopContext.use() + let navigation = WeftServer.Navigation.useNavigation() + let signOut = WeftServer.Rpc.use(Api.signOut) +
+ {switch user { + | Some(name) => + <> + {WeftDom.Jsx.string(`Hi, ${name}`)} + + + | None => + + }} +
+} diff --git a/examples/weft_shop/src/client/AccountMenu.res.js b/examples/weft_shop/src/client/AccountMenu.res.js new file mode 100644 index 0000000..4179a43 --- /dev/null +++ b/examples/weft_shop/src/client/AccountMenu.res.js @@ -0,0 +1,57 @@ +'use weft entrypoint' +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js"; +import * as Api$WeftShop from "../shared/Api.res.js"; +import * as Link$WeftShop from "./Link.res.js"; +import * as Rpc$WeftServer from "@e-cline/weft_server/src/shared/Rpc.res.js"; +import * as Store$WeftShop from "./Store.res.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as ShopContext$WeftShop from "./ShopContext.res.js"; +import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js"; + +function AccountMenu(props) { + let match = ShopContext$WeftShop.use(); + let user = match.user; + let navigation = Navigation$WeftServer.useNavigation(); + let signOut = Rpc$WeftServer.use(Api$WeftShop.signOut, undefined); + return Jsx$WeftDom.Elements.jsx("div", { + id: "account-menu", + children: user !== undefined ? Jsx$WeftDom.jsxs(Jsx$WeftDom.jsxFragment, { + children: [ + Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: "/account", + id: "account-link", + children: `Hi, ` + user + }), + Jsx$WeftDom.Elements.jsx("button", { + id: "sign-out", + onClick: param => { + (async () => { + let error = await signOut(); + if (error.TAG !== "Ok") { + return Store$WeftShop.toast(Api$WeftShop.describe(error._0)); + } + Store$WeftShop.toast("Signed out"); + return await Navigation$WeftServer.refresh(navigation); + })(); + }, + children: "Sign out" + }) + ] + }) : Jsx$WeftDom.Elements.jsx("button", { + id: "sign-in", + onClick: param => Store$WeftShop.openSignIn(), + children: "Sign in" + }) + }); +} + +Entrypoint$Weft.mark(AccountMenu, "@e-cline/weft_shop_example/src/client/AccountMenu.res#"); + +let make = AccountMenu; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/AddToCart.res b/examples/weft_shop/src/client/AddToCart.res new file mode 100644 index 0000000..5f41c18 --- /dev/null +++ b/examples/weft_shop/src/client/AddToCart.res @@ -0,0 +1,63 @@ +/*** +Adds the picked variant to the cart (the `cart.add` RPC): the cart it answers with goes to the store, +so the header's count changes at once, with a toast. The quantity is this button's own state, kept +when the offer around it is fetched again for another variant (and clamped to what's in stock). +*/ + +@weft.component({kind: Entrypoint}) +let make = (~sku: string, ~name: string, ~stock: int) => { + let (quantity, setQuantity) = Weft.Hooks.useState(() => 1) + let (busy, setBusy) = Weft.Hooks.useState(() => false) + let (error, setError) = Weft.Hooks.useState(() => None) + let add = WeftServer.Rpc.use(Api.addToCart) + let most = Math.Int.min(stock, 10) + let quantity = Math.Int.max(1, Math.Int.min(quantity, most)) + let quantityId = Weft.Hooks.useId() +
+ + + + {switch error { + | Some(message) => + | None => WeftDom.Jsx.null + }} +
+} diff --git a/examples/weft_shop/src/client/AddToCart.res.js b/examples/weft_shop/src/client/AddToCart.res.js new file mode 100644 index 0000000..cf3b36c --- /dev/null +++ b/examples/weft_shop/src/client/AddToCart.res.js @@ -0,0 +1,93 @@ +'use weft entrypoint' +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js"; +import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js"; +import * as Dom$WeftDom from "@e-cline/weft_dom/src/shared/Dom.res.js"; +import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js"; +import * as Api$WeftShop from "../shared/Api.res.js"; +import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js"; +import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js"; +import * as Rpc$WeftServer from "@e-cline/weft_server/src/shared/Rpc.res.js"; +import * as Store$WeftShop from "./Store.res.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; + +function AddToCart(props) { + let stock = props.stock; + let name = props.name; + let sku = props.sku; + let match = Hooks$Weft.useState(() => 1); + let setQuantity = match[1]; + let match$1 = Hooks$Weft.useState(() => false); + let setBusy = match$1[1]; + let busy = match$1[0]; + let match$2 = Hooks$Weft.useState(() => {}); + let setError = match$2[1]; + let error = match$2[0]; + let add = Rpc$WeftServer.use(Api$WeftShop.addToCart, undefined); + let most = Math.min(stock, 10); + let quantity = Math.max(1, Math.min(match[0], most)); + let quantityId = Hooks$Weft.useId(); + return Jsx$WeftDom.Elements.jsxs("div", { + id: "add-to-cart", + children: [ + Jsx$WeftDom.Elements.jsx("label", { + htmlFor: quantityId, + children: "Quantity" + }), + Jsx$WeftDom.Elements.jsx("select", { + id: quantityId, + className: "quantity", + value: quantity.toString(), + disabled: stock === 0, + onChange: event => { + let value = Dom$WeftDom.Event.targetValue(event); + setQuantity(param => Stdlib_Option.getOr(Stdlib_Int.fromString(value, undefined), 1)); + }, + children: Stdlib_Array.fromInitializer(Math.max(1, most), index => index + 1 | 0).map(count => Jsx$WeftDom.Elements.jsxKeyed("option", { + value: count.toString(), + children: count.toString() + }, count.toString(), undefined)) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: "add", + disabled: stock === 0 || busy, + onClick: param => { + setBusy(param => true); + (async () => { + let cart = await add({ + sku: sku, + quantity: quantity + }); + if (cart.TAG === "Ok") { + setError(param => {}); + Store$WeftShop.setCart(cart._0); + Store$WeftShop.toast(`Added ` + quantity.toString() + ` × ` + name + ` to your cart`); + } else { + let error = cart._0; + setError(param => Api$WeftShop.describe(error)); + } + return setBusy(param => false); + })(); + }, + children: stock === 0 ? "Sold out" : ( + busy ? "Adding…" : "Add to cart" + ) + }), + error !== undefined ? Jsx$WeftDom.Elements.jsx("p", { + id: "add-error", + role: "alert", + children: error + }) : Jsx$WeftDom.$$null + ] + }); +} + +Entrypoint$Weft.mark(AddToCart, "@e-cline/weft_shop_example/src/client/AddToCart.res#"); + +let make = AddToCart; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/BlockFrame.res b/examples/weft_shop/src/client/BlockFrame.res new file mode 100644 index 0000000..c21ef0e --- /dev/null +++ b/examples/weft_shop/src/client/BlockFrame.res @@ -0,0 +1,66 @@ +/*** +Around every block of a server-driven page: an error boundary (a block whose service failed shows +why, with a Retry button, and the rest of the page is unaffected), and the block refetched by itself +(`useRefetchable`, from the block's own route): on Retry, and every `refreshEvery` seconds for a +block the server says is live. + +Retry refetches the block, then resets the boundary once the refetched block has rendered (not when +`refetch` resolves, which is when its render starts: a reset then would render the failed block +again), so what renders is the newly fetched block: another failure is caught again. +*/ + +@weft.component({kind: Entrypoint}) +let make = (~id: string, ~src: string, ~refreshEvery: option=?, ~children: Weft.Element.t) => { + let block = WeftServer.Navigation.useRefetchable(~src, children) + // Retry's reset, waiting for the refetch to have rendered. + let (retrying, setRetrying) = Weft.Hooks.useState(() => false) + let reset = Weft.Hooks.useRef(() => ()) + Weft.Hooks.useEffect2(() => { + if retrying && !block.isPending { + setRetrying(_ => false) + reset.current() + } + None + }, (retrying, block.isPending)) + // The interval calls the latest `refetch`. + let refetch = Weft.Hooks.useRef(block.refetch) + refetch.current = block.refetch + Weft.Hooks.useEffect2(() => + switch refreshEvery { + | Some(seconds) => + let timer = setInterval( + () => refetch.current()->Promise.catch(_ => Promise.resolve())->ignore, + seconds * 1000, + ) + Some(() => clearInterval(timer)) + | None => None + } + , (refreshEvery, src)) +
+ { + reset.current = resetBoundary + + }} + > + {block.element} + +
+} diff --git a/examples/weft_shop/src/client/BlockFrame.res.js b/examples/weft_shop/src/client/BlockFrame.res.js new file mode 100644 index 0000000..cb8030f --- /dev/null +++ b/examples/weft_shop/src/client/BlockFrame.res.js @@ -0,0 +1,85 @@ +'use weft entrypoint' +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js"; +import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js"; +import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as ErrorBoundary$Weft from "@e-cline/weft/src/shared/ErrorBoundary.res.js"; +import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js"; + +function BlockFrame(props) { + let refreshEvery = props.refreshEvery; + let src = props.src; + let id = props.id; + let block = Navigation$WeftServer.useRefetchable(src, props.children); + let match = Hooks$Weft.useState(() => false); + let setRetrying = match[1]; + let retrying = match[0]; + let reset = Hooks$Weft.useRef(() => {}); + Hooks$Weft.useEffect2(() => { + if (retrying && !block.isPending) { + setRetrying(param => false); + reset.current(); + } + }, [ + retrying, + block.isPending + ]); + let refetch = Hooks$Weft.useRef(block.refetch); + refetch.current = block.refetch; + Hooks$Weft.useEffect2(() => { + if (refreshEvery === undefined) { + return; + } + let timer = setInterval(() => { + Stdlib_Promise.$$catch(refetch.current(undefined), param => Promise.resolve()); + }, refreshEvery * 1000 | 0); + return () => { + clearInterval(timer); + }; + }, [ + refreshEvery, + src + ]); + return Jsx$WeftDom.Elements.jsx("section", { + id: `block-` + id, + className: "block", + title: block.isPending ? "pending" : "idle", + "aria-busy": block.isPending ? "true" : "false", + children: Jsx$WeftDom.jsx(ErrorBoundary$Weft.make, { + fallback: (error, resetBoundary) => { + reset.current = resetBoundary; + return Jsx$WeftDom.Elements.jsxs("div", { + id: `block-error-` + id, + className: "block-error", + role: "alert", + children: [ + Jsx$WeftDom.Elements.jsx("p", { + children: "Couldn't load this: " + ErrorBoundary$Weft.message(error) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `retry-` + id, + disabled: retrying, + onClick: param => { + setRetrying(param => true); + Stdlib_Promise.$$catch(refetch.current(undefined), param => Promise.resolve()); + }, + children: retrying ? "Retrying…" : "Retry" + }) + ] + }); + }, + children: block.element + }) + }); +} + +Entrypoint$Weft.mark(BlockFrame, "@e-cline/weft_shop_example/src/client/BlockFrame.res#"); + +let make = BlockFrame; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/Carousel.res b/examples/weft_shop/src/client/Carousel.res new file mode 100644 index 0000000..e873232 --- /dev/null +++ b/examples/weft_shop/src/client/Carousel.res @@ -0,0 +1,52 @@ +/*** A rail of cards (rendered on the server, as `children`), a page of four at a time. */ + +let pageSize = 4 + +@weft.component({kind: Entrypoint}) +let make = (~id: string, ~title: string, ~count: int, ~children: Weft.Element.t) => { + let (page, setPage) = Weft.Hooks.useState(() => 0) + let pages = Math.Int.max(1, (count + pageSize - 1) / pageSize) + // The rail can shrink under the page it was on (a refetch with fewer cards). + let page = Math.Int.min(page, pages - 1) + let first = page * pageSize +
+
+

{WeftDom.Jsx.string(title)}

+ {pages > 1 + ?
+ + + {WeftDom.Jsx.string( + `${Int.toString(first + 1)}–${Int.toString( + Math.Int.min(count, first + pageSize), + )} of ${Int.toString(count)}`, + )} + + +
+ : WeftDom.Jsx.null} +
+
+
+ children +
+
+
+} diff --git a/examples/weft_shop/src/client/Carousel.res.js b/examples/weft_shop/src/client/Carousel.res.js new file mode 100644 index 0000000..284b8c3 --- /dev/null +++ b/examples/weft_shop/src/client/Carousel.res.js @@ -0,0 +1,77 @@ +'use weft entrypoint' +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js"; +import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; + +function Carousel(props) { + let count = props.count; + let id = props.id; + let match = Hooks$Weft.useState(() => 0); + let setPage = match[1]; + let pages = Math.max(1, ((count + 4 | 0) - 1 | 0) / 4 | 0); + let page = Math.min(match[0], pages - 1 | 0); + let first = (page << 2); + return Jsx$WeftDom.Elements.jsxs("div", { + id: `carousel-` + id, + className: "carousel", + children: [ + Jsx$WeftDom.Elements.jsxs("div", { + className: "carousel-head", + children: [ + Jsx$WeftDom.Elements.jsx("h2", { + children: props.title + }), + pages > 1 ? Jsx$WeftDom.Elements.jsxs("div", { + className: "carousel-controls", + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: `prev-` + id, + "aria-label": "Previous", + disabled: page === 0, + onClick: param => setPage(param => page - 1 | 0), + children: "‹" + }), + Jsx$WeftDom.Elements.jsx("span", { + id: `position-` + id, + className: "carousel-position", + children: (first + 1 | 0).toString() + `–` + Math.min(count, first + 4 | 0).toString() + ` of ` + count.toString() + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `next-` + id, + "aria-label": "Next", + disabled: page === (pages - 1 | 0), + onClick: param => setPage(param => page + 1 | 0), + children: "›" + }) + ] + }) : Jsx$WeftDom.$$null + ] + }), + Jsx$WeftDom.Elements.jsx("div", { + className: "carousel-window", + children: Jsx$WeftDom.Elements.jsx("div", { + className: "carousel-track", + style: Object.fromEntries([[ + "transform", + `translateX(-` + (page * 100 | 0).toString() + `%)` + ]]), + children: props.children + }) + }) + ] + }); +} + +Entrypoint$Weft.mark(Carousel, "@e-cline/weft_shop_example/src/client/Carousel.res#"); + +let pageSize = 4; + +let make = Carousel; + +export { + pageSize, + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CartBadge.res b/examples/weft_shop/src/client/CartBadge.res new file mode 100644 index 0000000..08da2d5 --- /dev/null +++ b/examples/weft_shop/src/client/CartBadge.res @@ -0,0 +1,58 @@ +/*** +The cart in the header: how many items (from `ShopContext`, so an RPC's answer shows at once), and a +drawer listing them, rendered into `` (a portal) while it's open. +*/ + +open Types + +module Drawer = { + @weft.component + let make = (~cart: cart, ~onClose: unit => unit) => { + let {currency} = ShopContext.use() + WeftDom.Portal.create( + , + ~container=WeftDom.Dom.document->WeftDom.Dom.body, + ) + } +} + +@weft.component({kind: Entrypoint}) +let make = () => { + let {cart} = ShopContext.use() + let (isOpen, setOpen) = Weft.Hooks.useState(() => false) +
+ + {isOpen ? setOpen(_ => false)} /> : WeftDom.Jsx.null} +
+} diff --git a/examples/weft_shop/src/client/CartBadge.res.js b/examples/weft_shop/src/client/CartBadge.res.js new file mode 100644 index 0000000..47e789a --- /dev/null +++ b/examples/weft_shop/src/client/CartBadge.res.js @@ -0,0 +1,92 @@ +'use weft entrypoint' +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js"; +import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js"; +import * as Link$WeftShop from "./Link.res.js"; +import * as Money$WeftShop from "../shared/Money.res.js"; +import * as Portal$WeftDom from "@e-cline/weft_dom/src/shared/Portal.res.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js"; +import * as ShopContext$WeftShop from "./ShopContext.res.js"; + +function CartBadge$Drawer(props) { + let onClose = props.onClose; + let cart = props.cart; + let match = ShopContext$WeftShop.use(); + let currency = match.currency; + return Portal$WeftDom.create(Jsx$WeftDom.Elements.jsxs("aside", { + id: "cart-drawer", + role: "dialog", + "aria-label": "Cart", + children: [ + Jsx$WeftDom.Elements.jsx("h2", { + children: "Your cart" + }), + Primitive_object.equal(cart.lines, []) ? Jsx$WeftDom.Elements.jsx("p", { + id: "drawer-empty", + children: "Nothing here yet." + }) : Jsx$WeftDom.Elements.jsx("ul", { + id: "drawer-lines", + children: cart.lines.map(line => Jsx$WeftDom.Elements.jsxKeyed("li", { + id: `drawer-` + line.sku, + children: line.name + ` (` + line.variant + `) × ` + line.quantity.toString() + `: ` + Money$WeftShop.format(line.unitPrice * line.quantity | 0, currency) + }, line.sku, undefined)) + }), + Jsx$WeftDom.Elements.jsx("p", { + id: "drawer-subtotal", + children: `Subtotal: ` + Money$WeftShop.format(cart.subtotal, currency) + }), + Jsx$WeftDom.Elements.jsx("div", { + onClick: param => onClose(), + children: Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: "/cart", + id: "drawer-cart-link", + children: "View cart" + }) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: "drawer-close", + onClick: param => onClose(), + children: "Close" + }) + ] + }), document.body, undefined); +} + +let Drawer = { + make: CartBadge$Drawer +}; + +function CartBadge(props) { + let match = ShopContext$WeftShop.use(); + let cart = match.cart; + let match$1 = Hooks$Weft.useState(() => false); + let setOpen = match$1[1]; + let isOpen = match$1[0]; + return Jsx$WeftDom.Elements.jsxs("div", { + id: "cart-badge", + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: "cart-button", + "aria-expanded": isOpen ? "true" : "false", + onClick: param => setOpen(open_ => !open_), + children: `Cart (` + cart.count.toString() + `)` + }), + isOpen ? Jsx$WeftDom.jsx(CartBadge$Drawer, { + cart: cart, + onClose: () => setOpen(param => false) + }) : Jsx$WeftDom.$$null + ] + }); +} + +Entrypoint$Weft.mark(CartBadge, "@e-cline/weft_shop_example/src/client/CartBadge.res#"); + +let make = CartBadge; + +export { + Drawer, + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CartLines.res b/examples/weft_shop/src/client/CartLines.res new file mode 100644 index 0000000..aebba17 --- /dev/null +++ b/examples/weft_shop/src/client/CartLines.res @@ -0,0 +1,91 @@ +/*** +The cart's lines, with steppers. A change shows at once (the quantity it's going to be, kept in a +reducer while the `cart.setQuantity` RPC is out), then the cart the server answers with replaces the +lot (through the store, so the header's count follows). Lines come from `ShopContext`, the newest of +the server's cart and the client's. +*/ + +open Types + +type pending = Map.t + +type action = + | Change(string, int) + | Settled(string) + +let reducer = (state: pending, action) => { + let next = Map.fromArray(state->Map.entries->Iterator.toArray) + switch action { + | Change(sku, quantity) => next->Map.set(sku, quantity) + | Settled(sku) => next->Map.delete(sku)->ignore + } + next +} + +@weft.component({kind: Entrypoint}) +let make = () => { + let {cart, currency} = ShopContext.use() + let (pending, dispatch) = Weft.Hooks.useReducer(reducer, Map.make()) + let setQuantity = WeftServer.Rpc.use(Api.setQuantity) + let change = (sku, quantity) => { + dispatch(Change(sku, quantity)) + + ( + async () => { + switch await setQuantity({sku, quantity}) { + | Ok(cart) => Store.setCart(cart) + | Error(error) => Store.toast(Api.describe(error)) + } + dispatch(Settled(sku)) + } + )()->ignore + } + let lines = + cart.lines + ->Array.map(line => { + ...line, + quantity: pending->Map.get(line.sku)->Option.getOr(line.quantity), + }) + ->Array.filter(line => line.quantity > 0) + lines == [] + ?
+

{WeftDom.Jsx.string("Your cart is empty.")}

+ {WeftDom.Jsx.string("Keep shopping")} +
+ :
    + {lines + ->Array.map(line => +
  • + {WeftDom.Jsx.string(line.name)} + {WeftDom.Jsx.string(line.variant)} + + + + {WeftDom.Jsx.string(Int.toString(line.quantity))} + + + + + {WeftDom.Jsx.string(Money.format(line.unitPrice * line.quantity, currency))} + + +
  • + ) + ->WeftDom.Jsx.array} +
+} diff --git a/examples/weft_shop/src/client/CartLines.res.js b/examples/weft_shop/src/client/CartLines.res.js new file mode 100644 index 0000000..c37b992 --- /dev/null +++ b/examples/weft_shop/src/client/CartLines.res.js @@ -0,0 +1,138 @@ +'use weft entrypoint' +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js"; +import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js"; +import * as Api$WeftShop from "../shared/Api.res.js"; +import * as Link$WeftShop from "./Link.res.js"; +import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js"; +import * as Money$WeftShop from "../shared/Money.res.js"; +import * as Rpc$WeftServer from "@e-cline/weft_server/src/shared/Rpc.res.js"; +import * as Store$WeftShop from "./Store.res.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js"; +import * as ShopContext$WeftShop from "./ShopContext.res.js"; + +function reducer(state, action) { + let next = new Map(state.entries().toArray()); + if (action.TAG === "Change") { + next.set(action._0, action._1); + } else { + next.delete(action._0); + } + return next; +} + +function CartLines(props) { + let match = ShopContext$WeftShop.use(); + let currency = match.currency; + let match$1 = Hooks$Weft.useReducer(reducer, new Map()); + let dispatch = match$1[1]; + let pending = match$1[0]; + let setQuantity = Rpc$WeftServer.use(Api$WeftShop.setQuantity, undefined); + let change = (sku, quantity) => { + dispatch({ + TAG: "Change", + _0: sku, + _1: quantity + }); + (async () => { + let cart = await setQuantity({ + sku: sku, + quantity: quantity + }); + if (cart.TAG === "Ok") { + Store$WeftShop.setCart(cart._0); + } else { + Store$WeftShop.toast(Api$WeftShop.describe(cart._0)); + } + return dispatch({ + TAG: "Settled", + _0: sku + }); + })(); + }; + let lines = match.cart.lines.map(line => ({ + sku: line.sku, + slug: line.slug, + name: line.name, + variant: line.variant, + unitPrice: line.unitPrice, + quantity: Stdlib_Option.getOr(pending.get(line.sku), line.quantity) + })).filter(line => line.quantity > 0); + if (Primitive_object.equal(lines, [])) { + return Jsx$WeftDom.Elements.jsxs("div", { + id: "cart-empty", + children: [ + Jsx$WeftDom.Elements.jsx("p", { + children: "Your cart is empty." + }), + Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: "/", + id: "keep-shopping", + children: "Keep shopping" + }) + ] + }); + } else { + return Jsx$WeftDom.Elements.jsx("ul", { + id: "cart-lines", + children: lines.map(line => Jsx$WeftDom.Elements.jsxsKeyed("li", { + id: `line-` + line.sku, + className: "cart-line", + children: [ + Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: `/p/` + line.slug, + children: line.name + }), + Jsx$WeftDom.Elements.jsx("span", { + className: "line-variant", + children: line.variant + }), + Jsx$WeftDom.Elements.jsxs("span", { + className: "stepper", + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: `less-` + line.sku, + "aria-label": "One fewer", + onClick: param => change(line.sku, line.quantity - 1 | 0), + children: "−" + }), + Jsx$WeftDom.Elements.jsx("span", { + id: `quantity-` + line.sku, + className: "line-quantity", + children: line.quantity.toString() + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `more-` + line.sku, + "aria-label": "One more", + disabled: line.quantity >= 10, + onClick: param => change(line.sku, line.quantity + 1 | 0), + children: "+" + }) + ] + }), + Jsx$WeftDom.Elements.jsx("span", { + className: "line-total", + children: Money$WeftShop.format(line.unitPrice * line.quantity | 0, currency) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `remove-` + line.sku, + onClick: param => change(line.sku, 0), + children: "Remove" + }) + ] + }, line.sku, undefined)) + }); + } +} + +Entrypoint$Weft.mark(CartLines, "@e-cline/weft_shop_example/src/client/CartLines.res#"); + +let make = CartLines; + +export { + reducer, + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CartSummaryFrame.res b/examples/weft_shop/src/client/CartSummaryFrame.res new file mode 100644 index 0000000..7d93a8d --- /dev/null +++ b/examples/weft_shop/src/client/CartSummaryFrame.res @@ -0,0 +1,22 @@ +/*** +The cart's totals, rendered on the server (`children`: the discount, shipping, and what's left for +free shipping are the server's to work out), fetched again by themselves (`useRefetchable`) whenever +the cart changes: the version in `ShopContext` going up, from any entrypoint's RPC. +*/ + +@weft.component({kind: Entrypoint}) +let make = (~children: Weft.Element.t) => { + let {cart} = ShopContext.use() + let summary = WeftServer.Navigation.useRefetchable(~src="/cart/summary", children) + let refetch = Weft.Hooks.useRef(summary.refetch) + refetch.current = summary.refetch + let shown = Weft.Hooks.useRef(cart.version) + Weft.Hooks.useEffect1(() => { + if cart.version != shown.current { + shown.current = cart.version + refetch.current()->Promise.catch(_ => Promise.resolve())->ignore + } + None + }, [cart.version]) +
{summary.element}
+} diff --git a/examples/weft_shop/src/client/CartSummaryFrame.res.js b/examples/weft_shop/src/client/CartSummaryFrame.res.js new file mode 100644 index 0000000..0b07a53 --- /dev/null +++ b/examples/weft_shop/src/client/CartSummaryFrame.res.js @@ -0,0 +1,38 @@ +'use weft entrypoint' +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js"; +import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js"; +import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as ShopContext$WeftShop from "./ShopContext.res.js"; +import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js"; + +function CartSummaryFrame(props) { + let match = ShopContext$WeftShop.use(); + let cart = match.cart; + let summary = Navigation$WeftServer.useRefetchable("/cart/summary", props.children); + let refetch = Hooks$Weft.useRef(summary.refetch); + refetch.current = summary.refetch; + let shown = Hooks$Weft.useRef(cart.version); + Hooks$Weft.useEffect1(() => { + if (cart.version !== shown.current) { + shown.current = cart.version; + Stdlib_Promise.$$catch(refetch.current(undefined), param => Promise.resolve()); + } + }, [cart.version]); + return Jsx$WeftDom.Elements.jsx("div", { + id: "summary-frame", + "aria-busy": summary.isPending ? "true" : "false", + children: summary.element + }); +} + +Entrypoint$Weft.mark(CartSummaryFrame, "@e-cline/weft_shop_example/src/client/CartSummaryFrame.res#"); + +let make = CartSummaryFrame; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CheckoutForm.res b/examples/weft_shop/src/client/CheckoutForm.res new file mode 100644 index 0000000..c314ad5 --- /dev/null +++ b/examples/weft_shop/src/client/CheckoutForm.res @@ -0,0 +1,223 @@ +/*** +Checking out, in three steps (a reducer): the address (checked as the `checkout.placeOrder` RPC will +check it, before moving on), delivery, then a review and placing the order. Its fields' labels are +linked by IDs from `useId`. If someone else bought the last of something meanwhile, the server says +which, and nothing is charged; otherwise it's on to the order's page. +*/ + +open Types + +type step = Address | Delivery | Review + +type state = { + step: step, + details: checkout, + error: option, + placing: bool, +} + +type field = Name | Street | City | Postcode | GiftNote + +type action = + | Edit(field, string) + | Choose(delivery) + | Next + | Back + | Placing + | Failed(string) + +let reducer = (state, action) => + switch action { + | Edit(field, value) => + let details = state.details + { + ...state, + details: switch field { + | Name => {...details, name: value} + | Street => {...details, street: value} + | City => {...details, city: value} + | Postcode => {...details, postcode: value} + | GiftNote => {...details, giftNote: Some(value)} + }, + } + | Choose(delivery) => {...state, details: {...state.details, delivery}} + | Next => + switch state.step { + | Address => + switch Api.checkAddress(Api.trimmed(state.details)) { + | Ok() => {...state, step: Delivery, error: None} + | Error(error) => {...state, error: Some(Api.message(error))} + } + | Delivery => {...state, step: Review, error: None} + | Review => state + } + | Back => + switch state.step { + | Address | Delivery => {...state, step: Address, error: None} + | Review => {...state, step: Delivery, error: None} + } + | Placing => {...state, placing: true, error: None} + | Failed(error) => {...state, placing: false, error: Some(error)} + } + +module Field = { + @weft.component + let make = (~label: string, ~name: string, ~value: string, ~autoComplete: string, ~onEdit) => { + let id = Weft.Hooks.useId() +
+ + onEdit(WeftDom.Dom.Event.targetValue(event))} + /> +
+ } +} + +@weft.component({kind: Entrypoint}) +let make = (~total: cents, ~count: int) => { + let {currency, user} = ShopContext.use() + let (state, dispatch) = Weft.Hooks.useReducer( + reducer, + { + step: Address, + details: { + name: user->Option.getOr(""), + street: "", + city: "", + postcode: "", + delivery: Standard, + giftNote: None, + }, + error: None, + placing: false, + }, + ) + let navigation = WeftServer.Navigation.useNavigation() + let placeOrder = WeftServer.Rpc.use(Api.placeOrder) + let {details} = state + let edit = (field, value) => dispatch(Edit(field, value)) + let place = () => { + dispatch(Placing) + + ( + async () => + switch await placeOrder(details) { + | Ok(orderId) => await navigation->WeftServer.Navigation.navigate(`/orders/${orderId}`) + | Error(error) => dispatch(Failed(Api.describe(error))) + } + )()->ignore + } +
+
    + {[(Address, "Address"), (Delivery, "Delivery"), (Review, "Review")] + ->Array.map(((step, label)) => +
  1. + {WeftDom.Jsx.string(label)} +
  2. + ) + ->WeftDom.Jsx.array} +
+
{ + WeftDom.Dom.Event.preventDefault(event) + state.step == Review ? place() : dispatch(Next) + }} + > + {switch state.step { + | Address => +
+ {WeftDom.Jsx.string("Where to?")} + + + + +
+ | Delivery => +
+ {WeftDom.Jsx.string("How fast?")} + {[(Standard, "Standard (3–5 days)"), (Express, "Express (next day)")] + ->Array.map(((delivery, label)) => { + let value = delivery == Standard ? "standard" : "express" + + }) + ->WeftDom.Jsx.array} + +