mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
feat: new Form component, all features and improvements you dreamed of (#6485)
## User-facing features and how to test them In Components → Forms, insert **Form (Beta)**. The existing Webhook Form remains a separate component. - **Insert a ready-to-use contact Form.** In Components → Forms, insert **Form (Beta)**. It starts with accessible Name, Email, Subject, Message, and optional Attachment fields; sends submitted field values and uploaded files to the account holder; sends a customizable confirmation to the visitor without attaching the submitted file; and includes success and error feedback. The two Actions are named **Email to owner** and **Email to visitor**. *(#3023, #5499)* - **Use the contact Subject in both email subjects.** Owner and visitor subjects bind the submitted Subject input; the visitor subject also identifies the sender. Template and SDK tests pass. - **Format submitted Form data readably in Email bindings.** `formData.toString()` now renders labeled lines, nested values, and uploaded filenames in any Email Resource expression, while excluding passwords and internal Form fields. SDK and independent-bundle tests pass. Published staging delivery is verified in Batch 7. - **Choose Form actions.** Select the Form and open Properties & Attributes → Action. Add up to ten visible HTTP, GraphQL, or Email Resources; reorder, disable, or remove them. Submit and confirm enabled actions run together. *(#5499)* - **Send submitted fields and files.** Submit repeated fields and a file input to an HTTP Resource. Check the received multipart fields, order, filename, type, and bytes. Email Resources attach submitted files by default; turn off **Attach submitted files** to omit them. *(#3023, #5432)* - **Make the visitor confirmation friendly and signed.** Inherited content gets the approved friendly message and resolved sender name; custom text remains intact. Email Request preview and delivery use the same resolver. SDK and Builder preview tests cover defaults, custom overrides, blank content, sender identity, and preview parity. Suggested body: > Thank you for your message. > > We received it and will respond as soon as possible. > > Webstudio Team - **Identify the sender in the visitor confirmation.** The resolved sender name appears in the visitor subject and body. Focused tests pass. - **Include source page context in the owner email.** `browserInfo.pageUrl` is included in the owner message and the Email Request preview. Focused SDK and Builder tests pass. - **Use Form and visitor data.** In a Resource defined on the Form, bind `formData` and `browserInfo` in its URL, headers, or body. Local MCP Preview tests verify submitted fields, browser, and language. *(#5428)* - **Use page and query values.** On a dynamic page with query parameters, bind `system.params` and `system.search` in a Form Resource, then navigate and submit. A query key with one value is a string; repeated values are an ordered array. Confirm the current route values are available after navigation. Text bindings render repeated values as comma-joined strings. *(#5512, #3891)* - **Use Navigation mode for search and filters.** Remove every Resource from the new Form’s Action list. Its action becomes `?__webstudio__=navigate`, and submitting named fields updates the URL query and page Resources without a full page reload. Reconnect a Resource to return to Resource submission mode (`?__webstudio__=resources`). A plain Element with tag `form` keeps native browser behavior. - **Customize notification email.** In Project Settings → Emails, set recipients, sender, and Reply to. In each Email Resource, choose project recipients, custom addresses, or a visitor email input, and configure its Reply to, subject, body, and attachment setting. Verify a per-Resource override does not change another Form’s settings. *(#4414, #4385, #5658)* - **Choose Resource types from one ordered list.** In Variables → Type, find REST resource, GraphQL resource, Assets resource, Email resource, Sitemap resource, and Current date resource in that order. Confirm the labels and order in the Type dropdown. - **Inspect Resource requests and responses.** Open Variables → a Resource → Edit variable. Use **Load data** on Request when no request is shown; inspect HTTP request, response, and diagnostics. For Email, load the request preview and confirm it does not send an email. After submission, inspect saved HTTP and GraphQL exchanges without triggering another request. *(#4093 related; new Form Resource inspector)* - **Explain the Email Response before a submission.** Refresh builds the local request preview without sending an email; the Response tab tells users to submit the Form in Preview to see the Email Service response. Focused inspector test passes. - **Use action results in the Form.** Bind `formState`, `results`, and `errors` in the success and error content. Submit successful and failing actions and verify each result or error includes its action’s response details in configured order. *(#5499)* - **See submission feedback.** Submit a Form whose success or error message is outside the viewport and confirm that message scrolls into view. Repeat with feedback already visible, reduced motion, partial failure, and a configured success redirect; visible feedback should not move, and redirect should happen without scrolling first. *(#6487)* - **Redirect after success.** Set Success redirect and submit both successful and failing Forms. Only success follows the redirect. *(#3530)* - **Keep separate email threads.** Retries preserve a submission reference, while separate submissions appear in separate Gmail conversations; Batch 7 verifies both. *(#5018)* - **Keep existing Webhook Forms.** Insert the existing Webhook Form and verify its configured behavior remains intact. For its documented query-binding fixes, verify route params and repeated search values after navigation. *(#5512, #3891; #4093 and #5432 remain related legacy issues)* - **Keep native Form props out of the new Form editor:** The new Form metadata hides generated rendered-tag HTML props such as `method`, `encType`, `target`, and ARIA attributes, while its own Action, Success redirect, and visibility controls remain. The legacy Webhook Form behavior is unchanged. A Builder property-selector regression test verifies this. - **Allow up to 10 Form Actions:** The shared validator and Action picker now allow up to 10 Resource actions, and the Form control description reflects the limit. SDK tests accept 10 and reject 11; the generated CLI prebuild rejection coverage passes (130 tests). SDK and SDK Components typechecks, formatting, and diff checks pass. - **Make the default Form ready to use without edits:** The inserted contact Form has required Name, Email, Subject, and Message fields; its project Email Action resolves the current project contact address or account holder dynamically, with no personal data in the template; its visitor Email Action has a confirmation subject and message; success and error states give actionable feedback. `core-templates.test.tsx` verifies fields, routing, and copy; focused registry tests and typecheck pass. - **Make the default Form usable as a contact form:** Its Email Actions are named “Email to owner” and “Email to visitor.” The owner body binds the complete submitted `formData` object rather than manually listing fields, and an optional Attachment input sends its file only to the owner. Email Resource expressions render `formData.toString()` as readable labeled lines. The visitor receives a confirmation without submitted files. Registry/template tests and MCP E2E verify the inserted Form, owner message and attachment, visitor message, and Request preview. - **Format Form action errors clearly:** The default Form shows each action error as a spaced bullet inside a red-tinted alert panel. Registry template tests verify the alert and list structure; 8 template tests pass. - **Deprecate the existing Webhook Form before its next-PR removal:** The component metadata and compatibility exports in the React, React Router, and Remix packages are marked deprecated, and the guide says removal is planned. Keep form submission helpers still used by NativeForm active; remove legacy-only wrappers and template in the follow-up PR. ## MCP feature parity test plan Run these cases through Webstudio MCP on a disposable project. Create and configure the Form with MCP, save and read back the configuration, then test its generated Preview. Use only a controlled test mailbox for real email delivery. This plan mirrors the user-facing UI cases above. - Discover **Form (Beta)**, inspect its schema, insert the default contact Form, and read back its fields, Actions, success/error content, and absence of personal data. - Change the submitted Subject and Name. Verify owner and visitor subjects, visitor sender identity, friendly confirmation text, and the owner page URL. - Bind the complete `formData` object. Verify readable lines, nested values, filenames, and exclusion of password/internal fields. - Add HTTP, GraphQL, and Email Actions; reorder, disable, and remove them. Verify concurrent dispatch, result order, partial failure, and the 10/11 Action limit. - Submit repeated fields and one or multiple files to HTTP. Verify multipart order, filename, MIME type, and exact bytes. Verify Email attachments default on, then turn them off for the visitor Action and confirm the visitor receives no file. - Configure project email settings and Resource overrides. Save/reload each recipient mode, Sender, Reply to, subject, body, and attachment setting; verify overrides do not leak between Forms. - Bind `formData` and `browserInfo` from a Form-scoped Resource. Verify page URL, browser, language, and hosting-provided IP/referrer where the test environment supplies them. - Test dynamic route params and single/repeated query values before and after navigation. Verify Navigation mode updates the URL and bound page Resources without a full reload; reconnect an Action and verify Resource mode. Check native `form` Element behavior separately. - Verify Resource discovery order and method labels, then exercise HTTP/GraphQL Request, Response, Diagnostics, load/refresh, and saved exchange inspection. For Email, verify request preview sends nothing and the Response appears after actual submission. - Bind `formState`, `results`, and `errors`. Submit success, validation failure, partial Action failure, and full failure. Verify feedback content, visible/off-screen scrolling, reduced motion, and success-only redirect behavior. - Submit twice and verify separate email threads and stable retry identity, with no duplicate delivery after an ambiguous send. - Verify the existing Webhook Form remains editable and its route/query behavior remains intact, while new Form discovery/creation and native Form prop exposure match the documented component contract. ### Execution status and MCP limitation The `mcp-*` suite authors through a current-source MCP server and submits generated Preview Forms through `preview.submit-form`. Playwright covers Builder UI, navigation/rendering, and viewport assertions. The separate `forms.simulate-submission` operation submits synthetic values from the saved canvas Form directly to the shared SDK server Form engine, without starting Preview or a browser. Both paths use the same saved-draft preparation and SDK submission handler. Most automated email tests use a loopback receiver. The controlled branch-source MCP test below separately verifies delivery to a real test mailbox; it does not test the user’s connected MCP release. On 2026-10-14, the branch-source MCP was tested against a running local Builder and a copied disposable project. No npm package publish or connected-MCP release was needed for this local verification. - [x] Add a bounded `preview.submit-form` MCP operation for session-owned generated Preview Forms. It uses native form controls and validation, supports repeated values and bounded file uploads, returns state/results/errors/exchanges, and never replays an ambiguous send. The MCP-client E2E verifies one owner Email delivery and one HTTP delivery with ordered results against local receivers. Focused Vision, Project Build, CLI, and Builder E2E tests pass. - [x] Migrate browser Preview Form runtime cases to `preview.submit-form`; retain Playwright for Builder UI, navigation/rendering, and viewport assertions. The 52 MCP-named Builder E2E cases passed across the full 2-worker run (50/52) and a focused rerun of its two timed-out Email cases (2/2), including MCP submission and local receiver assertions. - [x] Add `forms.simulate-submission` to the current-source CLI MCP session. It reads the saved canvas Form, calls the Builder server route, and reuses the Preview Form engine and SDK submission handler. The older local CLI adapter rejected Email; the current server route supports it. Static Checkbox and multiple Select array names come from saved controls; dynamic array names may be supplied explicitly. Focused tests and independent Astra Medium review pass. - [x] Run the Luna Low `managed-form-runtime-v1` scenario against `forms.simulate-submission` through a real MCP client on the current source. `gpt-5.6-luna` at low reasoning passed 10/10 checks in 55 seconds. Three simulation calls produced success, an explicit 503 failure, and recovery against the controlled local HTTP receiver in the expected sequence. The agent used no Preview/browser submission or Email action. One `templates.get` MCP error was recovered before submission. - [x] Verify `forms.simulate-submission` from branch-source MCP against the local Builder server and a disposable saved Form. The supported local database was started, and `EMAIL_SERVICE_URL` plus `TRPC_SERVER_API_TOKEN` were supplied from the staging Infisical environment without printing values. Luna Low used MCP discovery, Form/resource inspection, `update-resource`, and simulation. Both Email Resources resolved only to `testing@webstudio.is`; simulation returned success, HTTP 200, two Email results with status 200, and no errors. Gmail independently confirmed both owner and visitor messages delivered to that controlled inbox at 15:46:51 UTC on 2026-10-14. The shared server route and CLI transport received independent Astra Medium review; focused CLI (164) and HTTP-client (45) tests, package typechecks, formatting, and diff checks pass. No npm release or production deployment was involved. - [x] Rename the new Form component label to **Form (Beta)** in the component picker and MCP discovery. Component IDs stay unchanged; focused registry, Builder, MCP, and CLI tests pass; Astra Medium reviewed the change. Pushed `592589ccfe`. ## MCP end-to-end verification ### Verified against this branch These checks use the current-source MCP session server to author disposable local Builder projects and `preview.submit-form` to submit generated-site Forms to local test receivers. Playwright verifies Builder UI, navigation/rendering, and viewport behavior. They verify this branch's code, not the connected MCP release, staging services, or real mailbox delivery. Current branch rechecks (2026-10-14): Project Build MCP tests 142/142, CLI MCP/evaluation harness tests 60/60, Vision CDP/Form tests 48/48, and the React Router Navigation browser test 4/4 pass. Builder, CLI, Project Build, Vision, and both React Router fixture typechecks pass; scoped lint, formatting, and diff checks pass. The earlier head passed PR CI; checks on commit `7438692ef5` are running. The separate full CLI prebuild suite now passes 131/131 after adding the required `statusText` to its private Email binding fixture. The main-merge CI failures in Project Build expression expectations, CLI Preview config import, formatting, and the SSG fixture size cap were fixed in `0272768260`; checks on that commit are pending. - [x] Verify all 52 MCP-named Builder E2E cases on the current head. The complete 2-worker run passed 50/52; two complex Email cases exceeded Playwright's 120-second per-test limit under concurrency, then both passed in a focused one-worker rerun (44.4s and 31.0s; 2/2). A serial full rerun reached 43/52 without assertion failures before the wrapper's 900-second command limit. An earlier full run passed 52/52 before the final Preview readiness fix. Coverage includes contact Forms, Email delivery and partial updates, attachments, readable `formData`, HTTP and GraphQL Actions, navigation and repeated query values, legacy Form editing, Resource inspectors, Form properties and type choices, feedback, and redirects. Full-run command: `E2E_TEST_COMMAND_TIMEOUT_SECONDS=1800 E2E_SKIP_BUILDER_BUILD=true E2E_INSTALL_PLAYWRIGHT=false pnpm --filter @webstudio-is/builder e2e -- 'mcp-' --project=chromium --workers=2`. - [x] Run the Builder Preview Form E2E suite serially: 5/5 passed, including authenticated Builder Preview submission, error rendering, redirects, submitted browser `FormData`, off-screen feedback scrolling, and Navigation mode without a Resource POST or canvas reload. - [x] Verify the local Email inspector server path. The route integration suite calls the real local `/rest/preview-form` handler and passes a controlled Email Service binding response through it; 28/28 tests pass. The Email Service boundary is stubbed, so this does not claim a staging Worker run. - [x] Verify the Form Action picker browser tests: 2/2 pass for add, disable, reorder, remove, the ten-Action limit, and rejection of an eleventh Action. The MCP runtime E2E separately verified ten deliveries and ordered results. - [x] Verify local Preview request context across two pages: scoped `formData`, `browserInfo.pageUrl`, trusted IP, user-agent, and language all remain associated with the correct Form Resource. - [x] Verify partial Email body updates through MCP. Custom and explicitly blank bodies preserve the other recipient, sender, and attachment settings; local Preview uses the requested body. Focused E2E passes 2/2. - [x] Verify the default Form template has no hardcoded email address, sender, or recipient, and the error Collection renders each message as a separated bullet in a red-tinted alert. Registry tests pass 11/11. - [x] Verify the Action picker UI adds, disables, reorders, and removes HTTP, GraphQL, and Email Resources; accepts 10 and blocks 11. Component browser tests pass 2/2. Runtime MCP E2Es separately verify Action order and concurrent dispatch; the picker test does not claim ten live deliveries. - [x] Verify the Luna Low managed-Form authoring evaluation: 20/20 checks pass. Validator tests pass 83/83. - [x] Verify the legacy Webhook Form is excluded from new MCP creation/copy paths while saved instances remain editable. Source tests and the existing 139 MCP checks pass; this does not mean the published MCP catalog has been refreshed. - [x] Formatting, `git diff --check`, and Builder, CLI, and SDK Components Registry typechecks pass. ## Runtime correctness - [x] Prevent automatic retry of an Email Action when the Email Service send may already have succeeded; report an ambiguous response without sending the same email again, and verify one Form submission cannot produce duplicate delivery. Pushed `07627e44fa`: Email Actions never automatically replay; HTTP retries remain. RPC and Preview accept legacy `{id}` and canonical replies, including successful canonical replies with an empty provider ID. Regression tests cover one dispatch after failures, timeouts, and malformed replies, response compatibility, and HTTP retry. All 143 focused SDK tests, SDK typecheck, scoped lint, diff checks, and independent Astra review pass. - [x] Keep ambiguous Email Action outcomes non-retriable in Builder so a possibly successful send is never replayed; preserve compatibility with both deployed legacy `{id}` and canonical `{ok, status, statusText, data}` Worker replies. Pushed `578a1a99`: canonical delivery IDs must be nonblank, while legacy replies remain supported. SDK regressions cover one dispatch after ambiguous RPC/Preview failures, timeouts, malformed replies, and empty IDs. ## New Form navigation mode and MCP E2E (2026-10-11) - [x] Give the new Form two submission modes using its valid action URL: `?__webstudio__=resources` when one or more Resources are connected and `?__webstudio__=navigate` when none are connected. Removing the last Resource switches to Navigation mode. Keep a plain `form` Element native and leave the legacy Webhook Form available but marked deprecated until the next release. Runtime tests cover both action markers; the MCP-authored browser E2E verifies navigation query updates and Resource revalidation. Legacy Webhook Form metadata, compatibility exports, component code, and docs now state its deprecation and support window. - [x] In the new Form’s Action control, when no Resources are connected, show centered “Navigation mode” with an info tooltip: “Submits the form fields as URL query parameters and navigates without a full page reload. Use this for search and filters that update page Resources from the URL.” - [x] Add MCP-authored end-to-end coverage for both modes and their distinctions. Luna/low authored Resource and Navigation Forms plus a native `form` Element through MCP, with separate registry searches for Form (Beta), Webhook Form, and Element; the final project passes the corrected structured evaluator. The MCP-authored Playwright E2E removes all Form Actions and verifies repeated and single query values update the URL and page Resource without a document reload or Resource POST. Existing local MCP Preview coverage verifies Resource submission, native GET behavior, legacy Webhook Form compatibility, and component discovery. ## Ports from the earlier implementation - [x] Resource selection and scoped `formData`/`browserInfo` access, parallel dispatch, protected egress, server destination limits, and bot-field validation are already in the new Form. - [x] Resource editor URL autofocus and known header/value suggestions are already reused. - [x] Centralize the `formData` and `browserInfo` parameter names for Builder, generator, and runtime checks. - [x] Default HTTP body forwarding and automatic multipart for uploads are now ported to the new Resource graph. - [x] Success/error state slots and success redirect are now ported into the new Form and Router/Remix adapters. - [x] Add response `results`/`errors` bindings and one retry per failed or timed-out destination. - [x] Port feedback scrolling to the new Form in Router and Remix adapters, with browser regression coverage. - [x] Adapt the existing bot-field protection for the new Form; saved Webhook Forms keep their prior bot-field behavior. The narrowly scoped route/query fixes for #5512 and #3891 are tracked separately below. - [x] Local new-Form bot checks: one fresh hidden value per submission, five-minute expiration, malformed/missing rejection before dispatch, Brave handling, and stripping internal form ID/bot fields before Resource delivery. Evidence: `packages/sdk/src/managed-form-submission.ts`, `managed-form-submission.test.ts`, `packages/sdk-components-react/src/form-submission.browser.test.ts`, and managed generated-route cases in `packages/cli/src/prebuild.test.ts`. ## Batch 1 — New Form foundation - [x] Make the existing Forms-section **Form** tile insert the new Form component while retaining the existing Webhook Form tile and behavior. - [x] Make the new Form a managed Resource submission component. With no destination, it shows a configuration error and cannot dispatch or navigate. Use Element with `tag=form` for ordinary browser form behavior. - [x] Exclude unrelated legacy-only runtime work from this feature. Existing component metadata is deprecated; the new Form has no legacy compatibility or migration contract. The targeted #5512/#3891 route/query fixes are tracked separately in Batch 2a. - [x] Verify the Resource-only component with focused render tests, package typecheck, and component story generation. - [x] Verify the Batch 1 new Form acceptance criteria in the Builder and browser. ### Acceptance criteria - Inserting the new Form creates the default contact Form with named inputs, a submit button, and two selected Email Resource Actions: one for project recipients and one for the visitor email field. If all destinations are removed, it shows a configuration error and cannot dispatch or navigate until a destination is configured. - Element with `tag=form` retains browser validation, method, encoding, action, and navigation. - Acceptance covers the new Form only. Apart from the explicitly tracked #5512/#3891 route/query binding fixes in Batch 2a, legacy compatibility, old native-mode settings, and old-component migration are outside the feature contract. **Verification:** Builder catalog and insertion tests; Chromium empty-Resource blocking test; generated Element `tag=form` Playwright test with a local receiver (`packages/sdk-components-react/src/element-form.test.tsx`, `40c6f972c8`, focused 1/1) proves invalid input sends nothing and valid native multipart POST navigates using the configured action, method, and encoding; generated published output for the new Form; relevant typechecks and lint. The user confirmed the test inbox received the test email attachment. Staging Builder Preview verified Email and Webhook delivery. The user confirmed the published staging route is live; the route returns HTTP 200 and includes a file input. Published delivery to both Webhooks and final Form-state behavior are verified in Batch 7 below. Live Production Form submissions are outside this PR’s verification scope. ## Batch 2 — Resource-only submission, scoped data, and Resource selection - [x] Give the new Form one submission path with one to ten selected Resource destinations. Keep Actions and Success redirect as its Form controls; plain HTML forms use Element with `tag=form`. An empty selection reports an error without dispatch or navigation; tests cover this path. - [x] Expose one submission-only `formData` object, keyed by HTML name. Repeated names are ordered lists; a known unchecked checkbox group is an empty list. Preserve files and hidden inputs. - [x] Expose safe `browserInfo`: trusted IP, user agent, language, and referrer. Never expose raw request headers, cookies, or authorization. - [x] Make explicit Resource expression bindings to a Form's `formData` and `browserInfo` available only to Resources defined within that Form; a selected HTTP Action with an unset or empty body implicitly receives submitted Form data regardless of Resource scope. - [x] Resource variables are created in **Variables** at their normal scope. The Actions selector lists only action-capable Resource variables visible in the Form's variable scope: defined on the Form or one of its ancestors. Exclude sibling, descendant, and unrelated scopes. Resources defined on the Form can bind its `formData` and `browserInfo`; ancestor Resources cannot bind values from a descendant Form. Carry forward URL autofocus and known header/value autocomplete. Enforce up to ten Actions per Form in the Builder and server. - [x] Keep **Actions** as a control inside **Properties & Attributes**, with a plus menu and selected Resource rows. The plus menu offers only existing action-capable HTTP, Email, and GraphQL Resource variables visible in the Form's current variable scope (defined on the Form or an ancestor); exclude System data variables and resources from sibling, descendant, or unrelated scopes. Resource variables are created in **Variables** at their normal scope; Actions only selects existing variables. Disable already-selected Resources in the menu. Selected rows expose enable, disable, and remove controls. A simple click on the Action name opens the existing Resource editor through selection/open state, leaves its Variables row visibly selected, and scrolls it into view, while preserving page and canvas selection; drag, enable/disable, and remove controls must not open it. The eye toggles enabled state; the minus removes the Action from this Form only. Preserve explicit selection, ordering, and the ten-Action limit. Published-site submission checks are recorded in Batch 7. - [x] Cover scope, expressions, repeated names, renames, files, copying, deletion, save/reload, and unavailable bindings with focused tests. - [x] Ensure `mapResourceExpressionsMutable` remaps Email `senderExpression` and `recipientsExpression` data-source IDs; `fragment.test.tsx` verifies copy/paste/fragment rebinding (focused regression 1/1, full file 34/34). Project-build typecheck and `git diff --check` pass. - [x] Add focused fragment coverage for Email `senderExpression` and `recipientsExpression` bound to variables inside a copied Form, asserting references remap to the cloned variable IDs (not only restoration of external parent bindings by name). `fragment.test.tsx` passes 34/34 and combined project-build data+fragment tests pass 165/165; assertions verify both expressions remap to cloned variable IDs distinct from the source IDs. - [x] Implement and locally test Batch 2a current-page parameters and repeated query values in managed and legacy Form Resource bindings (#5512, #3891). - [x] Add generated published-route loader integration coverage for a native GET Form with repeated query keys; repeated `system.search` values reach an expression-bound Resource in both `defaults` and `react-router` templates. Focused prebuild tests pass 2/2. - **Issue scope:** #5512 is fixed for legacy generated HTML Form actions and the new Form path; generated-route tests cover both route templates. #3891 exposes repeated values as ordered arrays through `system.search` while preserving strings for single values; SDK, Builder Preview, generated action, and generated native-GET loader regressions cover the behavior. - [x] Local #5512/#3891 implementation and regression: the new Form's generated action uses current `system.params` and `system.search`, where repeated values are ordered arrays; SDK search tests and the Builder native GET preview test preserve repeated values (`packages/cli/src/prebuild.test.ts`, `packages/sdk/src/system-search.test.ts`, `apps/builder/app/canvas/interceptor.test.ts`). - [x] Locally verify #5428: bind trusted `browserInfo` values into `X-Forwarded-For`, `User-Agent`, and `Accept-Language` Resource headers, inspect the generated action’s outbound request, and document the setup and trusted client-IP source for each hosting adapter without forwarding cookies or authorization. - [x] Local generated-route verification (`e9d5209882`): Remix and React Router send the three configured headers to a test destination; Cloudflare context uses `CF-Connecting-IP`, Node omits unavailable IP, and arbitrary forwarding, cookie, and authorization headers are not forwarded. Setup and hosting sources are documented. ### Form property inventory - [x] Expose only the Actions control and **Success redirect** as Form controls. The generic HTML attribute picker and native `id`, `class`, `action`, `method`, `target`, and `encType` controls are unavailable for this component. - [x] Rename the Form control label from **Actions** to singular **Action**. Staging verification for the Form property panel is recorded in Batch 7. ### Acceptance criteria - Action and Success redirect are the only Form controls. The component label `Form` is system metadata and is not editable as an HTML attribute; the Form has no `id`, `class`, `action`, `method`, `target`, or `encType` controls. - **Action** is a control inside **Properties & Attributes**. Its plus menu offers only existing action-capable HTTP, Email, and GraphQL Resource variables visible in the Form's current variable scope (defined on the Form or one of its ancestors). It excludes System data variables and Resources in sibling, descendant, or unrelated scopes. Resource variables are created in **Variables** at their normal scope; Actions only selects existing variables. Already-selected Resources are disabled in the menu. Selected Resources appear as rows with enable, disable, and remove controls, and can be reordered. Clicking an Action name opens the corresponding Resource’s existing Edit variable dialog and highlights its Variables row by setting the existing selection/open state directly, without synthetic clicks; preserve the current page and canvas selection. Drag, enable/disable, and remove controls do not open the editor. The ten-Action limit is enforced. Resources defined on the Form can bind that Form's `formData` and `browserInfo`; ancestor Resources are visible but cannot bind values from the descendant Form. An empty selection shows no canvas warning before submit; attempting to submit shows the configuration error, dispatches nothing, and does not navigate. The Form has no native or direct-URL submission mode. - The new Form supports Resource Actions only; do not add a legacy/native-mode compatibility path or migration requirement. - A Resource defined on the Form can bind that Form's `formData` and safe `browserInfo`; a visible ancestor Resource cannot access values from its descendant Form. Resource variables are created in Variables at their normal scope. The plus menu lists only existing action-capable Resources from the Form and ancestor scopes, excluding sibling, descendant, unrelated scopes, and System variables. Forms explicitly select each action; none are auto-added. Already-selected Resources are disabled in the menu. Bindings survive editing and save/reload. - On a `/products/:slug` page visited as `/products/chair?source=newsletter`, a submitted Resource can use `system.params.slug` as `chair` and `system.search.source` as `newsletter` in its URL, headers, or body. Values come from the current submission's page, including after navigation; missing parameters remain absent. - Repeated names retain form order, unchecked checkbox groups resolve to empty lists, and files stay file values. The Resource editor focuses URL and suggests known header names and values. ## Batch 2a — Keep page parameters and every selected query value (#5512, #3891) New Form Resource bindings must receive the current page's parameters, and a multiple-select GET form must not lose selections when a binding reads the URL. - [x] Extend #5512 route/query propagation to legacy native Form actions: generated routes pass route params and ordered query values while preserving the managed Form path. The regenerated runtime contract and 26 fixture routes are pushed in `8c4bda7791`; `check:generated-api`, fixture builds, and generated-route regressions pass. - [x] Implement route/query propagation in both managed and legacy Form Actions: populate `system.params` from the current route, preserve single-value `system.search` strings and expose repeated query values as ordered arrays through the same binding. Generated action and loader regressions cover both templates; the consolidated CLI prebuild suite and focused native-GET loader tests pass. - [x] Verify #5512 with the new Form's Resource bindings using `system.params` and `system.search`. Cover URL, header, and body bindings, dynamic pages, missing values, and submission after navigation in React Router and Remix. - [x] Reproduce #3891 with a native GET form and a multiple-select input. Keep the browser's standard repeated query keys; do not join values with commas or rewrite the submitted URL. - [x] Use `URLSearchParams` to preserve every value in order, including empty values, repeated identical values, and values containing commas or encoded characters. Keep an object-based binding interface so property autocomplete still works. - [x] Inspect existing `system.search` consumers and record the compatibility choice here. Keep existing single-value bindings working, expose all repeated values to bindings, and make any changed behavior for existing repeated-key bindings explicit. Do not silently discard values or break existing expressions. - [x] Apply the agreed representation consistently in the Builder, published page data, client navigation, and new Form action path. Update affected types, binding suggestions, generated code, and fixtures. - [x] Add passing SDK, Builder, and generated-route regressions for current page parameters and repeated query values in new Form bindings; document single-value and ordered multi-value query bindings with working examples. - [x] Local new-Form generated-route regressions cover current page parameters and repeated query values in URL, headers, and body after navigation. A red-before/green-after test proves configured body bindings survive alongside submitted fields; CLI binding examples document single and repeated query values. - [x] Record regression coverage for #5512/#3891 on new Form bindings: generated-route and Builder tests cover current-page parameters and ordered repeated, empty, comma, and encoded query values, with SDK coverage for scalar compatibility. **Compatibility choice:** `system.search[name]` is a string when the query key occurs once and an ordered string array when it occurs more than once. Existing single-value expressions are unchanged; text bindings stringify arrays as comma-separated values. The relevant consumers in Builder system/preview and generated page/action templates were reviewed. Focused SDK, Builder, and generated-route tests cover the behavior. ### Acceptance criteria - On a `/products/chair?source=newsletter` page, a new Form Resource can bind `system.params.slug` as `chair` and `system.search.source` as `newsletter` after navigation. Existing Webhook Form behavior is unchanged except for the explicitly tracked #5512/#3891 route/query binding fixes in Batch 2a. - Selecting two options in a native GET form leaves both values in the URL and makes both available to bindings in their original order. Single-value access still works, and property autocomplete remains available. - Empty values, identical repeated values, commas, encoded characters, reloads, and client navigation do not drop or corrupt query values. - Verify current-page and repeated-query bindings for the new Form and ordinary Element `tag=form` browser behavior. Legacy Webhook Form changes are limited to the explicitly tracked #5512/#3891 route/query fixes in Batch 2a; other legacy compatibility is not a closure condition. ## Batch 3 — HTTP Resource submission - [x] POST to each selected HTTP Resource. A selected Action whose Resource body is unset or empty implicitly receives all submitted fields regardless of Resource scope; an edited body can omit, rename, add, or compute fields. Explicit `formData` expression bindings remain limited to Resources defined within that Form. - [x] Use JSON for text-only data and switch to multipart when the resolved body contains a file. Set JSON content type unless configured otherwise; preserve repeated fields and file bytes, and remove stale `Content-Type` so fetch writes the multipart boundary. - [x] Add an explicit `auto`/JSON/multipart choice. Reject an incompatible body before that destination's fetch; dependency-free requests are preflighted before any destination dispatch. - [x] Resolve bindings and validate final outbound URLs and redirects before sending. Block internal and private destinations; keep credentials and sensitive request configuration off the public page. - [x] Add generated-site and server tests for JSON, multipart, mapping, files, repeated fields, invalid configuration, and destination safety. - [x] New Form route proof: compiled Remix and React Router managed actions forward repeated same-name text values and uploaded files as separate multipart entries, preserving order, duplicate values, filenames, MIME types, and bytes (`3c5b72a`; focused CLI tests passed 2/2). Existing Webhook Form behavior remains at its pre-PR baseline; migration is out of scope. - [x] Make file inputs easy to configure in the Builder: choose the file input type and set its field name, required state, accepted file types (`accept`), and multiple-file selection (`multiple`). Keep these settings after saving, reopening, and publishing. Verified on `d7f67307f9` with Builder insert/save/reopen, generated page, Chromium required/optional/multiple upload, SDK template, typecheck, lint, and format checks. ### Acceptance criteria - Every selected HTTP destination receives POST. A Resource with an unset or empty body receives all Form data implicitly, regardless of where the Resource is defined. Explicit `formData` expression bindings remain limited to Resources defined within that Form. Text-only bodies arrive as JSON; bodies with uploads arrive as multipart with field names, filenames, MIME types, file bytes, and repeated values. - A site creator can add and configure a file input using the Builder without custom code. Published forms support one or multiple selected files, block submission when a required upload is empty, and submit normally when an optional upload is empty. The `accept` setting guides the file picker; it is not server-side file validation. - An edited body can rename, omit, add, or compute fields. A Resource outside the Form sends only its own configured data. - An incompatible explicit body format fails before its destination fetch. Dependency-free requests are validated before any dispatch; a body that depends on another Resource is validated once that dependency resolves, before its own destination fetch. Resolved destinations and each redirect reject private or internal addresses, and secrets stay off the public page. ## Batch 4 — Project Emails settings and Resource editor - [x] Add Project Settings → **Emails**. Move the current Contact email list without changing its stored value. - [x] Configure site-wide defaults for Sender (name and email, or email alone), recipients, owner subject, and plain-text owner body. Published sites receive project default changes after republishing. - [x] Add the Email Resource as a normal Resource, creatable inside or outside a Form. It can inherit, override, and reset project defaults. Only a Form-scoped Email Resource can bind that Form's data. Surface an unavailable Form-only binding clearly; never send incomplete template text silently. - [x] Resolve team recipients from the project list or owner fallback, support a custom comma-separated override, and default Sender to the owner with a per-Resource override. Form data cannot choose team recipients. - [x] Configure the subject, plain-text owner body, and whether submitted files should be attached. With no project body or Resource body, any selected non-Visitor Email Resource uses readable submitted fields and optional `browserInfo` interpolation, regardless of Resource scope. Automatic owner-email text includes hidden fields while excluding passwords and internal bot fields. - [x] Test project settings storage, inheritance/override/reset, recipient fallback, Form scope, expression validation, and preservation of the existing Contact email setting. A complete project owner body containing `${...}`, backslashes, or backticks renders as literal text; an empty project body retains the automatic `formData` and `browserInfo` interpolations. ### Acceptance criteria - Existing Contact recipients appear in Project Settings → Emails without data loss. Existing legacy forms retain their pre-PR behavior except for the explicitly tracked #5512/#3891 current-page route and repeated-query bindings. - New Email Resources inherit published project defaults until overridden; reset restores inheritance, and settings edits affect live sites only after republishing. - Team recipients use the project list or fall back to the owner. A custom comma-separated recipient list and Sender override work; submitted values cannot choose recipients or From. - With no project owner body or Resource body, a selected non-Visitor Email Resource uses readable submitted-field and `browserInfo` content regardless of whether it is defined on the Form, an ancestor, or globally. Explicit expressions can bind Form-only values only from Resources defined within that Form. A nonempty project body is the complete literal owner message, inherited until a Resource body expression overrides it; reset restores inheritance. Automatic text includes hidden form fields but excludes passwords and internal bot fields. ## Batch 4a — Sender and recipient email format (#4414, #5658) Use one **Sender** field that accepts either `Acme Support <support@example.com>` or `support@example.com`, using the same address format as recipients. The optional name becomes the displayed sender name, and the entered email receives replies. Webstudio's authenticated From address stays fixed. - [x] Add the Sender field in Project Settings → Emails, with inheritance, override, and reset in each Email Resource. Default it to the project owner's email. This single field supplies the display name and Reply-To; do not add separate sender-name and Reply-To inputs. - [x] Parse Sender as exactly one mailbox and retain its optional name and reply address; a plain email has no custom display name. - [x] Use the site owner profile username as the default visible From name when Sender has no explicit name; explicit Sender names take precedence, and missing usernames fall back to “Site Owner”. Keep the authenticated From address fixed. - [x] Accept plain and named mailboxes in the Contact email list, project recipients, and Email Resource recipient overrides. - [x] Verify existing project address-count limits remain enforced through both Builder and server updates; the separate five-team-delivery runtime cap is tracked in Batch 5. - [x] Use `Acme Support <support@example.com>` as the Sender placeholder and `Acme Support <support@example.com>, team@example.com` in email-list inputs. - [x] Use an established email-address parser in Builder and server validation. Handle quoted names containing commas, such as `"Example, Alexs" <support@example.com>`, reject malformed addresses and header-injection input, and count parsed recipient mailboxes including duplicates for the runtime delivery cap. - [x] Preserve parsed display names in generated Email Resource requests and keep existing plain-address settings readable. - [x] Test both Sender formats, mixed recipient parsing, inheritance/reset, generated request values, and the five-team-delivery runtime cap. - [x] Local Builder E2E verifies Project Emails Sender and named recipients save to persisted project settings and remain after reopening (`b9ebf21fee`; `apps/builder/e2e/tests/project-settings-runtime.[shard-2].[shard-5].[shard-6].e2e.ts`; focused run 2/2). - [x] Document the Sender/recipient input format, reply behavior, inheritance, republishing, visitor Email Resource delivery, and the current Cloud-unavailable delivery status without claiming that saving settings sends email. - [x] Local generated-graph verification (`1730840feb`): a translated project body is the entire literal request body, a Resource body expression overrides it, reset restores inheritance, and an empty project body retains automatic `formData`/`browserInfo` text. Project Emails help and docs explain plain-text behavior. ### Acceptance criteria - Entering `Acme Support <support@example.com>` in Sender displays “Acme Support” as the sender and directs replies to `support@example.com`, while From uses Webstudio's sending address. - Entering only `support@example.com` works without a separate name field and directs replies to that address. - With a plain Sender address, the visible From name uses the site owner profile username, or “Site Owner” if unavailable; a named Sender overrides it. The From address remains Webstudio’s fixed sending address. - Plain and named recipients can be mixed in a list; a quoted comma inside a name does not create another recipient. - Names and addresses survive saving, publishing, and delivery, and existing plain-address settings continue working. - The project owner confirmed that the email customization already planned in Batches 4 and 4a is sufficient to close #4414 and #5658. Sender supplies the display name and reply address; Webstudio remains the authenticated sender. Do not add custom From addresses, sending-domain verification, or a separate follow-up requirement to close these issues. Do not describe Reply-To as a custom From address. - Site creators can edit the notification subject, plain-text template, content, and language, with project defaults and per-Resource overrides. Together with Batch 4, this covers the email customization requested in #4414. ## Batch 5 — Email delivery and visitor confirmation - [x] Implement the Cloud-only `EMAIL_SERVICE` adapter for new Email Resources. Attach every submitted file by default; preflight the 5 MiB encoded-message and 32-attachment limits and return explicit service errors. Staging published-Form Email delivery is verified in Batch 7. - [x] Implement visitor confirmation as an ordinary Email Resource selected in Actions, addressed through a submitted visitor email field and dispatched alongside other Actions. Its optional body starts empty and uses the existing Resource editor and expression bindings. Email Service prepends a mandatory confirmation preamble with the site URL. Remove the Form-level confirmation toggle and email-field selector; SDK and generated-route tests cover delivery. - [x] A failed visitor-addressed Email Resource appears in `errors` and does not change aggregate Form success; SDK tests cover this. - [x] Before any destination runs, count team-recipient deliveries across all reachable Email Resources, counting duplicates and shared dependencies once. Reject the whole submission above five at runtime; permit at most one visitor-addressed receipt separately. No cross-Resource Builder or publish-time cap substitutes for this preflight. - [x] Local subject validation (`1fa8e66d9d`): resolved Email Resource subjects must be strings without CR/LF. Validate after expressions are evaluated and before any selected destination dispatch. SDK tests cover CR/LF from direct `formData` and `browserInfo` subject bindings and zero provider or selected fetches (`managed-form-resources-generator.test.ts`, `managed-form-submission.test.ts`, `resource-loader.test.ts`; focused tests 88/88 passed). Dependency-bound Email is rejected after any required lookup and before selected destination dispatch. - [x] Verify resolved owner and visitor-confirmation subjects in the generated server path with provider fakes. Owner subject preflight covers CR/LF from visitor-derived bindings before any selected destination or provider send; confirmation subject preflight likewise sends no HTTP or email request. Published staging delivery is verified in Batch 7. - [x] Test the local private-service envelope and response path with a provider fake: recipients, Reply-To, optional Sender display name, plain text, attachments, recipient and size/count preflight, privacy, confirmation ordering, partial failure, quota error, and zero sends on failed preflight. Compiled Remix and React Router action tests cover confirmation with an HTTP sibling and Node's explicit unsupported response. ### Cloudflare Email Sending integration plan Companion SaaS PR: [webstudio-is/webstudio-saas#666](https://github.com/webstudio-is/webstudio-saas/pull/666). The Email Service contract uses the canonical Worker `webstudio-email-service`, Cloudflare's native per-location Rate Limiting binding, and the private `EMAIL_SERVICE` service binding for published-site sends to `https://email-service.internal/v1/send`. Builder Preview uses the authenticated shared Email Service endpoint `https://apps.webstudio.is/v1/preview-send` with server-side `TRPC_SERVER_API_TOKEN`. The shared Email Service Worker remains deployed at version `8769175b-15d2-4c09-a312-b78dc93fd2f3`; this PR did not deploy or change it. The named staging Preview was deleted and its former URL returns 404; staging published-site Workers use the canonical Worker through the private service binding. An authenticated malformed production Preview request returned `400 invalid_request` / `Invalid email fields` before send. This proves only authentication and validation, not successful submission or delivery. Staging published-Form delivery is verified in Batch 7; limiter verification is tracked in companion SaaS PR #666. A separate direct Email Service test delivery was confirmed received; no email was sent by the malformed-request preflight. - [x] Enforce Cloudflare's 5 MiB **total encoded message** limit and 32-attachment cap for ordinary outbound email, accounting for base64 expansion; report provider quota, suppression, sender, size, and count errors without claiming delivery. These email limits are separate from the Form's 25 MiB incoming upload limit and Webstudio's five team-delivery preflight. Local Builder preflight/provider-fake tests and the companion SaaS Worker tests cover the limits and stable error mapping; staging published-Form delivery is verified in Batch 7. - [x] Test the local generated-site private-service contract with provider fakes for recipients, Reply-To, optional Sender display name, body, attachments, confirmation timing, partial failures, quota/limit errors, and zero sends for failed preflight (`packages/sdk/src/managed-form-email.test.ts`, `packages/cli/src/prebuild.test.ts`; `7507c3fbf7`, `ab04be99d4`, `7c950eecec`). #### Acceptance criteria - Staging verification must prove the selected private call path works for a Workers for Platforms published site; the central Worker alone owns `send_email`, and a submitted Form never receives provider credentials or direct sending capability. - A Cloud published Form delivers owner mail and the optional confirmation with the configured envelope and content; 5 MiB/32-attachment violations and provider failures produce clear `errors`. HTTP siblings retain the existing partial-failure semantics. - The Email Service contract is for the new Form only. Existing Webhook Forms do not use this Email Service as part of this feature; no legacy Email Service compatibility or migration is included. ### Acceptance criteria - A Cloud Email Resource sends the configured plain-text subject and body from Webstudio's sending address with the configured display name, with every submitted file attached under its original filename and MIME type, or returns an explicit size/count error. - More than five total team-recipient deliveries across Email Resources rejects the whole submission before any destination runs; duplicate addresses count again. - A visitor receipt is a normal Email Resource explicitly selected in Actions and addressed to the submitted visitor email field. It runs in parallel with the other selected Actions, with no separate Form-level toggle or email-field selector. Its Resource body is empty by default; the Email Service always prepends the fixed platform receipt and published site URL before the Resource body. The Resource body uses the normal editor and full existing expression/binding support. A failed visitor Email Resource appears in `errors` but is nonfatal to the aggregate Form result. - Resolved owner and confirmation subjects are strings without CR or LF. An invalid subject, including one produced from visitor data, is rejected before the email provider is called; the provider fake records no send. - Provider integration checks exercise the central Email Worker request/response contract. Mocked Builder delivery alone does not satisfy this gate. - Confirmation failure appears in `errors` while the aggregate submission remains successful; environments without Cloud email do not silently claim delivery. ## Batch 5a — Keep separate submissions in separate email threads (#5018) Site owners should see each form submission as a separate email conversation, including when several visitors submit the same form close together. - [x] Append a short, unique submission reference to each new Form owner-notification subject, keeping the configured subject text. Generate it once per submission and reuse it for that submission's delivery retries; submissions in the same second get different references. At `52fcec3640`, SDK tests cover default/custom subjects, two submissions at one mocked time, retry stability, empty/overlong preflight with zero HTTP/email sends, and the 979-character boundary; Form and Project Emails docs describe the suffix. Batch 7 verifies that separate submissions create separate Gmail conversations. - [x] Apply per-submission subject references to new Form owner notifications (`52fcec3640`). - [x] Add local regressions for new Form default/custom subjects, same-time identical submissions, stable retry references, and subject-length preflight (`managed-form-email.test.ts`). Document the suffix in the new Form and Project Emails docs. ### Acceptance criteria - Two separate submissions of the same form appear as separate conversations in Gmail with conversation view enabled. - Both default and custom subjects keep their configured text and include a different reference for each submission. - Retrying delivery within one submission keeps its reference. This does not add delivery deduplication or submission storage. - This submission-reference behavior applies to new Form owner notifications only; it does not add delivery deduplication or submission storage. ## Batch 6 — Parallel delivery and Form feedback - [x] Resolve dependency Resources needed for bindings, preflight every selected primary destination’s body format and deterministic URL parse/protocol/credentials/denied-hostname policy before dispatching any selected primary destination, then start selected primary destinations in parallel. Dependency Resources may make outbound requests during resolution. DNS/public-IP pinning and redirect checks remain in protected transport at each request hop; other transport failures can occur after a sibling dispatches. Overall success requires all primary destinations to succeed. - [x] Local deterministic group preflight (`e9b72d5d78`, `8bf4f4c8bf`): red-before generated actions in Remix and React Router prove a dependent JSON+File body or a denied selected URL blocks all selected POSTs; SDK policy tests cover allowed URLs and denied scheme, credentials, and host cases. Nonselected dependencies may run once to resolve bindings. Provider and ingress behavior are covered by service tests; staging verification is recorded in Batch 7 below. - [x] Until Cloud email delivery is integrated, reject any selected Email Resource or reachable Email dependency before dispatching an HTTP sibling. Return an explicit unsupported-delivery error without claiming success. - [x] Retry only a failed or timed-out destination once within that submission, including non-2xx responses. Never resend a known success within the same submission. A new browser submission starts over; keep no delivery history or cross-submission deduplication. - [x] Keep aggregate HTTP status as transport metadata only; expose no `status` variable. Bind each Resource's final status code and response body through ordered `results` and `errors` arrays. Expose no response headers. - [x] Redirect only after overall success when configured. Render the initial, success, and error states in the inserted Form template. - [x] Fix #6487 feedback auto-scroll regression: success and error feedback now scrolls into the viewport only when needed, uses instant behavior for reduced motion, and success redirect skips scrolling. MCP-authored generated-Preview tests assert actual viewport position for off-screen feedback, no movement for visible feedback, partial failure scrolling, and no scroll before redirect; the full serial MCP E2E suite passes (36/36 including setup). - [x] Copy the existing Webhook Form spam protection into the new Form's managed submission path: generate the hidden browser field, validate it on the server before delivery, preserve the Brave handling, and remove internal fields before sending to Resources or email. **Email Service rate-limit checkpoint:** The pushed SaaS code uses Cloudflare's native Rate Limiting binding only on email sends: 50 recipient checks per project UUID per Cloudflare location per 60 seconds. Each recipient consumes a check; over-limit sends return 429 before provider delivery. Preview and published sends carry the trusted project UUID. This is approximate per location, with no Durable Object, SQL storage, or account-wide quota. The 60-attempt direct Worker diagnostic and isolated non-production check are tracked in companion SaaS PR #666; no live-user Form submission is part of this Builder PR. - [x] Locally test concurrent starts, retries, timeouts, partial success, results and errors bindings, redirect, and feedback scrolling in both generated adapters and browser components. - [x] Local new-Form behavior: generated Router and Remix adapters test concurrent selected starts, one retry after non-2xx/network/timeout failure, final partial-success `results`/`errors`, and no replay of successful siblings or dependencies (`packages/cli/src/prebuild.test.ts`). Builder insertion tests verify `results` and `errors` bindings and confirm aggregate `status` is not exposed as a variable (`apps/builder/app/shared/instance-utils/insert.test.tsx`); browser tests cover success-only redirect and visible/offscreen feedback for the new Form. ### Acceptance criteria - Primary destinations begin in parallel. Each failed or timed-out destination gets one retry within the same submission; known successes are not resent, and overall success requires every primary destination. - Ordered `results` and `errors` expose each Action Resource's `resourceId` and `resourceName`, final status code, and response body to bindings, without response headers. Both arrays preserve configured Action order even when responses finish out of order. Aggregate HTTP status remains transport metadata and is not exposed as a Form variable. - Redirect occurs only after overall success, without scrolling to inline feedback first. Otherwise the correct feedback state renders and scrolls into view only when outside the viewport. Already-visible feedback does not move the page, and animated scrolling respects reduced motion. This applies to the new Form. - New Form submissions use the adapted hidden browser check: valid browser and Brave submissions pass; missing, malformed, or expired evidence stops delivery before any destination runs; internal bot fields never reach Resources or email. - The Email Service code returns 429 for recipient checks over the native per-project-UUID, per-location 50-per-60-second limit before provider delivery. HTTP/Webhook Actions and webhook-only Forms are not throttled by it. The 60-request direct Worker diagnostic and webhook control are tracked in companion SaaS PR #666; no production Form submission is part of this Builder PR. ## Batch 6a — Legacy-only work excluded from this feature ### Acceptance criteria - The new Form accepts a fresh valid submission after an earlier failed attempt without stale bot data. - No legacy compatibility path is added to the SaaS Email Service. ## Batch 6b — Shared submission runtime for the new Form (related to #4093) Use shared new-Form submission behavior across supported hosting runtimes while keeping page data current after a submission. This batch does not complete #4093's remaining legacy Webhook Form work. - [x] Move new Form submission, pending/error/success handling, and response handling into shared code that does not require Remix or React Router hooks or their form components. Generated hosting adapters call the shared managed-Form handler; existing Webhook Forms keep their original behavior. - [x] Local new-Form lifecycle: router-free Form submission uses shared `submitFormData`; generated Router and Remix adapters supply the server request and page-data refresh behavior. Legacy compatibility adapters and lifecycle changes are not included. - [x] Submit through a normal HTTP endpoint with a defined JSON response and small Router/Remix request and page-data-refresh adapters. Keep current HTTP Resource credentials, validation, bot checks, and destination protection on the server. - [x] Local managed-Form route: generated same-origin `/__ws-form` POST returns structured JSON for new Form submissions in compiled Remix and React Router handlers; route coverage includes root, nested, and dynamic pages, repeated multipart fields/files, bot rejection, and Resource results. Staging Cloud email delivery is verified in Batch 7; the Email Service rate-limit verification is tracked in companion SaaS PR #666. - [x] Integrate the Cloud `EMAIL_SERVICE` adapter for new Email Resources into the generated server action, with provider-fake response and preflight tests. The Email Service per-site rate limit is tracked separately below. - [x] Replace the automatic page-data refresh currently supplied by Remix. After a successful submission changes data, refresh the current page's Resources without a full-page reload. Handle navigation during submission and refresh failures without resending a completed submission. - [x] Test the new Form's shared component without router context against a test HTTP endpoint, then test both hosting adapters. Cover response bindings, page-data refresh, files, redirects, errors, and retries. - [x] Local browser and generated-route matrix: router-free Form tests cover pending/results, files, and HTTP/network failures; generated Router and Remix adapter tests cover retries, redirects, loader refresh, navigation, and refresh failure without replaying POST. Compiled-handler tests cover both adapters; release/staging verification is complete in Batch 7. - [x] Real router-free HTTP test (`679eb8e0c275`, `packages/sdk-components-react/src/native-form-http.test.tsx`): the shared new Form runs without a router provider in Chromium and sends multipart POST to a local same-origin JSON endpoint without mocking `fetch`. It reports pending while the response is held, then success with `results` and a later error with `errors`, without native navigation. Release/staging verification is complete in Batch 7. - [x] Page-data-refresh red-before/green-after proof: with the exact `native-form-refresh.browser.test.tsx` files from `20989a2d64` placed temporarily in detached pre-fix `41580dbe9c`, React Router and Remix each failed 3/5 tests: successful submissions left displayed data at `before`, and the refresh-failure path never ran its second loader call. On PR head `679eb8e0c275`, both adapters pass 5/5. The temporary checkout was restored cleanly; the red/green regression evidence is recorded here. - [x] Local new-Form regression tests cover pending/results, errors, redirect behavior, selected destinations, retries, and page-data refresh. Remove legacy Webhook-specific retry, feedback, lifecycle, and converter claims; they are outside the PR scope. - [x] Update the hosting and Form docs to explain the generated server endpoint, dynamic hosting requirement, and static-export limitation. The generated Cloud action calls the private `EMAIL_SERVICE` binding when present. Staging published-Form delivery is verified in Batch 7. ### Acceptance criteria - The shared submission component works without a Remix or React Router provider; supported hosting adapters connect it to server delivery and page-data refresh. - A successful data-changing submission refreshes the displayed data without reloading the whole page or sending the submission again. - Resource response values remain available through the bindings from Batch 6, with consistent success/error behavior across supported runtimes. - The generated new-Form submission path uses the shared handler; existing CLI conversion and legacy submission behavior remain at their pre-PR baseline except for the #5512/#3891 route/query binding fixes in Batch 2a. - Server protections and secret handling remain intact. Static exports do not silently claim managed submission support. ## Batch 7 — New-Form integration, docs, and release gate - [x] Mark only the legacy `Form` and `RemixForm` component metadata deprecated and keep `NativeForm` as the sole Forms insert tile. Preserve the old component IDs, registrations, templates, and supported settings for saved instances (`820aed68ed`; `apps/builder/app/shared/nano-states/components.test.tsx`, focused Chromium 2/2). - [x] Local file-input and HTTP upload segments: Builder insertion/save/reopen retains name, required, accept, and multiple settings (`apps/builder/app/shared/instance-utils/insert.test.tsx`); browser tests cover required, optional, and multiple files (`packages/sdk-components-react/src/native-form.browser.test.tsx`); compiled Remix/React Router generated actions preserve repeated multipart fields and original file names, MIME types, and bytes (`packages/cli/src/prebuild.test.ts`, `3c5b72a`). - [x] Partial published-site staging check: one controlled `/copy-1` submission with unique test value `Staging E2E 2026-10-05` entered the managed Form success state for the selected project-owner Email Resource configured with subject `New form submission`. The exact wire HTTP response and owner inbox receipt are not independently verified; the user is checking that inbox. This does not close the full multi-destination, file, confirmation, coverage. - [x] Update Form, Resource, Project Emails, and hosting docs for File Input settings, HTTP uploads and limits, visitor-addressed Email Resource with mandatory confirmation preamble/site URL and optional body, and plain HTML forms using Element with `tag=form`. - [x] Document the current managed Form's combined 25 MiB upload-request limit, multiple-file failure guidance, and separate HTTP Resource body limit (`33b2c7eab9`); Email provider attachment limits are documented in the Email Service README, and published staging delivery is verified in Batch 7. - [x] Document local Resource selection/scope and Auto/JSON/multipart upload behavior, plus Project Emails Sender, recipient, owner-body, inheritance, and visitor Email Resource settings (`b9dbb6ee55`, `7507c3fbf7`). Form, Project Emails, and self-hosting docs now describe conditional Cloud `EMAIL_SERVICE` delivery, explicit missing-service failure, and self-hosted integration requirements (`61fcf3f3d5`); published staging behavior remains unverified. - [x] Document Cloud Email attachment count and encoded content/request limits in Form docs (`61fcf3f3d5`). - [x] Document provider/quota errors and the Cloud email hosting requirement. Builder Form, Project Emails, and self-hosting docs cover new-Form visitor behavior and hosting limits. Sending-domain setup and companion SaaS deployment are tracked in the companion SaaS PR. - [x] Refresh the current bundle-version metadata in all eight static project fixtures and synced user metadata in seven; `packages/protocol/src/fixtures.test.ts` passes 17/17 locally and Linux/Windows fixture CI both pass on head `70a2091c69`. - [x] Complete relevant local checks on current PR work. Focused tests, typecheck, lint, formatting, and diff checks pass locally; full GitHub CI passes on `84f24e8d12`. - [x] Reviewed each new-Form issue row against current code/tests; no additional legacy compatibility beyond the explicitly tracked #5512/#3891 route/query fixes is a closure condition. Evidence: #3023 file and attachment checks in `packages/sdk/src/managed-form-email.test.ts` and `packages/cli/src/prebuild.test.ts`; #5512 and #3891 page parameters and all query values in `packages/cli/src/prebuild.test.ts`; #5432 repeated multipart values/files in the same generated-route suite; #5499 parallel destinations and five-recipient preflight in `packages/sdk/src/managed-form-handler.test.ts`, `managed-form-resources-generator.test.ts`, and `managed-form-submission.test.ts`; #3530 redirect behavior in `packages/sdk-components-react/src/native-form.browser.test.tsx`; #5428 trusted browser info headers in `packages/cli/src/prebuild.test.ts`; #5658 sender/reply-to validation in `packages/sdk/src/managed-form-email.test.ts` and the companion SaaS Worker tests; #6487 feedback scrolling in `packages/sdk-components-react/src/form-feedback-scroll.browser.test.tsx`; #4414 and #4385 project defaults, overrides, and recipient isolation in `packages/sdk/src/email-resource.test.ts` and `apps/builder/app/builder/features/settings-panel/resource-panel.test.ts`; #5018 unique submission references and retry behavior in `packages/sdk/src/managed-form-submission.ts` and `managed-form-email.test.ts`. These are merge-time issue mappings, not production delivery evidence. ### Acceptance criteria - The insert menu offers one new Form. Deprecated old components and their migration are outside acceptance; no legacy compatibility path is added. - Staging end-to-end coverage exercises an empty Form blocked from navigation and a configured Form submitting to its Resource destinations, plus two HTTP Resources, two Email Resources, files, confirmation, and partial failure through Builder → publish → submit. - Form, Resource, Project Emails, and hosting docs match the released behavior, including the Cloud email requirement. Generated contracts, focused checks, full CI, and staging tests pass before the PR is ready. ## Batch 8 — Submit current-draft Forms from Builder Preview - [x] Add an authenticated Builder server endpoint for Preview submissions. It checks the Builder session and exact project build permission before effects; route tests cover authorized and rejected requests. - [x] Load current draft Form configuration and invoke the shared SDK runtime handler without a prior publish or draft/published contract comparison; Preview route and graph tests cover changed drafts. - [x] Build the draft Resource graph with publish-equivalent scope, dependency, and defaults rules. Reuse the existing `evaluateExpressionSync` implementation for supported expressions; do not add QuickJS, WASM, AST, or another expression engine. Focused graph and expression tests cover parity. - [x] Reuse the shared SDK managed-form handler in Builder Preview and generated published-site routes. Their request, auth, and configuration entry points remain runtime-specific; SDK and generated-route tests cover both paths. - [x] Execute HTTP Resource Actions from the Builder server in Preview, and from the published-site Worker after publish. The Email Service handles email only; Preview route tests assert the outbound HTTP boundary. - [x] Route Preview Email Resources server-to-server through the Email Service with a server-only credential; published-site Workers use the private `EMAIL_SERVICE` binding. Builder checks user and project permission first; tests cover the Email Service call contract and rejected auth. - [x] Removed the unapproved Preview warning that submissions send real emails and webhook requests; new user-facing warning copy requires explicit approval. - [x] Node tests execute the shared handler locally with a stubbed Email Service boundary, without Cloudflare infrastructure. Staging published-Form Email delivery is verified in Batch 7; the production Worker limiter check is tracked in companion SaaS PR #666. - [x] Keep a permanent Builder Preview transport Playwright E2E that submits a current draft through the Preview backend and verifies the response without publishing or triggering email/webhook side effects (`apps/builder/e2e/tests/preview-managed-form.[shard-2].e2e.ts`; focused 2/2). - [x] Preview converts rejected `onManagedSubmit` callbacks into a structured 500 failure, clears stale result and pending state, and shows the current error. Browser regression covers a prior structured failure followed by a rejected callback (`packages/sdk-components-react/src/native-form.browser.test.tsx`). The new Form also cancels native submission when its `onSubmit` callback throws; a Chromium browser regression clicks a submitter with `formAction`/`formMethod` overrides and verifies cancellation, no Resource dispatch, and visible failure (`28f7a93eed`). ### Acceptance criteria - A Form in an unpublished project can be submitted from Builder Preview without publishing first. - The Preview endpoint rejects unauthenticated users and users without edit access to that project before any HTTP request or email send occurs. - Preview uses the current draft. Changes to controls, Actions, Resource dependencies, effective email settings, visitor-addressed Email Resources, or redirects can be tested immediately without republishing or a published-contract comparison. - Draft expression results match published Resource evaluation for supported syntax using the existing `evaluateExpressionSync` implementation; no second expression engine is introduced. - HTTP Resource requests go directly from the Builder server to their configured destinations. Email Resource requests go through the central Email Service Worker, which rejects requests without the server credential; that credential never appears in browser traffic. - Results, errors, uploads, response bindings, feedback, and redirects match published submission behavior while keeping the Builder open. - The generated published-site Worker continues to use the same shared handler and public visitor submission path, and calls Email Service through `EMAIL_SERVICE`. - Local Node E2E tests exercise the shared handler and Builder Preview authorization without Cloudflare; a separate integration check verifies the real Email Service contract. ## Production release boundary **The production Email Service Worker was deployed at version `8769175b-15d2-4c09-a312-b78dc93fd2f3` under the user's explicit request to update the code and Worker to use `TRPC_SERVER_API_TOKEN`. An authenticated malformed validation request returned `400 invalid_request` before send; that request sent no email.** This Builder PR makes no deployment to the live site; live-user Form submissions are outside its verification scope. ## Boundaries and known tradeoffs - No old-component migration, submission storage or history, account-wide email quota feature, CC/BCC, custom From address or customer sending-domain verification, HTML email, preview text, or separate branding editor. Builder Preview submission is included in Batch 8 and runs against the current draft without requiring publication; it is a real submission, not a separate email test-send feature. Cloudflare account sending quotas still govern delivery; migrating Plunk authentication or other Plunk operations is separate follow-up work. - The Discord requests for an explicit CAPTCHA challenge, direct Google Sheets connection, multi-thumb/vertical slider, and an exact submission copy to an unverified visitor are not covered here. A visitor-addressed Email Resource always gets the fixed platform preamble and site URL; its optional builder-configured body may use normal Resource bindings. - A timeout retry can duplicate a delivery that succeeded remotely. A visitor-addressed Email Resource can send to an unverified submitted address; the native Email Service limit of 50 recipient checks per project UUID per Cloudflare location per minute can reject legitimate local bursts; it is approximate and not a global cap. - The five team-delivery cap is a server preflight across the whole Form; it is intentionally not calculated across independent Resources in the Builder. - Project defaults apply site-wide. Form-only expressions resolve only in Resources scoped inside that Form. ## Additional Builder and Resource behavior ### Builder UI - [x] Add the `formState` bullet to the Action tooltip after the exact user-facing description is approved; verify it with the focused tooltip test. The exact wording was approved; covered by focused Action tooltip tests. > Sends each form submission to all selected destinations at the same time. > - formData — submitted fields from this Form. > - browserInfo — visitor details. > - formState — current Form state: initial, success, or error. > - results — each action’s response, in order. > - errors — failed actions and their messages. - [x] In the Variables list and Edit variable preview, show server-provided `browserInfo` after an authenticated Builder Preview submission when available. Use only trusted `cf-connecting-ip`, ignore spoofed `x-forwarded-for`, and sanitize Referer by removing query and fragment before exposing it. Keep `previewBrowserInfo` private to Builder memory and strip it before Canvas pubsub. Route, bridge, Variables list, and Edit variable preview tests cover these paths; focused preview tests pass (2/2) and bridge tests pass (7/7). Published Form behavior stays unchanged. - [x] Recognize supported Chromium browsers without requiring `window.chrome`; user-agent detection and unsupported-browser regression coverage are committed. - [x] Add the approved “Clear all” button below multiple visible toasts and dismiss the stack; shared toast implementation and browser regression are committed. - [x] Remove the background from the maximize button in code and expression editors; keep the icon button transparent, consistent with other editor controls. `code-editor-base.browser.test.tsx` verifies transparent hover styling. - [x] Implement separate tooltips for the Form variables `formData`, `browserInfo`, `formState`, `results`, and `errors`; exact wording was approved on 2026-10-08, and `variables-section.test.tsx` verifies the displayed descriptions. - [x] Add a description to the Action label tooltip and show a yellow warning triangle with “Add at least one action” when the Action list is empty. The warning disappears once an Action is added; covered by the Form Action control browser test. - [x] Reset the Form Preview runtime state to `initial` when leaving Preview, so the Builder returns from success/error feedback to the editable Form state without changing saved bindings or published behavior. Add regression tests. - [x] Route every managed Form submission error through the user-styled Error Message instance. Make its message dynamic from the submission error result, remove static fallback text and raw React alerts, and cover validation, partial Action failure, and service errors. - [x] When a Form, Form content, Success Message, or Error Message instance is selected on the canvas, show that selected instance in Builder regardless of its bound `show` value. This is an editor selection override only; preserve the saved binding and runtime behavior. Add regression tests for each state. - [x] Add the approved description “Sends each form submission to all selected destinations at the same time” and a multiline list for `formData`, `browserInfo`, `formState`, `results`, and `errors` to the Action label tooltip; use lowercase “action” in descriptions. Remove the aggregate `status` variable; Form state remains `initial`/`success`/`error`, and each Action’s HTTP status stays in its result/error entry. Retain the approved “Add at least one action” warning when no Action is selected. The Action reset button says “Reset.” Focused insertion and Action UI tests cover these behaviors. - [x] The Action plus-button tooltip says “Add a resource to submit this Form”; the focused Form Action control browser test covers it. - [x] Rename the Form’s component prop from `submission` to `action`, matching the singular **Action** control. Store selected Actions as an ordered array of Resource references with enabled state. Execute enabled requests in parallel while preserving Action order in `results` and `errors`. Update generated metadata, runtime handling, and tests; do not add legacy compatibility. - [x] Add Delete, Copy, and Paste to each variable row menu and its right-click context menu. Within Variables, right-click anywhere allows Paste; target-specific commands are disabled without a variable row. Remove the Variables section header menu. The Edit variable dialog menu has Delete, Copy, and Refresh only; remove its redundant quick-copy button and do not offer Paste there. Serialize definitions as versioned, namespaced JSON; omit internal IDs, create fresh IDs on paste, preserve and remap expression references, and report unresolved references. Base menu and transfer tests pass. - [x] Rename user-facing Builder UI section and control labels from `Data variables` to `Variables`; retain `CSS variables` for clarity. Update UI tests. - [x] Give variable labels a tooltip that shows defining scope, type/value-kind details, and Delete with its shortcut. Option-click deletes the local variable; deleting a shadow reveals its ancestor. UI tests cover the tooltip and Option-click deletion. - [x] Clicking the rendered “Value comes from” source label navigates to and focuses its defining instance. `variables-section.test.tsx` exercises the real ancestor “Body” source label and Global root label; the full focused file passes 9/9. - [x] Label a Global Root user variable source as “Global root,” not “System,” and keep the source label clickable to navigate to and focus its definition. `variables-section.test.tsx` verifies the “Global root” label, absence of “System,” and source navigation. - [x] Fix the verified source-label behavior defect: clicking “Value comes from” must reliably focus its defining Variables row after ancestor or Global root navigation. Regression tests pass both standalone and in-suite: `variables-section.test.tsx` passes 12/12, and isolated ancestor/Global root navigation-focus cases pass 3/3 each. - [x] Prevent deletion of the managed Form’s required `formState`, `results`, and `errors` variables by stable identity in the Builder UI, including after renaming, through Variables row menus, the Edit variable menu, right-click context menus, and Option-click; ordinary user variables remain deletable. `variable-menu.test.tsx` covers the menu paths and ordinary deletion; focused suite passes 21/21. The required-variable rule is enforced in both the Variables UI and the mutation/domain layer. - [x] Show a yellow warning triangle only when a variable name matches an ancestor; allow the child-scope name. Tests cover an existing shadow and an ancestor rename. - [x] Keep the variable row highlighted while its Edit variable dialog is open; use the shared list-row open state and cover it with a regression test. - [x] Use the shared warning color token for variable-shadow triangles in both the Variables list and Edit variable dialog; the Builder UI test verifies the token. - [x] Reject duplicate Resource variable names on the same instance while allowing a nested scope to shadow a parent name; add validation and regression tests. - [x] Use sentence case for the Form setting label: `Success redirect`. - [x] Expose Form-scoped runtime values as inspectable JSON in Builder Preview. `formData` reflects the current named controls and values before submission, including empty values; `browserInfo` shows its safe context values. Add focused UI and runtime tests. - [x] Add `resourceName` alongside `resourceId` in each ordered result/error and relevant inspection entry; verify naming, partial failure, retries, and bindings without exposing secrets. - [x] Independently rechecked #3891 against the implemented contract: `system.search` returns a string for one value and an ordered array for repeated query values. `packages/sdk/src/system-search.ts` and its regressions cover repeated, empty, comma-containing, and encoded values; Batch 2a retains the generated-route and documentation evidence. - [x] Verify that a selected Email Resource defined on the Form, an ancestor, or global scope uses the existing generated submitted-fields default body when both its Resource body and Project Emails body are unset. Preserve explicit Resource/Project body overrides, including an explicitly empty Resource body, Visitor receipt behavior, and rejection of explicit out-of-scope `formData` bindings. Draft and published graph tests cover all three scopes and both override types in `managed-form-resources-generator.test.ts` and `managed-form-draft-graph.test.ts`; focused SDK tests pass (73 tests). ### Resource editor - [x] In Project Settings → Emails, show all long explanations in the existing info tooltips beside their related controls, preserving the exact existing copy. Specifically verify the Recipients and Owner plain-text body explanations; `section-emails.test.tsx` currently covers tooltip content and keyboard access. - [x] Restore the approved attachment behavior sentence in the Project Settings → Emails documentation, in the Visitor email paragraph only: “When a Form includes file inputs, submitted files are attached to the outgoing Email Resource message by default. Turn off ‘Attach submitted files’ to omit them.” Do not add this text to the settings UI. Added in `c96fb7c409`; `pnpm check:generated-docs` and `git diff --check` pass. - [x] Replace the outdated Visitor attachment statement in the Form guide with the same approved sentence used in Project Settings documentation: “When a Form includes file inputs, submitted files are attached to the outgoing Email Resource message by default. Turn off ‘Attach submitted files’ to omit them.” Added in `40dd8c4af3`; `pnpm check:generated-docs` and `git diff --check` pass. - [x] **Email Resource Type description:** The exact description was approved, and `variable-popover-type.test.tsx` asserts the Email option displays “Send a plain-text email through Webstudio Cloud when a Form is submitted.” Focused type-selector suite passes 7/7; Builder typecheck passes. - [x] **Method dropdown copy approval:** Exact copy is approved and tested: GET “Read data from a server.”; POST “Send data to create or process something.”; PUT “Replace data on a server.”; DELETE “Delete data from a server.” Keep the existing Form-submission note with POST; focused Resource editor tests verify all four descriptions and the POST note. - [x] Keep Email Resource field labels as `Sender`, `Recipients`, `Subject`, and `Body`; use the existing binding control and Expression Editor for supported values, and show validation errors in the standard input tooltip rather than below the field. `resource-panel.test.ts` asserts each label appears exactly once, covers the binding controls and Sender/Custom recipient Expression Editor save/unmount/remount/reopen, and verifies validation errors appear in standard input tooltips; the focused suite passes 30/30. - [x] Give Email Resource recipient controls programmatic accessible names by associating the existing visible “Recipients” label with its selector and using existing text for the Custom recipient input and Visitor email selector. Keep visible copy unchanged. `resource-panel.test.ts` passes 32/32; Builder typecheck, changed-file `oxlint --deny-warnings`, and `oxfmt --check` pass. - [x] Remove the separate Project default reset button; switching away from Custom recipients clears its value. Show the custom address input directly below the mode selector and the Visitor field selector without redundant field labels. `resource-panel.test.ts` covers literal and bound recipient clearing when switching to Project and Visitor modes, mode-specific controls, and no duplicate Recipients label; the focused suite passes 30/30, including Sender and Custom recipient bindings through the Expression Editor and save/unmount/remount/reopen. - [x] Verify and test the REST and GraphQL type-option descriptions against the approved wording: “A REST resource is a configuration for secure data fetching.” and “A GraphQL resource is a configuration for secure data fetching with GraphQL.” Preserve the existing “You can safely use secrets in any field.” note. `variable-popover-type.test.tsx` covers both descriptions. - [x] **Visitor email field info tooltip:** Approved exact copy for the info tooltip beside the Visitor email field selector: “Adds a note with this site’s URL to help recipients identify where the message came from and discourage spam.” Keep it visible only when Visitor email field is selected; verify the text opens on hover and keyboard focus and closes on blur in `resource-panel.test.ts`. - [x] Match the Edit variable dialog menu trigger icon size to the other header controls and make the menu’s minimum width about twice its current width; `variable-menu.test.tsx` covers the header icon and 360px minimum width. - [x] Simplify Builder Email Resource preflight: remove duplicated Worker-authoritative mailbox and attachment metadata policy checks; retain a small pre-Base64 attachment size guard, subject safety, and aggregate recipient protections. Tests verify early size rejection, valid attachment delivery, and Worker rejection propagation. - [x] Replace the superficial Headers and Search params label info-tooltip descriptions with clear technical explanations of their purpose; propose and obtain approval for exact user-facing copy before implementation. The approved label info icons explain these sections; no additional plus-button tooltips are needed. Exact wording approved; covered by focused Resource editor tests. - [x] **Recipient selector descriptions:** Add the approved descriptions exactly: Project recipients — “Send to the addresses in Project Settings, or to the account holder if none are set.”; Custom recipients — “Send to the addresses entered below.”; Visitor email field — “Choose a Form input to use as the recipient’s email address.” Verify copy and visibility in `resource-panel.test.ts`. - [x] **Move Visitor email guidance into its recipient option description:** Removed the extra info icon next to the selected Visitor email field input. The Recipients dropdown option now explains that the confirmation email goes to the selected Form input address. `resource-panel.test.ts` passes (41/41); formatting, lint, and diff checks pass. Pushed as `a85c77308f`. - [x] Put the Form POST-method note in Method dropdown option descriptions instead of inline copy; keyboard and pointer behavior are covered by the Resource editor tests. - [x] **Format option descriptions:** Approved exact copy: label `Format`; help below the control “Choose how to send the body.”; `auto` — “Uses text/plain for text values, application/json for other values, and multipart/form-data when files are included.”; `application/json` — “Sends an object or array as JSON.”; `multipart/form-data` — “Sends fields as form data, including files.” `resource-panel.test.ts` verifies the descriptions, below-control help, standalone GET hiding, Form Action GET visibility, and Body editor; the focused suite passes 34/34. - [x] **Headers and Search params add-button tooltips:** Superseded by the user's clarification: the approved info icons beside the Headers and Search params labels explain those sections, so no additional tooltips on the plus buttons are needed. The existing info-icon descriptions remain covered by the focused Resource editor tests. - [x] **Project Settings → Emails helper-text placement:** Move the long inline explanations (including Recipients and Owner plain-text body help) into adjacent info tooltips so the settings stay compact. Do not add or rewrite user-facing copy without explicit approval; preserve approved behavior and cover tooltip placement with focused tests. - [x] **Shadowing warning tooltip copy:** Reverted the unapproved proposed wording and retained the existing tooltip. `variables-section.test.tsx` verifies the parent-scope shadow condition, including an existing shadow and an ancestor rename; a unique name removes the warning. - [x] Add an info tooltip beside Cache max age explaining the response cache lifetime in seconds. A focused Resource editor test verifies it. - [x] Use the approved info icons beside the Headers and Search params labels to explain those sections; no additional plus-button tooltips are needed. Use Minus icons to remove individual rows. Focused Resource editor tests cover the label info tooltips and row removal. - [x] Keep the Body label and remove the Body content type selector. Infer text/plain for string bodies and application/json for objects, arrays, numbers, and booleans; preserve automatic multipart handling for files, explicit request-format validation, and useful editor/error behavior. - [x] Label the body-format dropdown Format and show the approved help “Choose how to send the body.” below it. Use the effective method: hide Body and Format for a standalone GET Resource; a GET-configured Resource selected as a Form Action is effectively POST and keeps both controls visible. Focused editor tests verify the help placement, visibility, and selected body format. - [x] Show Response, Request, and Diagnostics tabs for HTTP Resources, with Response first and selected by default. Capture the actual outgoing method, URL, headers, and body plus response status/status text, headers, final URL when available, and body from the latest Form Preview submission, including retry attempts. `request-inspector.test.tsx` verifies the order and default selection, including the captured numeric response status and actual response body for both HTTP and Email exchanges (focused suite 7/7); route and inspector tests verify captured exchange data, no replay or substitution from current config, and authenticated Preview-only, no-store, ephemeral, bounded, redacted capture. Files show metadata instead of bytes. Email inspection shows the captured transport request and actual HTTP response, with credentials and encoded attachment bytes redacted; the logical delivery outcome remains separate for Form failure handling. Published staging submission and attachment delivery are verified in Batch 7. - [x] Classify Email inspection exchanges by Resource kind, never URL alone; focused sanitizer and inspector tests verify that an ordinary HTTP Resource targeting the Email Service URL keeps normal request-body redaction. - [x] Preserve actual Email Service response headers in the Email inspection Response view; `resource-loader.test.ts` verifies transport capture and `preview-form-inspection.server.test.ts` verifies safe headers reach the inspection view. - [x] Preserve the actual Email Service HTTP response body/status separately from the logical Email delivery result, so provider-level failures remain Form failures even when HTTP returns 200. Read the response body once so inspection and delivery validation share the same parse; keep captured data bounded. Add regressions for malformed success and non-JSON error responses. Single response read, bounded inspection snapshot, provider body and logical outcome regressions; SDK tests pass. - [x] Keep the Variables panel context menu scoped to the panel: right-clicking a variable row targets that row, blank panel space allows Paste, and right-clicking inside the Edit variable dialog does not open the panel menu. The dialog menu offers Delete, Copy, and Refresh only; `variable-menu.test.tsx` covers row, blank-space, and dialog context-menu behavior. - [x] Keep Email Resource attachments enabled by default. The default contact Form’s owner Action keeps attachments enabled, while its visitor confirmation explicitly opts out because it does not include the submitted file. `managed-form-email.test.ts`, `resource-panel.test.ts`, and the default contact Form E2E cover defaults and opt-out. - [x] Support bindings for Email Resource Sender and Custom recipients using the existing binding/Expression Editor pattern; evaluate and validate resolved mailbox values before dispatch. `resource-panel.test.ts` verifies Sender and Custom recipient bindings through the Expression Editor and save/unmount/remount/reopen (30/30). `email-resource.test.ts` verifies resolution and rejects empty, non-string, and invalid values; `managed-form-handler.test.ts` verifies invalid bound values are rejected before Resource fetch or email send (35/35 combined SDK tests). - [x] Memoize Email Resource recipient expression scope inputs (`recipientScope` and aliases) so editing a recipient value does not restart asynchronous expression evaluation on every render; add focused regression coverage. Unrelated Resource edit regression passes; `resource-panel.test.ts` 34/34. - [x] Make all Email binding tests use CodeMirror’s platform-aware Mod shortcut (Meta on macOS, Control elsewhere), including Sender/Custom recipient round-trip and Project-mode bound-recipient clearing, so they pass Ubuntu CI. `resource-panel.test.ts` contains no hard-coded Meta/Control keyboard chords and passes the full focused suite (30/30). - [x] Ensure generated server modules import Email binding helpers only once when both regular Resources and managed Forms use Sender or Custom recipients bindings; generated-site resource-only and combined-module tests in `packages/cli/src/prebuild.test.ts` assert one helper import (2/2), and the CLI typecheck passes. - [x] Ensure generated modules for generic (non-managed) Email Resources using `senderExpression` or `recipientsExpression` import the runtime resolver helpers. `packages/cli/src/prebuild.test.ts` verifies one helper import and typechecks a mixed generated site with page-level and managed-Form Email bindings, plus a page-level-only site; both focused cases pass (2/2). - [x] Enforce the five-recipient total before dispatch for Email Resources with Sender or Custom recipients bindings. Count known/literal recipients even when the Email Resource depends on another Resource; reject recipient expressions whose count depends on a Resource output before any send. Preserve Form-variable bindings. `managed-form-handler.test.ts` covers a resolved six-mailbox binding and verifies rejection before HTTP fetch or email send; existing dependency tests cover Resource-output counts. - [x] Prevent or support Email recipient expressions that depend on another Resource. The editor currently accepts these expressions, but managed-submission preflight rejects them; align editor validation and runtime behavior, with focused regressions. Editor binding scope now excludes Resource outputs; server preflight remains fail-closed; focused tests and Builder typecheck pass. - [x] Disallow Email recipient bindings that depend on another Resource in the Builder editor, while keeping server preflight fail-closed. Resolving dependencies before the recipient-limit check could trigger arbitrary requests; add focused editor and preflight regressions. Editor binding scope now excludes Resource outputs; server preflight remains fail-closed; focused tests and Builder typecheck pass. - [x] Fix Type and Recipients dropdowns in a narrow sidebar so each dropdown panel matches its input trigger width, using Name as the reference; focused tests cover Type, Recipients, and Method. Flattening removes the separate Resource dropdown. - [x] Flatten System resources into the single Type list: remove the separate “System resource” category/dropdown and list Sitemap, Current date, Assets, and Email alongside the other Type choices. When editing an existing System Resource, changing between categories (Sitemap/Current date/Assets ↔ Email) persists the selected type. Preserve existing Assets Resources whose URL expressions use valid non-JSON string-literal syntax; add a reopen regression in `variable-popover-type.test.tsx`. Assets non-JSON URL expression reopen regression passes; Builder popover tests pass. - [x] Fix the Builder typecheck failures in `resource-panel.test.ts` binding tests caused by nullable queried-button assertions, then rerun and pass the Builder typecheck. Builder typecheck passes and `resource-panel.test.ts` passes 30/30. - [x] Add a generated published-route regression proving that an unset/default Form Action body with an uploaded File selects multipart automatically and preserves filename, MIME type, and bytes. `packages/cli/src/prebuild.test.ts` runs the generated endpoint for both defaults and React Router adapters; the focused test passes 2/2. - [x] Add regression coverage that an ordinary Project/Custom Email Resource with `includeAttachments` absent resolves default-on through the Email Service payload. `packages/sdk/src/managed-form-email.test.ts` covers both recipient modes and asserts attachment payloads; the focused suite passes 29/29, and `pnpm oxfmt --check packages/sdk/src/managed-form-email.test.ts` passes. - [x] Resolve current changed-file lint findings (two `no-explicit-any` findings in changed tests and one `react-hooks/exhaustive-deps` finding in `variable-popover.tsx`), then rerun scoped lint. Changed-file `oxlint --deny-warnings` passes on 50 TypeScript files; `oxfmt --check` passes on all 48 matched changed files; Builder typecheck passes; focused settings/variables suites pass 47/47. - [x] Simplify `handleManagedFormSubmission` by removing the redundant one-Visitor-Email validation and relying on the shared `validateManagedFormRecipientLimit` check; add or retain focused preflight regression coverage. Handler and submission suites pass 48 tests, and SDK typecheck passes. ## Additional Form and Builder behavior - [x] **Use the existing upload icon for the File input component:** The shared `UploadIcon` is used in the File Input template and asserted by `core-templates.test.tsx`. - [x] **Verify Success redirect in Builder Preview:** On successful submission, keep navigation inside Preview for internal routes, resolve relative URLs against the previewed page, and navigate the preview frame for external URLs. Add end-to-end coverage for a matched internal route, an unmatched route (which currently silently does nothing), and hash navigation (which currently updates the hash without scrolling). Preserve error feedback and never redirect after a failed action. Matched internal, unmatched internal, and delayed hash target cases pass in Preview E2E; failed actions retain error feedback and do not redirect. - [x] **Finish Email diagnostics loading:** The Email Resource Diagnostics tab no longer remains pending without a request key; focused regression coverage verifies the settled empty state. - [x] **Separate variable editors by type:** The dialog composes focused modules for HTTP, GraphQL, Email, Assets, Parameter, and scalar types; shared controls remain neutral. Focused Builder tests and typecheck pass. ## Issue checklist Eleven issues below are intended to close when this PR merges; #5432 and #4093 are related but remain open. Each closure row must be implemented and verified first; keep remaining work unchecked. | Issue | Required result before merge | Batch | | --- | --- | --- | | #3023 | Configure file inputs in the Builder; deliver single/multiple files to HTTP destinations and Cloud email within documented limits. | 3, 5, 7 | | #5512 | Current-page parameters work in new and legacy Form Resource bindings, including after navigation. | 2a | | #4093 | Related issue for the legacy Webhook Form; this PR implements Resource Actions and response bindings in the new Form but does not change legacy submission beyond the explicitly tracked #5512/#3891 route/query binding fixes, shared lifecycle, or CLI conversion behavior. Keep #4093 open. | 2, 3, 6, 6b | | #5432 | The new Form sends repeated field values as standard multipart entries without losing selections. Legacy Webhook Form behavior remains unchanged except for the separately tracked #5512/#3891 route/query binding fixes; this PR does not close the original issue, and migration is separate. | 2, 3 | | #5499 | One submission can deliver to email and multiple HTTP destinations together; document the ten-destination and five-team-delivery limits. | 2, 5, 6 | | #3530 | A successful submission follows the configured redirect; failure does not redirect. | 6 | | #5428 | A destination receives the trusted visitor IP, user agent, and language through configured Resource headers, with setup documented. | 2, 7 | | #5658 | Custom subject and Sender work as agreed, with provider-level tests for From and Reply-To. | 4, 4a | | #6487 | New Forms reveal off-screen feedback, leave visible feedback alone, respect reduced motion, and do not scroll before redirecting. | 6 | | #4414 | Editable name/reply address, subject, plain-text template, and translated content work with project defaults and per-Resource overrides. Webstudio remains the sender, as agreed. | 4, 4a, 5 | | #4385 | Two forms in the same project can send to different configured recipients without changing each other's delivery. | 4, 5 | | #5018 | Separate new-Form submissions produce separate Gmail conversations; retries retain the same submission reference. | 5a | | #3891 | Every selected query value is available in new and legacy Form Resource bindings through `system.search`, with a single value remaining a string and repeated values becoming an ordered array. | 2a | - [x] After PR #6530 merged (`a49a6769bd`), verified the new Form needs no port: React Router and Remix use the same Form submit path, which creates fresh `FormData` and calls `set` once for the bot field on each request. `generates one fresh bot field for each managed submission` passes (shared browser suite 7/7); Router managed Form tests pass 2/2, Router refresh tests 7/7, and Remix refresh tests 5/5. # Related issues Closes #3023 Closes #5512 Related #4093 Related #5432 Closes #5499 Closes #3530 Closes #5428 Closes #5658 Closes #6487 Closes #4414 Closes #4385 Closes #5018 Closes #3891 Related #6520 — [Multi-step Forms with resumable local drafts](https://github.com/webstudio-is/webstudio/issues/6520) The existing Webhook Form bug #6437 and prior legacy-form fix #6436 are outside this new-Form feature and are not claimed as closed here. The old email planning PR #6486 is superseded by this description. - [x] **Use the canonical server token for Preview email:** The user confirmed the canonical `TRPC_SERVER_API_TOKEN` is supplied from Infisical in Preview and Production. Builder Preview now sends this token to Email Service `/v1/preview-send`; the redundant `EMAIL_SERVICE_PREVIEW_TOKEN` wiring is removed. Focused route tests verify authorization, missing-token behavior, webhook-only behavior, and redaction. - [x] **Defer validation for a new Resource URL:** Untouched blank URLs are not marked invalid until save; edited invalid values validate on blur. Regression tests pass. - [x] **Handle safe public Resource redirects:** Public GET/HEAD redirects are revalidated and allowed without forwarding credentials; unsafe destinations and cross-origin body requests remain blocked. Protected-fetch tests pass. ## Form component identity correction (2026-10-11) - [x] Keep component picker labels and compatibility behavior clear: show the new component as **Form (Beta)**, and keep the existing **Webhook Form** available in its current Data category through the next release. Update the guide for the 10-Action limit and Navigation mode when Actions are empty. Builder component-list and registry template tests pass. - [x] Use one canonical managed component identity, `Form`, for Resource submission and Navigation mode. Keep `WebhookForm` only as the legacy component through the next release, and keep generic `Element` with tag `form` for native browser behavior. Update exports, metadata, templates, adapters, migrations, docs, CLI/MCP discovery, and E2E identifiers; verify saved projects migrate without changing legacy Webhook Forms. Project migration tests pass (25/25), and the CLI prebuild suite verifies saved bundles are migrated before rendering and endpoint generation. ## Framework-neutral Form core (2026-10-11) - [x] Move managed Form contracts, browser submission behavior, and server delivery into SDK Form entries. Keep React hooks/rendering in the React adapter and router refresh timing in the router adapter; preserve compatibility exports. Builder and CLI consume the dedicated server entry. StrictMode, cancellation, redirect fallback, and refresh-failure behavior have regression tests. SDK build and published types, SDK/React/router/CLI/Builder typechecks, focused SDK/React/router/CLI tests, generated route tests, and the browser bundle boundary check pass. Astra Medium reviewed the architecture and final diff; no remaining findings. ## SDK package boundaries (2026-10-11) - [x] Document each SDK package’s architectural role and dependency constraints. Added root READMEs for `sdk`, `sdk-cli`, and `sdk-components-registry`; clarified the existing React SDK, React component, Radix, Router, Remix, and animation package READMEs. Formatting and diff checks pass. ## Form submission lifecycle - [x] **Handle rejected submissions when the controller refuses them:** Consume abort-aware submission promises when a duplicate or disposed Form submission is refused. Regression tests cover both paths; four lifecycle tests, SDK typecheck, and diff checks pass. ## Unified Canvas selection reveal (2026-10-12) - [x] Reduce selection reveal metadata and resolver ceremony while preserving occurrence validation, owner/source boundaries, and focused coverage. Replaced ten one-element rule arrays with one optional rule each, removed the unused fallback field and redundant source union, combined selector validation with path construction, and bounded the source search. The optimization removes 24 production lines and 10 test lines (net), with the shared resolver and derived override layer intact. Focused Builder tests pass 66/66, Radix 20/20, registry 1/1; SDK, Radix, registry, and Builder typechecks, scoped lint, formatting, and diff checks pass. The nested Radix portal check uses a rendered harness rather than the full Canvas. Independent Astra Medium re-review found no correctness findings. Pushed commit `5bc392d3788ef004f9a35c9a12d82dab6087053a`; `builder-pr/whr.staging` SHA verified. - [x] Replace duplicated Radix Navigator selection handlers with declarative component metadata and one Builder-owned, occurrence-aware selection override resolver. Native Select, HeadSlot, and AnimateChildren hooks remain for their separate editor behavior. - [x] Reveal selected `ws:show=false` instances and ancestors through resolved prop overrides, including Form success/error feedback, while preserving normal published rendering. - [x] Resolve fixed `open` and effective item/tab `value` rules for Dialog, Popover, Tooltip, Select, Collapsible, Tabs, Accordion, and Navigation Menu. DialogOverlay retains its existing reveal behavior. Preserve unrelated memory props and Preview behavior. - [x] Verify resolver, lifecycle, nested Dialog portal mounting, Collection occurrence isolation, authored expression updates, and Canvas prop behavior. Local focused Builder suites pass 66/66; Radix 20/20; registry 2/2. SDK, Radix, registry, and Builder typechecks, scoped lint, formatting, and diff checks pass. Independent Astra Medium review findings were fixed and rechecked. Pushed commit `f8bb1078195e17704ed856c4334683584e983485`; `builder-pr/whr.staging` SHA verified. These are local automated checks, not live UI verification. ### Remaining Navigator hook migration - [x] Migrated every remaining Navigator hook consumer: native Option/Select and tagged `ws:element` use effective option values and HTML text fallback; HeadSlot uses a fixed self override; AnimateChildren uses a fixed override only for its exact selection. - [x] Removed Navigator hook implementations, `Hook`/`HookContext`/`InstanceData`/`InstancePath` and `getClosestInstance`, hook subscriptions and store, library registration, package hook entrypoints/build inputs, and the stale catalog hook capability field. - [x] Added behavior coverage for evaluated/bound native values, empty/zero values, HTML whitespace/NBSP fallback, repeated occurrences, nearest owner, stale selection, HeadSlot rendering, exact animation selection, Preview, deselection, and no saved-prop/memory mutation. Existing Radix runtime index behavior remains covered. - [x] Completed Astra Medium self-review and independent Astra High review. Fixed the High review finding: native fallback values must preserve non-ASCII whitespace. - [x] Final repository search found no remaining Navigator hook consumers or registrations. Validation: - `pnpm --filter @webstudio-is/builder exec vitest run app/shared/nano-states/selection-reveal.test.ts app/shared/nano-states/selection-reveal-render.browser.test.tsx app/shared/nano-states/components.test.tsx app/shared/nano-states/props.test.ts app/shared/nano-states/instance-selection.test.ts app/canvas/instance-selection-events.test.ts app/canvas/selected-instance-effects.test.ts` — 7 files, 84 tests passed; the final resolver rerun passed 14 tests after adding one additional authored-expression regression (85 tests across the same files). - `pnpm --filter @webstudio-is/project-build exec vitest run src/runtime/component-catalog.test.ts src/runtime/component-templates.test.ts` — 16 tests passed. - `pnpm --filter @webstudio-is/sdk-components-react-radix test` — 20 tests passed. - `pnpm --filter @webstudio-is/builder exec tsc --noEmit` — passed. - `pnpm --filter @webstudio-is/sdk --filter @webstudio-is/react-sdk --filter @webstudio-is/sdk-components-react --filter @webstudio-is/sdk-components-animation --filter @webstudio-is/sdk-components-registry --filter @webstudio-is/project-build typecheck` — all 6 packages passed. - `pnpm exec oxlint --deny-warnings` on all changed TypeScript files, `pnpm exec oxfmt --check` on all changed files, and `git diff --check` — passed. Native HTML options use the browser's effective value semantics (authored value or text); their runtime has no numeric index default. Radix items continue using their existing runtime index fallback. All three consumers migrated; no hook mechanism remains.
This commit is contained in:
1 parent
2570bcdd69
commit
59d4f00ce7
596 files changed
+71717
-4997
No files matched your search
@@ -26,12 +26,18 @@ runs:
|
||||
- name: Discover shards
|
||||
id: shards
|
||||
shell: bash
|
||||
run: echo "value=$(pnpm --dir apps/builder exec tsx ./e2e/print-shards.ts)" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
shards=$(pnpm --dir apps/builder exec tsx ./e2e/print-shards.ts)
|
||||
echo "value=$shards" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Builder
|
||||
shell: bash
|
||||
run: pnpm --filter @webstudio-is/builder build
|
||||
|
||||
- name: Build generated React Router components
|
||||
shell: bash
|
||||
run: pnpm --filter @webstudio-is/sdk-components-react-router build
|
||||
|
||||
- name: Generate schema snapshot
|
||||
shell: bash
|
||||
run: pnpm --filter @webstudio-is/builder e2e:schema-cache
|
||||
|
||||
@@ -58,7 +58,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
pnpm --filter='./fixtures/*' --sequential run fixtures:sync 2>&1 | tee fixtures-sync.log
|
||||
# Build webstudio-features from its checked-in snapshot; its remote data is mutable.
|
||||
pnpm --filter='./fixtures/*' --filter='!./fixtures/webstudio-features' --sequential run fixtures:sync 2>&1 | tee fixtures-sync.log
|
||||
sync_status=${PIPESTATUS[0]}
|
||||
if [ "$sync_status" -ne 0 ]; then
|
||||
message=$(tail -n 40 fixtures-sync.log | sed -e 's/%/%25/g' -e 's/\r/%0D/g' -e 's/$/%0A/' | tr -d '\n')
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
tests:
|
||||
name: tests (${{ matrix.environment }}, ${{ matrix.suite.name }})
|
||||
timeout-minutes: 7
|
||||
timeout-minutes: ${{ (matrix.suite.name == 'builder-app' || matrix.suite.name == 'packages') && 12 || 7 }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
builder-e2e:
|
||||
name: builder-e2e (${{ matrix.shard }})
|
||||
timeout-minutes: 7
|
||||
timeout-minutes: ${{ matrix.shard == 'shard-5' && 18 || 12 }}
|
||||
|
||||
needs: builder-e2e-build
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Role } from "@webstudio-is/project";
|
||||
import { initializeClientSync, getSyncClient } from "~/shared/sync/sync-client";
|
||||
import { usePreventUnload } from "~/shared/sync/project-queue";
|
||||
import { usePublish, $publisher } from "~/shared/pubsub";
|
||||
import { subscribePreviewFormRequests } from "~/shared/preview-form-parent";
|
||||
import { Inspector } from "./inspector";
|
||||
import { Topbar } from "./shared/topbar";
|
||||
import { Footer } from "./features/footer";
|
||||
@@ -335,6 +336,7 @@ export const Builder = (props: BuilderProps) => {
|
||||
useEffect(() => {
|
||||
$publisher.set({ publish });
|
||||
}, [publish]);
|
||||
useEffect(() => subscribePreviewFormRequests(publish), [publish]);
|
||||
|
||||
const project = useStore($project);
|
||||
|
||||
|
||||
@@ -34,10 +34,16 @@ const useTooSmallMessage = () => {
|
||||
return message;
|
||||
};
|
||||
|
||||
export const isChromiumBrowser = (userAgent: string) =>
|
||||
/(?:Chrome|Chromium)\//.test(userAgent);
|
||||
|
||||
const useIsUnsupportedBrowser = () => {
|
||||
const [isUnsupportedBrowser, setIsUnsupportedBrowser] = useState(false);
|
||||
useEffect(() => {
|
||||
if ("chrome" in window || isFeatureEnabled("unsupportedBrowsers")) {
|
||||
if (
|
||||
isChromiumBrowser(navigator.userAgent) ||
|
||||
isFeatureEnabled("unsupportedBrowsers")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -1,7 +1,7 @@
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { UnsupportedBrowserDialog } from "./blocking-alerts";
|
||||
import { isChromiumBrowser, UnsupportedBrowserDialog } from "./blocking-alerts";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
@@ -52,3 +52,24 @@ test("shows an accessible modal dialog and lets the user continue", () => {
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("recognizes Chromium from its user agent without window.chrome", () => {
|
||||
expect(
|
||||
isChromiumBrowser(
|
||||
"Mozilla/5.0 Chrome/131.0.0.0 Safari/537.36 HeadlessChrome/131.0.0.0"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isChromiumBrowser(
|
||||
"Mozilla/5.0 Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isChromiumBrowser("Mozilla/5.0 Firefox/132.0 Gecko/20100101 Firefox/132.0")
|
||||
).toBe(false);
|
||||
expect(
|
||||
isChromiumBrowser(
|
||||
"Mozilla/5.0 AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15"
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -127,7 +127,7 @@ export const DataVariablesGroup = ({
|
||||
name="dataVariable"
|
||||
heading={
|
||||
<CommandGroupHeading>
|
||||
Data variables ({options.length})
|
||||
Variables ({options.length})
|
||||
</CommandGroupHeading>
|
||||
}
|
||||
actions={[
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
InfoTooltip,
|
||||
cssVar,
|
||||
keyframes,
|
||||
ScrollArea,
|
||||
@@ -45,7 +46,6 @@ import { showAttributeMeta } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
EyeClosedIcon,
|
||||
EyeOpenIcon,
|
||||
InfoCircleIcon,
|
||||
MarkdownEmbedIcon,
|
||||
SettingsIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
@@ -1122,8 +1122,8 @@ export const NavigatorTree = () => {
|
||||
selectInstanceOnFocus([ROOT_INSTANCE_ID], event),
|
||||
}}
|
||||
action={
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About Global root variables"
|
||||
side="bottom"
|
||||
disableHoverableContent={true}
|
||||
content={
|
||||
@@ -1132,9 +1132,7 @@ export const NavigatorTree = () => {
|
||||
instance on every page.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon color={cssVar("--foreground-secondary")} />
|
||||
</Tooltip>
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TreeNodeLabel
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
PanelContent,
|
||||
Button,
|
||||
cssVar,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
Flex,
|
||||
Grid,
|
||||
InputErrorsTooltip,
|
||||
InputField,
|
||||
InfoTooltip,
|
||||
Label,
|
||||
DialogTitleActions,
|
||||
ScrollArea,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Tooltip,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { InfoCircleIcon, TrashIcon } from "@webstudio-is/icons";
|
||||
import { TrashIcon } from "@webstudio-is/icons";
|
||||
import { type Folder, getFolderById } from "@webstudio-is/sdk";
|
||||
import {
|
||||
folderSettingsDefaultValues,
|
||||
@@ -105,15 +105,10 @@ const FormFields = ({
|
||||
<Grid gap={1}>
|
||||
<Flex align="center" css={{ gap: theme.spacing[3] }}>
|
||||
<Label htmlFor={fieldIds.slug}>Slug</Label>
|
||||
<Tooltip
|
||||
content={"Slug will be used as part of the path to the page"}
|
||||
variant="wrapped"
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Tooltip>
|
||||
<InfoTooltip
|
||||
label="About folder slug"
|
||||
content="Slug will be used as part of the path to the page"
|
||||
/>
|
||||
</Flex>
|
||||
<InputErrorsTooltip errors={errors.slug}>
|
||||
<InputField
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SmallIconButton,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
InfoTooltip,
|
||||
ScrollArea,
|
||||
PanelBanner,
|
||||
Link,
|
||||
@@ -88,16 +89,10 @@ export const StagingDomain = () => (
|
||||
<Grid flow="column" align="center" gap={2}>
|
||||
<Flex align="center" gap={1} css={{ width: theme.spacing[20] }}>
|
||||
<Label>Domain:</Label>
|
||||
<Tooltip
|
||||
<InfoTooltip
|
||||
label="About domain name"
|
||||
content="Domain can't be renamed once published"
|
||||
variant="wrapped"
|
||||
>
|
||||
<InfoCircleIcon
|
||||
tabIndex={0}
|
||||
style={{ flexShrink: 0 }}
|
||||
color={cssVar("--foreground-secondary")}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<InputField text="mono" value="my-project" disabled />
|
||||
</Grid>
|
||||
@@ -108,16 +103,10 @@ export const StagingDomain = () => (
|
||||
<Grid flow="column" align="center" gap={2}>
|
||||
<Flex align="center" gap={1} css={{ width: theme.spacing[20] }}>
|
||||
<Label>Password:</Label>
|
||||
<Tooltip
|
||||
<InfoTooltip
|
||||
label="About staging password"
|
||||
content="Read-only password for staging"
|
||||
variant="wrapped"
|
||||
>
|
||||
<InfoCircleIcon
|
||||
tabIndex={0}
|
||||
style={{ flexShrink: 0 }}
|
||||
color={cssVar("--foreground-secondary")}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<InputField text="mono" value="abc123" readOnly />
|
||||
</Grid>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Button,
|
||||
cssVar,
|
||||
Tooltip,
|
||||
InfoTooltip,
|
||||
IconButton,
|
||||
Grid,
|
||||
Flex,
|
||||
@@ -463,16 +464,10 @@ const ChangeProjectDomain = ({
|
||||
<Grid flow="column" align="center" gap={2}>
|
||||
<Flex align="center" gap={1} css={{ width: theme.spacing[20] }}>
|
||||
<Label htmlFor={id}>Domain:</Label>
|
||||
<Tooltip
|
||||
<InfoTooltip
|
||||
label="About domain name"
|
||||
content="Domain can't be renamed once published. Unpublish to enable renaming."
|
||||
variant="wrapped"
|
||||
>
|
||||
<InfoCircleIcon
|
||||
tabIndex={0}
|
||||
style={{ flexShrink: 0 }}
|
||||
color={cssVar("--foreground-secondary")}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<InputField
|
||||
text="mono"
|
||||
@@ -522,16 +517,10 @@ const ChangeProjectDomain = ({
|
||||
<Grid flow="column" align="center" gap={2}>
|
||||
<Flex align="center" gap={1} css={{ width: theme.spacing[20] }}>
|
||||
<Label htmlFor={`${id}-password`}>Password:</Label>
|
||||
<Tooltip
|
||||
<InfoTooltip
|
||||
label="About staging password"
|
||||
content="This password is read-only and cannot be changed. It is the same for every user. This prevents phishing attacks."
|
||||
variant="wrapped"
|
||||
>
|
||||
<InfoCircleIcon
|
||||
tabIndex={0}
|
||||
style={{ flexShrink: 0 }}
|
||||
color={cssVar("--foreground-secondary")}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<InputField
|
||||
text="mono"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { JsonControl } from "./json";
|
||||
import { JsonCodeControl } from "./json-code";
|
||||
import { TextContent } from "./text-content";
|
||||
import { ResourceControl } from "./resource-control";
|
||||
import { FormActionControl } from "./form-action";
|
||||
import { TagControl } from "./tag-control";
|
||||
import { TimeZoneControl } from "./time-zone";
|
||||
|
||||
@@ -68,6 +69,10 @@ export const renderControl = ({
|
||||
return <ResourceControl key={key} meta={meta} prop={prop} {...rest} />;
|
||||
}
|
||||
|
||||
if (meta.control === "form-action") {
|
||||
return <FormActionControl key={key} meta={meta} prop={prop} {...rest} />;
|
||||
}
|
||||
|
||||
if (meta.control === "code") {
|
||||
return <CodeControl key={key} meta={meta} prop={prop} {...rest} />;
|
||||
}
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { FormSubmission, Prop } from "@webstudio-is/sdk";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { FormActionControl } from "./form-action";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
const originalInstances = $instances.get();
|
||||
const originalDataSources = $dataSources.get();
|
||||
const originalResources = $resources.get();
|
||||
|
||||
const names = ["HTTP", "GraphQL", "Email"] as const;
|
||||
const setup = (resourceNames: readonly string[]) => {
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map(
|
||||
resourceNames.map((name) => [
|
||||
name,
|
||||
{
|
||||
type: "resource" as const,
|
||||
id: name,
|
||||
name,
|
||||
scopeInstanceId: "form",
|
||||
resourceId: `resource-${name}`,
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
$resources.set(
|
||||
new Map(
|
||||
resourceNames.map((name) => [
|
||||
`resource-${name}`,
|
||||
{
|
||||
id: `resource-${name}`,
|
||||
name,
|
||||
control:
|
||||
name === "GraphQL"
|
||||
? ("graphql" as const)
|
||||
: name === "Email"
|
||||
? ("email" as const)
|
||||
: undefined,
|
||||
method: "post" as const,
|
||||
url: '"https://example.test/submit"',
|
||||
headers: [],
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
let saved: FormSubmission = [];
|
||||
const Harness = () => {
|
||||
const [action, setAction] = useState<FormSubmission>([]);
|
||||
const prop: Prop = {
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: action,
|
||||
};
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div data-floating-panel-container>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={prop}
|
||||
computedValue={action}
|
||||
meta={{ type: "json", control: "form-action", required: false }}
|
||||
onChange={(change) => {
|
||||
if (change.type === "json") {
|
||||
saved = change.value as FormSubmission;
|
||||
setAction(saved);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
act(() => root?.render(<Harness />));
|
||||
const addButton = () =>
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Add action"]')!;
|
||||
const add = async (name: string) => {
|
||||
await act(async () => userEvent.click(addButton()));
|
||||
const option = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === name);
|
||||
expect(option, `picker option ${name}`).toBeDefined();
|
||||
await act(async () => userEvent.click(option!));
|
||||
};
|
||||
return {
|
||||
container,
|
||||
addButton,
|
||||
add,
|
||||
get saved() {
|
||||
return saved;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
$instances.set(originalInstances);
|
||||
$dataSources.set(originalDataSources);
|
||||
$resources.set(originalResources);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("Action picker adds, disables, reorders, and removes HTTP, GraphQL, and Email resources", async () => {
|
||||
const ui = setup(names);
|
||||
for (const name of names) {
|
||||
await ui.add(name);
|
||||
}
|
||||
expect(ui.saved).toEqual(
|
||||
names.map((dataSourceId) => ({ dataSourceId, enabled: true }))
|
||||
);
|
||||
expect(
|
||||
Array.from(ui.container.querySelectorAll('[data-list-item="true"]')).map(
|
||||
(row) => row.getAttribute("aria-label")
|
||||
)
|
||||
).toEqual(names.map((name) => `Action ${name}`));
|
||||
|
||||
await act(async () =>
|
||||
ui.container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Disable action GraphQL"]'
|
||||
)!
|
||||
.click()
|
||||
);
|
||||
expect(ui.saved).toEqual([
|
||||
{ dataSourceId: "HTTP", enabled: true },
|
||||
{ dataSourceId: "GraphQL", enabled: false },
|
||||
{ dataSourceId: "Email", enabled: true },
|
||||
]);
|
||||
expect(
|
||||
ui.container.querySelector('[aria-label="Action GraphQL"]')
|
||||
).toHaveStyle({ opacity: "0.2" });
|
||||
|
||||
const source = ui.container.querySelector<HTMLElement>(
|
||||
'[aria-label="Action Email"] [data-drag-handle]'
|
||||
)!;
|
||||
const target = ui.container.querySelector<HTMLElement>(
|
||||
'[aria-label="Action HTTP"]'
|
||||
)!;
|
||||
const start = source.getBoundingClientRect();
|
||||
const end = target.getBoundingClientRect();
|
||||
const pointerId = 1;
|
||||
await act(async () => {
|
||||
source.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
pointerId,
|
||||
clientX: start.x + 8,
|
||||
clientY: start.y + 8,
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", {
|
||||
bubbles: true,
|
||||
pointerId,
|
||||
clientX: end.x + 8,
|
||||
clientY: end.y + 4,
|
||||
})
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", {
|
||||
bubbles: true,
|
||||
pointerId,
|
||||
clientX: end.x + 8,
|
||||
clientY: end.y + 4,
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointerup", {
|
||||
bubbles: true,
|
||||
pointerId,
|
||||
clientX: end.x + 8,
|
||||
clientY: end.y + 4,
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(ui.saved).toEqual([
|
||||
{ dataSourceId: "Email", enabled: true },
|
||||
{ dataSourceId: "HTTP", enabled: true },
|
||||
{ dataSourceId: "GraphQL", enabled: false },
|
||||
]);
|
||||
// useDrag suppresses the click immediately following a drag for 300 ms.
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await act(async () =>
|
||||
ui.container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Remove action HTTP"]')!
|
||||
.click()
|
||||
);
|
||||
expect(ui.saved).toEqual([
|
||||
{ dataSourceId: "Email", enabled: true },
|
||||
{ dataSourceId: "GraphQL", enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Action picker accepts ten resources and blocks an eleventh", async () => {
|
||||
const names = Array.from({ length: 11 }, (_, index) => `HTTP ${index + 1}`);
|
||||
const ui = setup(names);
|
||||
for (const name of names.slice(0, 10)) {
|
||||
await ui.add(name);
|
||||
}
|
||||
expect(ui.saved).toHaveLength(10);
|
||||
expect(ui.addButton().disabled).toBe(true);
|
||||
await act(async () => ui.addButton().click());
|
||||
expect(document.querySelector('[role="menuitem"]')).toBeNull();
|
||||
expect(ui.saved.map(({ dataSourceId }) => dataSourceId)).toEqual(
|
||||
names.slice(0, 10)
|
||||
);
|
||||
});
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { Prop } from "@webstudio-is/sdk";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import { selectInstance } from "~/shared/nano-states";
|
||||
import { $selectedPageId } from "~/shared/nano-states/pages";
|
||||
import {
|
||||
$assets,
|
||||
$breakpoints,
|
||||
$dataSources,
|
||||
$instances,
|
||||
$pages,
|
||||
$projectSettings,
|
||||
$props,
|
||||
$resources,
|
||||
$styleSourceSelections,
|
||||
$styleSources,
|
||||
$styles,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { registerContainers, serverSyncStore } from "~/shared/sync/sync-stores";
|
||||
import { FormActionControl } from "./form-action";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
registerContainers();
|
||||
let root: Root | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
selectInstance(undefined);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("Actions references an existing Resource without creating another", async () => {
|
||||
serverSyncStore.transactionManager.currentStack = [];
|
||||
serverSyncStore.transactionManager.undoneStack = [];
|
||||
serverSyncStore.popAll();
|
||||
const pages = createDefaultPages({
|
||||
rootInstanceId: "body",
|
||||
homePageId: "home",
|
||||
});
|
||||
$pages.set(pages);
|
||||
$selectedPageId.set("home");
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [{ type: "id" as const, value: "form" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"form",
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$props.set(new Map());
|
||||
$breakpoints.set(new Map());
|
||||
$styleSourceSelections.set(new Map());
|
||||
$styleSources.set(new Map());
|
||||
$styles.set(new Map());
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[
|
||||
"send",
|
||||
{
|
||||
type: "resource" as const,
|
||||
id: "send",
|
||||
scopeInstanceId: "form",
|
||||
name: "Send request",
|
||||
resourceId: "request",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$resources.set(
|
||||
new Map([
|
||||
[
|
||||
"request",
|
||||
{
|
||||
id: "request",
|
||||
name: "Send request",
|
||||
method: "post" as const,
|
||||
url: '"https://example.com/submit"',
|
||||
headers: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$assets.set(new Map());
|
||||
$projectSettings.set({ meta: {}, compiler: {} });
|
||||
selectInstance(["form"]);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const Harness = () => {
|
||||
const [submission, setSubmission] = useState<Prop>();
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div data-floating-panel-container>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={submission}
|
||||
computedValue={
|
||||
submission?.type === "json" ? submission.value : undefined
|
||||
}
|
||||
meta={{
|
||||
type: "json",
|
||||
control: "form-action",
|
||||
required: false,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (value.type === "json") {
|
||||
setSubmission({
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
...value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
await act(async () => root?.render(<Harness />));
|
||||
expect(container.textContent).not.toContain(
|
||||
"Select at least one Resource destination"
|
||||
);
|
||||
expect(container.textContent).not.toContain("Create Resource in Form");
|
||||
await act(async () => {
|
||||
await userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Add action"]')!
|
||||
);
|
||||
});
|
||||
const action = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Send request");
|
||||
expect(action).toBeDefined();
|
||||
await act(async () => await userEvent.click(action!));
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector('[data-list-item="true"]')).not.toBeNull();
|
||||
});
|
||||
expect($dataSources.get().size).toBe(1);
|
||||
expect($resources.get().size).toBe(1);
|
||||
expect(container.textContent).not.toContain(
|
||||
"Select at least one Resource destination"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,740 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { Prop } from "@webstudio-is/sdk";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$props,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { FormActionControl } from "./form-action";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
const previousInstances = $instances.get();
|
||||
const previousDataSources = $dataSources.get();
|
||||
const previousProps = $props.get();
|
||||
const previousResources = $resources.get();
|
||||
const requestResource = (
|
||||
id: string,
|
||||
control?: "email" | "graphql" | "system"
|
||||
) => ({
|
||||
id,
|
||||
name: id,
|
||||
control,
|
||||
method: "post" as const,
|
||||
url: '"https://example.com"',
|
||||
headers: [],
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
$instances.set(previousInstances);
|
||||
$dataSources.set(previousDataSources);
|
||||
$props.set(previousProps);
|
||||
$resources.set(previousResources);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("Action control handles an empty Resource list and can select an in-scope destination", async () => {
|
||||
$resources.set(new Map([["resource", requestResource("resource")]]));
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance",
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [{ type: "id", value: "form" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"form",
|
||||
{
|
||||
type: "instance",
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[
|
||||
"resourceDataSource",
|
||||
{
|
||||
type: "resource",
|
||||
id: "resourceDataSource",
|
||||
scopeInstanceId: "form",
|
||||
name: "Send request",
|
||||
resourceId: "resource",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const onChange = vi.fn();
|
||||
const render = (prop?: Prop) => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={prop}
|
||||
computedValue={prop?.type === "json" ? prop.value : undefined}
|
||||
meta={{ type: "json", control: "form-action", required: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
await act(async () => render());
|
||||
const actionLabel = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent === "Action"
|
||||
)!;
|
||||
await act(async () => userEvent.click(actionLabel()));
|
||||
expect(document.body.textContent).toContain(
|
||||
"Sends each form submission to all selected destinations at the same time."
|
||||
);
|
||||
const variableBullets = Array.from(
|
||||
document.body.querySelectorAll('[role="tooltip"] ul li')
|
||||
).map((item) => item.textContent?.trim());
|
||||
expect(variableBullets).toEqual([
|
||||
"formData — submitted fields from this Form",
|
||||
"browserInfo — visitor details",
|
||||
"formState — current Form state: initial, success, or error.",
|
||||
"results — each action’s response, in order",
|
||||
"errors — failed actions and their messages",
|
||||
]);
|
||||
expect(document.body.textContent).not.toContain("status —");
|
||||
expect(container.textContent).toContain("Navigation mode");
|
||||
await act(async () => userEvent.keyboard("{Escape}"));
|
||||
expect(
|
||||
container.querySelector('[aria-label="About navigation mode"]')
|
||||
).not.toBeNull();
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<SVGElement>('[aria-label="About navigation mode"]')
|
||||
?.focus()
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Submits the form fields as URL query parameters and navigates without a full page reload. Use this for search and filters that update page Resources from the URL."
|
||||
);
|
||||
await act(async () => userEvent.keyboard("{Escape}"));
|
||||
expect(container.textContent).not.toContain(
|
||||
"Select at least one Resource destination"
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"Create a Resource in Data variables to add an action."
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
render({
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [],
|
||||
})
|
||||
);
|
||||
expect(container.textContent).not.toContain(
|
||||
"Select at least one Resource destination"
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Add action"]')!
|
||||
)
|
||||
);
|
||||
const requestOption = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((option) => option.textContent?.includes("Send request"));
|
||||
await act(async () => requestOption?.click());
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "resourceDataSource", enabled: true }],
|
||||
});
|
||||
|
||||
await act(async () =>
|
||||
render({
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "resourceDataSource", enabled: true }],
|
||||
})
|
||||
);
|
||||
await act(async () =>
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[
|
||||
"resourceDataSource",
|
||||
{
|
||||
type: "resource",
|
||||
id: "resourceDataSource",
|
||||
scopeInstanceId: "form",
|
||||
name: "Renamed request",
|
||||
resourceId: "resource",
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
);
|
||||
expect(container.textContent).toContain("Renamed request");
|
||||
await act(async () => userEvent.click(actionLabel()));
|
||||
expect(document.body.textContent).toContain(
|
||||
"Sends each form submission to all selected destinations at the same time."
|
||||
);
|
||||
expect(document.body.textContent).toContain("Reset");
|
||||
expect(document.body.textContent).not.toContain("Reset value");
|
||||
expect(container.textContent).not.toContain("Navigation mode");
|
||||
await act(async () => userEvent.keyboard("{Escape}"));
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "resourceDataSource", enabled: true }],
|
||||
});
|
||||
|
||||
await act(async () =>
|
||||
render({
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: Array.from({ length: 10 }, (_, index) => ({
|
||||
dataSourceId: `resource-${index}`,
|
||||
enabled: true,
|
||||
})),
|
||||
})
|
||||
);
|
||||
expect(container.textContent).not.toContain("Create Resource in Form");
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Add action"]')
|
||||
?.disabled
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("a Form can select a Resource defined outside its scope", async () => {
|
||||
$resources.set(new Map([["requestId", requestResource("requestId")]]));
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance",
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [{ type: "id", value: "form" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"form",
|
||||
{
|
||||
type: "instance",
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[
|
||||
"externalResourceId",
|
||||
{
|
||||
type: "resource",
|
||||
id: "externalResourceId",
|
||||
scopeInstanceId: "body",
|
||||
name: "Shared request",
|
||||
resourceId: "requestId",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const onChange = vi.fn();
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={{
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [],
|
||||
}}
|
||||
computedValue={[]}
|
||||
meta={{ type: "json", control: "form-action", required: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Add action"]')!
|
||||
)
|
||||
);
|
||||
const requestOption = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((option) => option.textContent?.includes("Shared request"));
|
||||
expect(requestOption).toBeDefined();
|
||||
await act(async () => requestOption?.click());
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "externalResourceId", enabled: true }],
|
||||
});
|
||||
});
|
||||
|
||||
test("stored Form selection renders and a deleted Resource can be removed", async () => {
|
||||
$resources.set(new Map([["request-id", requestResource("request-id")]]));
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance",
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [{ type: "id", value: "form" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"form",
|
||||
{
|
||||
type: "instance",
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[
|
||||
"resource-id",
|
||||
{
|
||||
type: "resource",
|
||||
id: "resource-id",
|
||||
scopeInstanceId: "form",
|
||||
name: "Send request",
|
||||
resourceId: "request-id",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const savedProp: Prop = {
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "resource-id", enabled: true }],
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const onChange = vi.fn();
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={savedProp}
|
||||
computedValue={
|
||||
savedProp.type === "json" ? savedProp.value : undefined
|
||||
}
|
||||
meta={{ type: "json", control: "form-action", required: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
expect(container.textContent).toContain("Send request");
|
||||
expect(container.querySelectorAll('[data-list-item="true"]')).toHaveLength(1);
|
||||
|
||||
await act(async () => $dataSources.set(new Map()));
|
||||
expect(container.textContent).toContain("Deleted Resource");
|
||||
const row = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove action Deleted Resource"]'
|
||||
)!;
|
||||
await act(async () => {
|
||||
row.click();
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("Actions only offers eligible in-scope Resources and disables an added one", async () => {
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{
|
||||
type: "instance",
|
||||
id: "body",
|
||||
component: "Body",
|
||||
children: [
|
||||
{ type: "id", value: "form" },
|
||||
{ type: "id", value: "sibling" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"form",
|
||||
{
|
||||
type: "instance",
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"sibling",
|
||||
{ type: "instance", id: "sibling", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
const source = (id: string, scopeInstanceId: string, resourceId: string) => ({
|
||||
type: "resource" as const,
|
||||
id,
|
||||
scopeInstanceId,
|
||||
name: id,
|
||||
resourceId,
|
||||
});
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
["http", source("http", "form", "http-resource")],
|
||||
["email", source("email", "body", "email-resource")],
|
||||
["graphql", source("graphql", "form", "graphql-resource")],
|
||||
["system", source("system", "form", "system-resource")],
|
||||
["sibling", source("sibling", "sibling", "sibling-resource")],
|
||||
])
|
||||
);
|
||||
$resources.set(
|
||||
new Map([
|
||||
["http-resource", requestResource("http-resource")],
|
||||
["email-resource", requestResource("email-resource", "email")],
|
||||
["graphql-resource", requestResource("graphql-resource", "graphql")],
|
||||
["system-resource", requestResource("system-resource", "system")],
|
||||
["sibling-resource", requestResource("sibling-resource")],
|
||||
])
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const onChange = vi.fn();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={{
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "http", enabled: true }],
|
||||
}}
|
||||
computedValue={[{ dataSourceId: "http", enabled: true }]}
|
||||
meta={{ type: "json", control: "form-action", required: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(
|
||||
async () =>
|
||||
await userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Action http"]'
|
||||
)!
|
||||
)
|
||||
);
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
const addButton = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Add action"]'
|
||||
)!;
|
||||
await act(async () => await userEvent.hover(addButton));
|
||||
await expect
|
||||
.poll(() => document.querySelector('[role="tooltip"]')?.textContent)
|
||||
.toContain("Add a resource to submit this Form.");
|
||||
await act(async () => await userEvent.click(addButton));
|
||||
const items = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
);
|
||||
expect(items.map((item) => item.textContent)).toEqual([
|
||||
"email",
|
||||
"http",
|
||||
"graphql",
|
||||
]);
|
||||
expect(
|
||||
items
|
||||
.find((item) => item.textContent === "http")
|
||||
?.getAttribute("data-disabled")
|
||||
).not.toBeNull();
|
||||
await act(
|
||||
async () =>
|
||||
await userEvent.click(items.find((item) => item.textContent === "email")!)
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [
|
||||
{ dataSourceId: "http", enabled: true },
|
||||
{ dataSourceId: "email", enabled: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("a disabled Action stays visible and can be enabled or removed", async () => {
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
type: "instance",
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[
|
||||
"send",
|
||||
{
|
||||
type: "resource",
|
||||
id: "send",
|
||||
scopeInstanceId: "form",
|
||||
name: "Send request",
|
||||
resourceId: "request",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$resources.set(new Map([["request", requestResource("request")]]));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const onChange = vi.fn();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={{
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "send", enabled: true }],
|
||||
}}
|
||||
computedValue={[{ dataSourceId: "send", enabled: true }]}
|
||||
meta={{ type: "json", control: "form-action", required: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Disable action Send request"]'
|
||||
)
|
||||
?.click()
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "send", enabled: false }],
|
||||
});
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={{
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "send", enabled: false }],
|
||||
}}
|
||||
computedValue={[{ dataSourceId: "send", enabled: false }]}
|
||||
meta={{ type: "json", control: "form-action", required: false }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const row = container.querySelector<HTMLElement>('[data-list-item="true"]');
|
||||
expect(row?.getAttribute("aria-label")).toBe("Action Send request");
|
||||
expect(row?.textContent).toContain("Send request");
|
||||
expect(row?.querySelector("label")).toBeNull();
|
||||
expect(row?.getClientRects().length).toBeGreaterThan(0);
|
||||
expect(getComputedStyle(row!).opacity).toBe("0.2");
|
||||
expect(row?.hasAttribute("disabled")).toBe(false);
|
||||
expect(row?.parentElement?.getBoundingClientRect().left).toBeLessThan(
|
||||
container.getBoundingClientRect().left
|
||||
);
|
||||
const addButton = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Add action"]'
|
||||
)!;
|
||||
await act(async () => addButton.focus());
|
||||
await act(async () => userEvent.tab());
|
||||
expect(document.activeElement).toBe(row);
|
||||
await act(async () => userEvent.keyboard("{ArrowRight}"));
|
||||
expect(document.activeElement?.getAttribute("aria-label")).toBe(
|
||||
"Enable action Send request"
|
||||
);
|
||||
await act(async () => userEvent.keyboard("{Enter}"));
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [{ dataSourceId: "send", enabled: true }],
|
||||
});
|
||||
await act(async () => userEvent.keyboard("{ArrowRight}"));
|
||||
expect(document.activeElement?.getAttribute("aria-label")).toBe(
|
||||
"Remove action Send request"
|
||||
);
|
||||
await act(async () => userEvent.keyboard("{Enter}"));
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [],
|
||||
});
|
||||
const actionsLabel = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Action"
|
||||
);
|
||||
expect(actionsLabel).toBeDefined();
|
||||
await act(async () =>
|
||||
actionsLabel?.dispatchEvent(
|
||||
new MouseEvent("click", { altKey: true, bubbles: true })
|
||||
)
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
type: "json",
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ actions: [], mode: "Navigation mode", warning: undefined },
|
||||
{
|
||||
actions: [{ dataSourceId: "first", enabled: false }],
|
||||
mode: undefined,
|
||||
warning: "Enable at least one action",
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{ dataSourceId: "first", enabled: false },
|
||||
{ dataSourceId: "second", enabled: true },
|
||||
],
|
||||
mode: undefined,
|
||||
warning: undefined,
|
||||
},
|
||||
])(
|
||||
"an empty Action list selects Navigation mode; a non-empty disabled list does not",
|
||||
async ({ actions, mode, warning }) => {
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map(
|
||||
["first", "second"].map((id) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
type: "resource" as const,
|
||||
name: id,
|
||||
scopeInstanceId: "form",
|
||||
resourceId: "request",
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
$resources.set(
|
||||
new Map([
|
||||
[
|
||||
"request",
|
||||
{
|
||||
id: "request",
|
||||
name: "Request",
|
||||
method: "get",
|
||||
url: '"https://example.com"',
|
||||
headers: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormActionControl
|
||||
instanceId="form"
|
||||
propName="action"
|
||||
prop={{
|
||||
id: "action",
|
||||
instanceId: "form",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: actions,
|
||||
}}
|
||||
computedValue={actions}
|
||||
meta={{
|
||||
type: "json",
|
||||
control: "form-action",
|
||||
required: false,
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(container.textContent?.includes("Navigation mode")).toBe(
|
||||
mode === "Navigation mode"
|
||||
);
|
||||
if (mode) {
|
||||
expect(
|
||||
container.querySelector('[aria-label="About navigation mode"]')
|
||||
).not.toBeNull();
|
||||
}
|
||||
if (warning) {
|
||||
expect(container.textContent).toContain(warning);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
Box,
|
||||
InteractiveListArrowFocus,
|
||||
InteractiveListItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Flex,
|
||||
Grid,
|
||||
SmallIconButton,
|
||||
SmallToggleButton,
|
||||
Text,
|
||||
Tooltip,
|
||||
InfoTooltip,
|
||||
useSortable,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
EyeClosedIcon,
|
||||
EyeOpenIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import {
|
||||
isFormSubmission,
|
||||
maxFormActions,
|
||||
validateFormSubmission,
|
||||
type FormSubmission,
|
||||
type DataSource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { findAvailableVariables } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { type ControlProps } from "../shared";
|
||||
import { FieldLabel } from "../property-label";
|
||||
import { showVariable } from "../variable-navigation";
|
||||
|
||||
const ActionItem = ({
|
||||
id,
|
||||
index,
|
||||
variable,
|
||||
isEnabled,
|
||||
active,
|
||||
onToggle,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string;
|
||||
index: number;
|
||||
variable?: DataSource;
|
||||
isEnabled: boolean;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
onRemove: () => void;
|
||||
}) => {
|
||||
const isResource = variable?.type === "resource";
|
||||
const name = isResource ? variable.name : "Deleted Resource";
|
||||
return (
|
||||
<InteractiveListItem
|
||||
id={id}
|
||||
index={index}
|
||||
aria-label={`Action ${name}`}
|
||||
draggable
|
||||
active={active}
|
||||
css={{ opacity: isEnabled ? undefined : 0.2 }}
|
||||
onClick={(event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
isResource &&
|
||||
target instanceof Element &&
|
||||
target.closest("[data-drag-handle]") === null &&
|
||||
event.defaultPrevented === false
|
||||
) {
|
||||
showVariable(id);
|
||||
}
|
||||
}}
|
||||
label={
|
||||
<Text variant="labels" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
buttons={
|
||||
<>
|
||||
<Tooltip content={isEnabled ? "Disable action" : "Enable action"}>
|
||||
<SmallToggleButton
|
||||
tabIndex={-1}
|
||||
variant="normal"
|
||||
pressed={!isEnabled}
|
||||
aria-label={`${isEnabled ? "Disable" : "Enable"} action ${name}`}
|
||||
icon={isEnabled ? <EyeOpenIcon /> : <EyeClosedIcon />}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPressedChange={onToggle}
|
||||
/>
|
||||
</Tooltip>
|
||||
<SmallIconButton
|
||||
tabIndex={-1}
|
||||
variant="destructive"
|
||||
aria-label={`Remove action ${name}`}
|
||||
icon={<MinusIcon />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormActionControl = ({
|
||||
instanceId,
|
||||
prop,
|
||||
onChange,
|
||||
}: ControlProps<"form-action">) => {
|
||||
const instances = useStore($instances);
|
||||
const dataSources = useStore($dataSources);
|
||||
const resourcesById = useStore($resources);
|
||||
const invalidSavedValue =
|
||||
prop !== undefined &&
|
||||
(prop.type !== "json" || isFormSubmission(prop.value) === false);
|
||||
const action: FormSubmission =
|
||||
prop?.type === "json" && isFormSubmission(prop.value) ? prop.value : [];
|
||||
const resources = findAvailableVariables({
|
||||
startingInstanceId: instanceId,
|
||||
instances,
|
||||
dataSources,
|
||||
}).filter((variable) => {
|
||||
if (variable.type !== "resource") {
|
||||
return false;
|
||||
}
|
||||
const resource = resourcesById.get(variable.resourceId);
|
||||
return resource !== undefined && resource.control !== "system";
|
||||
});
|
||||
const update = (next: FormSubmission) =>
|
||||
onChange({ type: "json", value: next });
|
||||
const { dragItemId, placementIndicator, sortableRefCallback } = useSortable({
|
||||
items: action.map(({ dataSourceId }) => ({ id: dataSourceId })),
|
||||
onSort: (newIndex, oldIndex) => {
|
||||
const destinations = [...action];
|
||||
const [moved] = destinations.splice(oldIndex, 1);
|
||||
destinations.splice(newIndex, 0, moved);
|
||||
update(destinations);
|
||||
},
|
||||
});
|
||||
const error = invalidSavedValue
|
||||
? "Invalid Form action settings"
|
||||
: action.length === 0
|
||||
? undefined
|
||||
: validateFormSubmission(action);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex align="center" justify="between">
|
||||
<FieldLabel
|
||||
description={
|
||||
<Flex direction="column" gap="1">
|
||||
<Text>
|
||||
Sends each form submission to all selected destinations at the
|
||||
same time.
|
||||
</Text>
|
||||
<Box as="ul" css={{ margin: 0, paddingLeft: theme.spacing[4] }}>
|
||||
<li>
|
||||
<Text>
|
||||
<code>formData</code> — submitted fields from this Form
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text>
|
||||
<code>browserInfo</code> — visitor details
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text>
|
||||
<code>formState</code> — current Form state: initial,
|
||||
success, or error.
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text>
|
||||
<code>results</code> — each action’s response, in order
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text>
|
||||
<code>errors</code> — failed actions and their messages
|
||||
</Text>
|
||||
</li>
|
||||
</Box>
|
||||
</Flex>
|
||||
}
|
||||
resettable={invalidSavedValue || action.length > 0}
|
||||
resetLabel="Reset"
|
||||
onReset={() => update([])}
|
||||
>
|
||||
Action
|
||||
</FieldLabel>
|
||||
<DropdownMenu>
|
||||
<Tooltip content="Add a resource to submit this Form.">
|
||||
<span style={{ display: "flex" }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SmallIconButton
|
||||
aria-label="Add action"
|
||||
disabled={
|
||||
action.length >= maxFormActions || resources.length === 0
|
||||
}
|
||||
icon={<PlusIcon />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
{resources.map((variable) => (
|
||||
<DropdownMenuItem
|
||||
key={variable.id}
|
||||
disabled={action.some(
|
||||
({ dataSourceId }) => dataSourceId === variable.id
|
||||
)}
|
||||
onSelect={() => {
|
||||
if (
|
||||
action.length < maxFormActions &&
|
||||
!action.some(
|
||||
({ dataSourceId }) => dataSourceId === variable.id
|
||||
)
|
||||
) {
|
||||
update([
|
||||
...action,
|
||||
{ dataSourceId: variable.id, enabled: true },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{variable.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Flex>
|
||||
<Box css={{ py: theme.spacing[2] }}>
|
||||
<Flex direction="column" gap="2">
|
||||
{action.length > 0 && (
|
||||
<InteractiveListArrowFocus dragItemId={dragItemId}>
|
||||
<Grid
|
||||
ref={sortableRefCallback}
|
||||
css={{
|
||||
marginInline: `calc(-1 * ${theme.panel.paddingInline})`,
|
||||
}}
|
||||
>
|
||||
{action.map(({ dataSourceId: id, enabled }, index) => (
|
||||
<ActionItem
|
||||
key={id}
|
||||
id={id}
|
||||
index={index}
|
||||
variable={dataSources.get(id)}
|
||||
isEnabled={enabled}
|
||||
active={dragItemId === id}
|
||||
onToggle={() =>
|
||||
update(
|
||||
action.map((item) =>
|
||||
item.dataSourceId === id
|
||||
? { ...item, enabled: !item.enabled }
|
||||
: item
|
||||
)
|
||||
)
|
||||
}
|
||||
onRemove={() =>
|
||||
update(action.filter((item) => item.dataSourceId !== id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{placementIndicator}
|
||||
</Grid>
|
||||
</InteractiveListArrowFocus>
|
||||
)}
|
||||
{error && <Text>{error}</Text>}
|
||||
{!invalidSavedValue && action.length === 0 && (
|
||||
<Flex align="center" justify="center" gap="1">
|
||||
<Text>Navigation mode</Text>
|
||||
<InfoTooltip
|
||||
label="About navigation mode"
|
||||
content="Submits the form fields as URL query parameters and navigates without a full page reload. Use this for search and filters that update page Resources from the URL."
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -40,8 +40,8 @@ import {
|
||||
UrlField,
|
||||
MethodField,
|
||||
Headers,
|
||||
getResourceScopeForInstance,
|
||||
} from "../resource-panel";
|
||||
} from "../variable-editors/shared/resource-fields";
|
||||
import { getResourceScopeForInstance } from "../resource-scope";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import { type ControlProps, VerticalLayout } from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { DataSource, Instances } from "@webstudio-is/sdk";
|
||||
import { encodeDataVariableId } from "@webstudio-is/sdk";
|
||||
import { getResourceScopeForInstance } from "./resource-scope";
|
||||
import {
|
||||
getFormDataPreview,
|
||||
getBrowserInfoPreview,
|
||||
getFormParameterName,
|
||||
resolveFormParameterPreview,
|
||||
} from "./form-context-preview";
|
||||
|
||||
test("form context uses mounted browser controls and excludes unchecked controls", () => {
|
||||
const form = document.createElement("form");
|
||||
form.dataset.wsManagedFormId = "form";
|
||||
form.innerHTML = `
|
||||
<input name="name" value="Ada">
|
||||
<input name="unchecked" type="checkbox" value="yes">
|
||||
<input name="choice" type="radio" value="one">
|
||||
<input name="choice" type="radio" value="two" checked>
|
||||
<select name="city"><option value="lisbon">Lisbon</option></select>
|
||||
<input name="disabled" value="hidden" disabled>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
try {
|
||||
expect(getFormDataPreview("form")).toEqual({
|
||||
name: "Ada",
|
||||
unchecked: [],
|
||||
choice: "two",
|
||||
city: "lisbon",
|
||||
});
|
||||
expect(getFormDataPreview("other-form")).toEqual({});
|
||||
form.querySelector<HTMLInputElement>('[name="name"]')!.value = "Grace";
|
||||
expect(getFormDataPreview("form").name).toBe("Grace");
|
||||
} finally {
|
||||
form.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test("unmounted forms have no fabricated submission values", () => {
|
||||
expect(getFormDataPreview("not-mounted")).toEqual({});
|
||||
});
|
||||
|
||||
test("browser preview provides safe browser context without visitor IP or referrer", () => {
|
||||
expect(getBrowserInfoPreview()).toEqual({
|
||||
ip: "",
|
||||
referrer: "",
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
});
|
||||
});
|
||||
|
||||
test("Form parameter resolver gives the Resource scope the same public occurrence value", () => {
|
||||
const formData: DataSource = {
|
||||
id: "form-data",
|
||||
type: "parameter",
|
||||
name: "formData",
|
||||
scopeInstanceId: "form",
|
||||
};
|
||||
const browserInfo: DataSource = {
|
||||
id: "browser-info",
|
||||
type: "parameter",
|
||||
name: "browserInfo",
|
||||
scopeInstanceId: "form",
|
||||
};
|
||||
const unrelated: DataSource = {
|
||||
id: "unrelated",
|
||||
type: "parameter",
|
||||
name: "formData",
|
||||
scopeInstanceId: "other",
|
||||
};
|
||||
const instances: Instances = new Map([
|
||||
["form", { id: "form", type: "instance", component: "Form", children: [] }],
|
||||
[
|
||||
"other",
|
||||
{ id: "other", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
]);
|
||||
const selector = ["form", "collection[one]", "root"];
|
||||
const liveFormValues = new Map([
|
||||
[
|
||||
selector.join(","),
|
||||
{
|
||||
attachment: {
|
||||
__webstudioPreviewFile: true,
|
||||
name: "sample.txt",
|
||||
type: "text/plain",
|
||||
size: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
["form,collection[two],root", { message: "other occurrence" }],
|
||||
]);
|
||||
const liveBrowserInfo = new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
ip: "203.0.113.10",
|
||||
userAgent: "Preview",
|
||||
language: "en-GB",
|
||||
referrer: "",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const scope = getResourceScopeForInstance({
|
||||
page: undefined,
|
||||
instanceKey: selector.join(","),
|
||||
dataSources: new Map([
|
||||
[formData.id, formData],
|
||||
[browserInfo.id, browserInfo],
|
||||
[unrelated.id, unrelated],
|
||||
]),
|
||||
variableValuesByInstanceSelector: new Map(),
|
||||
formScopeInstanceId: "form",
|
||||
formScopeSelector: selector,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
});
|
||||
|
||||
for (const source of [formData, browserInfo]) {
|
||||
const preview = resolveFormParameterPreview(source, {
|
||||
instances,
|
||||
selector,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
});
|
||||
expect(preview?.value).toEqual(scope.variableValues.get(source.id));
|
||||
expect(scope.scope[encodeDataVariableId(source.id)]).toEqual(
|
||||
preview?.value
|
||||
);
|
||||
}
|
||||
expect(scope.variableValues.get(formData.id)).toEqual({
|
||||
attachment: { name: "sample.txt", type: "text/plain", size: 3 },
|
||||
});
|
||||
expect(scope.variableValues.has(unrelated.id)).toBe(false);
|
||||
expect(getFormParameterName(unrelated, { instances })).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { DataSource, Instances } from "@webstudio-is/sdk";
|
||||
import {
|
||||
browserInfoParameterName,
|
||||
formDataParameterName,
|
||||
type ManagedFormBrowserInfo,
|
||||
} from "@webstudio-is/sdk/runtime";
|
||||
import {
|
||||
getFormOccurrenceKey,
|
||||
readPreviewFormValues,
|
||||
toPublicPreviewValue,
|
||||
} from "~/shared/preview-form-values";
|
||||
|
||||
/** Read actual browser form controls when they are mounted in this document. */
|
||||
export const getFormDataPreview = (formId: string) => {
|
||||
if (typeof document === "undefined") {
|
||||
return {};
|
||||
}
|
||||
const form = Array.from(
|
||||
document.querySelectorAll<HTMLFormElement>("form[data-ws-managed-form-id]")
|
||||
).find((element) => element.dataset.wsManagedFormId === formId);
|
||||
return form ? readPreviewFormValues(form) : {};
|
||||
};
|
||||
|
||||
export const getBrowserInfoPreview = (
|
||||
serverInfo?: ManagedFormBrowserInfo
|
||||
): ManagedFormBrowserInfo => ({
|
||||
ip: serverInfo?.ip ?? "",
|
||||
userAgent:
|
||||
serverInfo?.userAgent ??
|
||||
(typeof navigator === "undefined" ? "" : navigator.userAgent),
|
||||
language:
|
||||
serverInfo?.language ??
|
||||
(typeof navigator === "undefined" ? "" : navigator.language),
|
||||
referrer: serverInfo?.referrer ?? "",
|
||||
...(serverInfo?.pageUrl ? { pageUrl: serverInfo.pageUrl } : {}),
|
||||
});
|
||||
|
||||
type FormPreviewContext = {
|
||||
formId?: string;
|
||||
instances?: Instances;
|
||||
};
|
||||
|
||||
/** Identify only submission parameters available in the active ResourceForm. */
|
||||
export const getFormParameterName = (
|
||||
source: DataSource,
|
||||
{ formId, instances }: FormPreviewContext
|
||||
) => {
|
||||
if (
|
||||
source.type !== "parameter" ||
|
||||
source.scopeInstanceId === undefined ||
|
||||
(source.name !== formDataParameterName &&
|
||||
source.name !== browserInfoParameterName) ||
|
||||
(formId === undefined && instances === undefined) ||
|
||||
(formId !== undefined && source.scopeInstanceId !== formId) ||
|
||||
(instances !== undefined &&
|
||||
instances.get(source.scopeInstanceId)?.component !== "Form")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return source.name;
|
||||
};
|
||||
|
||||
/** Resolve the same public Form value for a parameter editor, Variables row, or Resource scope. */
|
||||
export const resolveFormParameterPreview = (
|
||||
source: DataSource,
|
||||
{
|
||||
selector,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
...context
|
||||
}: FormPreviewContext & {
|
||||
selector?: readonly string[];
|
||||
liveFormValues: ReadonlyMap<string, Record<string, unknown>>;
|
||||
liveBrowserInfo: ReadonlyMap<string, ManagedFormBrowserInfo>;
|
||||
}
|
||||
) => {
|
||||
const name = getFormParameterName(source, context);
|
||||
if (name === undefined) {
|
||||
return;
|
||||
}
|
||||
const formId = source.scopeInstanceId!;
|
||||
const value =
|
||||
name === formDataParameterName
|
||||
? (liveFormValues.get(getFormOccurrenceKey(selector, formId) ?? "") ??
|
||||
getFormDataPreview(formId))
|
||||
: getBrowserInfoPreview(liveBrowserInfo.get(formId));
|
||||
return { name, value: toPublicPreviewValue(value) };
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import { fetch as builderFetch } from "~/shared/fetch.client";
|
||||
import { $project } from "~/shared/sync/data-stores";
|
||||
import { draftPersistence } from "~/shared/sync/draft-persistence";
|
||||
import { submitPreviewForm } from "~/shared/preview-form-bridge";
|
||||
import { subscribePreviewFormRequests } from "~/shared/preview-form-parent";
|
||||
import { $previewFormExchanges } from "~/shared/preview-form-inspection";
|
||||
import {
|
||||
$resourcePreviewExchanges,
|
||||
recordResourcePreviewExchange,
|
||||
} from "~/shared/preview-resource-inspection";
|
||||
import { FormResourcePreview } from "./variable-editors/form-resource-preview";
|
||||
|
||||
const listeners = vi.hoisted(
|
||||
() => new Map<string, Set<(payload: unknown) => void>>()
|
||||
);
|
||||
vi.mock("~/shared/pubsub", () => ({
|
||||
publish: ({ type, payload }: { type: string; payload?: unknown }) => {
|
||||
for (const listener of listeners.get(type) ?? []) {
|
||||
listener(structuredClone(payload));
|
||||
}
|
||||
},
|
||||
subscribe: (type: string, listener: (payload: unknown) => void) => {
|
||||
const handlers = listeners.get(type) ?? new Set();
|
||||
handlers.add(listener);
|
||||
listeners.set(type, handlers);
|
||||
return () => handlers.delete(listener);
|
||||
},
|
||||
}));
|
||||
vi.mock("@webstudio-is/protocol", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@webstudio-is/protocol")>()),
|
||||
parseBuilderUrl: () => ({ projectId: "project" }),
|
||||
}));
|
||||
vi.mock("~/shared/fetch.client", () => ({ fetch: vi.fn() }));
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
draftPersistence.reset();
|
||||
$project.set(undefined);
|
||||
listeners.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("Preview email response survives metadata refresh and editor reopening, but clears on project switch", async () => {
|
||||
$project.set({ id: "project", title: "Before" } as never);
|
||||
draftPersistence.reset("project");
|
||||
const exchange = {
|
||||
resourceId: "email-resource",
|
||||
resourceName: "Email",
|
||||
kind: "email" as const,
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://webstudio-email-service.wstd.workers.dev/trpc/email.send",
|
||||
headers: [{ name: "authorization", value: "[redacted]" }],
|
||||
body: { subject: "Contact", text: "Submitted message" },
|
||||
truncated: false,
|
||||
},
|
||||
response: {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [{ name: "x-request-id", value: "email-service-request" }],
|
||||
body: { id: "captured-email-service-response" },
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(builderFetch).mockResolvedValue(
|
||||
Response.json({
|
||||
success: true,
|
||||
status: 200,
|
||||
results: [
|
||||
{
|
||||
resourceId: "email-resource",
|
||||
status: 200,
|
||||
body: { id: "public-form-result" },
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
previewExchanges: [exchange],
|
||||
})
|
||||
);
|
||||
const publish = vi.fn(
|
||||
({ type, payload }: { type: string; payload?: unknown }) => {
|
||||
for (const listener of listeners.get(type) ?? []) {
|
||||
listener(structuredClone(payload));
|
||||
}
|
||||
}
|
||||
);
|
||||
const unsubscribe = subscribePreviewFormRequests(publish);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const editor = (
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variable={{
|
||||
id: "email-variable",
|
||||
type: "resource",
|
||||
resourceId: "email-resource",
|
||||
name: "Email",
|
||||
}}
|
||||
variableValue={undefined}
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
emptyPreviewMessage="Submit this Form in Preview to see the email service response."
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
const openResponse = async () => {
|
||||
const tab = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Response")!;
|
||||
await act(async () => {
|
||||
tab.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
tab.click();
|
||||
});
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(editor));
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll('[role="tab"]'),
|
||||
(tab) => tab.textContent
|
||||
)
|
||||
).toEqual(["Request", "Response", "Diagnostics"]);
|
||||
expect(
|
||||
container.querySelector('[role="tab"][data-state="active"]')?.textContent
|
||||
).toBe("Request");
|
||||
await openResponse();
|
||||
expect(container.textContent).toContain("Submit this Form in Preview");
|
||||
await act(async () => {
|
||||
const result = await submitPreviewForm({
|
||||
values: { message: "Submitted message" },
|
||||
managedFormId: "form",
|
||||
path: "/contact",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result).not.toHaveProperty("previewExchanges");
|
||||
});
|
||||
expect($previewFormExchanges.get().get("email-resource")?.attempts).toEqual(
|
||||
[exchange]
|
||||
);
|
||||
const activePanel = () =>
|
||||
container.querySelector('[role="tabpanel"][data-state="active"]');
|
||||
await expect
|
||||
.poll(() => activePanel()?.textContent)
|
||||
.toContain("captured-email-service-response");
|
||||
expect(activePanel()?.textContent).toContain("email-service-request");
|
||||
expect(activePanel()?.textContent).not.toContain("public-form-result");
|
||||
expect(activePanel()?.textContent).not.toContain(
|
||||
"Submit this Form in Preview"
|
||||
);
|
||||
|
||||
recordResourcePreviewExchange("explicit-preview", exchange);
|
||||
await act(async () => {
|
||||
$project.set({ id: "project", title: "Updated metadata" } as never);
|
||||
root.render(null);
|
||||
});
|
||||
expect($resourcePreviewExchanges.get().has("explicit-preview")).toBe(true);
|
||||
await act(async () => root.render(editor));
|
||||
await openResponse();
|
||||
await expect
|
||||
.poll(() => activePanel()?.textContent)
|
||||
.toContain("captured-email-service-response");
|
||||
expect(builderFetch).toHaveBeenCalledOnce();
|
||||
await act(async () => $project.set({ id: "another-project" } as never));
|
||||
expect($previewFormExchanges.get().size).toBe(0);
|
||||
expect($resourcePreviewExchanges.get().size).toBe(0);
|
||||
expect(container.textContent).toContain("Submit this Form in Preview");
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
@@ -150,12 +150,14 @@ export const FieldLabel = ({
|
||||
description,
|
||||
resettable = false,
|
||||
resetDisabled = false,
|
||||
resetLabel,
|
||||
onReset,
|
||||
children,
|
||||
}: {
|
||||
description?: string | ReactNode;
|
||||
resettable?: boolean;
|
||||
resetDisabled?: boolean;
|
||||
resetLabel?: string;
|
||||
onReset?: () => void;
|
||||
children: string;
|
||||
}) => (
|
||||
@@ -163,6 +165,7 @@ export const FieldLabel = ({
|
||||
color={resettable ? "local" : "default"}
|
||||
onReset={resettable ? onReset : undefined}
|
||||
resetDisabled={resetDisabled}
|
||||
resetLabel={resetLabel}
|
||||
description={
|
||||
typeof description === "string" ? (
|
||||
<Text
|
||||
|
||||
+6
-6
@@ -13,9 +13,9 @@ import {
|
||||
SectionTitleButton,
|
||||
SectionTitleLabel,
|
||||
FloatingPanel,
|
||||
CssValueListItem,
|
||||
InteractiveListItem,
|
||||
SmallIconButton,
|
||||
CssValueListArrowFocus,
|
||||
InteractiveListArrowFocus,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { MinusIcon, PlusIcon } from "@webstudio-is/icons";
|
||||
import type { AnimationKeyframe } from "@webstudio-is/sdk";
|
||||
@@ -225,7 +225,7 @@ const Keyframe = ({
|
||||
</Grid>
|
||||
}
|
||||
>
|
||||
<CssValueListItem
|
||||
<InteractiveListItem
|
||||
id={offsetPlaceholder.toString()}
|
||||
index={index}
|
||||
label={
|
||||
@@ -245,7 +245,7 @@ const Keyframe = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
></CssValueListItem>
|
||||
></InteractiveListItem>
|
||||
</FloatingPanel>
|
||||
);
|
||||
};
|
||||
@@ -292,7 +292,7 @@ export const Keyframes = ({
|
||||
>
|
||||
<SectionTitleLabel>Keyframes</SectionTitleLabel>
|
||||
</SectionTitle>
|
||||
<CssValueListArrowFocus>
|
||||
<InteractiveListArrowFocus>
|
||||
{keyframes.map((value, index) => (
|
||||
<Fragment key={keyRefs.current[index]}>
|
||||
<Keyframe
|
||||
@@ -327,7 +327,7 @@ export const Keyframes = ({
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -7,14 +7,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
rawTheme,
|
||||
CssValueListItem,
|
||||
InteractiveListItem,
|
||||
SmallToggleButton,
|
||||
SmallIconButton,
|
||||
Box,
|
||||
Label,
|
||||
Grid,
|
||||
useSortable,
|
||||
CssValueListArrowFocus,
|
||||
InteractiveListArrowFocus,
|
||||
FloatingPanel,
|
||||
DialogTitle,
|
||||
Tooltip,
|
||||
@@ -297,7 +297,7 @@ export const AnimationsSelect = ({
|
||||
</SectionTitle>
|
||||
}
|
||||
>
|
||||
<CssValueListArrowFocus dragItemId={dragItemId}>
|
||||
<InteractiveListArrowFocus dragItemId={dragItemId}>
|
||||
<Grid ref={sortableRefCallback}>
|
||||
{value.animations.map((animation, index) => {
|
||||
const isEnabled = isAnimationEnabled(animation.enabled) ?? true;
|
||||
@@ -333,7 +333,7 @@ export const AnimationsSelect = ({
|
||||
}
|
||||
offset={floatingPanelOffset}
|
||||
>
|
||||
<CssValueListItem
|
||||
<InteractiveListItem
|
||||
key={index}
|
||||
{...{ [copyAttribute]: index }}
|
||||
label={
|
||||
@@ -413,7 +413,7 @@ export const AnimationsSelect = ({
|
||||
})}
|
||||
{placementIndicator}
|
||||
</Grid>
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
</CollapsibleSectionRoot>
|
||||
</AnimationContextMenu>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,20 @@ const {
|
||||
shouldWriteBoundValue,
|
||||
} = __testing__;
|
||||
|
||||
test("Form and generic components render their property sections", () => {
|
||||
for (const component of ["Form", "Box"]) {
|
||||
expect(
|
||||
shouldRenderPropsSectionContainer({
|
||||
component,
|
||||
propsMetasSize: 1,
|
||||
hasVisibleProps: false,
|
||||
isContentMode: false,
|
||||
isDesignMode: true,
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("finds a legacy React-named expression by its standard attribute name", () => {
|
||||
const className: Prop = {
|
||||
id: "class-name",
|
||||
|
||||
+15
-1
@@ -137,11 +137,12 @@ describe("isPropVisibleInContentMode", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("shows unset file controls as content-editable asset props", () => {
|
||||
test("uses metadata to expose unset file controls in Content mode", () => {
|
||||
const fileMeta: PropMeta = {
|
||||
type: "string",
|
||||
control: "file",
|
||||
required: false,
|
||||
contentMode: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
@@ -154,6 +155,19 @@ describe("isPropVisibleInContentMode", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not expose a generic file control without Content mode metadata", () => {
|
||||
expect(
|
||||
isPropVisibleInContentMode(
|
||||
getInput({
|
||||
propName: "upload",
|
||||
propsMetas: new Map([
|
||||
["upload", { type: "string", control: "file", required: false }],
|
||||
]),
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps explicitly Design-mode file controls out of Content mode", () => {
|
||||
const fileMeta: PropMeta = {
|
||||
type: "string",
|
||||
|
||||
@@ -105,9 +105,6 @@ const isPropVisibleInContentMode = ({
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (propMeta?.type === "string" && propMeta.control === "file") {
|
||||
return true;
|
||||
}
|
||||
return propMeta?.contentMode === true;
|
||||
};
|
||||
|
||||
@@ -194,7 +191,6 @@ export const usePropsLogic = ({
|
||||
styleSources,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* In content edit mode we show only props marked with contentMode: true
|
||||
* In the future I hope the only thing we will show will be Components
|
||||
|
||||
@@ -168,3 +168,50 @@ test("keeps preview first when no query editor is available", () => {
|
||||
]);
|
||||
expect(tabs[0].getAttribute("data-state")).toBe("active");
|
||||
});
|
||||
|
||||
test("HTTP response tab names its response and loading state", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<RequestInspector
|
||||
previewLabel="Response"
|
||||
preview={<div>Response body</div>}
|
||||
previewPending
|
||||
/>
|
||||
)
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll('[role="tab"]'),
|
||||
(tab) => tab.textContent
|
||||
)
|
||||
).toEqual(["Response", "Diagnostics"]);
|
||||
expect(
|
||||
container.querySelector('[role="status"]')?.getAttribute("aria-label")
|
||||
).toBe("Loading response…");
|
||||
});
|
||||
|
||||
test("shows Request before Response and opens on Request", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() =>
|
||||
root?.render(
|
||||
<RequestInspector
|
||||
previewLabel="Response"
|
||||
request={<div>Request details</div>}
|
||||
preview={<div>Response details</div>}
|
||||
/>
|
||||
)
|
||||
);
|
||||
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
|
||||
|
||||
expect(tabs.map(({ textContent }) => textContent)).toEqual([
|
||||
"Request",
|
||||
"Response",
|
||||
"Diagnostics",
|
||||
]);
|
||||
expect(tabs[0].getAttribute("data-state")).toBe("active");
|
||||
});
|
||||
@@ -218,22 +218,34 @@ export const RequestInspector = ({
|
||||
queryContainerRef,
|
||||
preview,
|
||||
diagnostics,
|
||||
request,
|
||||
previewLabel = "Preview",
|
||||
queryPending = false,
|
||||
previewPending = false,
|
||||
requestPending = false,
|
||||
diagnosticsPending = false,
|
||||
onDiagnosticsOpen,
|
||||
}: {
|
||||
queryContainerRef?: Ref<HTMLDivElement>;
|
||||
preview: ReactNode;
|
||||
diagnostics?: ReactNode;
|
||||
request?: ReactNode;
|
||||
previewLabel?: "Preview" | "Response";
|
||||
queryPending?: boolean;
|
||||
previewPending?: boolean;
|
||||
requestPending?: boolean;
|
||||
diagnosticsPending?: boolean;
|
||||
onDiagnosticsOpen?: () => void;
|
||||
}) => (
|
||||
<PanelTabs
|
||||
key={queryContainerRef === undefined ? "preview" : "query"}
|
||||
defaultValue={queryContainerRef === undefined ? "preview" : "query"}
|
||||
defaultValue={
|
||||
queryContainerRef !== undefined
|
||||
? "query"
|
||||
: request !== undefined
|
||||
? "request"
|
||||
: "preview"
|
||||
}
|
||||
css={{ height: "100%", minWidth: 0, overflow: "hidden" }}
|
||||
>
|
||||
<PanelTabsList
|
||||
@@ -246,11 +258,24 @@ export const RequestInspector = ({
|
||||
{queryContainerRef !== undefined && (
|
||||
<PanelTabsTrigger value="query">Query</PanelTabsTrigger>
|
||||
)}
|
||||
<PanelTabsTrigger value="preview">Preview</PanelTabsTrigger>
|
||||
{request !== undefined && (
|
||||
<PanelTabsTrigger value="request">Request</PanelTabsTrigger>
|
||||
)}
|
||||
<PanelTabsTrigger value="preview">{previewLabel}</PanelTabsTrigger>
|
||||
<PanelTabsTrigger value="diagnostics" onClick={onDiagnosticsOpen}>
|
||||
Diagnostics
|
||||
</PanelTabsTrigger>
|
||||
</PanelTabsList>
|
||||
{request !== undefined && (
|
||||
<PanelTabsContent
|
||||
value="request"
|
||||
aria-busy={requestPending}
|
||||
css={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
{request}
|
||||
{requestPending && <RequestInspectorLoading label="Loading request…" />}
|
||||
</PanelTabsContent>
|
||||
)}
|
||||
{queryContainerRef !== undefined && (
|
||||
<PanelTabsContent
|
||||
value="query"
|
||||
@@ -276,7 +301,15 @@ export const RequestInspector = ({
|
||||
css={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
{preview}
|
||||
{previewPending && <RequestInspectorLoading label="Loading preview…" />}
|
||||
{previewPending && (
|
||||
<RequestInspectorLoading
|
||||
label={
|
||||
previewLabel === "Response"
|
||||
? "Loading response…"
|
||||
: "Loading preview…"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PanelTabsContent>
|
||||
<PanelTabsContent
|
||||
value="diagnostics"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { encodeDataVariableId } from "@webstudio-is/sdk";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import { computeExpressionWithinScope } from "@webstudio-is/project-build/runtime";
|
||||
import { __testing__ } from "./variable-editors/shared/resource-fields";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root | undefined;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
const setup = () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
return container;
|
||||
};
|
||||
|
||||
test("bound body content type follows its value type while keeping the code editor", async () => {
|
||||
const container = setup();
|
||||
const render = (scope: Record<string, unknown>) =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.BodyField
|
||||
scope={scope}
|
||||
aliases={new Map()}
|
||||
bodyFormat="auto"
|
||||
value={encodeDataVariableId("payload")}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
await act(async () =>
|
||||
render({ [encodeDataVariableId("payload")]: "plain text" })
|
||||
);
|
||||
await expect
|
||||
.poll(() => container.querySelector('input[name="header-value"]'))
|
||||
.not.toBeNull();
|
||||
expect(container.querySelector(".cm-editor")).not.toBeNull();
|
||||
const expression = container.querySelector<HTMLInputElement>(
|
||||
'input[name="header-value"]'
|
||||
)!.value;
|
||||
expect(
|
||||
await computeExpressionWithinScope(expression, {
|
||||
[encodeDataVariableId("payload")]: "plain text",
|
||||
})
|
||||
).toBe("text/plain");
|
||||
expect(
|
||||
await computeExpressionWithinScope(expression, {
|
||||
[encodeDataVariableId("payload")]: { field: "value" },
|
||||
})
|
||||
).toBe("application/json");
|
||||
await act(async () =>
|
||||
render({ [encodeDataVariableId("payload")]: { field: "value" } })
|
||||
);
|
||||
await expect.poll(() => container.querySelector(".cm-editor")).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector<HTMLTextAreaElement>('textarea[name="body"]')
|
||||
?.validationMessage
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("invalid JSON edits retain the JSON editor and recover validation when fixed", async () => {
|
||||
const container = setup();
|
||||
const render = (value: string) =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.BodyField
|
||||
scope={{}}
|
||||
aliases={new Map()}
|
||||
bodyFormat="auto"
|
||||
value={value}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
await act(async () => render("{ field: 1 }"));
|
||||
expect(container.querySelector(".cm-editor")).not.toBeNull();
|
||||
await act(async () => render("{ field:"));
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
container.querySelector<HTMLTextAreaElement>('textarea[name="body"]')
|
||||
?.validationMessage
|
||||
)
|
||||
.toBe("Expected valid JSON object in body");
|
||||
expect(container.querySelector(".cm-editor")).not.toBeNull();
|
||||
await act(async () => render("{ field: 2 }"));
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
container.querySelector<HTMLTextAreaElement>('textarea[name="body"]')
|
||||
?.validationMessage
|
||||
)
|
||||
.toBe("");
|
||||
expect(container.querySelector(".cm-editor")).not.toBeNull();
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,281 @@
|
||||
import { useMemo } from "react";
|
||||
import { computed } from "nanostores";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
areMapsShallowEqual,
|
||||
areSetsEqual,
|
||||
} from "~/shared/collection-equality";
|
||||
import {
|
||||
encodeDataVariableId,
|
||||
getResourceCycleDataSourceIds,
|
||||
SYSTEM_VARIABLE_ID,
|
||||
systemParameter,
|
||||
type DataSource,
|
||||
type DataSources,
|
||||
type Page,
|
||||
type PageTemplate,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
$livePreviewFormValues,
|
||||
$livePreviewBrowserInfo,
|
||||
} from "~/shared/preview-form-values";
|
||||
import {
|
||||
getFormParameterName,
|
||||
resolveFormParameterPreview,
|
||||
} from "./form-context-preview";
|
||||
import {
|
||||
$selectedInstancePathWithRoot,
|
||||
$selectedPage,
|
||||
$variableValuesByInstanceSelector,
|
||||
getInstanceKey,
|
||||
} from "~/shared/nano-states";
|
||||
import { $dataSources, $resources } from "~/shared/sync/data-stores";
|
||||
import type { InstancePath } from "@webstudio-is/project-build/runtime";
|
||||
|
||||
export const getResourceScopeForInstance = ({
|
||||
page,
|
||||
instanceKey,
|
||||
dataSources,
|
||||
variableValuesByInstanceSelector,
|
||||
includeResourceDataSources = false,
|
||||
formScopeInstanceId,
|
||||
formScopeSelector,
|
||||
liveFormValues = new Map(),
|
||||
liveBrowserInfo = new Map(),
|
||||
}: {
|
||||
page: undefined | Page | PageTemplate;
|
||||
instanceKey: undefined | string;
|
||||
dataSources: DataSources;
|
||||
variableValuesByInstanceSelector: Map<string, Map<string, unknown>>;
|
||||
includeResourceDataSources?: boolean;
|
||||
formScopeInstanceId?: string;
|
||||
formScopeSelector?: readonly string[];
|
||||
liveFormValues?: ReadonlyMap<string, Record<string, unknown>>;
|
||||
liveBrowserInfo?: ReadonlyMap<
|
||||
string,
|
||||
import("@webstudio-is/sdk/runtime").ManagedFormBrowserInfo
|
||||
>;
|
||||
}) => {
|
||||
const scope: Record<string, unknown> = {};
|
||||
const aliases = new Map<string, string>();
|
||||
const variableValues = new Map<DataSource["id"], unknown>();
|
||||
const hiddenDataSourceIds = new Set<DataSource["id"]>();
|
||||
for (const dataSource of dataSources.values()) {
|
||||
// Hide collection/component parameters from resource expressions. They are
|
||||
// internal scoped runtime values, and exposing them here would invite
|
||||
// request waterfalls/loops and complicate generated resource code.
|
||||
if (
|
||||
dataSource.type === "parameter" &&
|
||||
getFormParameterName(dataSource, { formId: formScopeInstanceId }) ===
|
||||
undefined
|
||||
) {
|
||||
hiddenDataSourceIds.add(dataSource.id);
|
||||
}
|
||||
if (
|
||||
dataSource.type === "resource" &&
|
||||
includeResourceDataSources === false
|
||||
) {
|
||||
hiddenDataSourceIds.add(dataSource.id);
|
||||
}
|
||||
}
|
||||
if (page?.systemDataSourceId) {
|
||||
hiddenDataSourceIds.delete(page.systemDataSourceId);
|
||||
}
|
||||
if (formScopeInstanceId) {
|
||||
for (const dataSource of dataSources.values()) {
|
||||
const preview = resolveFormParameterPreview(dataSource, {
|
||||
formId: formScopeInstanceId,
|
||||
selector: formScopeSelector,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
});
|
||||
if (preview === undefined) {
|
||||
continue;
|
||||
}
|
||||
const name = encodeDataVariableId(dataSource.id);
|
||||
scope[name] = preview.value;
|
||||
aliases.set(name, dataSource.name);
|
||||
variableValues.set(dataSource.id, preview.value);
|
||||
}
|
||||
}
|
||||
const values = variableValuesByInstanceSelector.get(instanceKey ?? "");
|
||||
if (values) {
|
||||
for (const [dataSourceId, value] of values) {
|
||||
if (hiddenDataSourceIds.has(dataSourceId)) {
|
||||
continue;
|
||||
}
|
||||
let dataSource = dataSources.get(dataSourceId);
|
||||
if (dataSourceId === SYSTEM_VARIABLE_ID) {
|
||||
dataSource = systemParameter;
|
||||
}
|
||||
if (dataSource) {
|
||||
if (
|
||||
getFormParameterName(dataSource, { formId: formScopeInstanceId }) !==
|
||||
undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const name = encodeDataVariableId(dataSourceId);
|
||||
scope[name] = value;
|
||||
aliases.set(name, dataSource.name);
|
||||
variableValues.set(dataSourceId, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { variableValues, scope, aliases };
|
||||
};
|
||||
|
||||
const getVariableInstanceKey = ({
|
||||
variable,
|
||||
instancePath,
|
||||
}: {
|
||||
variable: undefined | DataSource;
|
||||
instancePath: undefined | InstancePath;
|
||||
}) => {
|
||||
if (instancePath === undefined) {
|
||||
return;
|
||||
}
|
||||
// find instance key for variable instance
|
||||
for (const { instance, instanceSelector } of instancePath) {
|
||||
if (instance.id === variable?.scopeInstanceId) {
|
||||
return getInstanceKey(instanceSelector);
|
||||
}
|
||||
}
|
||||
// and fallback to currently selected instance
|
||||
return getInstanceKey(instancePath[0].instanceSelector);
|
||||
};
|
||||
|
||||
const createResourceScopeStore = (variable: DataSource | undefined) => {
|
||||
let cachedBaseInputs: readonly unknown[] | undefined;
|
||||
let cachedBaseValues: Map<string, unknown> | undefined;
|
||||
let cachedBaseResult:
|
||||
| ReturnType<typeof getResourceScopeForInstance>
|
||||
| undefined;
|
||||
let cachedCycleDataSourceIds: Set<DataSource["id"]> | undefined;
|
||||
let cachedResult:
|
||||
| {
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
variableValues: Map<DataSource["id"], unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return computed(
|
||||
[
|
||||
$selectedPage,
|
||||
$selectedInstancePathWithRoot,
|
||||
$variableValuesByInstanceSelector,
|
||||
$dataSources,
|
||||
$resources,
|
||||
$livePreviewFormValues,
|
||||
$livePreviewBrowserInfo,
|
||||
],
|
||||
(
|
||||
page,
|
||||
instancePath,
|
||||
variableValuesByInstanceSelector,
|
||||
dataSources,
|
||||
resources,
|
||||
liveFormValues,
|
||||
liveBrowserInfo
|
||||
) => {
|
||||
const variablePathIndex =
|
||||
variable === undefined
|
||||
? 0
|
||||
: (instancePath?.findIndex(
|
||||
({ instance }) => instance.id === variable.scopeInstanceId
|
||||
) ?? -1);
|
||||
const formScopeInstanceId =
|
||||
variablePathIndex < 0
|
||||
? undefined
|
||||
: instancePath
|
||||
?.slice(variablePathIndex)
|
||||
.find(({ instance }) => instance.component === "Form")?.instance
|
||||
.id;
|
||||
const formScopeSelector =
|
||||
formScopeInstanceId === undefined
|
||||
? undefined
|
||||
: instancePath?.find(
|
||||
({ instance }) => instance.id === formScopeInstanceId
|
||||
)?.instanceSelector;
|
||||
const instanceKey = getVariableInstanceKey({
|
||||
variable,
|
||||
instancePath,
|
||||
});
|
||||
const values = variableValuesByInstanceSelector.get(instanceKey ?? "");
|
||||
const currentBaseInputs = [
|
||||
page,
|
||||
instancePath,
|
||||
dataSources,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
] as const;
|
||||
const baseInputsMatch =
|
||||
cachedBaseInputs !== undefined &&
|
||||
cachedBaseInputs[0] === page &&
|
||||
cachedBaseInputs[1] === instancePath &&
|
||||
cachedBaseInputs[2] === dataSources &&
|
||||
cachedBaseInputs[3] === liveFormValues &&
|
||||
cachedBaseInputs[4] === liveBrowserInfo &&
|
||||
areMapsShallowEqual(cachedBaseValues, values);
|
||||
if (baseInputsMatch === false) {
|
||||
cachedBaseInputs = currentBaseInputs;
|
||||
cachedBaseValues = values;
|
||||
cachedBaseResult = getResourceScopeForInstance({
|
||||
page,
|
||||
instanceKey,
|
||||
dataSources,
|
||||
variableValuesByInstanceSelector,
|
||||
includeResourceDataSources: true,
|
||||
formScopeInstanceId,
|
||||
formScopeSelector,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
});
|
||||
}
|
||||
const cycleDataSourceIds = new Set(
|
||||
variable === undefined
|
||||
? []
|
||||
: variable.type === "resource"
|
||||
? getResourceCycleDataSourceIds({
|
||||
resourceDataSource: variable,
|
||||
resources,
|
||||
dataSources,
|
||||
})
|
||||
: [variable.id]
|
||||
);
|
||||
if (
|
||||
cachedResult !== undefined &&
|
||||
baseInputsMatch &&
|
||||
cachedCycleDataSourceIds !== undefined &&
|
||||
areSetsEqual(cachedCycleDataSourceIds, cycleDataSourceIds)
|
||||
) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const { scope, aliases, variableValues } = cachedBaseResult!;
|
||||
const newScope = { ...scope };
|
||||
const newAliases = new Map(aliases);
|
||||
const newVariableValues = new Map(variableValues);
|
||||
for (const dataSourceId of cycleDataSourceIds) {
|
||||
const key = encodeDataVariableId(dataSourceId);
|
||||
delete newScope[key];
|
||||
newAliases.delete(key);
|
||||
newVariableValues.delete(dataSourceId);
|
||||
}
|
||||
const result = {
|
||||
scope: newScope,
|
||||
aliases: newAliases,
|
||||
variableValues: newVariableValues,
|
||||
};
|
||||
cachedCycleDataSourceIds = cycleDataSourceIds;
|
||||
cachedResult = result;
|
||||
return result;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useResourceScope = ({ variable }: { variable?: DataSource }) => {
|
||||
const store = useMemo(() => createResourceScopeStore(variable), [variable]);
|
||||
return useStore(store);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { coreMetas, type WsComponentMeta } from "@webstudio-is/sdk";
|
||||
import { showAttribute } from "@webstudio-is/react-sdk";
|
||||
import { $selectedInstancePropsMetas } from "./shared";
|
||||
import {
|
||||
$registeredComponentMetas,
|
||||
selectInstance,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances, $props } from "~/shared/sync/data-stores";
|
||||
|
||||
afterEach(() => {
|
||||
selectInstance(undefined);
|
||||
$instances.set(new Map());
|
||||
$props.set(new Map());
|
||||
$registeredComponentMetas.set(new Map());
|
||||
});
|
||||
|
||||
test("does not expose rendered form attributes for the managed Form", () => {
|
||||
const form = {
|
||||
type: "instance" as const,
|
||||
id: "form",
|
||||
component: "Form",
|
||||
tag: "form",
|
||||
children: [],
|
||||
};
|
||||
$instances.set(new Map([[form.id, form]]));
|
||||
$props.set(new Map());
|
||||
const nativeFormMeta: WsComponentMeta = {
|
||||
label: "Form",
|
||||
htmlAttributes: "hide",
|
||||
props: {
|
||||
action: {
|
||||
type: "json",
|
||||
control: "form-action",
|
||||
required: false,
|
||||
},
|
||||
successRedirect: { type: "string", control: "url", required: false },
|
||||
},
|
||||
};
|
||||
$registeredComponentMetas.set(
|
||||
new Map([...Object.entries(coreMetas), ["Form", nativeFormMeta]])
|
||||
);
|
||||
selectInstance([form.id]);
|
||||
|
||||
const propsMetas = $selectedInstancePropsMetas.get();
|
||||
|
||||
expect(propsMetas.has("method")).toBe(false);
|
||||
expect(propsMetas.has("enctype")).toBe(false);
|
||||
expect(propsMetas.has("target")).toBe(false);
|
||||
expect(propsMetas.has("id")).toBe(false);
|
||||
expect(propsMetas.has("aria-label")).toBe(false);
|
||||
expect(propsMetas.has("action")).toBe(true);
|
||||
expect(propsMetas.has("successRedirect")).toBe(true);
|
||||
expect(propsMetas.has(showAttribute)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps rendered form attributes for components without the opt-out", () => {
|
||||
const form = {
|
||||
type: "instance" as const,
|
||||
id: "form",
|
||||
component: "Form",
|
||||
tag: "form",
|
||||
children: [],
|
||||
};
|
||||
$instances.set(new Map([[form.id, form]]));
|
||||
$props.set(new Map());
|
||||
$registeredComponentMetas.set(
|
||||
new Map([
|
||||
...Object.entries(coreMetas),
|
||||
[
|
||||
"Form",
|
||||
{
|
||||
label: "Form",
|
||||
props: {},
|
||||
} satisfies WsComponentMeta,
|
||||
],
|
||||
])
|
||||
);
|
||||
selectInstance([form.id]);
|
||||
|
||||
const propsMetas = $selectedInstancePropsMetas.get();
|
||||
|
||||
expect(propsMetas.has("method")).toBe(true);
|
||||
expect(propsMetas.has("enctype")).toBe(true);
|
||||
expect(propsMetas.has("id")).toBe(true);
|
||||
expect(propsMetas.has("aria-label")).toBe(true);
|
||||
});
|
||||
@@ -24,15 +24,14 @@ import {
|
||||
} from "@webstudio-is/sdk";
|
||||
import { getContentModePropNamesByTag } from "@webstudio-is/project-build/runtime";
|
||||
import type { PropMeta, Prop, Asset } from "@webstudio-is/sdk";
|
||||
import { InfoCircleIcon } from "@webstudio-is/icons";
|
||||
import {
|
||||
cssVar,
|
||||
Label as BaseLabel,
|
||||
useIsTruncated,
|
||||
Tooltip,
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
InfoTooltip,
|
||||
Text,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
@@ -145,17 +144,10 @@ export const Label = ({
|
||||
<Flex align="center" css={{ gap: theme.spacing[3], width: "100%" }}>
|
||||
<Box>{label}</Box>
|
||||
{readOnly && (
|
||||
<Tooltip
|
||||
content={
|
||||
"The value is controlled by an expression and cannot be changed."
|
||||
}
|
||||
variant="wrapped"
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Tooltip>
|
||||
<InfoTooltip
|
||||
label="About expression controlled value"
|
||||
content="The value is controlled by an expression and cannot be changed."
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
@@ -339,6 +331,30 @@ const $contentModePropNamesByTag = computed(
|
||||
getContentModePropNamesByTag
|
||||
);
|
||||
|
||||
const getInitialPropNames = (
|
||||
selectedInstance: ReturnType<typeof $selectedInstance.get>,
|
||||
metas: ReturnType<typeof $registeredComponentMetas.get>,
|
||||
instancePropsMetas: Map<string, PropMeta>
|
||||
) => {
|
||||
const initialPropNames = new Set<string>();
|
||||
if (selectedInstance) {
|
||||
const initialProps =
|
||||
metas.get(selectedInstance.component)?.initialProps ?? [];
|
||||
for (const propName of initialProps) {
|
||||
const htmlName = reactPropsToStandardAttributes[propName];
|
||||
initialPropNames.add(
|
||||
htmlName && instancePropsMetas.has(htmlName) ? htmlName : propName
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [propName, propMeta] of instancePropsMetas) {
|
||||
if (propName !== showAttribute && propMeta.required) {
|
||||
initialPropNames.add(propName);
|
||||
}
|
||||
}
|
||||
return initialPropNames;
|
||||
};
|
||||
|
||||
export const $selectedInstancePropsMetas = computed(
|
||||
[
|
||||
$selectedInstance,
|
||||
@@ -368,7 +384,7 @@ export const $selectedInstancePropsMetas = computed(
|
||||
return propMeta;
|
||||
};
|
||||
// add html attributes only when instance has tag
|
||||
if (tag) {
|
||||
if (tag && meta?.htmlAttributes !== "hide") {
|
||||
if (elementsByTag[tag].categories.includes("html-element")) {
|
||||
for (const attribute of [...ariaAttributes].reverse()) {
|
||||
propsMetas.set(attribute.name, toAttributeMeta(attribute));
|
||||
@@ -411,29 +427,5 @@ export const $selectedInstancePropsMetas = computed(
|
||||
|
||||
export const $selectedInstanceInitialPropNames = computed(
|
||||
[$selectedInstance, $registeredComponentMetas, $selectedInstancePropsMetas],
|
||||
(selectedInstance, metas, instancePropsMetas) => {
|
||||
const initialPropNames = new Set<string>();
|
||||
if (selectedInstance) {
|
||||
const initialProps =
|
||||
metas.get(selectedInstance.component)?.initialProps ?? [];
|
||||
for (const propName of initialProps) {
|
||||
// className -> class
|
||||
if (instancePropsMetas.has(reactPropsToStandardAttributes[propName])) {
|
||||
initialPropNames.add(reactPropsToStandardAttributes[propName]);
|
||||
} else {
|
||||
initialPropNames.add(propName);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [propName, propMeta] of instancePropsMetas) {
|
||||
// skip show attribute which is added as system prop
|
||||
if (propName === showAttribute) {
|
||||
continue;
|
||||
}
|
||||
if (propMeta.required) {
|
||||
initialPropNames.add(propName);
|
||||
}
|
||||
}
|
||||
return initialPropNames;
|
||||
}
|
||||
getInitialPropNames
|
||||
);
|
||||
@@ -0,0 +1,201 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
encodeDataVariableId,
|
||||
type DataSource,
|
||||
type Resource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { serializeVariable, deserializeVariable } from "./variable-clipboard";
|
||||
|
||||
const variable: DataSource = {
|
||||
id: "source",
|
||||
type: "resource",
|
||||
name: "Request",
|
||||
scopeInstanceId: "old",
|
||||
resourceId: "request",
|
||||
};
|
||||
const dependency: DataSource = {
|
||||
id: "input",
|
||||
type: "variable",
|
||||
name: "Input",
|
||||
scopeInstanceId: "old",
|
||||
value: { type: "string", value: "hello" },
|
||||
};
|
||||
const resource: Resource = {
|
||||
id: "request",
|
||||
name: "request",
|
||||
method: "post",
|
||||
url: `${encodeDataVariableId("input")}.url`,
|
||||
headers: [{ name: "X-Test", value: encodeDataVariableId("source") }],
|
||||
body: encodeDataVariableId("input"),
|
||||
email: { subject: encodeDataVariableId("input") },
|
||||
};
|
||||
const serialize = () =>
|
||||
serializeVariable(
|
||||
variable,
|
||||
new Map([[resource.id, resource]]),
|
||||
new Map([[dependency.id, dependency]])
|
||||
);
|
||||
|
||||
test("resource clipboard regenerates ids and remaps dependency and self references", () => {
|
||||
const destination = { ...dependency, id: "destination" };
|
||||
const pasted = deserializeVariable(
|
||||
serialize(),
|
||||
"new",
|
||||
new Map([[destination.id, destination]])
|
||||
);
|
||||
expect(pasted.variable.id).not.toBe(variable.id);
|
||||
expect(pasted.variable.name).toBe("Request");
|
||||
expect(pasted.variable.scopeInstanceId).toBe("new");
|
||||
expect(pasted.resource?.id).not.toBe(resource.id);
|
||||
expect(pasted.resource?.url).toBe(
|
||||
`${encodeDataVariableId("destination")}.url`
|
||||
);
|
||||
expect(pasted.resource?.headers[0].value).toBe(
|
||||
encodeDataVariableId(pasted.variable.id)
|
||||
);
|
||||
expect(pasted.resource?.body).toBe(encodeDataVariableId("destination"));
|
||||
expect(pasted.resource?.email?.subject).toBe(
|
||||
encodeDataVariableId("destination")
|
||||
);
|
||||
});
|
||||
|
||||
test("unresolved references are surfaced before pasting", () => {
|
||||
expect(() => deserializeVariable(serialize(), "new", new Map())).toThrow(
|
||||
"unresolved variables: Input"
|
||||
);
|
||||
});
|
||||
|
||||
test("clipboard retains variable JSON configuration and rejects foreign versions", () => {
|
||||
const staticVariable: DataSource = {
|
||||
id: "json",
|
||||
name: "Config",
|
||||
type: "variable",
|
||||
value: { type: "json", value: { nested: [1, true] } },
|
||||
};
|
||||
const text = serializeVariable(staticVariable, new Map(), new Map());
|
||||
expect(deserializeVariable(text, "new", new Map()).variable).toMatchObject({
|
||||
name: "Config",
|
||||
value: staticVariable.value,
|
||||
});
|
||||
expect(() =>
|
||||
deserializeVariable(text.replace("v0.1", "v0.2"), "new", new Map())
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("unbound expression references are reported and quoted names stay literal", () => {
|
||||
const unbound = {
|
||||
...resource,
|
||||
url: "Missing.url",
|
||||
body: '"Missing"',
|
||||
headers: [],
|
||||
email: undefined,
|
||||
};
|
||||
const text = serializeVariable(
|
||||
variable,
|
||||
new Map([[resource.id, unbound]]),
|
||||
new Map()
|
||||
);
|
||||
expect(() => deserializeVariable(text, "new", new Map())).toThrow(
|
||||
"unresolved variables: Missing"
|
||||
);
|
||||
});
|
||||
|
||||
test("clipboard uses local reference keys without original variable, Resource, or scope IDs", () => {
|
||||
const source = {
|
||||
...variable,
|
||||
id: "original-variable-id",
|
||||
scopeInstanceId: "original-scope-id",
|
||||
resourceId: "original-resource-id",
|
||||
};
|
||||
const input = { ...dependency, id: "original-dependency-id" };
|
||||
const config = {
|
||||
...resource,
|
||||
id: "original-resource-id",
|
||||
url: `${encodeDataVariableId(input.id)}.url`,
|
||||
body: encodeDataVariableId(source.id),
|
||||
headers: [],
|
||||
email: undefined,
|
||||
};
|
||||
const text = serializeVariable(
|
||||
source,
|
||||
new Map([[config.id, config]]),
|
||||
new Map([[input.id, input]])
|
||||
);
|
||||
for (const id of [
|
||||
source.id,
|
||||
source.scopeInstanceId,
|
||||
source.resourceId,
|
||||
input.id,
|
||||
encodeDataVariableId(source.id),
|
||||
encodeDataVariableId(input.id),
|
||||
]) {
|
||||
expect(text).not.toContain(id);
|
||||
}
|
||||
const destination = { ...input, id: "new-dependency" };
|
||||
const pasted = deserializeVariable(
|
||||
text,
|
||||
"new-scope",
|
||||
new Map([[destination.id, destination]])
|
||||
);
|
||||
expect(pasted.resource?.url).toBe(
|
||||
`${encodeDataVariableId(destination.id)}.url`
|
||||
);
|
||||
expect(pasted.resource?.body).toBe(encodeDataVariableId(pasted.variable.id));
|
||||
});
|
||||
|
||||
test("Email sender and recipient bindings use clipboard reference keys and destination IDs", () => {
|
||||
const config: Resource = {
|
||||
...resource,
|
||||
url: '""',
|
||||
body: undefined,
|
||||
headers: [],
|
||||
email: {
|
||||
senderExpression: encodeDataVariableId(dependency.id),
|
||||
recipientsExpression: `${encodeDataVariableId(dependency.id)}.recipients`,
|
||||
},
|
||||
};
|
||||
const text = serializeVariable(
|
||||
variable,
|
||||
new Map([[config.id, config]]),
|
||||
new Map([[dependency.id, dependency]])
|
||||
);
|
||||
expect(text).not.toContain(encodeDataVariableId(dependency.id));
|
||||
const destination = { ...dependency, id: "destination" };
|
||||
const pasted = deserializeVariable(
|
||||
text,
|
||||
"new",
|
||||
new Map([[destination.id, destination]])
|
||||
);
|
||||
expect(pasted.resource?.email).toEqual({
|
||||
senderExpression: encodeDataVariableId(destination.id),
|
||||
recipientsExpression: `${encodeDataVariableId(destination.id)}.recipients`,
|
||||
});
|
||||
expect(config.email?.senderExpression).toBe(
|
||||
encodeDataVariableId(dependency.id)
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["senderExpression", "recipientsExpression"] as const)(
|
||||
"Email %s resolves named bindings and rejects missing names",
|
||||
(field) => {
|
||||
const config: Resource = {
|
||||
...resource,
|
||||
url: '""',
|
||||
headers: [],
|
||||
body: undefined,
|
||||
email: { [field]: "Input" },
|
||||
};
|
||||
const text = serializeVariable(
|
||||
variable,
|
||||
new Map([[config.id, config]]),
|
||||
new Map()
|
||||
);
|
||||
expect(() => deserializeVariable(text, "new", new Map())).toThrow(
|
||||
"unresolved variables: Input"
|
||||
);
|
||||
expect(
|
||||
deserializeVariable(text, "new", new Map([[dependency.id, dependency]]))
|
||||
.resource?.email?.[field]
|
||||
).toBe(encodeDataVariableId(dependency.id));
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,171 @@
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
dataSource,
|
||||
resource,
|
||||
decodeDataVariableId,
|
||||
encodeDataVariableId,
|
||||
getResourceDataSourceIds,
|
||||
type DataSource,
|
||||
type Resource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
getExpressionIdentifiers,
|
||||
transpileExpression,
|
||||
} from "@webstudio-is/expression";
|
||||
import { mapResourceExpressionsMutable } from "@webstudio-is/project-build/runtime";
|
||||
|
||||
const namespace = "@webstudio/variable/v0.1";
|
||||
const payload = z.object({
|
||||
variable: dataSource,
|
||||
resource: resource.optional(),
|
||||
references: z.array(z.object({ id: z.string(), name: z.string() })),
|
||||
});
|
||||
|
||||
export const serializeVariable = (
|
||||
variable: DataSource,
|
||||
resources: ReadonlyMap<string, Resource>,
|
||||
dataSources: ReadonlyMap<string, DataSource>
|
||||
) => {
|
||||
if (variable.type === "parameter") {
|
||||
throw Error("Context parameters cannot be copied");
|
||||
}
|
||||
const resource =
|
||||
variable.type === "resource"
|
||||
? resources.get(variable.resourceId)
|
||||
: undefined;
|
||||
if (variable.type === "resource" && resource === undefined) {
|
||||
throw Error("Resource configuration is missing");
|
||||
}
|
||||
const ids = new Map([[variable.id, "variable"]]);
|
||||
const references = [...(resource ? getResourceDataSourceIds(resource) : [])]
|
||||
.filter((id) => id !== variable.id)
|
||||
.map((id, index) => {
|
||||
const key = `reference${index}`;
|
||||
ids.set(id, key);
|
||||
return { id: key, name: dataSources.get(id)?.name ?? "" };
|
||||
});
|
||||
const remap = (expression: string) =>
|
||||
expression.trim() === ""
|
||||
? expression
|
||||
: transpileExpression({
|
||||
expression,
|
||||
replaceVariable: (identifier) => {
|
||||
const sourceId = decodeDataVariableId(identifier);
|
||||
const key = sourceId && ids.get(sourceId);
|
||||
return key ? encodeDataVariableId(key) : undefined;
|
||||
},
|
||||
});
|
||||
const copiedResource = resource && {
|
||||
...resource,
|
||||
id: "resource",
|
||||
headers: resource.headers.map((header) => ({ ...header })),
|
||||
searchParams: resource.searchParams?.map((param) => ({ ...param })),
|
||||
email: resource.email && { ...resource.email },
|
||||
};
|
||||
if (copiedResource) {
|
||||
mapResourceExpressionsMutable(copiedResource, remap);
|
||||
}
|
||||
const copiedVariable = {
|
||||
...variable,
|
||||
id: "variable",
|
||||
scopeInstanceId: undefined,
|
||||
...(variable.type === "resource" && { resourceId: "resource" }),
|
||||
};
|
||||
return JSON.stringify({
|
||||
[namespace]: {
|
||||
variable: copiedVariable,
|
||||
resource: copiedResource,
|
||||
references,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Resolve references before mutating stores so a failed paste is atomic. */
|
||||
export const deserializeVariable = (
|
||||
text: string,
|
||||
scopeInstanceId: string,
|
||||
available: ReadonlyMap<string, DataSource>
|
||||
) => {
|
||||
const parsed = z.object({ [namespace]: payload }).parse(JSON.parse(text))[
|
||||
namespace
|
||||
];
|
||||
if (parsed.variable.type === "parameter") {
|
||||
throw Error("Context parameters cannot be pasted");
|
||||
}
|
||||
const id = nanoid();
|
||||
const remappedIds = new Map([[parsed.variable.id, id]]);
|
||||
const unresolved: string[] = [];
|
||||
// Verify every actual reference, even if the clipboard omitted its metadata.
|
||||
for (const referenceId of parsed.resource
|
||||
? getResourceDataSourceIds(parsed.resource)
|
||||
: []) {
|
||||
if (remappedIds.has(referenceId)) {
|
||||
continue;
|
||||
}
|
||||
const reference = parsed.references.find(({ id }) => id === referenceId);
|
||||
const target = [...available.values()].find(
|
||||
({ name }) => name === reference?.name
|
||||
);
|
||||
if (target) {
|
||||
remappedIds.set(referenceId, target.id);
|
||||
} else {
|
||||
unresolved.push(reference?.name ?? referenceId);
|
||||
}
|
||||
}
|
||||
const remappedNames = new Map<string, string>();
|
||||
if (parsed.resource) {
|
||||
mapResourceExpressionsMutable(parsed.resource, (expression) => {
|
||||
for (const identifier of getExpressionIdentifiers(expression)) {
|
||||
if (
|
||||
decodeDataVariableId(identifier) !== undefined ||
|
||||
["undefined", "NaN", "Infinity"].includes(identifier)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const target = [...available.values()].find(
|
||||
({ name }) => name === identifier
|
||||
);
|
||||
if (target) {
|
||||
remappedNames.set(identifier, encodeDataVariableId(target.id));
|
||||
} else if (!unresolved.includes(identifier)) {
|
||||
unresolved.push(identifier);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (unresolved.length) {
|
||||
throw Error(
|
||||
`Unable to paste: unresolved variables: ${unresolved.join(", ")}`
|
||||
);
|
||||
}
|
||||
const remap = (expression: string) =>
|
||||
expression.trim() === ""
|
||||
? expression
|
||||
: transpileExpression({
|
||||
expression,
|
||||
replaceVariable: (identifier) => {
|
||||
const referenceId = decodeDataVariableId(identifier);
|
||||
const targetId = referenceId && remappedIds.get(referenceId);
|
||||
return targetId
|
||||
? encodeDataVariableId(targetId)
|
||||
: remappedNames.get(identifier);
|
||||
},
|
||||
});
|
||||
let copiedResource: Resource | undefined;
|
||||
if (parsed.variable.type === "resource") {
|
||||
if (!parsed.resource) {
|
||||
throw Error("Resource configuration is missing");
|
||||
}
|
||||
copiedResource = parsed.resource;
|
||||
copiedResource.id = nanoid();
|
||||
mapResourceExpressionsMutable(copiedResource, remap);
|
||||
}
|
||||
const variable: DataSource = {
|
||||
...parsed.variable,
|
||||
id,
|
||||
scopeInstanceId,
|
||||
...(copiedResource && { resourceId: copiedResource.id }),
|
||||
};
|
||||
return { variable, resource: copiedResource };
|
||||
};
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
import { FormResourcePreview } from "./form-resource-preview";
|
||||
import { useResourcePreviewController } from "./shared/use-resource-preview-controller";
|
||||
import {
|
||||
forwardRef,
|
||||
lazy,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { type DataSource, type ResourceRequest } from "@webstudio-is/sdk";
|
||||
import {
|
||||
assetsResourceUrl,
|
||||
isAssetsResourceRequest,
|
||||
} from "@webstudio-is/sdk/runtime";
|
||||
import { createResourceFieldsFromFormData } from "@webstudio-is/project-build/runtime";
|
||||
import { $selectedInstance } from "~/shared/nano-states";
|
||||
import { $resources } from "~/shared/sync/data-stores";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import {
|
||||
$resourceDiagnosticsCache,
|
||||
$resourceDiagnosticsErrorCache,
|
||||
$resourcePerformanceCache,
|
||||
getResourceKey,
|
||||
invalidateAssets,
|
||||
loadResourceDiagnostics,
|
||||
} from "~/shared/resources";
|
||||
import { onNextTransactionComplete } from "~/shared/sync/project-queue";
|
||||
import { useResourceScope } from "../resource-scope";
|
||||
import { clearSettledDiagnosticsKey } from "../request-inspector";
|
||||
import { getRequestErrorDiagnostics } from "../request-error-diagnostics";
|
||||
import { ResourceDiagnosticsView } from "./shared/resource-diagnostics-view";
|
||||
import { CenteredPanelMessage } from "../shared";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
const AssetQueryForm = lazy(() =>
|
||||
import("../asset-query-form").then(({ AssetQueryForm }) => ({
|
||||
default: AssetQueryForm,
|
||||
}))
|
||||
);
|
||||
|
||||
/** The Assets editor and preview exchange one query portal through this hook. */
|
||||
export const useAssetsQueryBridge = () => {
|
||||
const [active, onActiveChange] = useState(false);
|
||||
const [pending, onPendingChange] = useState(false);
|
||||
const [sourceContainer, setSourceContainer] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
);
|
||||
const containerRef = useCallback(
|
||||
(element: HTMLDivElement | null) => setSourceContainer(element),
|
||||
[]
|
||||
);
|
||||
return {
|
||||
active,
|
||||
pending,
|
||||
sourceContainer,
|
||||
containerRef,
|
||||
onActiveChange,
|
||||
onPendingChange,
|
||||
};
|
||||
};
|
||||
|
||||
export const getReloadableAssetsResourceFormData = (
|
||||
form: HTMLFormElement | null
|
||||
) => {
|
||||
const formData = new FormData(form ?? undefined);
|
||||
return formData.get("asset-query-valid") === "false" ? undefined : formData;
|
||||
};
|
||||
|
||||
const AssetQueryLoadingFallback = ({
|
||||
onPendingChange,
|
||||
}: {
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
onPendingChange?.(true);
|
||||
return () => onPendingChange?.(false);
|
||||
}, [onPendingChange]);
|
||||
return <CenteredPanelMessage>Loading query editor…</CenteredPanelMessage>;
|
||||
};
|
||||
|
||||
export const AssetsResourceForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
variable?: DataSource;
|
||||
onChange?: () => void;
|
||||
querySourceContainer?: Element | null;
|
||||
onQueryActiveChange?: (active: boolean) => void;
|
||||
onQueryPendingChange?: (pending: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
variable,
|
||||
onChange,
|
||||
querySourceContainer,
|
||||
onQueryActiveChange,
|
||||
onQueryPendingChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { scope, aliases } = useResourceScope({ variable });
|
||||
const resources = useStore($resources);
|
||||
const resource =
|
||||
variable?.type === "resource"
|
||||
? resources.get(variable.resourceId)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
onQueryActiveChange?.(true);
|
||||
return () => {
|
||||
onQueryActiveChange?.(false);
|
||||
onQueryPendingChange?.(false);
|
||||
};
|
||||
}, [onQueryActiveChange, onQueryPendingChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: (formData) => {
|
||||
if (formData.get("asset-query-valid") === "false") {
|
||||
return false;
|
||||
}
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
if (scopeInstanceId === undefined) {
|
||||
return;
|
||||
}
|
||||
const resourceFields = createResourceFieldsFromFormData({
|
||||
control: "system",
|
||||
formData,
|
||||
});
|
||||
const result = executeRuntimeMutation({
|
||||
id: "resources.upsert",
|
||||
input: {
|
||||
resourceId: resource?.id,
|
||||
resource: resourceFields,
|
||||
dataSourceId: variable?.id,
|
||||
scopeInstanceId,
|
||||
dataSourceName: resourceFields.name,
|
||||
},
|
||||
});
|
||||
if (result !== undefined) {
|
||||
// The initial preview can finish before the updated build reaches the
|
||||
// server. Refresh again once merged-database planning sees the save.
|
||||
onNextTransactionComplete(invalidateAssets);
|
||||
}
|
||||
return result?.result;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<input type="hidden" name="method" value="post" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="url"
|
||||
value={JSON.stringify(assetsResourceUrl)}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<AssetQueryLoadingFallback onPendingChange={onQueryPendingChange} />
|
||||
}
|
||||
>
|
||||
<AssetQueryForm
|
||||
resource={resource}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
sourceContainer={querySourceContainer}
|
||||
onPendingChange={onQueryPendingChange}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
AssetsResourceForm.displayName = "AssetsResourceForm";
|
||||
|
||||
export const AssetsResourceEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
const query = useAssetsQueryBridge();
|
||||
const preview = useResourcePreviewController({
|
||||
variable: props.variable,
|
||||
formRef: props.formRef,
|
||||
getFormData: getReloadableAssetsResourceFormData,
|
||||
});
|
||||
const [pendingDiagnosticsKey, setPendingDiagnosticsKey] = useState<string>();
|
||||
const diagnosticsCache = useStore($resourceDiagnosticsCache);
|
||||
const diagnosticsErrorCache = useStore($resourceDiagnosticsErrorCache);
|
||||
const performanceCache = useStore($resourcePerformanceCache);
|
||||
const getDiagnosticsKey = (request: ResourceRequest | undefined) =>
|
||||
request === undefined ? undefined : getResourceKey(request);
|
||||
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions({
|
||||
onRefresh: () => void preview.reload(),
|
||||
refreshStatus: preview.pending ? "refreshing" : "idle",
|
||||
})}
|
||||
fields={
|
||||
<AssetsResourceForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
onChange={preview.onChange}
|
||||
querySourceContainer={query.sourceContainer}
|
||||
onQueryActiveChange={query.onActiveChange}
|
||||
onQueryPendingChange={query.onPendingChange}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<FormResourcePreview
|
||||
variable={props.variable}
|
||||
showEmptyLoadButton
|
||||
variableValue={preview.request}
|
||||
showSavedResourceRequest={preview.showSavedRequest}
|
||||
isComputingRequest={preview.pending}
|
||||
onLoadData={preview.reload}
|
||||
queryActive={query.active}
|
||||
queryPending={query.pending}
|
||||
queryContainerRef={query.containerRef}
|
||||
onDiagnosticsOpen={(request) => {
|
||||
if (!isAssetsResourceRequest(request)) {
|
||||
return;
|
||||
}
|
||||
const key = getResourceKey(request);
|
||||
if (diagnosticsCache.get(key)?.artifacts !== undefined) {
|
||||
return;
|
||||
}
|
||||
setPendingDiagnosticsKey(key);
|
||||
void loadResourceDiagnostics(request).finally(() =>
|
||||
setPendingDiagnosticsKey((pendingKey) =>
|
||||
clearSettledDiagnosticsKey(pendingKey, key)
|
||||
)
|
||||
);
|
||||
}}
|
||||
diagnosticsPending={(request) =>
|
||||
pendingDiagnosticsKey !== undefined &&
|
||||
getDiagnosticsKey(request) === pendingDiagnosticsKey
|
||||
}
|
||||
diagnostics={(request, requestError) => {
|
||||
const key = getDiagnosticsKey(request);
|
||||
return (
|
||||
<ResourceDiagnosticsView
|
||||
requestError={requestError}
|
||||
diagnosticsRequestError={getRequestErrorDiagnostics(
|
||||
key === undefined ? undefined : diagnosticsErrorCache.get(key)
|
||||
)}
|
||||
diagnostics={
|
||||
key === undefined ? undefined : diagnosticsCache.get(key)
|
||||
}
|
||||
performance={
|
||||
key === undefined ? undefined : performanceCache.get(key)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
|
||||
import { Row } from "../shared";
|
||||
import { ValuePreviewFrame } from "./shared/variable-value-preview";
|
||||
import { forwardRef, useId } from "react";
|
||||
import { Flex, Label, Switch, theme } from "@webstudio-is/design-system";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import { useValuePanelRef } from "./shared/variable-value-save";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
export const prepareBooleanValue = (previous: unknown) =>
|
||||
typeof previous === "boolean" ? previous : false;
|
||||
|
||||
export const BooleanForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
variable?: DataSource;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
>(({ variable, value: unknownValue, onChange }, ref) => {
|
||||
const value = typeof unknownValue === "boolean" ? unknownValue : false;
|
||||
useValuePanelRef({ ref, variable, type: "boolean" });
|
||||
const valueId = useId();
|
||||
return (
|
||||
<>
|
||||
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
|
||||
<Label htmlFor={valueId}>Value</Label>
|
||||
<Switch
|
||||
name="value"
|
||||
value="on"
|
||||
id={valueId}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
});
|
||||
BooleanForm.displayName = "BooleanForm";
|
||||
|
||||
export const BooleanEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions()}
|
||||
fields={
|
||||
<Row>
|
||||
<BooleanForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
value={props.value}
|
||||
onChange={props.onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
preview={<ValuePreviewFrame value={props.value} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AlertIcon } from "@webstudio-is/icons";
|
||||
import {
|
||||
Combobox,
|
||||
cssVar,
|
||||
Flex,
|
||||
Grid,
|
||||
InputErrorsTooltip,
|
||||
Label,
|
||||
ProChip,
|
||||
Select,
|
||||
Tooltip,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { type DataSource } from "@webstudio-is/sdk";
|
||||
import {
|
||||
findAvailableVariables,
|
||||
findUnsetVariableNames,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { validateDataVariableName } from "~/builder/shared/data-variable-utils";
|
||||
import { $permissions, $selectedInstance } from "~/shared/nano-states";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$props,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import type { VariableType } from "../shared/variable-types";
|
||||
|
||||
export const NameField = ({
|
||||
variable,
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onChange: onControlledChange,
|
||||
}: {
|
||||
variable: undefined | DataSource;
|
||||
defaultValue: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState("");
|
||||
const nameId = useId();
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
const validateName = useCallback(
|
||||
(value: string) => {
|
||||
const error = validateDataVariableName(
|
||||
value,
|
||||
variable?.id,
|
||||
scopeInstanceId
|
||||
);
|
||||
return error?.message ?? "";
|
||||
},
|
||||
[variable, scopeInstanceId]
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(defaultValue);
|
||||
const value = controlledValue ?? localValue;
|
||||
const setValue = (newValue: string) => {
|
||||
setLocalValue(newValue);
|
||||
onControlledChange?.(newValue);
|
||||
};
|
||||
const instances = useStore($instances);
|
||||
const dataSources = useStore($dataSources);
|
||||
const shadowed = scopeInstanceId
|
||||
? [
|
||||
...findAvailableVariables({
|
||||
startingInstanceId: scopeInstanceId,
|
||||
instances,
|
||||
dataSources: new Map(
|
||||
[...dataSources].filter(
|
||||
([, source]) => source.scopeInstanceId !== scopeInstanceId
|
||||
)
|
||||
),
|
||||
}).values(),
|
||||
].find(
|
||||
(source) =>
|
||||
source.scopeInstanceId !== scopeInstanceId && source.name === value
|
||||
)
|
||||
: undefined;
|
||||
useEffect(() => {
|
||||
ref.current?.setCustomValidity(validateName(value));
|
||||
}, [value, validateName]);
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Flex gap="1" align="center">
|
||||
<Label htmlFor={nameId}>Name</Label>
|
||||
{shadowed && (
|
||||
<Tooltip
|
||||
content={`This name shadows a variable from ${
|
||||
instances.get(shadowed.scopeInstanceId ?? "")?.label ??
|
||||
instances.get(shadowed.scopeInstanceId ?? "")?.component ??
|
||||
"an ancestor"
|
||||
}. Both variables are allowed.`}
|
||||
>
|
||||
<AlertIcon color={cssVar("--foreground-warning")} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
<InputErrorsTooltip errors={error ? [error] : undefined}>
|
||||
<Combobox<string>
|
||||
inputRef={ref}
|
||||
name="name"
|
||||
id={nameId}
|
||||
color={error ? "error" : undefined}
|
||||
itemToString={(item) => item ?? ""}
|
||||
getDescription={() => (
|
||||
<>
|
||||
Enter a new variable or select
|
||||
<br />
|
||||
a variable that has been used
|
||||
<br />
|
||||
in expressions but not yet created
|
||||
</>
|
||||
)}
|
||||
getItems={() => {
|
||||
// find unset variables for variable instance
|
||||
// and fallback to selected instance for new variables
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
if (scopeInstanceId === undefined) {
|
||||
return [];
|
||||
}
|
||||
return findUnsetVariableNames({
|
||||
startingInstanceId: scopeInstanceId,
|
||||
instances: $instances.get(),
|
||||
props: $props.get(),
|
||||
dataSources: $dataSources.get(),
|
||||
resources: $resources.get(),
|
||||
});
|
||||
}}
|
||||
value={value}
|
||||
onItemSelect={(newValue) => {
|
||||
ref.current?.setCustomValidity(validateName(newValue));
|
||||
setValue(newValue);
|
||||
setError("");
|
||||
}}
|
||||
onChange={(newValue = "") => {
|
||||
ref.current?.setCustomValidity(validateName(newValue));
|
||||
setValue(newValue);
|
||||
setError("");
|
||||
}}
|
||||
onBlur={() => ref.current?.checkValidity()}
|
||||
onInvalid={(event) => setError(event.currentTarget.validationMessage)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const TypeField = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: VariableType;
|
||||
onChange: (value: VariableType) => void;
|
||||
}) => {
|
||||
const { allowDynamicData } = useStore($permissions);
|
||||
const getResourceTypeLabel = (label: string) => (
|
||||
<Flex direction="row" gap="2" align="center">
|
||||
{label}
|
||||
{allowDynamicData === false && <ProChip>Pro</ProChip>}
|
||||
</Flex>
|
||||
);
|
||||
const optionsList: Array<{
|
||||
value: VariableType;
|
||||
disabled?: boolean;
|
||||
label: ReactNode;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "string",
|
||||
label: "String",
|
||||
description: "Any alphanumeric text.",
|
||||
},
|
||||
{
|
||||
value: "number",
|
||||
label: "Number",
|
||||
description: "Any number, can be used in math expressions.",
|
||||
},
|
||||
{
|
||||
value: "boolean",
|
||||
label: "Boolean",
|
||||
description: "A boolean is a true/false switch.",
|
||||
},
|
||||
{
|
||||
value: "json",
|
||||
label: "JSON",
|
||||
description: "Any JSON value",
|
||||
},
|
||||
{
|
||||
value: "resource",
|
||||
label: getResourceTypeLabel("REST resource"),
|
||||
description:
|
||||
"A REST resource is a configuration for secure data fetching. You can safely use secrets in any field.",
|
||||
},
|
||||
{
|
||||
value: "graphql-resource",
|
||||
label: getResourceTypeLabel("GraphQL resource"),
|
||||
description:
|
||||
"A GraphQL resource is a configuration for secure data fetching with GraphQL. You can safely use secrets in any field.",
|
||||
},
|
||||
{
|
||||
value: "assets-resource",
|
||||
label: getResourceTypeLabel("Assets resource"),
|
||||
description:
|
||||
"Loads all project assets by default, with optional filters, sorting, pagination, and file content.",
|
||||
},
|
||||
{
|
||||
value: "email-resource",
|
||||
label: getResourceTypeLabel("Email resource"),
|
||||
description:
|
||||
"Send a plain-text email through Webstudio Cloud when a Form is submitted.",
|
||||
},
|
||||
{
|
||||
value: "sitemap-resource",
|
||||
label: getResourceTypeLabel("Sitemap resource"),
|
||||
description: "Resource that loads the sitemap data of the current site.",
|
||||
},
|
||||
{
|
||||
value: "current-date-resource",
|
||||
label: getResourceTypeLabel("Current date resource"),
|
||||
description:
|
||||
"Provides current date information (year, month, day) normalized to midnight UTC. Time components are set to 00:00:00 to prevent React hydration errors.",
|
||||
},
|
||||
];
|
||||
const options = new Map(optionsList.map((option) => [option.value, option]));
|
||||
|
||||
return (
|
||||
<Grid gap="1">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
options={Array.from(options.keys())}
|
||||
getLabel={(option: VariableType) => options.get(option)?.label}
|
||||
getItemProps={(option) => ({
|
||||
disabled: options.get(option)?.disabled,
|
||||
})}
|
||||
getDescription={(option) => options.get(option)?.description}
|
||||
value={value}
|
||||
name="type"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { type RefObject, useState, useRef } from "react";
|
||||
import { RefreshIcon } from "@webstudio-is/icons";
|
||||
import {
|
||||
Button,
|
||||
DialogClose,
|
||||
DialogMaximize,
|
||||
DialogTitleActions,
|
||||
Tooltip,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
type DataSource,
|
||||
SYSTEM_VARIABLE_ID,
|
||||
hasAssetsResourceUrl,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { currentDateResourceUrl } from "@webstudio-is/sdk/runtime";
|
||||
import { isManagedFormParameter } from "@webstudio-is/project-build/runtime";
|
||||
import { formatValue } from "~/builder/shared/expression-editor";
|
||||
import { $resources, $instances } from "~/shared/sync/data-stores";
|
||||
import { Row } from "../../shared";
|
||||
import { canDeleteVariable, VariableMenu } from "../../variable-menu";
|
||||
import type { PanelApi } from "../shared/variable-panel-api";
|
||||
import type { VariableType } from "../shared/variable-types";
|
||||
import type { RefreshStatus } from "../shared/editor-types";
|
||||
import { prepareVariableEditorValue, variableEditors } from "../index";
|
||||
import { NameField, TypeField } from "./fields";
|
||||
|
||||
export const VariableEditorDialog = ({
|
||||
formRef,
|
||||
variable,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
formRef: RefObject<HTMLFormElement>;
|
||||
variable?: DataSource;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (saved: boolean) => void;
|
||||
}) => {
|
||||
const editorRef = useRef<undefined | PanelApi>(undefined);
|
||||
const isSystemVariable =
|
||||
variable?.id === SYSTEM_VARIABLE_ID ||
|
||||
isManagedFormParameter(variable, $instances.get());
|
||||
const [value, setValue] = useState<unknown>(() => {
|
||||
if (variable?.type === "variable") {
|
||||
if (variable.value.type === "json") {
|
||||
return formatValue(variable.value.value);
|
||||
}
|
||||
return variable.value.value;
|
||||
}
|
||||
});
|
||||
const [name, setName] = useState(variable?.name ?? "");
|
||||
|
||||
const resources = useStore($resources);
|
||||
const [variableType, setVariableType] = useState<VariableType>(() => {
|
||||
if (variable?.type === "resource") {
|
||||
const resource = resources.get(variable.resourceId);
|
||||
if (resource?.control === "system") {
|
||||
if (resource.url === JSON.stringify(currentDateResourceUrl)) {
|
||||
return "current-date-resource";
|
||||
}
|
||||
if (hasAssetsResourceUrl(resource)) {
|
||||
return "assets-resource";
|
||||
}
|
||||
return "sitemap-resource";
|
||||
}
|
||||
if (resource?.control === "graphql") {
|
||||
return "graphql-resource";
|
||||
}
|
||||
if (resource?.control === "email") {
|
||||
return "email-resource";
|
||||
}
|
||||
return "resource";
|
||||
}
|
||||
if (variable?.type === "parameter") {
|
||||
return variable.type;
|
||||
}
|
||||
if (variable?.type === "variable") {
|
||||
const type = variable.value.type;
|
||||
if (type === "string" || type === "number" || type === "boolean") {
|
||||
return type;
|
||||
}
|
||||
return "json";
|
||||
}
|
||||
return "string";
|
||||
});
|
||||
|
||||
const updateVariableType = (variableType: VariableType) => {
|
||||
setVariableType(variableType);
|
||||
setValue((previous: unknown) =>
|
||||
prepareVariableEditorValue[variableType](previous)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
if (isSystemVariable) {
|
||||
return;
|
||||
}
|
||||
const nameElement = formRef.current?.elements.namedItem("name");
|
||||
// make sure only name is valid and allow to save everything else
|
||||
// to avoid loosing complex configuration when closed accidentally
|
||||
if (
|
||||
nameElement instanceof HTMLInputElement &&
|
||||
nameElement.checkValidity()
|
||||
) {
|
||||
const saved = editorRef.current?.save(formData);
|
||||
onSave(saved !== false);
|
||||
// close popover whenever new variable is created
|
||||
// to prevent creating duplicated variable
|
||||
if (variable === undefined && saved !== false) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Editor = variableEditors[variableType];
|
||||
const commonFields = (
|
||||
<>
|
||||
<Row>
|
||||
<NameField
|
||||
variable={variable}
|
||||
defaultValue={variable?.name ?? ""}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
/>
|
||||
</Row>
|
||||
{variableType !== "parameter" && (
|
||||
<Row>
|
||||
<TypeField value={variableType} onChange={updateVariableType} />
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const titleActions = ({
|
||||
onRefresh,
|
||||
refreshStatus = "idle",
|
||||
}: {
|
||||
onRefresh?: () => void;
|
||||
refreshStatus?: RefreshStatus;
|
||||
} = {}) => (
|
||||
<DialogTitleActions>
|
||||
{variable && (
|
||||
<VariableMenu
|
||||
variable={variable}
|
||||
size="header"
|
||||
includePaste={false}
|
||||
canDelete={!isSystemVariable && canDeleteVariable(variable)}
|
||||
onDelete={onClose}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Tooltip content="Refresh resource data" side="bottom">
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Refresh resource data"
|
||||
prefix={<RefreshIcon />}
|
||||
color="ghost"
|
||||
disabled={refreshStatus === "refreshing"}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DialogMaximize />
|
||||
<DialogClose />
|
||||
</DialogTitleActions>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor
|
||||
key={variableType}
|
||||
ref={editorRef}
|
||||
variable={variable}
|
||||
formRef={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
disabled={isSystemVariable}
|
||||
commonFields={commonFields}
|
||||
title={variable ? "Edit variable" : "New variable"}
|
||||
titleActions={titleActions}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
variableType={variableType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const __testing__ = {
|
||||
NameField,
|
||||
TypeField,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import {
|
||||
DialogTitle,
|
||||
Flex,
|
||||
ScrollArea,
|
||||
SplitView,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
|
||||
export const VariableEditorLayout = ({
|
||||
formRef,
|
||||
onSubmit,
|
||||
disabled,
|
||||
commonFields,
|
||||
fields,
|
||||
preview,
|
||||
title,
|
||||
titleActions,
|
||||
}: {
|
||||
formRef: RefObject<HTMLFormElement>;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
disabled: boolean;
|
||||
commonFields: ReactNode;
|
||||
fields: ReactNode;
|
||||
preview: ReactNode;
|
||||
title: string;
|
||||
titleActions: ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
<SplitView
|
||||
defaultSize={{ value: 320, unit: "px" }}
|
||||
minimumStartSize={240}
|
||||
minimumEndSize={240}
|
||||
separatorLabel="Resize variable configuration"
|
||||
start={
|
||||
<ScrollArea css={{ display: "flex", flexDirection: "column" }}>
|
||||
<form
|
||||
ref={formRef}
|
||||
noValidate={true}
|
||||
style={{ display: "contents" }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(new FormData(event.currentTarget));
|
||||
}}
|
||||
>
|
||||
{/* Enter submits the editor through this default submit button. */}
|
||||
<button type="submit" hidden />
|
||||
<fieldset style={{ display: "contents" }} disabled={disabled}>
|
||||
<Flex
|
||||
direction="column"
|
||||
css={{
|
||||
overflow: "hidden",
|
||||
paddingBlock: theme.panel.paddingBlock,
|
||||
gap: theme.spacing[7],
|
||||
}}
|
||||
>
|
||||
{commonFields}
|
||||
{fields}
|
||||
</Flex>
|
||||
</fieldset>
|
||||
</form>
|
||||
</ScrollArea>
|
||||
}
|
||||
end={preview}
|
||||
/>
|
||||
<DialogTitle maximizable suffix={titleActions}>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createElement, createRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { Dialog, TooltipProvider } from "@webstudio-is/design-system";
|
||||
import { HttpResourceEditor } from "./http-resource-editor";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
test("Resource editor exposes its form save handle through its ref", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const editorRef = createRef<PanelApi>();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
createElement(
|
||||
TooltipProvider,
|
||||
undefined,
|
||||
createElement(
|
||||
Dialog,
|
||||
{ open: true },
|
||||
createElement(HttpResourceEditor, {
|
||||
ref: editorRef,
|
||||
formRef: createRef<HTMLFormElement>(),
|
||||
onSubmit: vi.fn(),
|
||||
disabled: false,
|
||||
commonFields: null,
|
||||
title: "New variable",
|
||||
titleActions: () => null,
|
||||
value: undefined,
|
||||
onValueChange: vi.fn(),
|
||||
variableType: "resource",
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(editorRef.current?.save(new FormData())).toBe(false);
|
||||
});
|
||||
+555
@@ -0,0 +1,555 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import {
|
||||
createFormEmailDataProxy,
|
||||
defaultVisitorConfirmationBody,
|
||||
encodeDataVariableId,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { internalFormFieldNames } from "@webstudio-is/sdk/runtime";
|
||||
import { buildEmailRequestPreview } from "./email-request-preview";
|
||||
|
||||
const managedFormDataSources = new Map([
|
||||
[
|
||||
"form-data",
|
||||
{
|
||||
id: "form-data",
|
||||
type: "parameter" as const,
|
||||
scopeInstanceId: "form",
|
||||
name: "formData",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
test("Email Request preview uses current Form data and settings without sending", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: {},
|
||||
projectMeta: {
|
||||
contactEmail: "owner@example.com",
|
||||
emailSender: "Acme <sender@example.com>",
|
||||
},
|
||||
scope: {},
|
||||
aliases: new Map(),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: {
|
||||
name: "Ada",
|
||||
email: "ada@example.com",
|
||||
upload: [
|
||||
{
|
||||
__webstudioPreviewFile: true,
|
||||
name: "sample.txt",
|
||||
type: "text/plain",
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
__webstudioPreviewFile: true,
|
||||
name: "",
|
||||
type: "application/octet-stream",
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
ordinary: { name: "not-a-file", type: "text/plain", size: 7 },
|
||||
password: "hidden-password",
|
||||
},
|
||||
browserInfo: {
|
||||
ip: "",
|
||||
userAgent: "Example browser",
|
||||
language: "en",
|
||||
referrer: "",
|
||||
},
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [{ type: "id", value: "password-input" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"password-input",
|
||||
{
|
||||
id: "password-input",
|
||||
type: "instance",
|
||||
component: "Input",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map([
|
||||
[
|
||||
"password-name",
|
||||
{
|
||||
id: "password-name",
|
||||
instanceId: "password-input",
|
||||
name: "name",
|
||||
type: "string",
|
||||
value: "password",
|
||||
},
|
||||
],
|
||||
[
|
||||
"password-type",
|
||||
{
|
||||
id: "password-type",
|
||||
instanceId: "password-input",
|
||||
name: "type",
|
||||
type: "string",
|
||||
value: "password",
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(preview.preview.to).toEqual([{ address: "owner@example.com" }]);
|
||||
expect(preview.preview.replyTo).toEqual({
|
||||
name: "Acme",
|
||||
address: "sender@example.com",
|
||||
});
|
||||
expect(preview.preview.text).toContain("Name: Ada");
|
||||
expect(preview.preview.text).toContain("Email: ada@example.com");
|
||||
expect(preview.preview.text).toContain("Upload: sample.txt");
|
||||
expect(preview.preview.text).not.toContain("hidden-password");
|
||||
expect(preview.preview.attachments).toEqual([
|
||||
{ name: "sample.txt", type: "text/plain", size: 3 },
|
||||
]);
|
||||
expect(JSON.stringify(preview)).not.toContain("contentBase64");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Email Request preview formats formData in an explicit body expression", async () => {
|
||||
const identifier = encodeDataVariableId("form-data");
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: { body: `"Submitted fields:\\n" + ${identifier}` },
|
||||
scope: {},
|
||||
aliases: new Map([[identifier, "formData"]]),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: { name: "Ada", subject: "Question" },
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance" as const,
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
});
|
||||
expect(preview.preview.text).toBe(
|
||||
"Submitted fields:\nName: Ada\nSubject: Question"
|
||||
);
|
||||
});
|
||||
|
||||
test("Email Request preview formats uploaded files like the submitted Email", async () => {
|
||||
const identifier = encodeDataVariableId("form-data");
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: { body: `"Submitted fields:\\n" + ${identifier}` },
|
||||
scope: {},
|
||||
aliases: new Map([[identifier, "formData"]]),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: {
|
||||
upload: {
|
||||
__webstudioPreviewFile: true,
|
||||
name: "sample.txt",
|
||||
type: "text/plain",
|
||||
size: 3,
|
||||
},
|
||||
},
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance" as const,
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
});
|
||||
const submittedFormData = createFormEmailDataProxy(
|
||||
{ upload: new File(["abc"], "sample.txt", { type: "text/plain" }) },
|
||||
undefined
|
||||
);
|
||||
expect(preview.preview.text).toBe(`Submitted fields:\n${submittedFormData}`);
|
||||
});
|
||||
|
||||
test("Email Request preview includes the known submission page URL", async () => {
|
||||
const browserInfoIdentifier = encodeDataVariableId("browser-info");
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: { body: `"Page: " + ${browserInfoIdentifier}.pageUrl` },
|
||||
scope: {},
|
||||
aliases: new Map([[browserInfoIdentifier, "browserInfo"]]),
|
||||
dataSources: new Map([
|
||||
[
|
||||
"browser-info",
|
||||
{
|
||||
id: "browser-info",
|
||||
type: "parameter" as const,
|
||||
scopeInstanceId: "form",
|
||||
name: "browserInfo",
|
||||
},
|
||||
],
|
||||
]),
|
||||
formId: "form",
|
||||
browserInfo: { pageUrl: "https://site.example/contact?topic=alpha" },
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance" as const,
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
});
|
||||
expect(preview.preview.text).toBe(
|
||||
"Page: https://site.example/contact?topic=alpha"
|
||||
);
|
||||
});
|
||||
|
||||
test("Visitor Email Request preview leaves missing addresses empty and never fabricates a send", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: {
|
||||
recipientMode: "visitor",
|
||||
visitorEmailField: "email",
|
||||
includeAttachments: false,
|
||||
},
|
||||
scope: {},
|
||||
aliases: new Map(),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: {
|
||||
email: "",
|
||||
upload: [
|
||||
{
|
||||
__webstudioPreviewFile: true,
|
||||
name: "sample.txt",
|
||||
type: "text/plain",
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
});
|
||||
expect(preview.preview.to).toEqual([]);
|
||||
expect(preview.preview.text).toBe(
|
||||
"Thank you for your message.\n\nWe received it and will respond as soon as possible.\n\nSite Owner"
|
||||
);
|
||||
expect(preview.preview).not.toHaveProperty("attachments");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Visitor Email Request preview uses sender defaults without rewriting custom copy", async () => {
|
||||
const identifier = encodeDataVariableId("form-data");
|
||||
const input = {
|
||||
projectMeta: { emailSender: "Nike GmbH <sender@example.com>" },
|
||||
scope: {},
|
||||
aliases: new Map([[identifier, "formData"]]),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: { email: "visitor@example.com", subject: "Question" },
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance" as const,
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
};
|
||||
const defaultPreview = await buildEmailRequestPreview({
|
||||
...input,
|
||||
settings: {
|
||||
recipientMode: "visitor",
|
||||
visitorEmailField: "email",
|
||||
subject: `${identifier}.subject`,
|
||||
body: JSON.stringify(defaultVisitorConfirmationBody),
|
||||
confirmationSubject: "formSubject",
|
||||
confirmationBody: "default",
|
||||
},
|
||||
});
|
||||
expect(defaultPreview.preview).toMatchObject({
|
||||
subject: "Nike GmbH received your email: Question",
|
||||
text: `${defaultVisitorConfirmationBody}\n\nNike GmbH`,
|
||||
});
|
||||
|
||||
const collidingCustomPreview = await buildEmailRequestPreview({
|
||||
...input,
|
||||
settings: {
|
||||
recipientMode: "visitor",
|
||||
visitorEmailField: "email",
|
||||
subject: JSON.stringify("We received your message: Question"),
|
||||
body: JSON.stringify(defaultVisitorConfirmationBody),
|
||||
},
|
||||
});
|
||||
expect(collidingCustomPreview.preview).toMatchObject({
|
||||
subject: "We received your message: Question",
|
||||
text: defaultVisitorConfirmationBody,
|
||||
});
|
||||
|
||||
const mixedPreview = await buildEmailRequestPreview({
|
||||
...input,
|
||||
settings: {
|
||||
recipientMode: "visitor",
|
||||
visitorEmailField: "email",
|
||||
subject: JSON.stringify("Custom subject"),
|
||||
body: JSON.stringify(defaultVisitorConfirmationBody),
|
||||
confirmationBody: "default",
|
||||
},
|
||||
});
|
||||
expect(mixedPreview.preview).toMatchObject({
|
||||
subject: "Custom subject",
|
||||
text: `${defaultVisitorConfirmationBody}\n\nNike GmbH`,
|
||||
});
|
||||
|
||||
const blankBodyPreview = await buildEmailRequestPreview({
|
||||
...input,
|
||||
settings: {
|
||||
recipientMode: "visitor",
|
||||
visitorEmailField: "email",
|
||||
body: '""',
|
||||
},
|
||||
});
|
||||
expect(blankBodyPreview.preview.text).toBe("");
|
||||
});
|
||||
|
||||
test.each(["name", "type"] as const)(
|
||||
"Email Request preview hides all Form values when an input has a dynamic %s",
|
||||
async (dynamicProp) => {
|
||||
const identifier = encodeDataVariableId("form-data");
|
||||
const internalName = [...internalFormFieldNames][0];
|
||||
const secretFormData = {
|
||||
password: "private-password",
|
||||
[internalName]: "private-bot-value",
|
||||
email: "visitor@example.com",
|
||||
};
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: {
|
||||
body: `(${identifier}.password ?? "") + (${identifier}[${JSON.stringify(
|
||||
internalName
|
||||
)}] ?? "")`,
|
||||
},
|
||||
projectMeta: { contactEmail: "owner@example.com" },
|
||||
scope: { [identifier]: secretFormData },
|
||||
aliases: new Map([[identifier, "formData"]]),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: secretFormData,
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [{ type: "id", value: "input" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"input",
|
||||
{
|
||||
id: "input",
|
||||
type: "instance",
|
||||
component: "Input",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map([
|
||||
[
|
||||
"input-name",
|
||||
{
|
||||
id: "input-name",
|
||||
instanceId: "input",
|
||||
name: "name",
|
||||
type: dynamicProp === "name" ? "expression" : "string",
|
||||
value: "password",
|
||||
},
|
||||
],
|
||||
[
|
||||
"input-type",
|
||||
{
|
||||
id: "input-type",
|
||||
instanceId: "input",
|
||||
name: "type",
|
||||
type: dynamicProp === "type" ? "expression" : "string",
|
||||
value: "password",
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(preview.preview.text).toBe("");
|
||||
expect(JSON.stringify(preview)).not.toContain("private-password");
|
||||
expect(JSON.stringify(preview)).not.toContain("private-bot-value");
|
||||
expect(JSON.stringify(preview)).not.toContain("visitor@example.com");
|
||||
}
|
||||
);
|
||||
|
||||
test("Email Request preview blocks explicit access to known password and internal fields", async () => {
|
||||
const identifier = encodeDataVariableId("form-data");
|
||||
const internalName = [...internalFormFieldNames][0];
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: {
|
||||
body: `(${identifier}.password ?? "") + (${identifier}[${JSON.stringify(
|
||||
internalName
|
||||
)}] ?? "")`,
|
||||
},
|
||||
scope: {
|
||||
[identifier]: {
|
||||
password: "private-password",
|
||||
[internalName]: "private-bot-value",
|
||||
},
|
||||
},
|
||||
aliases: new Map([[identifier, "formData"]]),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: {
|
||||
password: "private-password",
|
||||
[internalName]: "private-bot-value",
|
||||
},
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [{ type: "id", value: "password-input" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"password-input",
|
||||
{
|
||||
id: "password-input",
|
||||
type: "instance",
|
||||
component: "Input",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map([
|
||||
[
|
||||
"password-name",
|
||||
{
|
||||
id: "password-name",
|
||||
instanceId: "password-input",
|
||||
name: "name",
|
||||
type: "string",
|
||||
value: "password",
|
||||
},
|
||||
],
|
||||
[
|
||||
"password-type",
|
||||
{
|
||||
id: "password-type",
|
||||
instanceId: "password-input",
|
||||
name: "type",
|
||||
type: "string",
|
||||
value: "password",
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
expect(preview.preview.text).toBe("");
|
||||
expect(JSON.stringify(preview)).not.toContain("private-password");
|
||||
expect(JSON.stringify(preview)).not.toContain("private-bot-value");
|
||||
});
|
||||
|
||||
test("Email Request preview does not fall back to scoped Form data when current values are missing", async () => {
|
||||
const identifier = encodeDataVariableId("form-data");
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: { body: `${identifier}.password ?? ""` },
|
||||
scope: { [identifier]: { password: "private-password" } },
|
||||
aliases: new Map([[identifier, "formData"]]),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
});
|
||||
expect(preview.preview.text).toBe("");
|
||||
expect(JSON.stringify(preview)).not.toContain("private-password");
|
||||
});
|
||||
|
||||
test("Email Request preview preserves user variables named like managed Form parameters", async () => {
|
||||
const userFormData = encodeDataVariableId("user-form-data");
|
||||
const userBrowserInfo = encodeDataVariableId("user-browser-info");
|
||||
const managedFormData = encodeDataVariableId("form-data");
|
||||
const preview = await buildEmailRequestPreview({
|
||||
settings: {
|
||||
body: `${userFormData}.label + ":" + ${userBrowserInfo}.label + ":" + ${managedFormData}.email`,
|
||||
},
|
||||
scope: {
|
||||
[userFormData]: { label: "user-form" },
|
||||
[userBrowserInfo]: { label: "user-browser" },
|
||||
[managedFormData]: { email: "stale@example.com" },
|
||||
},
|
||||
aliases: new Map([
|
||||
[userFormData, "formData"],
|
||||
[userBrowserInfo, "browserInfo"],
|
||||
[managedFormData, "formData"],
|
||||
]),
|
||||
dataSources: managedFormDataSources,
|
||||
formId: "form",
|
||||
formData: { email: "current@example.com" },
|
||||
instances: new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map(),
|
||||
});
|
||||
|
||||
expect(preview.preview.text).toBe(
|
||||
"user-form:user-browser:current@example.com"
|
||||
);
|
||||
});
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
getFormEmailStringifyOptions,
|
||||
createFormEmailDataProxy,
|
||||
parseEmailMailboxes,
|
||||
parseEmailSender,
|
||||
resolveEmailResourceSettings,
|
||||
resolveVisitorConfirmationCopy,
|
||||
type EmailResourceSettings,
|
||||
type DataSource,
|
||||
type DataSources,
|
||||
type Instances,
|
||||
type ProjectMeta,
|
||||
type Props,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { emailResourceSettings, findTreeInstanceIds } from "@webstudio-is/sdk";
|
||||
import { encodeDataVariableId } from "@webstudio-is/sdk";
|
||||
import {
|
||||
$livePreviewFormValues,
|
||||
$livePreviewBrowserInfo,
|
||||
getFormOccurrenceKey,
|
||||
isPreviewFileMetadata,
|
||||
toPublicPreviewValue,
|
||||
} from "~/shared/preview-form-values";
|
||||
import { $selectedInstanceSelector } from "~/shared/nano-states";
|
||||
import {
|
||||
$instances,
|
||||
$dataSources,
|
||||
$props,
|
||||
$projectSettings,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import {
|
||||
getBrowserInfoPreview,
|
||||
getFormDataPreview,
|
||||
} from "../form-context-preview";
|
||||
import { createJsonStringifyProxy } from "@webstudio-is/sdk/to-string";
|
||||
import { computeExpressionWithinScope } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
internalFormFieldNames,
|
||||
formDataParameterName,
|
||||
browserInfoParameterName,
|
||||
type ManagedFormBrowserInfo,
|
||||
} from "@webstudio-is/sdk/runtime";
|
||||
|
||||
type FileMetadata = { name: string; type: string; size: number };
|
||||
|
||||
/** Gather current editor inputs and local Form values without sending Email. */
|
||||
export const buildEmailRequestPreviewFromEditor = async ({
|
||||
form,
|
||||
variable,
|
||||
scope,
|
||||
aliases,
|
||||
}: {
|
||||
form: HTMLFormElement;
|
||||
variable?: DataSource;
|
||||
scope: Record<string, unknown>;
|
||||
aliases: ReadonlyMap<string, string>;
|
||||
}) => {
|
||||
const rawSettings = new FormData(form).get("email-settings");
|
||||
let settings: unknown;
|
||||
try {
|
||||
settings = JSON.parse(String(rawSettings ?? "{}"));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const parsedSettings = emailResourceSettings.safeParse(settings);
|
||||
if (!parsedSettings.success) {
|
||||
return;
|
||||
}
|
||||
const instances = $instances.get();
|
||||
const props = $props.get();
|
||||
const selected = $selectedInstanceSelector.get();
|
||||
const formId =
|
||||
selected?.find(
|
||||
(instanceId) => instances.get(instanceId)?.component === "Form"
|
||||
) ??
|
||||
Array.from(instances.values()).find(
|
||||
(instance) =>
|
||||
instance.component === "Form" &&
|
||||
variable?.scopeInstanceId !== undefined &&
|
||||
findTreeInstanceIds(instances, instance.id).has(
|
||||
variable.scopeInstanceId
|
||||
)
|
||||
)?.id;
|
||||
const formData =
|
||||
formId === undefined
|
||||
? undefined
|
||||
: ($livePreviewFormValues
|
||||
.get()
|
||||
.get(getFormOccurrenceKey(selected, formId) ?? "") ??
|
||||
getFormDataPreview(formId));
|
||||
const browserInfo =
|
||||
formId === undefined
|
||||
? undefined
|
||||
: getBrowserInfoPreview($livePreviewBrowserInfo.get().get(formId));
|
||||
return buildEmailRequestPreview({
|
||||
settings: parsedSettings.data,
|
||||
projectMeta: $projectSettings.get()?.meta,
|
||||
scope,
|
||||
aliases,
|
||||
dataSources: $dataSources.get(),
|
||||
formId,
|
||||
formData,
|
||||
browserInfo,
|
||||
instances,
|
||||
props,
|
||||
});
|
||||
};
|
||||
|
||||
const getFileMetadata = (value: unknown): FileMetadata[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(getFileMetadata);
|
||||
}
|
||||
if (typeof File !== "undefined" && value instanceof File) {
|
||||
if (value.name === "" && value.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ name: value.name, type: value.type, size: value.size }];
|
||||
}
|
||||
if (isPreviewFileMetadata(value)) {
|
||||
if (value.name === "" && value.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ name: value.name, type: value.type, size: value.size }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Preview only transports file metadata. Rebuild a metadata-only File so Email
|
||||
// expressions format it the same way as a submitted File, without loading bytes.
|
||||
const toEmailPreviewValue = (value: unknown): unknown => {
|
||||
if (isPreviewFileMetadata(value)) {
|
||||
const file = new File([], value.name, { type: value.type });
|
||||
Object.defineProperty(file, "size", { value: value.size });
|
||||
return file;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(toEmailPreviewValue);
|
||||
}
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(Object.getPrototypeOf(value) === Object.prototype ||
|
||||
Object.getPrototypeOf(value) === null)
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([name, entry]) => [
|
||||
name,
|
||||
toEmailPreviewValue(entry),
|
||||
])
|
||||
);
|
||||
}
|
||||
return toPublicPreviewValue(value);
|
||||
};
|
||||
|
||||
/** Show the known Email payload without creating a request or sending a message. */
|
||||
export const buildEmailRequestPreview = async ({
|
||||
settings,
|
||||
projectMeta,
|
||||
scope,
|
||||
aliases,
|
||||
dataSources,
|
||||
formId,
|
||||
formData,
|
||||
browserInfo,
|
||||
instances,
|
||||
props,
|
||||
}: {
|
||||
settings: EmailResourceSettings;
|
||||
projectMeta?: ProjectMeta;
|
||||
scope: Record<string, unknown>;
|
||||
aliases: ReadonlyMap<string, string>;
|
||||
dataSources: DataSources;
|
||||
formId?: string;
|
||||
formData?: Record<string, unknown>;
|
||||
browserInfo?: ManagedFormBrowserInfo;
|
||||
instances: Instances;
|
||||
props: Props;
|
||||
}) => {
|
||||
const resolved = resolveEmailResourceSettings({ settings, projectMeta });
|
||||
const formDataOptions =
|
||||
formId === undefined
|
||||
? undefined
|
||||
: getFormEmailStringifyOptions(instances, props, formId);
|
||||
// Dynamic field names or types prevent us from identifying password fields.
|
||||
// In that case the runtime omits default Form text, so the local expression
|
||||
// preview must not expose individual field values either.
|
||||
const visibleFormDataWithFileTags =
|
||||
formData === undefined
|
||||
? undefined
|
||||
: formDataOptions?.stringifyAs !== undefined
|
||||
? {}
|
||||
: Object.fromEntries(
|
||||
Object.entries(formData).filter(
|
||||
([name]) =>
|
||||
!internalFormFieldNames.has(name) &&
|
||||
!formDataOptions?.excludeKeys?.includes(name)
|
||||
)
|
||||
);
|
||||
const visibleFormData =
|
||||
visibleFormDataWithFileTags === undefined
|
||||
? undefined
|
||||
: Object.fromEntries(
|
||||
Object.entries(visibleFormDataWithFileTags).map(([name, value]) => [
|
||||
name,
|
||||
toEmailPreviewValue(value),
|
||||
])
|
||||
);
|
||||
const evaluationScope = { ...scope };
|
||||
for (const source of dataSources.values()) {
|
||||
if (source.type !== "parameter" || source.scopeInstanceId !== formId) {
|
||||
continue;
|
||||
}
|
||||
const identifier = encodeDataVariableId(source.id);
|
||||
if (aliases.has(identifier) === false) {
|
||||
continue;
|
||||
}
|
||||
if (source.name === formDataParameterName) {
|
||||
evaluationScope[identifier] = createFormEmailDataProxy(
|
||||
visibleFormData ?? {},
|
||||
formDataOptions
|
||||
);
|
||||
}
|
||||
if (source.name === browserInfoParameterName && browserInfo !== undefined) {
|
||||
evaluationScope[identifier] = createJsonStringifyProxy(browserInfo, {
|
||||
space: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
const evaluateText = async (expression: string) => {
|
||||
const value = await computeExpressionWithinScope(
|
||||
expression,
|
||||
evaluationScope
|
||||
);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
};
|
||||
const recipientValue =
|
||||
settings.recipientsExpression === undefined
|
||||
? resolved.recipients
|
||||
: parseEmailMailboxes(
|
||||
(await evaluateText(settings.recipientsExpression)) ?? ""
|
||||
);
|
||||
const visitorAddress =
|
||||
resolved.visitorEmailField === undefined
|
||||
? undefined
|
||||
: visibleFormData?.[resolved.visitorEmailField];
|
||||
const recipients =
|
||||
resolved.recipientMode === "visitor"
|
||||
? typeof visitorAddress === "string" &&
|
||||
parseEmailSender(visitorAddress)?.address === visitorAddress
|
||||
? [{ address: visitorAddress }]
|
||||
: []
|
||||
: recipientValue;
|
||||
const sender =
|
||||
settings.senderExpression === undefined
|
||||
? resolved.sender
|
||||
: parseEmailSender((await evaluateText(settings.senderExpression)) ?? "");
|
||||
const evaluatedSubject = await evaluateText(resolved.subject);
|
||||
const evaluatedBody =
|
||||
settings.body !== undefined || resolved.recipientMode === "visitor"
|
||||
? await evaluateText(resolved.body)
|
||||
: visibleFormData === undefined
|
||||
? await evaluateText(resolved.body)
|
||||
: `Form data:\n${String(
|
||||
createFormEmailDataProxy(visibleFormData, formDataOptions)
|
||||
)}` +
|
||||
(browserInfo === undefined
|
||||
? ""
|
||||
: `\n\nBrowser info:\n${String(
|
||||
createJsonStringifyProxy(browserInfo, { space: 2 })
|
||||
)}`);
|
||||
const copy =
|
||||
resolved.recipientMode === "visitor" &&
|
||||
evaluatedSubject !== undefined &&
|
||||
evaluatedBody !== undefined
|
||||
? resolveVisitorConfirmationCopy({
|
||||
subject: evaluatedSubject,
|
||||
body: evaluatedBody,
|
||||
fromName: sender?.name ?? resolved.fromName,
|
||||
confirmationSubject: resolved.confirmationSubject,
|
||||
confirmationBody: resolved.confirmationBody,
|
||||
})
|
||||
: undefined;
|
||||
const subject = copy?.subject ?? evaluatedSubject;
|
||||
const body = copy?.body ?? evaluatedBody;
|
||||
const attachments =
|
||||
resolved.includeAttachments && visibleFormDataWithFileTags !== undefined
|
||||
? Object.values(visibleFormDataWithFileTags).flatMap(getFileMetadata)
|
||||
: [];
|
||||
|
||||
return {
|
||||
preview: {
|
||||
...(recipients === undefined ? {} : { to: recipients }),
|
||||
...(subject === undefined ? {} : { subject }),
|
||||
...(body === undefined ? {} : { text: body }),
|
||||
...(sender === undefined ? {} : { replyTo: sender }),
|
||||
fromName: sender?.name ?? resolved.fromName,
|
||||
...(attachments.length === 0 ? {} : { attachments }),
|
||||
},
|
||||
};
|
||||
};
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
import { FormResourcePreview } from "./form-resource-preview";
|
||||
import {
|
||||
forwardRef,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
decodeDataVariableId,
|
||||
defaultEmailBody,
|
||||
defaultEmailSubject,
|
||||
defaultEmailConfirmationSubject,
|
||||
defaultVisitorConfirmationBody,
|
||||
findTreeInstanceIds,
|
||||
getDefaultFormEmailBodyExpression,
|
||||
getFormEmailFieldNames,
|
||||
isEmailBindingDataSourceAvailable,
|
||||
type DataSource,
|
||||
type EmailResourceSettings,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
getExpressionIdentifiers,
|
||||
isLiteralExpression,
|
||||
} from "@webstudio-is/expression";
|
||||
import {
|
||||
browserInfoParameterName,
|
||||
formDataParameterName,
|
||||
} from "@webstudio-is/sdk/runtime";
|
||||
import {
|
||||
Grid,
|
||||
InputErrorsTooltip,
|
||||
InputField,
|
||||
Label,
|
||||
Radio,
|
||||
RadioAndLabel,
|
||||
RadioGroup,
|
||||
Select,
|
||||
TextArea,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { $selectedInstance } from "~/shared/nano-states";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$props,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { evaluateExpressionWithinScope } from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import { useAsyncValue } from "~/shared/use-async-value";
|
||||
import {
|
||||
createResourceFieldsFromFormData,
|
||||
getExpressionErrorMessages,
|
||||
getResourceExpressionErrors,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
validateContactEmail,
|
||||
validateEmailSender,
|
||||
} from "@webstudio-is/project-build/contracts";
|
||||
import { Row } from "../shared";
|
||||
import { useResourceScope } from "../resource-scope";
|
||||
import { buildEmailRequestPreviewFromEditor } from "./email-request-preview";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
const EmailExpressionField = ({
|
||||
label,
|
||||
accessibleName,
|
||||
expression,
|
||||
placeholder,
|
||||
scope,
|
||||
aliases,
|
||||
error,
|
||||
onChange,
|
||||
multiline = false,
|
||||
}: {
|
||||
label?: string;
|
||||
accessibleName?: string;
|
||||
expression: string;
|
||||
placeholder: string;
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
error?: string;
|
||||
onChange: (expression: string) => void;
|
||||
multiline?: boolean;
|
||||
}) => {
|
||||
const id = useId();
|
||||
const evaluatedValue = useAsyncValue(
|
||||
() => evaluateExpressionWithinScope(expression, scope),
|
||||
[expression, scope],
|
||||
""
|
||||
);
|
||||
const value = String(evaluatedValue ?? "");
|
||||
const onChangeValue = (nextValue: string) =>
|
||||
onChange(JSON.stringify(nextValue));
|
||||
|
||||
const control = (
|
||||
<BindableExpressionControl
|
||||
expression={expression}
|
||||
value={value}
|
||||
bound={isLiteralExpression(expression) === false}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={onChangeValue}
|
||||
onChangeExpression={onChange}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange(JSON.stringify(String(evaluatedValue ?? "")))
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={error ? [error] : undefined}>
|
||||
{multiline ? (
|
||||
<TextArea
|
||||
id={id}
|
||||
aria-label={accessibleName}
|
||||
rows={4}
|
||||
autoGrow
|
||||
disabled={readOnly}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
color={error ? "error" : undefined}
|
||||
onChange={onChangeValue}
|
||||
/>
|
||||
) : (
|
||||
<InputField
|
||||
id={id}
|
||||
aria-label={accessibleName}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={readOnly}
|
||||
color={error ? "error" : undefined}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return label ? (
|
||||
<Row>
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{control}
|
||||
</Grid>
|
||||
</Row>
|
||||
) : (
|
||||
control
|
||||
);
|
||||
};
|
||||
|
||||
export const EmailResourceForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{ variable?: DataSource; onChange?: () => void }
|
||||
>(({ variable, onChange }, ref) => {
|
||||
const { scope, aliases } = useResourceScope({ variable });
|
||||
const resources = useStore($resources);
|
||||
const dataSources = useStore($dataSources);
|
||||
const instances = useStore($instances);
|
||||
const props = useStore($props);
|
||||
const attachmentId = useId();
|
||||
const recipientsLabelId = useId();
|
||||
const resource =
|
||||
variable?.type === "resource"
|
||||
? resources.get(variable.resourceId)
|
||||
: undefined;
|
||||
const { scope: emailBindingScope, aliases: emailBindingAliases } =
|
||||
useMemo(() => {
|
||||
const emailBindingScope = { ...scope };
|
||||
const emailBindingAliases = new Map(aliases);
|
||||
for (const [identifier] of aliases) {
|
||||
const dataSourceId = decodeDataVariableId(identifier);
|
||||
if (
|
||||
dataSourceId &&
|
||||
!isEmailBindingDataSourceAvailable(dataSources.get(dataSourceId))
|
||||
) {
|
||||
delete emailBindingScope[identifier];
|
||||
emailBindingAliases.delete(identifier);
|
||||
}
|
||||
}
|
||||
return { scope: emailBindingScope, aliases: emailBindingAliases };
|
||||
}, [aliases, dataSources, scope]);
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
const formId = Array.from(instances.values()).find(
|
||||
(instance) =>
|
||||
instance.component === "Form" &&
|
||||
scopeInstanceId !== undefined &&
|
||||
findTreeInstanceIds(instances, instance.id).has(scopeInstanceId)
|
||||
)?.id;
|
||||
const emailFields =
|
||||
formId === undefined
|
||||
? []
|
||||
: getFormEmailFieldNames(instances, props, formId);
|
||||
const [settings, setSettings] = useState<EmailResourceSettings>(
|
||||
resource?.email ?? {}
|
||||
);
|
||||
const [showCustomRecipientError, setShowCustomRecipientError] = useState(
|
||||
resource?.email?.recipientMode === "custom"
|
||||
);
|
||||
const formDataIdentifier = Array.from(aliases).find(
|
||||
([, alias]) => alias === formDataParameterName
|
||||
)?.[0];
|
||||
const browserInfoIdentifier = Array.from(aliases).find(
|
||||
([, alias]) => alias === browserInfoParameterName
|
||||
)?.[0];
|
||||
const defaultBody =
|
||||
settings.recipientMode === "visitor"
|
||||
? JSON.stringify(defaultVisitorConfirmationBody)
|
||||
: formDataIdentifier
|
||||
? getDefaultFormEmailBodyExpression(
|
||||
formDataIdentifier,
|
||||
browserInfoIdentifier
|
||||
)
|
||||
: JSON.stringify(defaultEmailBody);
|
||||
const setField = <K extends keyof EmailResourceSettings>(
|
||||
key: K,
|
||||
value: EmailResourceSettings[K]
|
||||
) => {
|
||||
onChange?.();
|
||||
setSettings((previous) => {
|
||||
const next = { ...previous, [key]: value };
|
||||
if (key === "subject") {
|
||||
delete next.confirmationSubject;
|
||||
}
|
||||
if (key === "body") {
|
||||
delete next.confirmationBody;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const unavailableFormBinding = (expression: string) =>
|
||||
Array.from(getExpressionIdentifiers(expression)).some((identifier) => {
|
||||
const id = decodeDataVariableId(identifier);
|
||||
const dataSource = id ? dataSources.get(id) : undefined;
|
||||
return (
|
||||
dataSource?.type === "parameter" &&
|
||||
(dataSource.name === formDataParameterName ||
|
||||
dataSource.name === browserInfoParameterName) &&
|
||||
aliases.has(identifier) === false
|
||||
);
|
||||
})
|
||||
? "This Form binding is unavailable outside its Form."
|
||||
: undefined;
|
||||
const unavailableResourceBinding = (expression: string) =>
|
||||
Array.from(getExpressionIdentifiers(expression)).some((identifier) => {
|
||||
const id = decodeDataVariableId(identifier);
|
||||
return (
|
||||
id !== undefined &&
|
||||
!isEmailBindingDataSourceAvailable(dataSources.get(id))
|
||||
);
|
||||
})
|
||||
? "Email fields cannot depend on another Resource."
|
||||
: undefined;
|
||||
const getEmailExpressionError = (
|
||||
key: "senderExpression" | "recipientsExpression" | "subject" | "body"
|
||||
) => {
|
||||
const expression = settings[key];
|
||||
if (expression === undefined) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
unavailableFormBinding(expression) ??
|
||||
unavailableResourceBinding(expression) ??
|
||||
getResourceExpressionErrors({ email: { [key]: expression } })[0] ??
|
||||
getExpressionErrorMessages({
|
||||
expression,
|
||||
availableVariables: new Set(emailBindingAliases.keys()),
|
||||
})[0]
|
||||
);
|
||||
};
|
||||
const senderError =
|
||||
settings.senderExpression !== undefined
|
||||
? getEmailExpressionError("senderExpression")
|
||||
: settings.sender === undefined || settings.sender === ""
|
||||
? undefined
|
||||
: validateEmailSender(settings.sender);
|
||||
const recipientValidationError =
|
||||
settings.recipientMode === "visitor"
|
||||
? !settings.visitorEmailField ||
|
||||
!emailFields.includes(settings.visitorEmailField)
|
||||
? "Select one named email input in this Form."
|
||||
: undefined
|
||||
: settings.recipientMode !== "custom"
|
||||
? undefined
|
||||
: settings.recipientsExpression !== undefined
|
||||
? getEmailExpressionError("recipientsExpression")
|
||||
: (validateContactEmail(settings.recipients ?? "") ??
|
||||
(settings.recipients
|
||||
? undefined
|
||||
: "Enter at least one recipient."));
|
||||
const recipientError =
|
||||
settings.recipientMode === "custom" && !showCustomRecipientError
|
||||
? undefined
|
||||
: recipientValidationError;
|
||||
const subjectError = getEmailExpressionError("subject");
|
||||
const bodyError = getEmailExpressionError("body");
|
||||
const setEmailExpression = (
|
||||
expressionKey: "senderExpression" | "recipientsExpression",
|
||||
valueKey: "sender" | "recipients",
|
||||
expression: string
|
||||
) => {
|
||||
let literalValue: string | undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(expression);
|
||||
if (typeof parsed === "string") {
|
||||
literalValue = parsed;
|
||||
}
|
||||
} catch {
|
||||
// Non-literal expressions are stored as bindings and validated at submit.
|
||||
}
|
||||
if (literalValue === undefined) {
|
||||
if (valueKey === "recipients") {
|
||||
setShowCustomRecipientError(true);
|
||||
}
|
||||
setField(expressionKey, expression);
|
||||
return;
|
||||
}
|
||||
if (valueKey === "recipients") {
|
||||
setShowCustomRecipientError(true);
|
||||
}
|
||||
onChange?.();
|
||||
setSettings((previous) => {
|
||||
const next = { ...previous };
|
||||
if (literalValue === "") {
|
||||
delete next[valueKey];
|
||||
} else {
|
||||
next[valueKey] = literalValue;
|
||||
}
|
||||
delete next[expressionKey];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: (formData) => {
|
||||
if (recipientValidationError) {
|
||||
setShowCustomRecipientError(true);
|
||||
}
|
||||
if (
|
||||
senderError ||
|
||||
recipientValidationError ||
|
||||
subjectError ||
|
||||
bodyError
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
if (scopeInstanceId === undefined) {
|
||||
return;
|
||||
}
|
||||
const parsedSettings = JSON.parse(
|
||||
String(formData.get("email-settings") ?? "{}")
|
||||
) as EmailResourceSettings;
|
||||
const resourceFields = createResourceFieldsFromFormData({
|
||||
control: "email",
|
||||
formData,
|
||||
});
|
||||
return executeRuntimeMutation({
|
||||
id: "resources.upsert",
|
||||
input: {
|
||||
resourceId: resource?.id,
|
||||
resource: { ...resourceFields, email: parsedSettings },
|
||||
dataSourceId: variable?.id,
|
||||
scopeInstanceId,
|
||||
dataSourceName: resourceFields.name,
|
||||
},
|
||||
})?.result;
|
||||
},
|
||||
}));
|
||||
const senderField = (
|
||||
<EmailExpressionField
|
||||
label="Reply to"
|
||||
expression={
|
||||
settings.senderExpression ?? JSON.stringify(settings.sender ?? "")
|
||||
}
|
||||
placeholder="John <john@example.com>"
|
||||
scope={emailBindingScope}
|
||||
aliases={emailBindingAliases}
|
||||
error={senderError}
|
||||
onChange={(expression) =>
|
||||
setEmailExpression("senderExpression", "sender", expression)
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input type="hidden" name="method" value="post" />
|
||||
<input type="hidden" name="url" value={'""'} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="email-settings"
|
||||
value={JSON.stringify(settings)}
|
||||
/>
|
||||
<Row>
|
||||
<Grid gap={1}>
|
||||
<Label id={recipientsLabelId}>Recipients</Label>
|
||||
<Select<"project" | "custom" | "visitor">
|
||||
aria-labelledby={recipientsLabelId}
|
||||
options={
|
||||
formId === undefined
|
||||
? ["project", "custom"]
|
||||
: ["project", "custom", "visitor"]
|
||||
}
|
||||
value={settings.recipientMode ?? "project"}
|
||||
getLabel={(value: "project" | "custom" | "visitor") =>
|
||||
value === "project"
|
||||
? "Project recipients (or owner)"
|
||||
: value === "custom"
|
||||
? "Custom recipients"
|
||||
: "Visitor email field"
|
||||
}
|
||||
getDescription={(value: "project" | "custom" | "visitor") =>
|
||||
value === "project"
|
||||
? "Send to the addresses in project settings, or to the account holder if none are set."
|
||||
: value === "custom"
|
||||
? "Send to the addresses entered below."
|
||||
: "Choose a Form input to use as the recipient’s email address. The confirmation email is sent to that address."
|
||||
}
|
||||
onChange={(value: "project" | "custom" | "visitor") => {
|
||||
setShowCustomRecipientError(false);
|
||||
if (value === "project") {
|
||||
onChange?.();
|
||||
setSettings((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next.recipientMode;
|
||||
delete next.recipients;
|
||||
delete next.recipientsExpression;
|
||||
return next;
|
||||
});
|
||||
} else if (value === "visitor") {
|
||||
onChange?.();
|
||||
setSettings((previous) => {
|
||||
const next = { ...previous, recipientMode: value };
|
||||
delete next.recipients;
|
||||
delete next.recipientsExpression;
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setField("recipientMode", value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{settings.recipientMode === "custom" && (
|
||||
<EmailExpressionField
|
||||
accessibleName="Custom recipients"
|
||||
expression={
|
||||
settings.recipientsExpression ??
|
||||
JSON.stringify(settings.recipients ?? "")
|
||||
}
|
||||
placeholder="John <john@example.com>, pete@example.com"
|
||||
scope={emailBindingScope}
|
||||
aliases={emailBindingAliases}
|
||||
error={recipientError}
|
||||
onChange={(expression) =>
|
||||
setEmailExpression(
|
||||
"recipientsExpression",
|
||||
"recipients",
|
||||
expression
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Row>
|
||||
{settings.recipientMode === "visitor" && (
|
||||
<Row>
|
||||
<Grid gap={1}>
|
||||
<InputErrorsTooltip
|
||||
errors={recipientError ? [recipientError] : undefined}
|
||||
>
|
||||
<Select
|
||||
fullWidth
|
||||
aria-label="Visitor email field"
|
||||
value={settings.visitorEmailField}
|
||||
placeholder="Select an email field"
|
||||
options={emailFields}
|
||||
getLabel={(name) => name}
|
||||
onChange={(name) => setField("visitorEmailField", name)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</Grid>
|
||||
</Row>
|
||||
)}
|
||||
{senderField}
|
||||
<EmailExpressionField
|
||||
label="Subject"
|
||||
expression={
|
||||
settings.subject ??
|
||||
JSON.stringify(
|
||||
settings.recipientMode === "visitor"
|
||||
? defaultEmailConfirmationSubject
|
||||
: defaultEmailSubject
|
||||
)
|
||||
}
|
||||
placeholder="New form submission"
|
||||
scope={emailBindingScope}
|
||||
aliases={emailBindingAliases}
|
||||
error={subjectError}
|
||||
onChange={(value) => setField("subject", value)}
|
||||
/>
|
||||
<EmailExpressionField
|
||||
label="Body"
|
||||
expression={settings.body ?? defaultBody}
|
||||
placeholder=""
|
||||
multiline
|
||||
scope={emailBindingScope}
|
||||
aliases={emailBindingAliases}
|
||||
error={bodyError}
|
||||
onChange={(value) => setField("body", value)}
|
||||
/>
|
||||
<Row>
|
||||
<Grid gap={1}>
|
||||
<Label id={`${attachmentId}-label`}>Attachments</Label>
|
||||
<RadioGroup
|
||||
aria-labelledby={`${attachmentId}-label`}
|
||||
value={
|
||||
settings.includeAttachments === false ? "exclude" : "include"
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
setField("includeAttachments", value === "include")
|
||||
}
|
||||
>
|
||||
<RadioAndLabel>
|
||||
<Radio value="include" id={`${attachmentId}-include`} />
|
||||
<Label htmlFor={`${attachmentId}-include`}>
|
||||
Attach submitted files
|
||||
</Label>
|
||||
</RadioAndLabel>
|
||||
<RadioAndLabel>
|
||||
<Radio value="exclude" id={`${attachmentId}-exclude`} />
|
||||
<Label htmlFor={`${attachmentId}-exclude`}>
|
||||
Do not attach files
|
||||
</Label>
|
||||
</RadioAndLabel>
|
||||
</RadioGroup>
|
||||
</Grid>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
});
|
||||
EmailResourceForm.displayName = "EmailResourceForm";
|
||||
|
||||
export const EmailResourceEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
const { scope, aliases } = useResourceScope({ variable: props.variable });
|
||||
const revisionRef = useRef(0);
|
||||
const [requestPreview, setRequestPreview] =
|
||||
useState<Awaited<ReturnType<typeof buildEmailRequestPreviewFromEditor>>>();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
revisionRef.current += 1;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onChange = () => {
|
||||
revisionRef.current += 1;
|
||||
setRequestPreview(undefined);
|
||||
setPending(false);
|
||||
};
|
||||
|
||||
const loadRequest = async () => {
|
||||
const revision = ++revisionRef.current;
|
||||
setRequestPreview(undefined);
|
||||
const form = props.formRef.current;
|
||||
if (form === null) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
const preview = await buildEmailRequestPreviewFromEditor({
|
||||
form,
|
||||
variable: props.variable,
|
||||
scope,
|
||||
aliases,
|
||||
});
|
||||
if (revision === revisionRef.current) {
|
||||
setRequestPreview(preview);
|
||||
}
|
||||
} catch {
|
||||
if (revision === revisionRef.current) {
|
||||
console.error("Unable to build Email request preview");
|
||||
}
|
||||
} finally {
|
||||
if (revision === revisionRef.current) {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions({
|
||||
onRefresh: () => void loadRequest(),
|
||||
refreshStatus: pending ? "refreshing" : "idle",
|
||||
})}
|
||||
fields={
|
||||
<EmailResourceForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<FormResourcePreview
|
||||
variable={props.variable}
|
||||
variableValue={undefined}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={pending}
|
||||
onLoadData={() => void loadRequest()}
|
||||
requestSnapshot={requestPreview}
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
emptyPreviewMessage="Submit this Form in Preview to see the email service response."
|
||||
suppressPreviewPending
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
$previewFormExchanges,
|
||||
getLatestPreviewExchange,
|
||||
} from "~/shared/preview-form-inspection";
|
||||
import {
|
||||
ResourceVariablePreview,
|
||||
type ResourcePreviewProps,
|
||||
} from "./shared/resource-variable-preview";
|
||||
|
||||
/** Add managed Form submission history to the generic Resource preview. */
|
||||
export const FormResourcePreview = (
|
||||
props: Omit<ResourcePreviewProps, "resolveInspection">
|
||||
) => {
|
||||
const inspections = useStore($previewFormExchanges);
|
||||
const inspection =
|
||||
props.variable?.type === "resource"
|
||||
? inspections.get(props.variable.resourceId)
|
||||
: undefined;
|
||||
const submissionInspection = inspection?.attempts.length
|
||||
? {
|
||||
exchange: inspection.attempts.at(-1)!,
|
||||
revision: inspection.revision,
|
||||
responseAttempts: inspection.attempts.map(
|
||||
({ resourceId, resourceName, response, outcome }, index) => ({
|
||||
attempt: index + 1,
|
||||
resourceId,
|
||||
resourceName,
|
||||
...response,
|
||||
...(outcome === undefined ? {} : { outcome }),
|
||||
})
|
||||
),
|
||||
requestAttempts: inspection.attempts.map(
|
||||
({ resourceId, resourceName, request, kind }, index) => ({
|
||||
attempt: index + 1,
|
||||
resourceId,
|
||||
resourceName,
|
||||
kind,
|
||||
...request,
|
||||
})
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<ResourceVariablePreview
|
||||
{...props}
|
||||
resolveInspection={(resourceInspection) => {
|
||||
const exchange = getLatestPreviewExchange({
|
||||
formInspection: inspection,
|
||||
resourceInspection,
|
||||
});
|
||||
const isSubmission =
|
||||
exchange !== undefined && exchange === submissionInspection?.exchange;
|
||||
return {
|
||||
exchange,
|
||||
responseAttempts: isSubmission
|
||||
? submissionInspection?.responseAttempts
|
||||
: undefined,
|
||||
requestAttempts: isSubmission
|
||||
? submissionInspection?.requestAttempts
|
||||
: undefined,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import { useResourcePreviewController } from "./shared/use-resource-preview-controller";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
import { FormResourcePreview } from "./form-resource-preview";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
generateObjectExpression,
|
||||
isLiteralExpression,
|
||||
parseExpressionObject,
|
||||
parseStringLiteralExpression,
|
||||
} from "@webstudio-is/expression";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import {
|
||||
Grid,
|
||||
InputErrorsTooltip,
|
||||
Label,
|
||||
TextArea,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { $selectedInstance } from "~/shared/nano-states";
|
||||
import { $resources } from "~/shared/sync/data-stores";
|
||||
import { evaluateExpressionWithinScope } from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { ExpressionEditor } from "~/builder/shared/expression-editor";
|
||||
import {
|
||||
EditorDialog,
|
||||
EditorDialogButton,
|
||||
EditorDialogControl,
|
||||
} from "~/shared/code-editor-base";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import { useAsyncValue } from "~/shared/use-async-value";
|
||||
import { createResourceFieldsFromFormData } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
CacheMaxAge,
|
||||
Headers,
|
||||
UrlField,
|
||||
isCacheControl,
|
||||
isContentType,
|
||||
parseHeaders,
|
||||
} from "./shared/resource-fields";
|
||||
import { useResourceScope } from "../resource-scope";
|
||||
import { Row } from "../shared";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
const zGraphqlBody = z.object({
|
||||
query: z.string(),
|
||||
variables: z.optional(z.record(z.string(), z.unknown())),
|
||||
});
|
||||
|
||||
export const GraphqlResourceForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{ variable?: DataSource; onChange?: () => void }
|
||||
>(({ variable, onChange }, ref) => {
|
||||
const { scope, aliases } = useResourceScope({ variable });
|
||||
|
||||
const resources = useStore($resources);
|
||||
const resource =
|
||||
variable?.type === "resource"
|
||||
? resources.get(variable.resourceId)
|
||||
: undefined;
|
||||
|
||||
const [url, setUrl] = useState(resource?.url ?? `""`);
|
||||
const parsedHeaders = parseHeaders(resource?.headers ?? []);
|
||||
const [maxAge, setMaxAge] = useState(parsedHeaders.maxAge);
|
||||
const [headers, setHeaders] = useState(parsedHeaders.headers);
|
||||
|
||||
const [bodyExpressions] = useState(
|
||||
() => parseExpressionObject(resource?.body ?? "") ?? new Map()
|
||||
);
|
||||
const queryId = useId();
|
||||
const [query, setQuery] = useState(
|
||||
() => parseStringLiteralExpression(bodyExpressions.get("query") ?? "") ?? ""
|
||||
);
|
||||
const [variables, setVariables] = useState(
|
||||
() => bodyExpressions.get("variables") ?? "{}"
|
||||
);
|
||||
const [isVariablesLiteral, setIsVariablesLiteral] = useState(() =>
|
||||
isLiteralExpression(variables)
|
||||
);
|
||||
const [variablesError, setVariablesError] = useState("");
|
||||
const variablesRef = useRef<HTMLInputElement>(null);
|
||||
const evaluatedVariables = useAsyncValue(
|
||||
() => evaluateExpressionWithinScope(variables, scope),
|
||||
[scope, variables],
|
||||
undefined
|
||||
);
|
||||
useEffect(() => {
|
||||
variablesRef.current?.setCustomValidity(
|
||||
typeof evaluatedVariables === "object" && evaluatedVariables !== null
|
||||
? ""
|
||||
: "Expected valid JSON object in GraphQL variables"
|
||||
);
|
||||
setVariablesError("");
|
||||
}, [evaluatedVariables]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: (formData) => {
|
||||
// preserve existing instance scope when edit
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
if (scopeInstanceId === undefined) {
|
||||
return;
|
||||
}
|
||||
const resourceFields = createResourceFieldsFromFormData({
|
||||
control: "graphql",
|
||||
formData,
|
||||
});
|
||||
return executeRuntimeMutation({
|
||||
id: "resources.upsert",
|
||||
input: {
|
||||
resourceId: resource?.id,
|
||||
resource: resourceFields,
|
||||
dataSourceId: variable?.id,
|
||||
scopeInstanceId,
|
||||
dataSourceName: resourceFields.name,
|
||||
},
|
||||
})?.result;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<input type="hidden" name="method" value="post" />
|
||||
{!headers.some(({ name }) => isContentType(name)) && (
|
||||
<>
|
||||
<input type="hidden" name="header-name" value="Content-Type" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="header-value"
|
||||
value={`"application/json"`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="hidden"
|
||||
name="body"
|
||||
value={generateObjectExpression(
|
||||
new Map([
|
||||
["query", JSON.stringify(query)],
|
||||
["variables", variables],
|
||||
])
|
||||
)}
|
||||
/>
|
||||
|
||||
<Row>
|
||||
<UrlField
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
value={url}
|
||||
onChange={(value) => {
|
||||
onChange?.();
|
||||
setUrl(value);
|
||||
}}
|
||||
onCurlPaste={(curl) => {
|
||||
onChange?.();
|
||||
// update all feilds when curl is paste into url field
|
||||
setUrl(JSON.stringify(curl.url));
|
||||
const parsedHeaders = parseHeaders(
|
||||
curl.headers.map((header) => ({
|
||||
name: header.name,
|
||||
value: JSON.stringify(header.value),
|
||||
}))
|
||||
);
|
||||
setMaxAge(parsedHeaders.maxAge);
|
||||
setHeaders(parsedHeaders.headers);
|
||||
const body = zGraphqlBody.safeParse(curl.body);
|
||||
if (body.success) {
|
||||
setQuery(body.data.query);
|
||||
setVariables(JSON.stringify(body.data.variables, null, 2));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={queryId}>Query</Label>
|
||||
<EditorDialogControl>
|
||||
<TextArea
|
||||
name="query"
|
||||
id={queryId}
|
||||
rows={3}
|
||||
maxRows={10}
|
||||
autoGrow={true}
|
||||
value={query}
|
||||
onChange={(value) => {
|
||||
onChange?.();
|
||||
setQuery(value);
|
||||
}}
|
||||
/>
|
||||
<EditorDialog
|
||||
title="GraphQL query"
|
||||
content={
|
||||
<TextArea
|
||||
grow={true}
|
||||
value={query}
|
||||
onChange={(value) => {
|
||||
onChange?.();
|
||||
setQuery(value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EditorDialogButton />
|
||||
</EditorDialog>
|
||||
</EditorDialogControl>
|
||||
</Grid>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Grid gap={1}>
|
||||
<Label>GraphQL variables</Label>
|
||||
{/* use invisible text input to reflect expression editor in form
|
||||
type=hidden does not emit invalid event */}
|
||||
<input
|
||||
ref={variablesRef}
|
||||
style={{ display: "none" }}
|
||||
type="text"
|
||||
name="variables"
|
||||
data-color={variablesError ? "error" : undefined}
|
||||
value={variables}
|
||||
onChange={() => {}}
|
||||
onInvalid={(event) =>
|
||||
setVariablesError(event.currentTarget.validationMessage)
|
||||
}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={variables}
|
||||
value={
|
||||
isVariablesLiteral
|
||||
? variables
|
||||
: (JSON.stringify(evaluatedVariables, null, 2) ?? "")
|
||||
}
|
||||
bound={isVariablesLiteral === false}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) => {
|
||||
onChange?.();
|
||||
setVariables(value);
|
||||
}}
|
||||
onChangeExpression={(value) => {
|
||||
onChange?.();
|
||||
setVariables(value);
|
||||
setIsVariablesLiteral(isLiteralExpression(value));
|
||||
}}
|
||||
onRemove={(value) => {
|
||||
onChange?.();
|
||||
setVariables(JSON.stringify(value));
|
||||
setIsVariablesLiteral(true);
|
||||
}}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip
|
||||
errors={variablesError ? [variablesError] : undefined}
|
||||
>
|
||||
{/* wrap with div to position error tooltip */}
|
||||
<div>
|
||||
<ExpressionEditor
|
||||
color={variablesError ? "error" : undefined}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
onChange={onChangeValue}
|
||||
onChangeComplete={() =>
|
||||
variablesRef.current?.checkValidity()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<CacheMaxAge
|
||||
value={maxAge}
|
||||
onChange={(newMaxAge) => {
|
||||
onChange?.();
|
||||
setMaxAge(newMaxAge);
|
||||
setHeaders((headers) =>
|
||||
headers.filter(({ name }) => !isCacheControl(name))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Headers
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
headers={headers}
|
||||
onChange={(newHeaders) => {
|
||||
onChange?.();
|
||||
// reset dedicated fields
|
||||
if (newHeaders.some(({ name }) => isCacheControl(name))) {
|
||||
setMaxAge(undefined);
|
||||
}
|
||||
setHeaders(newHeaders);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
});
|
||||
GraphqlResourceForm.displayName = "GraphqlResourceForm";
|
||||
|
||||
export const GraphqlResourceEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
const preview = useResourcePreviewController({
|
||||
variable: props.variable,
|
||||
formRef: props.formRef,
|
||||
});
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions({
|
||||
onRefresh: () => void preview.reload(),
|
||||
refreshStatus: preview.pending ? "refreshing" : "idle",
|
||||
})}
|
||||
fields={
|
||||
<GraphqlResourceForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
onChange={preview.onChange}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<FormResourcePreview
|
||||
variable={props.variable}
|
||||
showEmptyLoadButton
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={preview.request}
|
||||
showSavedResourceRequest={preview.showSavedRequest}
|
||||
isComputingRequest={preview.pending}
|
||||
onLoadData={preview.reload}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import { useResourcePreviewController } from "./shared/use-resource-preview-controller";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
import { FormResourcePreview } from "./form-resource-preview";
|
||||
import {
|
||||
forwardRef,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
isFormSubmission,
|
||||
type DataSource,
|
||||
type Resource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { Grid, Label, Select, Text } from "@webstudio-is/design-system";
|
||||
import { $selectedInstance } from "~/shared/nano-states";
|
||||
import { $instances, $props, $resources } from "~/shared/sync/data-stores";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import { createResourceFieldsFromFormData } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
BodyField,
|
||||
CacheMaxAge,
|
||||
Headers,
|
||||
MethodField,
|
||||
SearchParams,
|
||||
UrlField,
|
||||
isCacheControl,
|
||||
isContentType,
|
||||
parseHeaders,
|
||||
} from "./shared/resource-fields";
|
||||
import { useResourceScope } from "../resource-scope";
|
||||
import { Row } from "../shared";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
export const HttpResourceForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{ variable?: DataSource; onChange?: () => void }
|
||||
>(({ variable, onChange }, ref) => {
|
||||
const { scope, aliases } = useResourceScope({ variable });
|
||||
const props = useStore($props);
|
||||
const instances = useStore($instances);
|
||||
const formAction =
|
||||
variable !== undefined &&
|
||||
Array.from(props.values()).some(
|
||||
(prop) =>
|
||||
instances.get(prop.instanceId)?.component === "Form" &&
|
||||
prop.name === "action" &&
|
||||
prop.type === "json" &&
|
||||
isFormSubmission(prop.value) &&
|
||||
prop.value.some(({ dataSourceId }) => dataSourceId === variable.id)
|
||||
);
|
||||
|
||||
const resources = useStore($resources);
|
||||
const resource =
|
||||
variable?.type === "resource"
|
||||
? resources.get(variable.resourceId)
|
||||
: undefined;
|
||||
const parsedHeaders = parseHeaders(resource?.headers ?? []);
|
||||
|
||||
const [url, setUrl] = useState(resource?.url ?? `""`);
|
||||
const urlValidatorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [method, setMethod] = useState<Resource["method"]>(
|
||||
resource?.method ?? "get"
|
||||
);
|
||||
const [searchParams, setSearchParams] = useState(
|
||||
resource?.searchParams ?? []
|
||||
);
|
||||
const [headers, setHeaders] = useState<Resource["headers"]>(
|
||||
resource?.bodyFormat === "json" || resource?.bodyFormat === "multipart"
|
||||
? parsedHeaders.headers.filter(({ name }) => !isContentType(name))
|
||||
: parsedHeaders.headers
|
||||
);
|
||||
const [maxAge, setMaxAge] = useState(parsedHeaders.maxAge);
|
||||
const [body, setBody] = useState(resource?.body);
|
||||
const [bodyFormat, setBodyFormat] = useState<Resource["bodyFormat"]>(
|
||||
resource?.bodyFormat ?? "auto"
|
||||
);
|
||||
const bodyFormatId = useId();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: (formData) => {
|
||||
if (urlValidatorRef.current?.checkValidity() === false) {
|
||||
return false;
|
||||
}
|
||||
// preserve existing instance scope when edit
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
if (scopeInstanceId === undefined) {
|
||||
return;
|
||||
}
|
||||
const resourceFields = createResourceFieldsFromFormData({ formData });
|
||||
return executeRuntimeMutation({
|
||||
id: "resources.upsert",
|
||||
input: {
|
||||
resourceId: resource?.id,
|
||||
resource: resourceFields,
|
||||
dataSourceId: variable?.id,
|
||||
scopeInstanceId,
|
||||
dataSourceName: resourceFields.name,
|
||||
},
|
||||
})?.result;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<MethodField
|
||||
value={method}
|
||||
getAdditionalDescription={(method) =>
|
||||
formAction && method === "post"
|
||||
? "Form submissions use POST. This method applies elsewhere."
|
||||
: undefined
|
||||
}
|
||||
onChange={(value) => {
|
||||
onChange?.();
|
||||
setMethod(value);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<UrlField
|
||||
autoFocus
|
||||
inputRef={urlValidatorRef}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
value={url}
|
||||
onChange={(urlExpression, searchParams) => {
|
||||
onChange?.();
|
||||
setUrl(urlExpression);
|
||||
if (searchParams) {
|
||||
setSearchParams((prev) => [...prev, ...searchParams]);
|
||||
}
|
||||
}}
|
||||
onCurlPaste={(curl) => {
|
||||
onChange?.();
|
||||
// update all feilds when curl is paste into url field
|
||||
setMethod(curl.method);
|
||||
setUrl(JSON.stringify(curl.url));
|
||||
setSearchParams(
|
||||
(curl.searchParams ?? []).map((header) => ({
|
||||
name: header.name,
|
||||
value: JSON.stringify(header.value),
|
||||
}))
|
||||
);
|
||||
const parsedHeaders = parseHeaders(
|
||||
curl.headers.map((header) => ({
|
||||
name: header.name,
|
||||
value: JSON.stringify(header.value),
|
||||
}))
|
||||
);
|
||||
setMaxAge(parsedHeaders.maxAge);
|
||||
setHeaders(parsedHeaders.headers);
|
||||
setBody(JSON.stringify(curl.body));
|
||||
setBodyFormat("auto");
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<SearchParams
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
searchParams={searchParams}
|
||||
onChange={(value) => {
|
||||
onChange?.();
|
||||
setSearchParams(value);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<CacheMaxAge
|
||||
value={maxAge}
|
||||
onChange={(newMaxAge) => {
|
||||
onChange?.();
|
||||
setMaxAge(newMaxAge);
|
||||
// reset header
|
||||
setHeaders((headers) =>
|
||||
headers.filter(({ name }) => !isCacheControl(name))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<Headers
|
||||
suggestHeaders
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
headers={headers}
|
||||
onChange={(newHeaders) => {
|
||||
onChange?.();
|
||||
if (bodyFormat !== "auto") {
|
||||
newHeaders = newHeaders.filter(
|
||||
({ name }) => !isContentType(name)
|
||||
);
|
||||
}
|
||||
// reset dedicated fields
|
||||
if (newHeaders.some(({ name }) => isCacheControl(name))) {
|
||||
setMaxAge(undefined);
|
||||
}
|
||||
setHeaders(newHeaders);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
{(method !== "get" || formAction) && (
|
||||
<>
|
||||
<Row>
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={bodyFormatId}>Format</Label>
|
||||
<Select<NonNullable<Resource["bodyFormat"]>>
|
||||
id={bodyFormatId}
|
||||
value={bodyFormat ?? "auto"}
|
||||
options={["auto", "json", "multipart"]}
|
||||
getLabel={(value: NonNullable<Resource["bodyFormat"]>) =>
|
||||
({
|
||||
auto: "auto",
|
||||
json: "application/json",
|
||||
multipart: "multipart/form-data",
|
||||
})[value]
|
||||
}
|
||||
getDescription={(value: NonNullable<Resource["bodyFormat"]>) =>
|
||||
({
|
||||
auto: "Uses text/plain for text values, application/json for other values, and multipart/form-data when files are included.",
|
||||
json: "Sends an object or array as JSON.",
|
||||
multipart: "Sends fields as form data, including files.",
|
||||
})[value]
|
||||
}
|
||||
onChange={(value) => {
|
||||
onChange?.();
|
||||
setBodyFormat(value);
|
||||
if (value !== "auto") {
|
||||
setHeaders((headers) =>
|
||||
headers.filter(({ name }) => !isContentType(name))
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text variant="small" color="subtle">
|
||||
Choose how to send the body.
|
||||
</Text>
|
||||
<input type="hidden" name="body-format" value={bodyFormat} />
|
||||
</Grid>
|
||||
</Row>
|
||||
<Row>
|
||||
<BodyField
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
value={body ?? ""}
|
||||
hasContentTypeHeader={headers.some(({ name }) =>
|
||||
isContentType(name)
|
||||
)}
|
||||
bodyFormat={bodyFormat}
|
||||
onChangeStart={onChange}
|
||||
onChange={setBody}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
HttpResourceForm.displayName = "HttpResourceForm";
|
||||
|
||||
export const HttpResourceEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
const preview = useResourcePreviewController({
|
||||
variable: props.variable,
|
||||
formRef: props.formRef,
|
||||
});
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions({
|
||||
onRefresh: () => void preview.reload(),
|
||||
refreshStatus: preview.pending ? "refreshing" : "idle",
|
||||
})}
|
||||
fields={
|
||||
<HttpResourceForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
onChange={preview.onChange}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<FormResourcePreview
|
||||
variable={props.variable}
|
||||
showEmptyLoadButton
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={preview.request}
|
||||
showSavedResourceRequest={preview.showSavedRequest}
|
||||
isComputingRequest={preview.pending}
|
||||
onLoadData={preview.reload}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
ForwardRefExoticComponent,
|
||||
PropsWithoutRef,
|
||||
RefAttributes,
|
||||
} from "react";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
import type { VariableType } from "./shared/variable-types";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
import { StringEditor, prepareStringValue } from "./string-editor";
|
||||
import { NumberEditor, prepareNumberValue } from "./number-editor";
|
||||
import { BooleanEditor, prepareBooleanValue } from "./boolean-editor";
|
||||
import { JsonEditor, prepareJsonValue } from "./json-editor";
|
||||
import { ParameterEditor } from "./parameter-editor";
|
||||
import { HttpResourceEditor } from "./http-resource-editor";
|
||||
import { GraphqlResourceEditor } from "./graphql-resource-editor";
|
||||
import { EmailResourceEditor } from "./email-resource-editor";
|
||||
import { AssetsResourceEditor } from "./assets-resource-editor";
|
||||
import { SystemResourceEditor } from "./system-resource-editor";
|
||||
|
||||
export const variableEditors: Record<
|
||||
VariableType,
|
||||
ForwardRefExoticComponent<
|
||||
PropsWithoutRef<VariableEditorProps> & RefAttributes<PanelApi | undefined>
|
||||
>
|
||||
> = {
|
||||
parameter: ParameterEditor,
|
||||
string: StringEditor,
|
||||
number: NumberEditor,
|
||||
boolean: BooleanEditor,
|
||||
json: JsonEditor,
|
||||
resource: HttpResourceEditor,
|
||||
"graphql-resource": GraphqlResourceEditor,
|
||||
"email-resource": EmailResourceEditor,
|
||||
"assets-resource": AssetsResourceEditor,
|
||||
"sitemap-resource": SystemResourceEditor,
|
||||
"current-date-resource": SystemResourceEditor,
|
||||
};
|
||||
|
||||
export const prepareVariableEditorValue: Record<
|
||||
VariableType,
|
||||
(previous: unknown) => unknown
|
||||
> = {
|
||||
parameter: (previous) => previous,
|
||||
string: prepareStringValue,
|
||||
number: prepareNumberValue,
|
||||
boolean: prepareBooleanValue,
|
||||
json: prepareJsonValue,
|
||||
resource: () => undefined,
|
||||
"graphql-resource": () => undefined,
|
||||
"email-resource": () => undefined,
|
||||
"assets-resource": () => undefined,
|
||||
"sitemap-resource": () => undefined,
|
||||
"current-date-resource": () => undefined,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
|
||||
import { Row } from "../shared";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { Flex, Label, theme } from "@webstudio-is/design-system";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import { ExpressionEditor } from "~/builder/shared/expression-editor";
|
||||
import { validateDataVariableJsonValue } from "@webstudio-is/project-build/runtime";
|
||||
import { useValuePanelRef } from "./shared/variable-value-save";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
import { ValuePreviewFrame } from "./shared/variable-value-preview";
|
||||
import { parseJsonExpression } from "@webstudio-is/expression";
|
||||
|
||||
export const prepareJsonValue = (previous: unknown) => previous || "{}";
|
||||
|
||||
export const JsonForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
variable?: DataSource;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
>(({ variable, value: unknownValue, onChange }, ref) => {
|
||||
const value = typeof unknownValue === "string" ? unknownValue : "";
|
||||
const [valueError, setValueError] = useState("");
|
||||
const valueRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
valueRef.current?.setCustomValidity(validateDataVariableJsonValue(value));
|
||||
setValueError("");
|
||||
}, [value]);
|
||||
useValuePanelRef({ ref, variable, type: "json" });
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={valueRef}
|
||||
style={{ display: "none" }}
|
||||
name="value"
|
||||
data-color={valueError ? "error" : undefined}
|
||||
value={value}
|
||||
onChange={() => {}}
|
||||
onInvalid={(event) =>
|
||||
setValueError(event.currentTarget.validationMessage)
|
||||
}
|
||||
/>
|
||||
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
|
||||
<Label>Value</Label>
|
||||
<ExpressionEditor
|
||||
showLineNumbers
|
||||
color={valueError ? "error" : undefined}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChangeComplete={() => valueRef.current?.checkValidity()}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
});
|
||||
JsonForm.displayName = "JsonForm";
|
||||
|
||||
export const JsonVariablePreview = ({ value }: { value: unknown }) => {
|
||||
return <ValuePreviewFrame value={parseJsonExpression(String(value))} />;
|
||||
};
|
||||
|
||||
export const JsonEditor = forwardRef<PanelApi | undefined, VariableEditorProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions()}
|
||||
fields={
|
||||
<Row>
|
||||
<JsonForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
value={props.value}
|
||||
onChange={props.onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
preview={<JsonVariablePreview value={props.value} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
|
||||
import { Row } from "../shared";
|
||||
import { forwardRef, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
Flex,
|
||||
Label,
|
||||
InputErrorsTooltip,
|
||||
InputField,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import { validateDataVariableNumberValue } from "@webstudio-is/project-build/runtime";
|
||||
import { useValuePanelRef } from "./shared/variable-value-save";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
import { ValuePreviewFrame } from "./shared/variable-value-preview";
|
||||
|
||||
export const prepareNumberValue = (previous: unknown) =>
|
||||
typeof previous === "number" ? previous : "";
|
||||
|
||||
export const NumberForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
variable?: DataSource;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
>(({ variable, value: unknownValue, onChange }, ref) => {
|
||||
const value =
|
||||
typeof unknownValue === "number" || typeof unknownValue === "string"
|
||||
? unknownValue
|
||||
: "";
|
||||
const [valueError, setValueError] = useState("");
|
||||
const valueRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
valueRef.current?.setCustomValidity(validateDataVariableNumberValue(value));
|
||||
setValueError("");
|
||||
}, [value]);
|
||||
useValuePanelRef({ ref, variable, type: "number" });
|
||||
const valueId = useId();
|
||||
return (
|
||||
<>
|
||||
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
|
||||
<Label htmlFor={valueId}>Value</Label>
|
||||
<InputErrorsTooltip errors={valueError ? [valueError] : undefined}>
|
||||
<InputField
|
||||
inputRef={valueRef}
|
||||
name="value"
|
||||
id={valueId}
|
||||
inputMode="numeric"
|
||||
color={valueError ? "error" : undefined}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={() => valueRef.current?.checkValidity()}
|
||||
onInvalid={(event) =>
|
||||
setValueError(event.currentTarget.validationMessage)
|
||||
}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
});
|
||||
NumberForm.displayName = "NumberForm";
|
||||
|
||||
export const NumberVariablePreview = ({ value }: { value: unknown }) => {
|
||||
const parsed = Number(value);
|
||||
return <ValuePreviewFrame value={Number.isNaN(parsed) ? value : parsed} />;
|
||||
};
|
||||
|
||||
export const NumberEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions()}
|
||||
fields={
|
||||
<Row>
|
||||
<NumberForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
value={props.value}
|
||||
onChange={props.onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
preview={<NumberVariablePreview value={props.value} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
import { z } from "zod";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { computed } from "nanostores";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import {
|
||||
$selectedInstanceKeyWithRoot,
|
||||
$selectedInstanceSelector,
|
||||
$variableValuesByInstanceSelector,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$livePreviewBrowserInfo,
|
||||
$livePreviewFormValues,
|
||||
} from "~/shared/preview-form-values";
|
||||
import { resolveFormParameterPreview } from "../form-context-preview";
|
||||
import { useResourceScope } from "../resource-scope";
|
||||
import { ValuePreviewFrame } from "./shared/variable-value-preview";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
export const ParameterForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{ variable?: DataSource }
|
||||
>(({ variable }, ref) => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: (formData) => {
|
||||
// only existing parameter variables can be renamed
|
||||
if (variable?.scopeInstanceId === undefined) {
|
||||
return;
|
||||
}
|
||||
const scopeInstanceId = variable.scopeInstanceId;
|
||||
const name = z.string().parse(formData.get("name"));
|
||||
executeRuntimeMutation({
|
||||
id: "variables.update",
|
||||
input: {
|
||||
dataSourceId: variable.id,
|
||||
values: { scopeInstanceId, name },
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
return <></>;
|
||||
});
|
||||
ParameterForm.displayName = "ParameterForm";
|
||||
|
||||
const $instanceVariableValues = computed(
|
||||
[$selectedInstanceKeyWithRoot, $variableValuesByInstanceSelector],
|
||||
(instanceKey, variableValuesByInstanceSelector) =>
|
||||
variableValuesByInstanceSelector.get(instanceKey ?? "") ??
|
||||
new Map<string, unknown>()
|
||||
);
|
||||
|
||||
export const ParameterVariablePreview = ({
|
||||
variable,
|
||||
}: {
|
||||
variable?: DataSource;
|
||||
}) => {
|
||||
const variableValues = useStore($instanceVariableValues);
|
||||
const instances = useStore($instances);
|
||||
const liveFormValues = useStore($livePreviewFormValues);
|
||||
const liveBrowserInfo = useStore($livePreviewBrowserInfo);
|
||||
const selectedInstanceSelector = useStore($selectedInstanceSelector);
|
||||
const resourceScope = useResourceScope({ variable });
|
||||
let value =
|
||||
variable === undefined
|
||||
? undefined
|
||||
: (resourceScope.variableValues.get(variable.id) ??
|
||||
variableValues.get(variable.id));
|
||||
if (variable !== undefined) {
|
||||
value =
|
||||
resolveFormParameterPreview(variable, {
|
||||
instances,
|
||||
selector: selectedInstanceSelector,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
})?.value ?? value;
|
||||
}
|
||||
return <ValuePreviewFrame value={value} />;
|
||||
};
|
||||
|
||||
export const ParameterEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions()}
|
||||
fields={<ParameterForm ref={ref} variable={props.variable} />}
|
||||
preview={<ParameterVariablePreview variable={props.variable} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
File renamed without changes.
+4
-1
@@ -151,7 +151,10 @@ export const generateCurl = (request: CurlRequest) => {
|
||||
for (const header of request.headers) {
|
||||
args.push(
|
||||
// escape json in headers
|
||||
`--header "${header.name}: ${serializeValue(header.value).replaceAll('"', '\\"')}"`
|
||||
`--header "${header.name}: ${serializeValue(header.value).replaceAll(
|
||||
'"',
|
||||
'\\"'
|
||||
)}"`
|
||||
);
|
||||
}
|
||||
if (request.body) {
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import type { VariableType } from "./variable-types";
|
||||
|
||||
export type RefreshStatus = "idle" | "refreshing";
|
||||
|
||||
export type VariableEditorProps = {
|
||||
variable?: DataSource;
|
||||
formRef: RefObject<HTMLFormElement>;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
disabled: boolean;
|
||||
commonFields: ReactNode;
|
||||
title: string;
|
||||
titleActions: (options?: {
|
||||
onRefresh?: () => void;
|
||||
refreshStatus?: RefreshStatus;
|
||||
}) => ReactNode;
|
||||
value: unknown;
|
||||
onValueChange: (value: unknown) => void;
|
||||
variableType: VariableType;
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
export const requestHeaderNames = [
|
||||
"Accept",
|
||||
"Accept-Language",
|
||||
"Authorization",
|
||||
"Cache-Control",
|
||||
"Content-Type",
|
||||
"Idempotency-Key",
|
||||
"If-Match",
|
||||
"If-None-Match",
|
||||
"Prefer",
|
||||
"X-API-Key",
|
||||
"X-Requested-With",
|
||||
] as const;
|
||||
|
||||
const requestHeaderValues: Record<string, readonly string[]> = {
|
||||
accept: ["application/json", "text/plain", "application/xml", "*/*"],
|
||||
"cache-control": ["no-cache", "no-store", "max-age=0"],
|
||||
"content-type": [
|
||||
"application/json",
|
||||
"application/x-www-form-urlencoded",
|
||||
"text/plain",
|
||||
],
|
||||
prefer: ["respond-async", "return=minimal", "return=representation"],
|
||||
"x-requested-with": ["XMLHttpRequest"],
|
||||
};
|
||||
|
||||
export const getRequestHeaderValueSuggestions = (name: string) =>
|
||||
requestHeaderValues[name.trim().toLowerCase()] ?? [];
|
||||
File renamed without changes.
+2
-2
@@ -4,11 +4,11 @@ import type { ResourcePerformance } from "~/shared/resource-diagnostics";
|
||||
import {
|
||||
ContentDatabaseDiagnostics,
|
||||
ResourcePerformanceDiagnostics,
|
||||
} from "./content-database-diagnostics";
|
||||
} from "../../content-database-diagnostics";
|
||||
import {
|
||||
RequestErrorDiagnostics,
|
||||
type RequestErrorDiagnosticsValue,
|
||||
} from "./request-error-diagnostics";
|
||||
} from "../../request-error-diagnostics";
|
||||
|
||||
export const ResourceDiagnosticsView = ({
|
||||
requestError,
|
||||
+715
@@ -0,0 +1,715 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { type Resource } from "@webstudio-is/sdk";
|
||||
import {
|
||||
isLiteralExpression,
|
||||
parseStringLiteralExpression,
|
||||
parseJsonExpression,
|
||||
} from "@webstudio-is/expression";
|
||||
import {
|
||||
serializeValue,
|
||||
getResourceBodyFormatError,
|
||||
} from "@webstudio-is/sdk/runtime";
|
||||
import {
|
||||
Box,
|
||||
Combobox,
|
||||
Flex,
|
||||
Grid,
|
||||
InputErrorsTooltip,
|
||||
InfoTooltip,
|
||||
InputField,
|
||||
Label,
|
||||
Select,
|
||||
SmallIconButton,
|
||||
Text,
|
||||
TextArea,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { MinusIcon, PlusIcon } from "@webstudio-is/icons";
|
||||
import { evaluateExpressionWithinScope } from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { ExpressionEditor } from "~/builder/shared/expression-editor";
|
||||
import { useAsyncValue } from "~/shared/use-async-value";
|
||||
import {
|
||||
validateResourceBodyExpression,
|
||||
validateResourceUrlExpression,
|
||||
type ResourceBodyInputType,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { parseCurl, type CurlRequest } from "./curl";
|
||||
import {
|
||||
getRequestHeaderValueSuggestions,
|
||||
requestHeaderNames,
|
||||
} from "./request-header-suggestions";
|
||||
|
||||
export const UrlField = ({
|
||||
scope,
|
||||
aliases,
|
||||
value,
|
||||
onChange,
|
||||
onCurlPaste,
|
||||
autoFocus,
|
||||
inputRef,
|
||||
}: {
|
||||
aliases: Map<string, string>;
|
||||
scope: Record<string, unknown>;
|
||||
value: string;
|
||||
onChange: (
|
||||
urlExpression: string,
|
||||
searchParams?: Resource["searchParams"]
|
||||
) => void;
|
||||
onCurlPaste: (curl: CurlRequest) => void;
|
||||
autoFocus?: boolean;
|
||||
inputRef?: { current: HTMLTextAreaElement | null };
|
||||
}) => {
|
||||
const urlId = useId();
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const touched = useRef(false);
|
||||
const change = (
|
||||
expression: string,
|
||||
searchParams?: Resource["searchParams"]
|
||||
) => {
|
||||
touched.current = true;
|
||||
onChange(expression, searchParams);
|
||||
};
|
||||
// revalidate and hide error message
|
||||
// until validity is checks again
|
||||
useEffect(() => {
|
||||
void validateResourceUrlExpression(value, scope).then((error) => {
|
||||
ref.current?.setCustomValidity(error);
|
||||
});
|
||||
setError("");
|
||||
}, [value, scope]);
|
||||
const evaluatedValue = useAsyncValue(
|
||||
() => evaluateExpressionWithinScope(value, scope),
|
||||
[scope, value],
|
||||
undefined
|
||||
);
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Label
|
||||
htmlFor={urlId}
|
||||
css={{ display: "flex", alignItems: "center", gap: theme.spacing[3] }}
|
||||
>
|
||||
URL
|
||||
<InfoTooltip
|
||||
label="About resource URL"
|
||||
content="You can paste a URL or cURL. cURL is a format that can be executed directly in your terminal because it contains the entire Resource configuration."
|
||||
disableHoverableContent={true}
|
||||
/>
|
||||
</Label>
|
||||
<input type="hidden" readOnly={true} name="url" value={value} />
|
||||
<BindableExpressionControl
|
||||
expression={value}
|
||||
value={String(evaluatedValue ?? "")}
|
||||
bound={isLiteralExpression(value) === false}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) => change(JSON.stringify(value))}
|
||||
onChangeExpression={change}
|
||||
onRemove={(value) => change(JSON.stringify(value))}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={error ? [error] : undefined}>
|
||||
<TextArea
|
||||
ref={(element) => {
|
||||
ref.current = element;
|
||||
if (inputRef) {
|
||||
inputRef.current = element;
|
||||
}
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
name="url-validator"
|
||||
id={urlId}
|
||||
rows={1}
|
||||
grow={true}
|
||||
disabled={readOnly}
|
||||
color={error ? "error" : undefined}
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
const curl = parseCurl(value);
|
||||
if (curl) {
|
||||
onCurlPaste(curl);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.searchParams.size > 0) {
|
||||
const searchParams: Resource["searchParams"] = [];
|
||||
for (const [name, value] of url.searchParams) {
|
||||
searchParams.push({ name, value: JSON.stringify(value) });
|
||||
}
|
||||
// remove all search params from url
|
||||
url.search = "";
|
||||
// update text value as string literal
|
||||
change(JSON.stringify(url.href), searchParams);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// serialize without changes when url is invalid
|
||||
}
|
||||
onChangeValue(value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (touched.current) {
|
||||
event.currentTarget.checkValidity();
|
||||
}
|
||||
}}
|
||||
onInvalid={(event) =>
|
||||
setError(event.currentTarget.validationMessage)
|
||||
}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const MethodField = ({
|
||||
value,
|
||||
onChange,
|
||||
getAdditionalDescription,
|
||||
}: {
|
||||
value: Resource["method"];
|
||||
onChange: (value: Resource["method"]) => void;
|
||||
getAdditionalDescription?: (method: Resource["method"]) => string | undefined;
|
||||
}) => {
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Label>Method</Label>
|
||||
<Select<Resource["method"]>
|
||||
fullWidth
|
||||
options={["get", "post", "put", "delete"]}
|
||||
getLabel={(method: Resource["method"]) => method.toUpperCase()}
|
||||
getDescription={(method) => (
|
||||
<Box css={{ width: "100%" }}>
|
||||
{[
|
||||
{
|
||||
get: "Read data from a server.",
|
||||
post: "Send data to create or process something.",
|
||||
put: "Replace data on a server.",
|
||||
delete: "Delete data from a server.",
|
||||
}[method],
|
||||
getAdditionalDescription?.(method),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</Box>
|
||||
)}
|
||||
name="method"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type ExpressionPair = Resource["headers"][number];
|
||||
|
||||
const ExpressionNameValuePair = ({
|
||||
aliases,
|
||||
scope,
|
||||
nameFieldName,
|
||||
valueFieldName,
|
||||
valueValidatorName,
|
||||
deleteLabel,
|
||||
nameSuggestions = [],
|
||||
valueSuggestions = [],
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onDelete,
|
||||
autoFocusName,
|
||||
}: {
|
||||
aliases: Map<string, string>;
|
||||
scope: Record<string, unknown>;
|
||||
nameFieldName: string;
|
||||
valueFieldName: string;
|
||||
valueValidatorName: string;
|
||||
deleteLabel: string;
|
||||
nameSuggestions?: readonly string[];
|
||||
valueSuggestions?: readonly string[];
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (name: string, value: string) => void;
|
||||
onDelete: () => void;
|
||||
autoFocusName: boolean;
|
||||
}) => {
|
||||
const evaluatedValue = useAsyncValue(
|
||||
() => evaluateExpressionWithinScope(value, scope),
|
||||
[scope, value],
|
||||
undefined
|
||||
);
|
||||
const isValueString = typeof evaluatedValue === "string";
|
||||
return (
|
||||
<Grid
|
||||
gap={2}
|
||||
align="center"
|
||||
css={{ gridTemplateColumns: `120px 1fr min-content` }}
|
||||
>
|
||||
{nameSuggestions.length > 0 ? (
|
||||
<Combobox<string>
|
||||
modal={false}
|
||||
autoFocus={autoFocusName}
|
||||
placeholder="Name"
|
||||
name={nameFieldName}
|
||||
value={name}
|
||||
getItems={() => [...nameSuggestions]}
|
||||
itemToString={(item) => item ?? ""}
|
||||
onItemSelect={(selected) => onChange(selected, value)}
|
||||
onChange={(nextName) => {
|
||||
if (nextName !== undefined) {
|
||||
onChange(nextName, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InputField
|
||||
// autofocus only new fields
|
||||
autoFocus={autoFocusName}
|
||||
placeholder="Name"
|
||||
name={nameFieldName}
|
||||
value={name}
|
||||
onChange={(event) => onChange(event.target.value, value)}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="hidden"
|
||||
readOnly={true}
|
||||
name={valueFieldName}
|
||||
value={value}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={value}
|
||||
value={serializeValue(evaluatedValue) ?? ""}
|
||||
bound={isLiteralExpression(value) === false}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) => onChange(name, JSON.stringify(value))}
|
||||
onChangeExpression={(value) => onChange(name, value)}
|
||||
onRemove={(value) => onChange(name, JSON.stringify(value))}
|
||||
renderControl={({ value, readOnly, onChangeValue }) =>
|
||||
valueSuggestions.length > 0 ? (
|
||||
<Combobox<string>
|
||||
modal={false}
|
||||
placeholder="Value"
|
||||
name={valueValidatorName}
|
||||
disabled={readOnly || !isValueString}
|
||||
value={value}
|
||||
getItems={() => [...valueSuggestions]}
|
||||
itemToString={(item) => item ?? ""}
|
||||
onItemSelect={onChangeValue}
|
||||
onChange={(nextValue) => {
|
||||
if (nextValue !== undefined) {
|
||||
onChangeValue(nextValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InputField
|
||||
placeholder="Value"
|
||||
name={valueValidatorName}
|
||||
disabled={readOnly || !isValueString}
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SmallIconButton
|
||||
aria-label={`Delete ${deleteLabel}`}
|
||||
variant="destructive"
|
||||
icon={<MinusIcon />}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpressionPairs = ({
|
||||
scope,
|
||||
aliases,
|
||||
label,
|
||||
itemLabel,
|
||||
description,
|
||||
nameFieldName,
|
||||
valueFieldName,
|
||||
valueValidatorName,
|
||||
nameSuggestions,
|
||||
getValueSuggestions,
|
||||
shouldAutoFocusName,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
label: string;
|
||||
itemLabel: string;
|
||||
description: string;
|
||||
nameFieldName: string;
|
||||
valueFieldName: string;
|
||||
valueValidatorName: string;
|
||||
nameSuggestions?: readonly string[];
|
||||
getValueSuggestions?: (name: string) => readonly string[];
|
||||
shouldAutoFocusName: (item: ExpressionPair) => boolean;
|
||||
values: ExpressionPair[];
|
||||
onChange: (values: ExpressionPair[]) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Flex justify="between" align="center">
|
||||
<Flex align="center" gap={1}>
|
||||
<Label>{label}</Label>
|
||||
<InfoTooltip
|
||||
label={`About ${label.toLowerCase()}`}
|
||||
content={description}
|
||||
/>
|
||||
</Flex>
|
||||
<SmallIconButton
|
||||
aria-label={`Add another ${itemLabel}`}
|
||||
icon={<PlusIcon />}
|
||||
// Use an empty string expression as the default value.
|
||||
onClick={() => onChange([...values, { name: "", value: `""` }])}
|
||||
/>
|
||||
</Flex>
|
||||
<Grid gap={2}>
|
||||
{values.map((item, index) => (
|
||||
<ExpressionNameValuePair
|
||||
key={index}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
nameFieldName={nameFieldName}
|
||||
valueFieldName={valueFieldName}
|
||||
valueValidatorName={valueValidatorName}
|
||||
deleteLabel={itemLabel}
|
||||
nameSuggestions={nameSuggestions}
|
||||
valueSuggestions={getValueSuggestions?.(item.name)}
|
||||
autoFocusName={shouldAutoFocusName(item)}
|
||||
name={item.name}
|
||||
value={item.value}
|
||||
onChange={(name, value) => {
|
||||
const next = [...values];
|
||||
next[index] = { name, value };
|
||||
onChange(next);
|
||||
}}
|
||||
onDelete={() =>
|
||||
onChange(values.filter((_, position) => position !== index))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{values.length === 0 && (
|
||||
<Text color="subtle" align="center">
|
||||
No {label.toLowerCase()}
|
||||
</Text>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchParams = ({
|
||||
searchParams,
|
||||
...props
|
||||
}: {
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
searchParams: NonNullable<Resource["searchParams"]>;
|
||||
onChange: (searchParams: NonNullable<Resource["searchParams"]>) => void;
|
||||
}) => (
|
||||
<ExpressionPairs
|
||||
{...props}
|
||||
label="Search params"
|
||||
itemLabel="search param"
|
||||
description="Search params are name-value pairs added to the URL after ?. Use them to send filters, search terms, or other request options."
|
||||
nameFieldName="search-param-name"
|
||||
valueFieldName="search-param-value"
|
||||
valueValidatorName="search-param-value-literal"
|
||||
shouldAutoFocusName={(item) => item.name === ""}
|
||||
values={searchParams}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Headers = ({
|
||||
headers,
|
||||
...props
|
||||
}: {
|
||||
aliases: Map<string, string>;
|
||||
scope: Record<string, unknown>;
|
||||
headers: Resource["headers"];
|
||||
onChange: (headers: Resource["headers"]) => void;
|
||||
suggestHeaders?: boolean;
|
||||
}) => {
|
||||
const hasMounted = useRef(false);
|
||||
useEffect(() => {
|
||||
hasMounted.current = true;
|
||||
}, []);
|
||||
return (
|
||||
<ExpressionPairs
|
||||
{...props}
|
||||
label="Headers"
|
||||
itemLabel="header"
|
||||
description="Headers are name-value pairs sent with the request. Use them to tell the server how to interpret the request or who is making it."
|
||||
nameFieldName="header-name"
|
||||
valueFieldName="header-value"
|
||||
valueValidatorName="header-value-validator"
|
||||
nameSuggestions={props.suggestHeaders ? [...requestHeaderNames] : []}
|
||||
getValueSuggestions={
|
||||
props.suggestHeaders ? getRequestHeaderValueSuggestions : undefined
|
||||
}
|
||||
shouldAutoFocusName={(item) =>
|
||||
item.name === "" && (!props.suggestHeaders || hasMounted.current)
|
||||
}
|
||||
values={headers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CacheMaxAge = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: undefined | string;
|
||||
onChange: (newValue: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Flex align="center" css={{ gap: theme.spacing[3] }}>
|
||||
<Label htmlFor="resource-panel-max-age">Cache max age</Label>
|
||||
<InfoTooltip
|
||||
label="About cache max age"
|
||||
content="How long to cache the response, in seconds."
|
||||
disableHoverableContent={true}
|
||||
/>
|
||||
</Flex>
|
||||
<InputField
|
||||
id="resource-panel-max-age"
|
||||
suffix={
|
||||
<Text variant="small" color="subtle" css={{ paddingInline: "2px" }}>
|
||||
S
|
||||
</Text>
|
||||
}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
{value && (
|
||||
<>
|
||||
<input type="hidden" name="header-name" value="Cache-Control" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="header-value"
|
||||
value={`"max-age=${value}"`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
type BodyType = ResourceBodyInputType;
|
||||
|
||||
const toMime = (bodyType: BodyType) => {
|
||||
if (bodyType === "json") {
|
||||
return "application/json";
|
||||
}
|
||||
if (bodyType === "text") {
|
||||
return "text/plain";
|
||||
}
|
||||
};
|
||||
|
||||
const getBodyType = (value: unknown): BodyType => {
|
||||
if (typeof value === "string") {
|
||||
return "text";
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "object" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return "json";
|
||||
}
|
||||
};
|
||||
|
||||
export const BodyField = ({
|
||||
scope,
|
||||
aliases,
|
||||
hasContentTypeHeader = false,
|
||||
bodyFormat,
|
||||
value,
|
||||
onChangeStart,
|
||||
onChange,
|
||||
}: {
|
||||
aliases: Map<string, string>;
|
||||
scope: Record<string, unknown>;
|
||||
hasContentTypeHeader?: boolean;
|
||||
bodyFormat: Resource["bodyFormat"];
|
||||
value: string;
|
||||
onChangeStart?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
}) => {
|
||||
const [isBodyLiteral, setIsBodyLiteral] = useState(
|
||||
() => value === "" || isLiteralExpression(value)
|
||||
);
|
||||
const [bodyError, setBodyError] = useState("");
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const evaluatedValue = useAsyncValue(
|
||||
() => evaluateExpressionWithinScope(value, scope),
|
||||
[scope, value],
|
||||
parseJsonExpression(value)
|
||||
);
|
||||
const inferredBodyType = getBodyType(evaluatedValue);
|
||||
const [lastBodyType, setLastBodyType] = useState(inferredBodyType);
|
||||
useEffect(() => {
|
||||
if (inferredBodyType !== undefined || value === "") {
|
||||
setLastBodyType(inferredBodyType);
|
||||
}
|
||||
}, [inferredBodyType, value]);
|
||||
// Keep the JSON editor while an in-progress edit is temporarily invalid.
|
||||
const bodyType = inferredBodyType ?? lastBodyType;
|
||||
const effectiveBodyType = bodyFormat === "auto" ? bodyType : "json";
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
void validateResourceBodyExpression(value, effectiveBodyType, scope).then(
|
||||
async (error) => {
|
||||
let validationError: string = error;
|
||||
if (bodyFormat === "auto" && value !== "") {
|
||||
const body = await evaluateExpressionWithinScope(value, scope);
|
||||
if (
|
||||
body === null ||
|
||||
typeof body === "number" ||
|
||||
typeof body === "boolean"
|
||||
) {
|
||||
validationError = "";
|
||||
}
|
||||
}
|
||||
if (error === "" && value !== "" && bodyFormat !== "auto") {
|
||||
const body = await evaluateExpressionWithinScope(value, scope);
|
||||
validationError =
|
||||
getResourceBodyFormatError({
|
||||
name: "",
|
||||
method: "post",
|
||||
url: "",
|
||||
searchParams: [],
|
||||
headers: [],
|
||||
body,
|
||||
bodyFormat,
|
||||
}) ?? "";
|
||||
}
|
||||
if (!canceled) {
|
||||
bodyRef.current?.setCustomValidity(validationError);
|
||||
}
|
||||
}
|
||||
);
|
||||
setBodyError("");
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [value, effectiveBodyType, bodyFormat, scope]);
|
||||
const updateBody = (newBody: string) => {
|
||||
onChangeStart?.();
|
||||
onChange(newBody);
|
||||
};
|
||||
const displayedValue = isBodyLiteral
|
||||
? value
|
||||
: effectiveBodyType === "json"
|
||||
? (JSON.stringify(evaluatedValue, null, 2) ?? "")
|
||||
: String(evaluatedValue ?? "");
|
||||
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Label>Body</Label>
|
||||
{bodyFormat === "auto" && bodyType && !hasContentTypeHeader && (
|
||||
<>
|
||||
<input type="hidden" name="header-name" value="Content-Type" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="header-value"
|
||||
value={
|
||||
isLiteralExpression(value)
|
||||
? JSON.stringify(toMime(bodyType))
|
||||
: `typeof (${value}) === "string" ? "text/plain" : "application/json"`
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
style={{ display: "none" }}
|
||||
name="body"
|
||||
data-color={bodyError ? "error" : undefined}
|
||||
value={value}
|
||||
onChange={() => {}}
|
||||
onInvalid={(event) =>
|
||||
setBodyError(event.currentTarget.validationMessage)
|
||||
}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={value}
|
||||
value={displayedValue}
|
||||
bound={isBodyLiteral === false}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={updateBody}
|
||||
onChangeExpression={(value) => {
|
||||
updateBody(value);
|
||||
setIsBodyLiteral(isLiteralExpression(value));
|
||||
}}
|
||||
onRemove={(value) => {
|
||||
updateBody(JSON.stringify(value));
|
||||
setIsBodyLiteral(true);
|
||||
}}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={bodyError ? [bodyError] : undefined}>
|
||||
<div>
|
||||
<ExpressionEditor
|
||||
showLineNumbers
|
||||
color={bodyError ? "error" : undefined}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
onChange={onChangeValue}
|
||||
onChangeComplete={() => bodyRef.current?.checkValidity()}
|
||||
/>
|
||||
</div>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const isCacheControl = (name: string) =>
|
||||
name.toLowerCase() === "cache-control";
|
||||
export const isContentType = (name: string) =>
|
||||
name.toLowerCase() === "content-type";
|
||||
|
||||
export const parseHeaders = (headers: Resource["headers"]) => {
|
||||
let maxAge: undefined | string;
|
||||
const newHeaders = headers.filter((header) => {
|
||||
// cast raw expression result to string
|
||||
const value = String(
|
||||
parseStringLiteralExpression(header.value) ?? ""
|
||||
).toLowerCase();
|
||||
if (isCacheControl(header.name)) {
|
||||
// move simple header like Cache-Control: max-age=10 to dedicated input
|
||||
// preserve more complex cache-control
|
||||
const matched = value.match(/^max-age=(\d+)$/);
|
||||
if (matched) {
|
||||
[, maxAge] = matched;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// store json and text in dedicated input
|
||||
// and preserve other types
|
||||
if (isContentType(header.name)) {
|
||||
if (value === "application/json") {
|
||||
return false;
|
||||
}
|
||||
if (value === "text/plain") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { headers: newHeaders, maxAge };
|
||||
};
|
||||
|
||||
export const __testing__ = { BodyField };
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { Button, Flex, Grid, Text } from "@webstudio-is/design-system";
|
||||
import {
|
||||
resourceRequest,
|
||||
type DataSource,
|
||||
type ResourceRequest,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { formatValue } from "~/builder/shared/expression-editor";
|
||||
import { EditorContent, foldGutterExtension } from "~/shared/code-editor-base";
|
||||
import {
|
||||
$resourcePreviewExchanges,
|
||||
type PreviewResourceExchange,
|
||||
} from "~/shared/preview-resource-inspection";
|
||||
import { $resources } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$pendingResourceKeys,
|
||||
$resourcesCache,
|
||||
computeResourceRequest,
|
||||
getResourceKey,
|
||||
} from "~/shared/resources";
|
||||
import { useResourceScope } from "../../resource-scope";
|
||||
import { RequestInspector } from "../../request-inspector";
|
||||
import {
|
||||
getRequestErrorDiagnostics,
|
||||
RequestErrorDiagnostics,
|
||||
type RequestErrorDiagnosticsValue,
|
||||
} from "../../request-error-diagnostics";
|
||||
import { ResourceDiagnosticsView } from "./resource-diagnostics-view";
|
||||
|
||||
export type ResourcePreviewProps = {
|
||||
resolveInspection?: (resourceInspection?: {
|
||||
exchange: PreviewResourceExchange;
|
||||
revision: number;
|
||||
}) => {
|
||||
exchange?: PreviewResourceExchange;
|
||||
responseAttempts?: unknown[];
|
||||
requestAttempts?: unknown[];
|
||||
};
|
||||
variable?: DataSource;
|
||||
variableValue: unknown;
|
||||
showEmptyLoadButton?: boolean;
|
||||
emptyPreviewMessage?: ReactNode;
|
||||
inspectSubmission?: boolean;
|
||||
alwaysShowRequestTab?: boolean;
|
||||
showSavedResourceRequest?: boolean;
|
||||
isComputingRequest?: boolean;
|
||||
onLoadData?: () => void;
|
||||
queryActive?: boolean;
|
||||
queryPending?: boolean;
|
||||
queryContainerRef?: (element: HTMLDivElement | null) => void;
|
||||
requestSnapshot?: unknown;
|
||||
suppressPreviewPending?: boolean;
|
||||
diagnostics?: (
|
||||
request: ResourceRequest | undefined,
|
||||
requestError: RequestErrorDiagnosticsValue | undefined
|
||||
) => ReactNode;
|
||||
onDiagnosticsOpen?: (request: ResourceRequest) => void;
|
||||
diagnosticsPending?: (request: ResourceRequest | undefined) => boolean;
|
||||
};
|
||||
|
||||
export const ResourceVariablePreview = ({
|
||||
variable,
|
||||
variableValue,
|
||||
resolveInspection,
|
||||
showEmptyLoadButton = false,
|
||||
emptyPreviewMessage,
|
||||
inspectSubmission: inspectSubmissionByDefault = false,
|
||||
alwaysShowRequestTab = false,
|
||||
showSavedResourceRequest = false,
|
||||
isComputingRequest = false,
|
||||
onLoadData,
|
||||
queryActive = false,
|
||||
queryPending = false,
|
||||
queryContainerRef,
|
||||
requestSnapshot: customRequestSnapshot,
|
||||
suppressPreviewPending,
|
||||
diagnostics,
|
||||
onDiagnosticsOpen,
|
||||
diagnosticsPending,
|
||||
}: ResourcePreviewProps) => {
|
||||
const pendingResourceKeys = useStore($pendingResourceKeys);
|
||||
const resources = useStore($resources);
|
||||
const resourceExchanges = useStore($resourcePreviewExchanges);
|
||||
const resourcesCache = useStore($resourcesCache);
|
||||
const resourceScope = useResourceScope({ variable });
|
||||
const [resolvedResourceRequest, setResolvedResourceRequest] = useState<
|
||||
ResourceRequest | undefined
|
||||
>(() => resourceRequest.safeParse(variableValue).data);
|
||||
useEffect(() => {
|
||||
const parsedResourceRequest = resourceRequest.safeParse(variableValue).data;
|
||||
if (parsedResourceRequest !== undefined) {
|
||||
setResolvedResourceRequest(parsedResourceRequest);
|
||||
return;
|
||||
}
|
||||
if (variable?.type !== "resource" || !showSavedResourceRequest) {
|
||||
setResolvedResourceRequest(undefined);
|
||||
return;
|
||||
}
|
||||
const resource = resources.get(variable.resourceId);
|
||||
if (resource === undefined) {
|
||||
setResolvedResourceRequest(undefined);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setResolvedResourceRequest(undefined);
|
||||
void computeResourceRequest(resource, resourceScope.variableValues)
|
||||
.then((request) => {
|
||||
if (active) {
|
||||
setResolvedResourceRequest(request);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setResolvedResourceRequest(undefined);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
resources,
|
||||
resourceScope.variableValues,
|
||||
variable,
|
||||
variableValue,
|
||||
showSavedResourceRequest,
|
||||
]);
|
||||
const parsedResourceRequest = resourceRequest.safeParse(variableValue).data;
|
||||
const computedResourceRequest =
|
||||
parsedResourceRequest ??
|
||||
(variable?.type === "resource" && showSavedResourceRequest
|
||||
? resolvedResourceRequest
|
||||
: undefined);
|
||||
const previewPending =
|
||||
isComputingRequest ||
|
||||
(computedResourceRequest !== undefined &&
|
||||
pendingResourceKeys.has(getResourceKey(computedResourceRequest)));
|
||||
let computedValue: unknown;
|
||||
let computedResourceKey: string | undefined;
|
||||
if (computedResourceRequest) {
|
||||
const resourceKey = getResourceKey(computedResourceRequest);
|
||||
computedResourceKey = resourceKey;
|
||||
computedValue = resourcesCache.get(resourceKey);
|
||||
}
|
||||
const resourceInspection =
|
||||
computedResourceKey === undefined
|
||||
? undefined
|
||||
: resourceExchanges.get(computedResourceKey);
|
||||
const resolvedInspection = resolveInspection?.(resourceInspection);
|
||||
const latestExchange =
|
||||
resolvedInspection?.exchange ?? resourceInspection?.exchange;
|
||||
if (latestExchange) {
|
||||
computedValue = {
|
||||
resourceId: latestExchange.resourceId,
|
||||
resourceName: latestExchange.resourceName,
|
||||
...latestExchange.response,
|
||||
ok: latestExchange.outcome?.ok ?? latestExchange.response.status < 400,
|
||||
attempts: resolvedInspection?.responseAttempts,
|
||||
};
|
||||
}
|
||||
const extensions = useMemo(() => [javascript({}), foldGutterExtension], []);
|
||||
const editorProps = {
|
||||
readOnly: true,
|
||||
chromeless: true,
|
||||
extensions,
|
||||
// compute value as json lazily only when dialog is open
|
||||
// by spliting into separate component which is invoked
|
||||
// only when dialog content is rendered
|
||||
value: formatValue(computedValue),
|
||||
onChange: () => {},
|
||||
onChangeComplete: () => {},
|
||||
};
|
||||
const loadDataButton = (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={previewPending || onLoadData === undefined}
|
||||
onClick={onLoadData}
|
||||
>
|
||||
{previewPending ? "Loading..." : "Load data"}
|
||||
</Button>
|
||||
);
|
||||
const previewContent = (
|
||||
<Grid
|
||||
align="stretch"
|
||||
css={{
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
boxSizing: "content-box",
|
||||
position: "relative",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
}}
|
||||
>
|
||||
<EditorContent {...editorProps} />
|
||||
{computedValue === undefined && emptyPreviewMessage !== undefined && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
css={{ position: "absolute", inset: 0, padding: 16 }}
|
||||
>
|
||||
<Text color="moreSubtle">{emptyPreviewMessage}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{showEmptyLoadButton && !computedValue && (
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
css={{ position: "absolute", inset: 0 }}
|
||||
>
|
||||
{loadDataButton}
|
||||
</Flex>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
const inspectSubmission =
|
||||
inspectSubmissionByDefault || latestExchange !== undefined;
|
||||
if (!showEmptyLoadButton && !inspectSubmission) {
|
||||
return previewContent;
|
||||
}
|
||||
const requestErrorDiagnostics = getRequestErrorDiagnostics(
|
||||
latestExchange
|
||||
? latestExchange.outcome
|
||||
? { ...latestExchange.outcome, data: latestExchange.outcome.body }
|
||||
: { ...latestExchange.response, data: latestExchange.response.body }
|
||||
: computedValue
|
||||
);
|
||||
const preview =
|
||||
requestErrorDiagnostics === undefined ? (
|
||||
previewContent
|
||||
) : (
|
||||
<RequestErrorDiagnostics value={requestErrorDiagnostics} />
|
||||
);
|
||||
const requestSnapshot = latestExchange
|
||||
? (resolvedInspection?.requestAttempts ?? {
|
||||
resourceId: latestExchange.resourceId,
|
||||
resourceName: latestExchange.resourceName,
|
||||
...latestExchange.request,
|
||||
})
|
||||
: customRequestSnapshot;
|
||||
return (
|
||||
<RequestInspector
|
||||
previewLabel={inspectSubmission ? "Response" : "Preview"}
|
||||
request={
|
||||
alwaysShowRequestTab || requestSnapshot !== undefined ? (
|
||||
requestSnapshot !== undefined ? (
|
||||
<EditorContent
|
||||
{...editorProps}
|
||||
value={formatValue(requestSnapshot)}
|
||||
/>
|
||||
) : (
|
||||
<Flex align="center" justify="center" css={{ height: "100%" }}>
|
||||
{loadDataButton}
|
||||
</Flex>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
queryContainerRef={queryActive ? queryContainerRef : undefined}
|
||||
preview={inspectSubmission ? previewContent : preview}
|
||||
queryPending={queryPending}
|
||||
previewPending={suppressPreviewPending ? false : previewPending}
|
||||
requestPending={previewPending}
|
||||
onDiagnosticsOpen={
|
||||
computedResourceRequest === undefined || onDiagnosticsOpen === undefined
|
||||
? undefined
|
||||
: () => onDiagnosticsOpen(computedResourceRequest)
|
||||
}
|
||||
diagnosticsPending={diagnosticsPending?.(computedResourceRequest)}
|
||||
diagnostics={
|
||||
diagnostics?.(computedResourceRequest, requestErrorDiagnostics) ?? (
|
||||
<ResourceDiagnosticsView requestError={requestErrorDiagnostics} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { type DataSource } from "@webstudio-is/sdk";
|
||||
import { createResourceValueFromFormData } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
computeResourceRequest,
|
||||
loadResourcePreview,
|
||||
} from "~/shared/resources";
|
||||
import { useResourceScope } from "../../resource-scope";
|
||||
|
||||
/** Owns the preview request for HTTP-like resource editors. */
|
||||
export const useResourcePreviewController = ({
|
||||
variable,
|
||||
formRef,
|
||||
getFormData,
|
||||
}: {
|
||||
variable?: DataSource;
|
||||
formRef: RefObject<HTMLFormElement>;
|
||||
getFormData?: (form: HTMLFormElement | null) => FormData | undefined;
|
||||
}) => {
|
||||
const resourceScope = useResourceScope({ variable });
|
||||
const releaseRef = useRef<(() => void) | undefined>(undefined);
|
||||
const revisionRef = useRef(0);
|
||||
const [request, setRequest] = useState<unknown>();
|
||||
const [showSavedRequest, setShowSavedRequest] = useState(true);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const cancel = () => {
|
||||
revisionRef.current += 1;
|
||||
releaseRef.current?.();
|
||||
releaseRef.current = undefined;
|
||||
setPending(false);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
revisionRef.current += 1;
|
||||
releaseRef.current?.();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onChange = () => {
|
||||
cancel();
|
||||
setRequest(undefined);
|
||||
setShowSavedRequest(false);
|
||||
};
|
||||
const reload = async () => {
|
||||
cancel();
|
||||
const revision = revisionRef.current;
|
||||
setRequest(undefined);
|
||||
setShowSavedRequest(false);
|
||||
const formData = getFormData
|
||||
? getFormData(formRef.current)
|
||||
: new FormData(formRef.current ?? undefined);
|
||||
if (formData === undefined) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
const resource = createResourceValueFromFormData({
|
||||
id: variable?.id ?? "new",
|
||||
formData,
|
||||
});
|
||||
const resourceRequest = await computeResourceRequest(
|
||||
resource,
|
||||
resourceScope.variableValues
|
||||
);
|
||||
if (revision !== revisionRef.current) {
|
||||
return;
|
||||
}
|
||||
releaseRef.current = loadResourcePreview(resourceRequest);
|
||||
setRequest(resourceRequest);
|
||||
} catch {
|
||||
if (revision === revisionRef.current) {
|
||||
console.error("Unable to load resource preview");
|
||||
}
|
||||
} finally {
|
||||
if (revision === revisionRef.current) {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
request,
|
||||
showSavedRequest,
|
||||
pending,
|
||||
onChange,
|
||||
reload,
|
||||
};
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type PanelApi = {
|
||||
save: (formData: FormData) => void | false | { dataSourceId: string };
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export type VariableType =
|
||||
| "parameter"
|
||||
| "string"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "json"
|
||||
| "resource"
|
||||
| "email-resource"
|
||||
| "graphql-resource"
|
||||
| "sitemap-resource"
|
||||
| "current-date-resource"
|
||||
| "assets-resource";
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from "react";
|
||||
import { Grid } from "@webstudio-is/design-system";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { EditorContent, foldGutterExtension } from "~/shared/code-editor-base";
|
||||
import { formatValue } from "~/builder/shared/expression-editor";
|
||||
|
||||
export const ValuePreviewFrame = ({ value }: { value: unknown }) => {
|
||||
const extensions = useMemo(() => [javascript({}), foldGutterExtension], []);
|
||||
return (
|
||||
<Grid
|
||||
align="stretch"
|
||||
css={{
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
boxSizing: "content-box",
|
||||
position: "relative",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
}}
|
||||
>
|
||||
<EditorContent
|
||||
readOnly
|
||||
chromeless
|
||||
extensions={extensions}
|
||||
value={formatValue(value)}
|
||||
onChange={() => {}}
|
||||
onChangeComplete={() => {}}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
import { useImperativeHandle, type Ref } from "react";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import { $selectedInstance } from "~/shared/nano-states";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import { createDataVariableValueFromInput } from "@webstudio-is/project-build/runtime";
|
||||
import type { PanelApi } from "./variable-panel-api";
|
||||
|
||||
export type ValueVariableType = "string" | "number" | "boolean" | "json";
|
||||
|
||||
const saveVariable = (
|
||||
variable: undefined | DataSource,
|
||||
type: ValueVariableType,
|
||||
formData: FormData
|
||||
) => {
|
||||
// preserve existing instance scope when edit
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
if (scopeInstanceId === undefined) {
|
||||
return;
|
||||
}
|
||||
const name = z.string().parse(formData.get("name"));
|
||||
const value = z.string().nullable().parse(formData.get("value"));
|
||||
const variableValue = createDataVariableValueFromInput({ type, value });
|
||||
if (variable === undefined) {
|
||||
executeRuntimeMutation({
|
||||
id: "variables.create",
|
||||
input: {
|
||||
scopeInstanceId,
|
||||
name,
|
||||
value: variableValue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
executeRuntimeMutation({
|
||||
id: "variables.update",
|
||||
input: {
|
||||
dataSourceId: variable.id,
|
||||
values: {
|
||||
scopeInstanceId,
|
||||
name,
|
||||
value: variableValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const useValuePanelRef = ({
|
||||
ref,
|
||||
variable,
|
||||
type,
|
||||
}: {
|
||||
ref: Ref<undefined | PanelApi>;
|
||||
variable?: DataSource;
|
||||
type: ValueVariableType;
|
||||
}) => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: (formData) => {
|
||||
saveVariable(variable, type, formData);
|
||||
},
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
import { Row } from "../shared";
|
||||
import { ValuePreviewFrame } from "./shared/variable-value-preview";
|
||||
import { forwardRef, useId } from "react";
|
||||
import { Flex, Label, TextArea, theme } from "@webstudio-is/design-system";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import {
|
||||
EditorDialog,
|
||||
EditorDialogButton,
|
||||
EditorDialogControl,
|
||||
} from "~/shared/code-editor-base";
|
||||
import { useValuePanelRef } from "./shared/variable-value-save";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
|
||||
export const prepareStringValue = (previous: unknown) =>
|
||||
typeof previous === "string" ? previous : "";
|
||||
|
||||
export const StringForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
variable?: DataSource;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
>(({ variable, value: unknownValue, onChange }, ref) => {
|
||||
const value = typeof unknownValue === "string" ? unknownValue : "";
|
||||
useValuePanelRef({ ref, variable, type: "string" });
|
||||
const valueId = useId();
|
||||
return (
|
||||
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
|
||||
<Label htmlFor={valueId}>Value</Label>
|
||||
<EditorDialogControl>
|
||||
<TextArea
|
||||
name="value"
|
||||
rows={1}
|
||||
maxRows={10}
|
||||
autoGrow={true}
|
||||
id={valueId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<EditorDialog
|
||||
title="Variable value"
|
||||
content={
|
||||
<TextArea
|
||||
grow={true}
|
||||
id={valueId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EditorDialogButton />
|
||||
</EditorDialog>
|
||||
</EditorDialogControl>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
StringForm.displayName = "StringForm";
|
||||
|
||||
export const StringEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions()}
|
||||
fields={
|
||||
<Row>
|
||||
<StringForm
|
||||
ref={ref}
|
||||
variable={props.variable}
|
||||
value={props.value}
|
||||
onChange={props.onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
preview={<ValuePreviewFrame value={props.value} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { type DataSource } from "@webstudio-is/sdk";
|
||||
import {
|
||||
sitemapResourceUrl,
|
||||
currentDateResourceUrl,
|
||||
} from "@webstudio-is/sdk/runtime";
|
||||
import { createResourceFieldsFromFormData } from "@webstudio-is/project-build/runtime";
|
||||
import { $selectedInstance } from "~/shared/nano-states";
|
||||
import { $resources } from "~/shared/sync/data-stores";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import type { PanelApi } from "./shared/variable-panel-api";
|
||||
import { FormResourcePreview } from "./form-resource-preview";
|
||||
import { VariableEditorLayout } from "./dialog/layout";
|
||||
import { useResourcePreviewController } from "./shared/use-resource-preview-controller";
|
||||
import type { VariableEditorProps } from "./shared/editor-types";
|
||||
|
||||
export const SystemResourceForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
variable?: DataSource;
|
||||
resourceType: "sitemap-resource" | "current-date-resource";
|
||||
}
|
||||
>(({ variable, resourceType }, ref) => {
|
||||
const resources = useStore($resources);
|
||||
const resource =
|
||||
variable?.type === "resource"
|
||||
? resources.get(variable.resourceId)
|
||||
: undefined;
|
||||
const localResourceUrl =
|
||||
resourceType === "sitemap-resource"
|
||||
? sitemapResourceUrl
|
||||
: currentDateResourceUrl;
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: (formData) => {
|
||||
const scopeInstanceId =
|
||||
variable?.scopeInstanceId ?? $selectedInstance.get()?.id;
|
||||
if (scopeInstanceId === undefined) {
|
||||
return;
|
||||
}
|
||||
const resourceFields = createResourceFieldsFromFormData({
|
||||
control: "system",
|
||||
formData,
|
||||
});
|
||||
return executeRuntimeMutation({
|
||||
id: "resources.upsert",
|
||||
input: {
|
||||
resourceId: resource?.id,
|
||||
resource: resourceFields,
|
||||
dataSourceId: variable?.id,
|
||||
scopeInstanceId,
|
||||
dataSourceName: resourceFields.name,
|
||||
},
|
||||
})?.result;
|
||||
},
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<input type="hidden" name="method" value="get" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="url"
|
||||
value={JSON.stringify(localResourceUrl)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
SystemResourceForm.displayName = "SystemResourceForm";
|
||||
|
||||
export const SystemResourceEditor = forwardRef<
|
||||
PanelApi | undefined,
|
||||
VariableEditorProps
|
||||
>((props, ref) => {
|
||||
const resourceType = props.variableType;
|
||||
const preview = useResourcePreviewController({
|
||||
variable: props.variable,
|
||||
formRef: props.formRef,
|
||||
});
|
||||
if (
|
||||
resourceType !== "sitemap-resource" &&
|
||||
resourceType !== "current-date-resource"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<VariableEditorLayout
|
||||
{...props}
|
||||
titleActions={props.titleActions({
|
||||
onRefresh: () => void preview.reload(),
|
||||
refreshStatus: preview.pending ? "refreshing" : "idle",
|
||||
})}
|
||||
fields={
|
||||
<SystemResourceForm
|
||||
ref={ref}
|
||||
resourceType={resourceType}
|
||||
variable={props.variable}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<FormResourcePreview
|
||||
variable={props.variable}
|
||||
showEmptyLoadButton
|
||||
variableValue={preview.request}
|
||||
showSavedResourceRequest={preview.showSavedRequest}
|
||||
isComputingRequest={preview.pending}
|
||||
onLoadData={preview.reload}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,936 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import {
|
||||
encodeDataSourceVariable,
|
||||
type DataSource,
|
||||
type Prop,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $selectedPageId, selectInstance } from "~/shared/nano-states";
|
||||
import {
|
||||
$pages,
|
||||
$instances,
|
||||
$dataSources,
|
||||
$resources,
|
||||
$props,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { registerContainers } from "~/shared/sync/sync-stores";
|
||||
import { VariableContextMenu, VariableMenu } from "./variable-menu";
|
||||
import { VariablePopoverTrigger } from "./variable-popover";
|
||||
import { __testing__ as variablesSectionTesting } from "./variables-section";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
registerContainers();
|
||||
let root: Root | undefined;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
selectInstance(undefined);
|
||||
$instances.set(new Map());
|
||||
$pages.set(undefined);
|
||||
$dataSources.set(new Map());
|
||||
$resources.set(new Map());
|
||||
$props.set(new Map());
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("copy and paste on the same instance creates a unique variable and Resource name", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "variable",
|
||||
type: "resource",
|
||||
name: "Request",
|
||||
scopeInstanceId: "scope",
|
||||
resourceId: "request",
|
||||
};
|
||||
const other: DataSource = {
|
||||
id: "other",
|
||||
type: "variable",
|
||||
name: "Request 2",
|
||||
scopeInstanceId: "scope",
|
||||
value: { type: "string", value: "" },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"scope",
|
||||
{ id: "scope", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "scope" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["scope"]);
|
||||
$dataSources.set(
|
||||
new Map<string, DataSource>([
|
||||
[variable.id, variable],
|
||||
[other.id, other],
|
||||
])
|
||||
);
|
||||
$resources.set(
|
||||
new Map([
|
||||
[
|
||||
"request",
|
||||
{
|
||||
id: "request",
|
||||
name: "Request",
|
||||
method: "get",
|
||||
url: '\"https://example.com\"',
|
||||
headers: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
let text = "";
|
||||
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(
|
||||
async (value) => {
|
||||
text = value;
|
||||
}
|
||||
);
|
||||
vi.spyOn(navigator.clipboard, "readText").mockImplementation(
|
||||
async () => text
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(<VariableMenu variable={variable} canDelete />)
|
||||
);
|
||||
const open = container.querySelector(
|
||||
'button[aria-label="Open variable menu"]'
|
||||
)!;
|
||||
const action = async (name: string) => {
|
||||
await act(async () => userEvent.click(open));
|
||||
const item = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === name)!;
|
||||
await act(async () => userEvent.click(item));
|
||||
};
|
||||
await action("Copy");
|
||||
await expect.poll(() => text).not.toBe("");
|
||||
await action("Paste");
|
||||
await expect.poll(() => $dataSources.get().size).toBe(3);
|
||||
const pasted = [...$dataSources.get().values()].find(
|
||||
({ name }) => name === "Request 3"
|
||||
);
|
||||
expect(pasted?.id).not.toBe(variable.id);
|
||||
expect(pasted?.type).toBe("resource");
|
||||
if (pasted?.type !== "resource") {
|
||||
throw Error("Expected a Resource variable");
|
||||
}
|
||||
const resource = $resources.get().get(pasted.resourceId);
|
||||
expect(resource?.id).not.toBe("request");
|
||||
expect(resource?.name).toBe("Request 3");
|
||||
expect(resource?.url).toBe('\"https://example.com\"');
|
||||
});
|
||||
|
||||
test.each(["formState", "results", "errors"])(
|
||||
"required Form variable %s cannot be deleted",
|
||||
async (name) => {
|
||||
const variable: DataSource = {
|
||||
id: `form-${name}`,
|
||||
type: "variable",
|
||||
name,
|
||||
scopeInstanceId: "form",
|
||||
value:
|
||||
name === "formState"
|
||||
? { type: "string", value: "initial" }
|
||||
: { type: "json", value: [] },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "form" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["form"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const reference = encodeDataSourceVariable(variable.id);
|
||||
const requiredProp: Prop =
|
||||
name === "formState"
|
||||
? {
|
||||
id: "form-state-binding",
|
||||
instanceId: "form",
|
||||
name: "state",
|
||||
type: "expression",
|
||||
value: reference,
|
||||
}
|
||||
: {
|
||||
id: `${name}-binding`,
|
||||
instanceId: "form",
|
||||
name: "onResultChange",
|
||||
type: "action",
|
||||
value: [
|
||||
{
|
||||
type: "execute",
|
||||
args: ["result"],
|
||||
code: `({ ${name}: ${reference} = result.${name} })`,
|
||||
},
|
||||
],
|
||||
};
|
||||
$props.set(new Map([[requiredProp.id, requiredProp]]));
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(<VariableMenu variable={variable} canDelete />)
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
container.querySelector('button[aria-label="Open variable menu"]')!
|
||||
)
|
||||
);
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).toBe("true");
|
||||
}
|
||||
);
|
||||
|
||||
test.each(["formState", "results", "errors"])(
|
||||
"renamed required Form variable %s cannot be deleted",
|
||||
async (name) => {
|
||||
const variable: DataSource = {
|
||||
id: `form-${name}`,
|
||||
type: "variable",
|
||||
name: `renamed ${name}`,
|
||||
scopeInstanceId: "form",
|
||||
value:
|
||||
name === "formState"
|
||||
? { type: "string", value: "initial" }
|
||||
: { type: "json", value: [] },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "form" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["form"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const reference = encodeDataSourceVariable(variable.id);
|
||||
const requiredProp: Prop =
|
||||
name === "formState"
|
||||
? {
|
||||
id: "form-state-binding",
|
||||
instanceId: "form",
|
||||
name: "state",
|
||||
type: "expression",
|
||||
value: reference,
|
||||
}
|
||||
: {
|
||||
id: `${name}-binding`,
|
||||
instanceId: "form",
|
||||
name: "onResultChange",
|
||||
type: "action",
|
||||
value: [
|
||||
{
|
||||
type: "execute",
|
||||
args: ["result"],
|
||||
code: `({ ${name}: ${reference} = result.${name} })`,
|
||||
},
|
||||
],
|
||||
};
|
||||
$props.set(new Map([[requiredProp.id, requiredProp]]));
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(<VariableMenu variable={variable} canDelete />)
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
container.querySelector('button[aria-label="Open variable menu"]')!
|
||||
)
|
||||
);
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).toBe("true");
|
||||
}
|
||||
);
|
||||
|
||||
test.each(["formState", "results", "errors"])(
|
||||
"renamed required Form variable %s cannot be deleted from the Edit variable header menu",
|
||||
async (name) => {
|
||||
const variable: DataSource = {
|
||||
id: `form-${name}`,
|
||||
type: "variable",
|
||||
name: `renamed ${name}`,
|
||||
scopeInstanceId: "form",
|
||||
value:
|
||||
name === "formState"
|
||||
? { type: "string", value: "initial" }
|
||||
: { type: "json", value: [] },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "form" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["form"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const reference = encodeDataSourceVariable(variable.id);
|
||||
const requiredProp: Prop =
|
||||
name === "formState"
|
||||
? {
|
||||
id: "form-state-binding",
|
||||
instanceId: "form",
|
||||
name: "state",
|
||||
type: "expression",
|
||||
value: reference,
|
||||
}
|
||||
: {
|
||||
id: `${name}-binding`,
|
||||
instanceId: "form",
|
||||
name: "onResultChange",
|
||||
type: "action",
|
||||
value: [
|
||||
{
|
||||
type: "execute",
|
||||
args: ["result"],
|
||||
code: `({ ${name}: ${reference} = result.${name} })`,
|
||||
},
|
||||
],
|
||||
};
|
||||
$props.set(new Map([[requiredProp.id, requiredProp]]));
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariablePopoverTrigger variable={variable}>
|
||||
<button type="button">Open Edit variable</button>
|
||||
</VariablePopoverTrigger>
|
||||
)
|
||||
);
|
||||
await act(async () => userEvent.click(container.querySelector("button")!));
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
'[data-variable-editor-dialog] button[aria-label="Open variable menu"]'
|
||||
)!
|
||||
)
|
||||
);
|
||||
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).toBe("true");
|
||||
}
|
||||
);
|
||||
|
||||
test.each(["formState", "results", "errors"])(
|
||||
"renamed required Form variable %s cannot be deleted from the row context menu",
|
||||
async (name) => {
|
||||
const variable: DataSource = {
|
||||
id: `form-${name}`,
|
||||
type: "variable",
|
||||
name: `renamed ${name}`,
|
||||
scopeInstanceId: "form",
|
||||
value:
|
||||
name === "formState"
|
||||
? { type: "string", value: "initial" }
|
||||
: { type: "json", value: [] },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "form" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["form"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const reference = encodeDataSourceVariable(variable.id);
|
||||
const requiredProp: Prop =
|
||||
name === "formState"
|
||||
? {
|
||||
id: "form-state-binding",
|
||||
instanceId: "form",
|
||||
name: "state",
|
||||
type: "expression",
|
||||
value: reference,
|
||||
}
|
||||
: {
|
||||
id: `${name}-binding`,
|
||||
instanceId: "form",
|
||||
name: "onResultChange",
|
||||
type: "action",
|
||||
value: [
|
||||
{
|
||||
type: "execute",
|
||||
args: ["result"],
|
||||
code: `({ ${name}: ${reference} = result.${name} })`,
|
||||
},
|
||||
],
|
||||
};
|
||||
$props.set(new Map([[requiredProp.id, requiredProp]]));
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariableContextMenu>
|
||||
<button data-id={variable.id}>Required Form variable</button>
|
||||
</VariableContextMenu>
|
||||
)
|
||||
);
|
||||
const row = container.querySelector("button[data-id]")!;
|
||||
await act(async () => {
|
||||
row.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
pointerType: "mouse",
|
||||
})
|
||||
);
|
||||
row.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).toBe("true");
|
||||
}
|
||||
);
|
||||
|
||||
test("ordinary Form-scoped variable remains deletable from the row context menu", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "custom-variable",
|
||||
type: "variable",
|
||||
name: "custom",
|
||||
scopeInstanceId: "form",
|
||||
value: { type: "string", value: "" },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "form" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["form"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariableContextMenu>
|
||||
<button data-id={variable.id}>Custom variable</button>
|
||||
</VariableContextMenu>
|
||||
)
|
||||
);
|
||||
const row = container.querySelector("button[data-id]")!;
|
||||
await act(async () => {
|
||||
row.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
pointerType: "mouse",
|
||||
})
|
||||
);
|
||||
row.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem).not.toBeUndefined();
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
|
||||
test("ordinary local variable remains deletable from the Variables row menu", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "custom-variable",
|
||||
type: "variable",
|
||||
name: "custom",
|
||||
scopeInstanceId: "scope",
|
||||
value: { type: "string", value: "" },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"scope",
|
||||
{ id: "scope", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "scope" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["scope"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<variablesSectionTesting.VariablesItem
|
||||
variable={variable}
|
||||
source="local"
|
||||
index={0}
|
||||
value={undefined}
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.hover(container.querySelector('[aria-label="Variable custom"]')!)
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Open variable menu"]'
|
||||
)!
|
||||
)
|
||||
);
|
||||
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
|
||||
test("ordinary local variable remains deletable from the Edit variable header menu", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "custom-variable",
|
||||
type: "variable",
|
||||
name: "custom",
|
||||
scopeInstanceId: "scope",
|
||||
value: { type: "string", value: "" },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"scope",
|
||||
{ id: "scope", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "scope" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["scope"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariablePopoverTrigger variable={variable}>
|
||||
<button type="button">Open Edit variable</button>
|
||||
</VariablePopoverTrigger>
|
||||
)
|
||||
);
|
||||
await act(async () => userEvent.click(container.querySelector("button")!));
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
'[data-variable-editor-dialog] button[aria-label="Open variable menu"]'
|
||||
)!
|
||||
)
|
||||
);
|
||||
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
|
||||
test("Edit variable menu offers Delete, Copy, and Refresh without Paste", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "resource-variable",
|
||||
type: "resource",
|
||||
name: "Request",
|
||||
scopeInstanceId: "scope",
|
||||
resourceId: "request",
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"scope",
|
||||
{ id: "scope", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "scope" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["scope"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const onRefresh = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariableMenu
|
||||
variable={variable}
|
||||
canDelete
|
||||
includePaste={false}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
container.querySelector('button[aria-label="Open variable menu"]')!
|
||||
)
|
||||
);
|
||||
const items = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
);
|
||||
expect(items.map((item) => item.textContent)).toEqual([
|
||||
"Delete",
|
||||
"Copy",
|
||||
"Refresh",
|
||||
]);
|
||||
await act(async () => userEvent.click(items[2]!));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("Edit variable menu has a wider panel while keeping the standard header icon", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "resource-variable",
|
||||
type: "resource",
|
||||
name: "Request",
|
||||
scopeInstanceId: "scope",
|
||||
resourceId: "request",
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariableMenu variable={variable} size="header" includePaste={false} />
|
||||
)
|
||||
);
|
||||
const trigger = container.querySelector(
|
||||
'button[aria-label="Open variable menu"]'
|
||||
)!;
|
||||
expect(trigger.querySelector("svg")?.getAttribute("width")).toBe("16");
|
||||
await act(async () => userEvent.click(trigger));
|
||||
const menu = document.querySelector<HTMLElement>('[role="menu"]');
|
||||
expect(menu).not.toBeNull();
|
||||
expect(getComputedStyle(menu!).minWidth).toBe("360px");
|
||||
});
|
||||
|
||||
test("the Variables context menu offers variable actions on rows and Paste elsewhere", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "variable",
|
||||
type: "variable",
|
||||
name: "Request body",
|
||||
scopeInstanceId: "scope",
|
||||
value: { type: "string", value: "{}" },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"scope",
|
||||
{ id: "scope", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "scope" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["scope"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariableContextMenu>
|
||||
<button data-id="variable">Request body row</button>
|
||||
<button onPointerDown={(event) => event.stopPropagation()}>
|
||||
Add variable
|
||||
</button>
|
||||
<div
|
||||
data-testid="blank-panel-space"
|
||||
style={{ width: 100, height: 40 }}
|
||||
/>
|
||||
</VariableContextMenu>
|
||||
)
|
||||
);
|
||||
|
||||
const openContextMenu = async (element: HTMLElement) => {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
pointerType: "mouse",
|
||||
})
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
};
|
||||
|
||||
await openContextMenu(container.querySelector('[data-id="variable"]')!);
|
||||
const menuItems = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'));
|
||||
expect(menuItems().map((item) => item.textContent)).toEqual([
|
||||
"Delete",
|
||||
"Copy",
|
||||
"Paste",
|
||||
]);
|
||||
expect(menuItems()[0]?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
});
|
||||
|
||||
// A keyboard-triggered contextmenu has no pointerdown event.
|
||||
const row = container.querySelector('[data-id="variable"]')!;
|
||||
await act(async () => {
|
||||
row.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
expect(menuItems()[0]?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
});
|
||||
|
||||
// The Add button stops pointerdown, but its contextmenu must clear the row target.
|
||||
await openContextMenu(container.querySelectorAll("button")[1]!);
|
||||
expect(menuItems().map((item) => item.textContent)).toEqual([
|
||||
"Delete",
|
||||
"Copy",
|
||||
"Paste",
|
||||
]);
|
||||
expect(menuItems()[0]?.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(menuItems()[1]?.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(menuItems()[2]?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
});
|
||||
|
||||
await openContextMenu(
|
||||
container.querySelector('[data-testid="blank-panel-space"]')!
|
||||
);
|
||||
expect(menuItems()[0]?.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(menuItems()[1]?.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(menuItems()[2]?.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
|
||||
test("right-clicking inside Edit variable does not open the Variables context menu", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "variable",
|
||||
type: "variable",
|
||||
name: "Request body",
|
||||
scopeInstanceId: "scope",
|
||||
value: { type: "string", value: "{}" },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"scope",
|
||||
{ id: "scope", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "scope" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["scope"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariableContextMenu>
|
||||
<VariablePopoverTrigger variable={variable}>
|
||||
<button type="button">Open Edit variable</button>
|
||||
</VariablePopoverTrigger>
|
||||
</VariableContextMenu>
|
||||
)
|
||||
);
|
||||
await act(async () => userEvent.click(container.querySelector("button")!));
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
||||
|
||||
const editor = document.querySelector<HTMLElement>(
|
||||
"[data-variable-editor-dialog]"
|
||||
);
|
||||
expect(editor).not.toBeNull();
|
||||
const editorInput = editor!.querySelector<HTMLInputElement>("input");
|
||||
expect(editorInput).not.toBeNull();
|
||||
|
||||
const fieldLabel = editor!.querySelector<HTMLElement>("label");
|
||||
expect(fieldLabel).not.toBeNull();
|
||||
for (const target of [editorInput!, fieldLabel!]) {
|
||||
await act(async () => {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
pointerType: "mouse",
|
||||
})
|
||||
);
|
||||
target.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
expect(document.querySelector('[role="menu"]')).toBeNull();
|
||||
}
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("an inherited page variable cannot be deleted from the context menu", async () => {
|
||||
const variable: DataSource = {
|
||||
id: "variable",
|
||||
type: "parameter",
|
||||
name: "System data",
|
||||
scopeInstanceId: "parent",
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"parent",
|
||||
{
|
||||
id: "parent",
|
||||
type: "instance",
|
||||
component: "Box",
|
||||
children: [{ type: "id", value: "child" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"child",
|
||||
{ id: "child", type: "instance", component: "Box", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(
|
||||
createDefaultPages({
|
||||
rootInstanceId: "parent",
|
||||
homePageId: "home",
|
||||
systemDataSourceId: variable.id,
|
||||
})
|
||||
);
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["child"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<VariableContextMenu>
|
||||
<button data-id="variable">System data row</button>
|
||||
</VariableContextMenu>
|
||||
)
|
||||
);
|
||||
|
||||
const row = container.querySelector('[data-id="variable"]')!;
|
||||
await act(async () => {
|
||||
row.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
pointerType: "mouse",
|
||||
})
|
||||
);
|
||||
row.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
const deleteItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]')
|
||||
).find((item) => item.textContent === "Delete");
|
||||
expect(deleteItem?.getAttribute("aria-disabled")).toBe("true");
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
Button,
|
||||
SmallIconButton,
|
||||
toast,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { EllipsesIcon } from "@webstudio-is/icons";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import {
|
||||
findAvailableVariables,
|
||||
findUsedVariables,
|
||||
isRequiredManagedFormVariable,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { $selectedInstance, $selectedPage } from "~/shared/nano-states";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$resources,
|
||||
$pages,
|
||||
$props,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { serverSyncStore } from "~/shared/sync/sync-stores";
|
||||
import { readClipboardText } from "~/shared/clipboard";
|
||||
import {
|
||||
DeleteDataVariableDialog,
|
||||
deleteDataVariable,
|
||||
validateDataVariableName,
|
||||
} from "~/builder/shared/data-variable-utils";
|
||||
import { serializeVariable, deserializeVariable } from "./variable-clipboard";
|
||||
|
||||
type VariableToDelete = { id: string; name: string; usages: number };
|
||||
|
||||
const getVariableToDelete = (variable: DataSource): VariableToDelete => {
|
||||
const instance = $selectedInstance.get();
|
||||
const usages = instance
|
||||
? (findUsedVariables({
|
||||
startingInstanceId: instance.id,
|
||||
instances: $instances.get(),
|
||||
pages: $pages.get(),
|
||||
props: $props.get(),
|
||||
dataSources: $dataSources.get(),
|
||||
resources: $resources.get(),
|
||||
}).get(variable.id) ?? 0)
|
||||
: 0;
|
||||
return { id: variable.id, name: variable.name, usages };
|
||||
};
|
||||
|
||||
const copyVariable = async (variable: DataSource) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
serializeVariable(variable, $resources.get(), $dataSources.get())
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Unable to copy variable"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const pasteVariable = async () => {
|
||||
const text = await readClipboardText();
|
||||
const instance = $selectedInstance.get();
|
||||
if (text === undefined || !instance) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const copied = deserializeVariable(
|
||||
text,
|
||||
instance.id,
|
||||
new Map(
|
||||
findAvailableVariables({
|
||||
startingInstanceId: instance.id,
|
||||
instances: $instances.get(),
|
||||
dataSources: $dataSources.get(),
|
||||
}).map((variable) => [variable.id, variable])
|
||||
)
|
||||
);
|
||||
const originalName = copied.variable.name;
|
||||
let suffix = 2;
|
||||
let error = validateDataVariableName(
|
||||
copied.variable.name,
|
||||
undefined,
|
||||
instance.id
|
||||
);
|
||||
while (error?.type === "duplicate") {
|
||||
copied.variable.name = `${originalName} ${suffix++}`;
|
||||
error = validateDataVariableName(
|
||||
copied.variable.name,
|
||||
undefined,
|
||||
instance.id
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
throw Error(error.message);
|
||||
}
|
||||
if (copied.resource) {
|
||||
copied.resource.name = copied.variable.name;
|
||||
}
|
||||
serverSyncStore.createTransaction(
|
||||
[$dataSources, $resources],
|
||||
(dataSources, resources) => {
|
||||
dataSources.set(copied.variable.id, copied.variable);
|
||||
if (copied.resource) {
|
||||
resources.set(copied.resource.id, copied.resource);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Unable to paste variable"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const canDeleteVariable = (
|
||||
variable: DataSource | undefined,
|
||||
isLocal = variable?.scopeInstanceId === $selectedInstance.get()?.id
|
||||
) => {
|
||||
if (!variable) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
isRequiredManagedFormVariable(variable, {
|
||||
instances: $instances.get(),
|
||||
props: $props.get(),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isLocal &&
|
||||
(variable.type !== "parameter" ||
|
||||
variable.id === $selectedPage.get()?.systemDataSourceId)
|
||||
);
|
||||
};
|
||||
|
||||
export const VariableMenu = ({
|
||||
variable,
|
||||
canDelete = false,
|
||||
onDelete,
|
||||
onRefresh,
|
||||
onOpenChange,
|
||||
size = "small",
|
||||
includePaste = true,
|
||||
}: {
|
||||
variable?: DataSource;
|
||||
canDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
size?: "small" | "header";
|
||||
includePaste?: boolean;
|
||||
}) => {
|
||||
const [deleting, setDeleting] = useState<VariableToDelete>();
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{size === "header" ? (
|
||||
<Button
|
||||
aria-label="Open variable menu"
|
||||
data-variable-id={variable?.id}
|
||||
prefix={<EllipsesIcon />}
|
||||
color="ghost"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
) : (
|
||||
<SmallIconButton
|
||||
aria-label="Open variable menu"
|
||||
data-variable-id={variable?.id}
|
||||
icon={<EllipsesIcon />}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
css={{ minWidth: size === "header" ? 360 : 180 }}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={!canDelete || !canDeleteVariable(variable)}
|
||||
onSelect={() => {
|
||||
if (!variable) {
|
||||
return;
|
||||
}
|
||||
setDeleting(getVariableToDelete(variable));
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!variable || variable.type === "parameter"}
|
||||
onSelect={async () => {
|
||||
if (!variable) {
|
||||
return;
|
||||
}
|
||||
await copyVariable(variable);
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
{onRefresh !== undefined && (
|
||||
<DropdownMenuItem onSelect={onRefresh}>Refresh</DropdownMenuItem>
|
||||
)}
|
||||
{includePaste && (
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
await pasteVariable();
|
||||
}}
|
||||
>
|
||||
Paste
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteDataVariableDialog
|
||||
variable={deleting}
|
||||
onClose={() => setDeleting(undefined)}
|
||||
onConfirm={(id) => {
|
||||
deleteDataVariable(id);
|
||||
onDelete?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariableContextMenu = ({ children }: { children: ReactNode }) => {
|
||||
const [targetId, setTargetId] = useState<string>();
|
||||
const [deleting, setDeleting] = useState<VariableToDelete>();
|
||||
const variable = targetId ? $dataSources.get().get(targetId) : undefined;
|
||||
const setTargetFromEvent = (target: EventTarget | null) => {
|
||||
const item =
|
||||
target instanceof Element
|
||||
? target.closest<HTMLElement>("[data-id], [data-variable-id]")
|
||||
: null;
|
||||
setTargetId(
|
||||
item?.getAttribute("data-variable-id") ??
|
||||
item?.getAttribute("data-id") ??
|
||||
undefined
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setTargetId(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 2) {
|
||||
setTargetFromEvent(event.target);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
setTargetFromEvent(event.target);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "contents" }}>{children}</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={!canDeleteVariable(variable)}
|
||||
onSelect={() => {
|
||||
if (variable && canDeleteVariable(variable)) {
|
||||
setDeleting(getVariableToDelete(variable));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={!variable || variable.type === "parameter"}
|
||||
onSelect={() => {
|
||||
if (variable && variable.type !== "parameter") {
|
||||
void copyVariable(variable);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => void pasteVariable()}>
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<DeleteDataVariableDialog
|
||||
variable={deleting}
|
||||
onClose={() => setDeleting(undefined)}
|
||||
onConfirm={(id) => {
|
||||
deleteDataVariable(id);
|
||||
setDeleting(undefined);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { atom } from "nanostores";
|
||||
import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk";
|
||||
import { $activeInspectorPanel } from "~/builder/shared/nano-states";
|
||||
import { showInstance } from "~/builder/features/command-panel/shared/instance-list";
|
||||
import { $selectedInstance, selectInstance } from "~/shared/nano-states";
|
||||
|
||||
export const $variableToFocus = atom<
|
||||
{ id: string; scopeInstanceId: string } | undefined
|
||||
>();
|
||||
export const $variableToOpen = atom<{ id: string } | undefined>();
|
||||
|
||||
export const showVariable = (id: string) => {
|
||||
// Open the existing variable in the current scope without changing the
|
||||
// selected canvas instance or navigating to where the variable was defined.
|
||||
$variableToFocus.set({
|
||||
id,
|
||||
scopeInstanceId: $selectedInstance.get()?.id ?? ROOT_INSTANCE_ID,
|
||||
});
|
||||
$variableToOpen.set({ id });
|
||||
};
|
||||
|
||||
export const showVariableAtSource = (id: string, scopeInstanceId: string) => {
|
||||
if (scopeInstanceId === ROOT_INSTANCE_ID) {
|
||||
// Root-scoped variables are visible from any page. Select the root in the
|
||||
// current page instead of navigating to the home page.
|
||||
selectInstance([ROOT_INSTANCE_ID]);
|
||||
$activeInspectorPanel.set("settings");
|
||||
} else {
|
||||
showInstance(scopeInstanceId, "settings");
|
||||
}
|
||||
$variableToFocus.set({ id, scopeInstanceId });
|
||||
};
|
||||
+308
-5
@@ -2,21 +2,83 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { ResourceRequest } from "@webstudio-is/sdk";
|
||||
import { ROOT_INSTANCE_ID, type ResourceRequest } from "@webstudio-is/sdk";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
const { previewLoaderCalls, emailPreviewMockState } = vi.hoisted(() => ({
|
||||
previewLoaderCalls: vi.fn(),
|
||||
emailPreviewMockState: { fail: false },
|
||||
}));
|
||||
vi.mock("./variable-editors/email-request-preview", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("./variable-editors/email-request-preview")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
buildEmailRequestPreviewFromEditor: (
|
||||
...args: Parameters<typeof actual.buildEmailRequestPreviewFromEditor>
|
||||
) => {
|
||||
if (emailPreviewMockState.fail) {
|
||||
throw new Error("Unable to evaluate Email expression");
|
||||
}
|
||||
return actual.buildEmailRequestPreviewFromEditor(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("~/shared/resources", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("~/shared/resources")>();
|
||||
return {
|
||||
...actual,
|
||||
loadResourcePreview: (
|
||||
...args: Parameters<typeof actual.loadResourcePreview>
|
||||
) => {
|
||||
previewLoaderCalls(args[0]);
|
||||
return actual.loadResourcePreview(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$projectSettings,
|
||||
$props,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { $livePreviewFormValues } from "~/shared/preview-form-values";
|
||||
import { $resourcePreviewExchanges } from "~/shared/preview-resource-inspection";
|
||||
import {
|
||||
$livePreviewBrowserInfo,
|
||||
recordPreviewBrowserInfo,
|
||||
} from "~/shared/preview-form-values";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
let resetResources: (() => void) | undefined;
|
||||
const initialDataSources = $dataSources.get();
|
||||
const initialResources = $resources.get();
|
||||
const initialProps = $props.get();
|
||||
const initialProjectSettings = $projectSettings.get();
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
resetResources?.();
|
||||
resetResources = undefined;
|
||||
$instances.set(new Map());
|
||||
$dataSources.set(initialDataSources);
|
||||
$resources.set(initialResources);
|
||||
$props.set(initialProps);
|
||||
$projectSettings.set(initialProjectSettings);
|
||||
$livePreviewFormValues.set(new Map());
|
||||
$resourcePreviewExchanges.set(new Map());
|
||||
const { selectInstance } = await import("~/shared/nano-states");
|
||||
selectInstance(undefined);
|
||||
$livePreviewBrowserInfo.set(new Map());
|
||||
emailPreviewMockState.fail = false;
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -97,8 +159,7 @@ test("loads an unsaved system resource while an unrelated page request is pendin
|
||||
expect(option).toBeDefined();
|
||||
await act(async () => userEvent.click(option!));
|
||||
};
|
||||
await selectOption("String", "System resource");
|
||||
await selectOption("Sitemap", "Current date");
|
||||
await selectOption("String", "Current date resource");
|
||||
|
||||
const loadButton = Array.from(
|
||||
dialog.querySelectorAll<HTMLButtonElement>("button")
|
||||
@@ -130,11 +191,253 @@ test("loads an unsaved system resource while an unrelated page request is pendin
|
||||
);
|
||||
});
|
||||
|
||||
await selectOption("System resource", "GraphQL");
|
||||
// Switching System Resource subtypes must discard the completed preview.
|
||||
await selectOption("Current date resource", "Sitemap resource");
|
||||
expect(dialog.textContent).not.toContain("2026-09-23");
|
||||
const sitemapLoadButton = Array.from(
|
||||
dialog.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((button) => button.textContent === "Load data");
|
||||
expect(sitemapLoadButton).toBeDefined();
|
||||
await act(async () => userEvent.click(sitemapLoadButton!));
|
||||
await vi.waitFor(() => expect(loaderCalls).toHaveLength(2));
|
||||
const sitemapRequests = JSON.parse(
|
||||
String(loaderCalls[1].body)
|
||||
) as ResourceRequest[];
|
||||
const sitemapPreview = sitemapRequests.find(
|
||||
(request) => request.url === "/$resources/sitemap.xml"
|
||||
);
|
||||
expect(sitemapPreview).toBeDefined();
|
||||
|
||||
// A late response for the old subtype cannot replace the new subtype's UI.
|
||||
await selectOption("Sitemap resource", "Current date resource");
|
||||
respond(
|
||||
Response.json([
|
||||
[getResourceKey(sitemapPreview!), { data: "stale sitemap response" }],
|
||||
])
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(dialog.textContent).not.toContain("stale sitemap response");
|
||||
});
|
||||
|
||||
await selectOption("Current date resource", "GraphQL resource");
|
||||
expect(
|
||||
Array.from(dialog.querySelectorAll("button")).some(
|
||||
(button) => button.textContent === "Load data"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Edit variable preview shows server browser info for a Form variable", async () => {
|
||||
const { VariablePopoverTrigger } = await import("./variable-popover");
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
recordPreviewBrowserInfo("form", {
|
||||
ip: "203.0.113.10",
|
||||
userAgent: "Trusted Preview browser",
|
||||
language: "en-GB",
|
||||
referrer: "https://builder.example/project",
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<div data-floating-panel-container>
|
||||
<VariablePopoverTrigger
|
||||
variable={{
|
||||
id: "browser-info",
|
||||
type: "parameter",
|
||||
name: "browserInfo",
|
||||
scopeInstanceId: "form",
|
||||
}}
|
||||
>
|
||||
<button type="button">Edit browserInfo</button>
|
||||
</VariablePopoverTrigger>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.click(container.querySelector("button")!);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(dialog?.textContent).toContain("203.0.113.10");
|
||||
expect(dialog?.textContent).toContain("Trusted Preview browser");
|
||||
expect(dialog?.textContent).toContain("en-GB");
|
||||
expect(dialog?.textContent).toContain("https://builder.example/project");
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: "valid settings", fail: false },
|
||||
{ label: "an evaluation failure", fail: true },
|
||||
])(
|
||||
"Email Request Load data handles $label without sending",
|
||||
async ({ fail }) => {
|
||||
const { VariablePopoverTrigger } = await import("./variable-popover");
|
||||
const { selectInstance } = await import("~/shared/nano-states");
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [{ type: "id", value: "email-input" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"email-input",
|
||||
{
|
||||
id: "email-input",
|
||||
type: "instance",
|
||||
component: "Input",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$props.set(
|
||||
new Map([
|
||||
[
|
||||
"email-name",
|
||||
{
|
||||
id: "email-name",
|
||||
instanceId: "email-input",
|
||||
name: "name",
|
||||
type: "string",
|
||||
value: "email",
|
||||
},
|
||||
],
|
||||
[
|
||||
"email-type",
|
||||
{
|
||||
id: "email-type",
|
||||
instanceId: "email-input",
|
||||
name: "type",
|
||||
type: "string",
|
||||
value: "email",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$projectSettings.set({
|
||||
meta: { contactEmail: "owner@example.com" },
|
||||
compiler: {},
|
||||
});
|
||||
$resources.set(
|
||||
new Map([
|
||||
[
|
||||
"email-resource",
|
||||
{
|
||||
id: "email-resource",
|
||||
name: "Notify owner",
|
||||
control: "email",
|
||||
method: "post",
|
||||
url: '""',
|
||||
headers: [],
|
||||
email: {},
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const variable = {
|
||||
id: "email-variable",
|
||||
type: "resource" as const,
|
||||
name: "Notify owner",
|
||||
resourceId: "email-resource",
|
||||
scopeInstanceId: "form",
|
||||
};
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
selectInstance(["form", ROOT_INSTANCE_ID]);
|
||||
$livePreviewFormValues.set(
|
||||
new Map([["form", { email: "visitor@example.com", name: "Ada" }]])
|
||||
);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<div data-floating-panel-container>
|
||||
<VariablePopoverTrigger variable={variable}>
|
||||
<button type="button">Edit Email Resource</button>
|
||||
</VariablePopoverTrigger>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () => userEvent.click(container.querySelector("button")!));
|
||||
const dialog = await vi.waitFor(() => {
|
||||
const current = document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
expect(current).not.toBeNull();
|
||||
return current!;
|
||||
});
|
||||
const requestTab = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Request");
|
||||
expect(requestTab).toBeDefined();
|
||||
const diagnosticsTab = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Diagnostics");
|
||||
expect(diagnosticsTab).toBeDefined();
|
||||
await act(async () => userEvent.click(diagnosticsTab!));
|
||||
expect(
|
||||
dialog.querySelector('[data-state="active"][aria-busy="false"]')
|
||||
).not.toBeNull();
|
||||
expect(dialog.textContent).not.toContain("Loading diagnostics…");
|
||||
await act(async () => userEvent.click(requestTab!));
|
||||
const loadButton = Array.from(
|
||||
dialog.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((button) => button.textContent === "Load data");
|
||||
expect(loadButton).toBeDefined();
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const priorCalls = fetchSpy.mock.calls.length;
|
||||
const priorPreviewLoads = previewLoaderCalls.mock.calls.length;
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
emailPreviewMockState.fail = fail;
|
||||
await act(async () => userEvent.click(loadButton!));
|
||||
if (!fail) {
|
||||
await vi.waitFor(() => {
|
||||
expect(dialog.textContent).toContain("owner@example.com");
|
||||
expect(dialog.textContent).toContain("visitor@example.com");
|
||||
expect(dialog.textContent).toContain("Ada");
|
||||
expect(dialog.textContent).toContain('"preview"');
|
||||
});
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Unable to build Email request preview"
|
||||
);
|
||||
expect(loadButton?.disabled).toBe(false);
|
||||
expect(dialog.textContent).not.toContain('"preview"');
|
||||
});
|
||||
}
|
||||
expect(fetchSpy.mock.calls.length).toBe(priorCalls);
|
||||
expect(previewLoaderCalls.mock.calls.length).toBe(priorPreviewLoads);
|
||||
expect($resourcePreviewExchanges.get().size).toBe(0);
|
||||
fetchSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { DataSource, Resource } from "@webstudio-is/sdk";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import { $selectedPageId, selectInstance } from "~/shared/nano-states";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$pages,
|
||||
$props,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { registerContainers } from "~/shared/sync/sync-stores";
|
||||
import { VariablePopoverTrigger } from "./variable-popover";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
registerContainers();
|
||||
let root: Root | undefined;
|
||||
const initialDataSources = $dataSources.get();
|
||||
const initialResources = $resources.get();
|
||||
const initialInstances = $instances.get();
|
||||
const initialPages = $pages.get();
|
||||
const initialProps = $props.get();
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
$dataSources.set(initialDataSources);
|
||||
$resources.set(initialResources);
|
||||
$instances.set(initialInstances);
|
||||
$pages.set(initialPages);
|
||||
$props.set(initialProps);
|
||||
selectInstance(undefined);
|
||||
$selectedPageId.set(undefined);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test.each(["new", "existing"] as const)(
|
||||
"failed %s Email save keeps the editor and draft until corrected",
|
||||
async (mode) => {
|
||||
const resource: Resource = {
|
||||
id: "email-resource",
|
||||
name: "Notify",
|
||||
control: "email",
|
||||
method: "post",
|
||||
url: '""',
|
||||
headers: [],
|
||||
email: {},
|
||||
};
|
||||
const variable: DataSource = {
|
||||
id: "email-variable",
|
||||
name: "Notify",
|
||||
type: "resource",
|
||||
resourceId: resource.id,
|
||||
scopeInstanceId: "body",
|
||||
};
|
||||
$resources.set(
|
||||
new Map(mode === "existing" ? [[resource.id, resource]] : [])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map(mode === "existing" ? [[variable.id, variable]] : [])
|
||||
);
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{ id: "body", type: "instance", component: "Body", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "body" }));
|
||||
$props.set(new Map());
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["body"]);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<VariablePopoverTrigger
|
||||
variable={mode === "existing" ? variable : undefined}
|
||||
>
|
||||
<button type="button">Open</button>
|
||||
</VariablePopoverTrigger>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () => userEvent.click(container.querySelector("button")!));
|
||||
const dialog = await vi.waitFor(() => {
|
||||
const current = document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
expect(current).not.toBeNull();
|
||||
return current!;
|
||||
});
|
||||
if (mode === "new") {
|
||||
await act(async () =>
|
||||
userEvent.fill(dialog.querySelector('input[name="name"]')!, "Notify")
|
||||
);
|
||||
const typeSelect = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>('[role="combobox"]')
|
||||
).find((element) => element.textContent?.includes("String"))!;
|
||||
await act(async () => userEvent.click(typeSelect));
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]')
|
||||
).find((option) => option.textContent?.startsWith("Email resource"))!
|
||||
)
|
||||
);
|
||||
}
|
||||
const sender = dialog.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="John <john@example.com>"]'
|
||||
)!;
|
||||
const subject = dialog.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="New form submission"]'
|
||||
)!;
|
||||
await act(async () => userEvent.fill(subject, "Draft subject"));
|
||||
await act(async () => userEvent.fill(sender, "invalid sender"));
|
||||
const closeButton = dialog.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Close"]'
|
||||
)!;
|
||||
await act(async () => {
|
||||
await userEvent.click(closeButton);
|
||||
await new Promise(requestAnimationFrame);
|
||||
});
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBe(dialog);
|
||||
expect(subject.value).toBe("Draft subject");
|
||||
expect(sender.value).toBe("invalid sender");
|
||||
expect($resources.get()).toEqual(
|
||||
new Map(mode === "existing" ? [[resource.id, resource]] : [])
|
||||
);
|
||||
expect($dataSources.get().size).toBe(mode === "existing" ? 1 : 0);
|
||||
|
||||
await act(async () => userEvent.fill(sender, "Acme <acme@example.com>"));
|
||||
await act(async () => {
|
||||
await userEvent.click(closeButton);
|
||||
await new Promise(requestAnimationFrame);
|
||||
});
|
||||
await expect
|
||||
.poll(() => document.querySelector('[role="dialog"]'))
|
||||
.toBeNull();
|
||||
expect($resources.get().size).toBe(1);
|
||||
expect($dataSources.get().size).toBe(1);
|
||||
expect(Array.from($resources.get().values())[0].email).toMatchObject({
|
||||
sender: "Acme <acme@example.com>",
|
||||
subject: JSON.stringify("Draft subject"),
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,333 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { parseStaticStringExpression } from "@webstudio-is/expression";
|
||||
import {
|
||||
assetsResourceUrl,
|
||||
sitemapResourceUrl,
|
||||
} from "@webstudio-is/sdk/runtime";
|
||||
import type { DataSource, Resource } from "@webstudio-is/sdk";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import {
|
||||
$dataSources,
|
||||
$instances,
|
||||
$pages,
|
||||
$props,
|
||||
$resources,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { $selectedPageId, selectInstance } from "~/shared/nano-states";
|
||||
import { registerContainers } from "~/shared/sync/sync-stores";
|
||||
import { __testing__ } from "./variable-popover";
|
||||
import { VariablePopoverTrigger } from "./variable-popover";
|
||||
import { JsonForm } from "./variable-editors/json-editor";
|
||||
|
||||
import { SystemResourceForm } from "./variable-editors/system-resource-editor";
|
||||
|
||||
const { TypeField } = __testing__;
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root | undefined;
|
||||
const initialResources = $resources.get();
|
||||
const initialDataSources = $dataSources.get();
|
||||
const initialInstances = $instances.get();
|
||||
const initialPages = $pages.get();
|
||||
const initialProps = $props.get();
|
||||
registerContainers();
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
$resources.set(initialResources);
|
||||
$dataSources.set(initialDataSources);
|
||||
$instances.set(initialInstances);
|
||||
$pages.set(initialPages);
|
||||
$props.set(initialProps);
|
||||
selectInstance(undefined);
|
||||
$selectedPageId.set(undefined);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("System resource types are listed directly in Type", async () => {
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "280px";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const onChange = vi.fn();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<>
|
||||
<TypeField value="sitemap-resource" onChange={onChange} />
|
||||
<SystemResourceForm resourceType="sitemap-resource" />
|
||||
</>
|
||||
)
|
||||
);
|
||||
|
||||
const selects = container.querySelectorAll<HTMLButtonElement>("button");
|
||||
expect(selects).toHaveLength(1);
|
||||
expect(selects[0].textContent).toContain("Sitemap resource");
|
||||
|
||||
await act(async () => await userEvent.click(selects[0]));
|
||||
expect(
|
||||
document
|
||||
.querySelector<HTMLElement>("[data-radix-popper-content-wrapper]")
|
||||
?.getBoundingClientRect().width
|
||||
).toBe(selects[0].getBoundingClientRect().width);
|
||||
const topLevelOptions = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]')
|
||||
).map((option) => option.textContent?.replace(/Pro$/, "").trim());
|
||||
expect(topLevelOptions).toEqual([
|
||||
"String",
|
||||
"Number",
|
||||
"Boolean",
|
||||
"JSON",
|
||||
"REST resource",
|
||||
"GraphQL resource",
|
||||
"Assets resource",
|
||||
"Email resource",
|
||||
"Sitemap resource",
|
||||
"Current date resource",
|
||||
]);
|
||||
expect(topLevelOptions).not.toContain("System resource");
|
||||
await act(
|
||||
async () =>
|
||||
await userEvent.click(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]')
|
||||
).find((option) => option.textContent?.startsWith("Email resource"))!
|
||||
)
|
||||
);
|
||||
expect(onChange).toHaveBeenCalledWith("email-resource");
|
||||
expect(container.querySelectorAll('input[name="url"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["resource", "A REST resource is a configuration for secure data fetching."],
|
||||
[
|
||||
"graphql-resource",
|
||||
"A GraphQL resource is a configuration for secure data fetching with GraphQL.",
|
||||
],
|
||||
] as const)(
|
||||
"%s Resource type description preserves its approved wording",
|
||||
async (value, copy) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(<TypeField value={value} onChange={vi.fn()} />)
|
||||
);
|
||||
|
||||
const typeSelect =
|
||||
container.querySelector<HTMLButtonElement>('[role="combobox"]');
|
||||
expect(typeSelect).not.toBeNull();
|
||||
await act(async () => await userEvent.click(typeSelect!));
|
||||
|
||||
const description = document.querySelector<HTMLElement>(
|
||||
'[data-select-description="content"]'
|
||||
);
|
||||
expect(description?.textContent).toContain(copy);
|
||||
expect(description?.textContent).toContain(
|
||||
"You can safely use secrets in any field."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test("Email Resource type displays its approved description", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(<TypeField value="email-resource" onChange={vi.fn()} />)
|
||||
);
|
||||
|
||||
const typeSelect =
|
||||
container.querySelector<HTMLButtonElement>('[role="combobox"]');
|
||||
expect(typeSelect).not.toBeNull();
|
||||
await act(async () => await userEvent.click(typeSelect!));
|
||||
|
||||
const description = document.querySelector<HTMLElement>(
|
||||
'[data-select-description="content"]'
|
||||
);
|
||||
expect(description?.textContent).toContain(
|
||||
"Send a plain-text email through Webstudio Cloud when a Form is submitted."
|
||||
);
|
||||
});
|
||||
|
||||
test("editing a System Resource and changing its Type persists the selected category", async () => {
|
||||
const resource: Resource = {
|
||||
id: "existing-resource",
|
||||
name: "Existing resource",
|
||||
control: "system",
|
||||
method: "get",
|
||||
url: JSON.stringify(sitemapResourceUrl),
|
||||
headers: [],
|
||||
};
|
||||
const variable: DataSource = {
|
||||
id: "existing-variable",
|
||||
type: "resource",
|
||||
name: "Existing resource",
|
||||
scopeInstanceId: "body",
|
||||
resourceId: resource.id,
|
||||
};
|
||||
$resources.set(new Map([[resource.id, resource]]));
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "body" }));
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{ type: "instance", id: "body", component: "Body", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$props.set(new Map());
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["body"]);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<VariablePopoverTrigger variable={variable}>
|
||||
<button type="button">Open</button>
|
||||
</VariablePopoverTrigger>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () => userEvent.click(container.querySelector("button")!));
|
||||
const editor = await vi.waitFor(() => {
|
||||
const current = document.querySelector<HTMLElement>(
|
||||
"[data-variable-editor-dialog]"
|
||||
);
|
||||
expect(current).not.toBeNull();
|
||||
return current!;
|
||||
});
|
||||
|
||||
const typeSelect = Array.from(
|
||||
editor.querySelectorAll<HTMLButtonElement>('[role="combobox"]')
|
||||
).find((element) => element.textContent?.includes("Sitemap resource"));
|
||||
expect(typeSelect).toBeDefined();
|
||||
await act(async () => userEvent.click(typeSelect!));
|
||||
await act(async () =>
|
||||
userEvent.click(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]')
|
||||
).find((option) => option.textContent?.startsWith("Email resource"))!
|
||||
)
|
||||
);
|
||||
|
||||
const form = editor.querySelector("form")!;
|
||||
const savedFormData = new FormData(form);
|
||||
expect(savedFormData.get("method")).toBe("post");
|
||||
expect(savedFormData.get("url")).toBe('""');
|
||||
expect(savedFormData.get("email-settings")).toBeTruthy();
|
||||
expect(form.querySelector('button[type="submit"][hidden]')).not.toBeNull();
|
||||
const nameInput = form.querySelector<HTMLInputElement>('input[name="name"]')!;
|
||||
await act(async () => {
|
||||
await userEvent.click(nameInput);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
});
|
||||
expect($resources.get().get(resource.id)).toMatchObject({
|
||||
control: "email",
|
||||
method: "post",
|
||||
url: '""',
|
||||
email: {},
|
||||
});
|
||||
expect($dataSources.get().get(variable.id)?.type).toBe("resource");
|
||||
});
|
||||
|
||||
test("reopening and saving an Assets Resource with a single-quoted URL keeps its type", async () => {
|
||||
const resource: Resource = {
|
||||
id: "assets-resource",
|
||||
name: "Assets",
|
||||
control: "system",
|
||||
method: "post",
|
||||
url: "'/$resources/assets'",
|
||||
headers: [],
|
||||
};
|
||||
const variable: DataSource = {
|
||||
id: "assets-variable",
|
||||
type: "resource",
|
||||
name: "Assets",
|
||||
scopeInstanceId: "body",
|
||||
resourceId: resource.id,
|
||||
};
|
||||
$resources.set(new Map([[resource.id, resource]]));
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "body" }));
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
{ type: "instance", id: "body", component: "Body", children: [] },
|
||||
],
|
||||
])
|
||||
);
|
||||
$props.set(new Map());
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["body"]);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<VariablePopoverTrigger variable={variable}>
|
||||
<button type="button">Open</button>
|
||||
</VariablePopoverTrigger>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () => userEvent.click(container.querySelector("button")!));
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)));
|
||||
|
||||
const editor = document.querySelector<HTMLElement>(
|
||||
"[data-variable-editor-dialog]"
|
||||
);
|
||||
expect(editor).not.toBeNull();
|
||||
expect(
|
||||
parseStaticStringExpression(
|
||||
editor?.querySelector<HTMLInputElement>('input[name="url"]')?.value ?? ""
|
||||
)
|
||||
).toBe(assetsResourceUrl);
|
||||
|
||||
const form = editor?.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
await act(async () => form?.requestSubmit());
|
||||
|
||||
expect(
|
||||
parseStaticStringExpression($resources.get().get(resource.id)?.url ?? "")
|
||||
).toBe(assetsResourceUrl);
|
||||
expect($resources.get().get(resource.id)?.method).toBe("post");
|
||||
});
|
||||
|
||||
test("Edit variable JSON value editor shows line numbers", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<JsonForm
|
||||
variable={undefined}
|
||||
value={'{\n "name": "Acme"\n}'}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
expect(container.querySelector(".cm-lineNumbers")).not.toBeNull();
|
||||
const maximizeButton = container.querySelector<HTMLButtonElement>("button");
|
||||
expect(maximizeButton).not.toBeNull();
|
||||
await act(async () => await userEvent.hover(maximizeButton!.parentElement!));
|
||||
await act(async () => await userEvent.click(maximizeButton!));
|
||||
await act(async () => await new Promise(requestAnimationFrame));
|
||||
expect(maximizeButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.querySelectorAll(".cm-lineNumbers").length).toBeGreaterThan(
|
||||
1
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { __testing__ } from "./variable-popover";
|
||||
|
||||
const { getReloadableResourceFormData } = __testing__;
|
||||
import { getReloadableAssetsResourceFormData } from "./variable-editors/assets-resource-editor";
|
||||
|
||||
test("blocks resource loads while the visible Assets query is invalid", () => {
|
||||
const form = document.createElement("form");
|
||||
@@ -10,8 +8,8 @@ test("blocks resource loads while the visible Assets query is invalid", () => {
|
||||
queryValidity.value = "false";
|
||||
form.appendChild(queryValidity);
|
||||
|
||||
expect(getReloadableResourceFormData(form)).toBeUndefined();
|
||||
expect(getReloadableAssetsResourceFormData(form)).toBeUndefined();
|
||||
|
||||
queryValidity.value = "true";
|
||||
expect(getReloadableResourceFormData(form)).toBeInstanceOf(FormData);
|
||||
expect(getReloadableAssetsResourceFormData(form)).toBeInstanceOf(FormData);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,818 @@
|
||||
import {
|
||||
$resourcePreviewExchanges,
|
||||
recordResourcePreviewExchange,
|
||||
} from "~/shared/preview-resource-inspection";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { ResourceRequest } from "@webstudio-is/sdk";
|
||||
import { $resourcesCache, getResourceKey } from "~/shared/resources";
|
||||
import { $dataSources, $instances, $pages } from "~/shared/sync/data-stores";
|
||||
import { $selectedPageId, selectInstance } from "~/shared/nano-states";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import {
|
||||
$previewFormExchanges,
|
||||
recordPreviewFormExchanges,
|
||||
} from "~/shared/preview-form-inspection";
|
||||
import {
|
||||
$livePreviewFormValues,
|
||||
$livePreviewBrowserInfo,
|
||||
recordPreviewBrowserInfo,
|
||||
} from "~/shared/preview-form-values";
|
||||
import { ParameterVariablePreview } from "./variable-editors/parameter-editor";
|
||||
import { FormResourcePreview } from "./variable-editors/form-resource-preview";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root | undefined;
|
||||
const previousCache = $resourcesCache.get();
|
||||
const openResponseTab = async (container: HTMLElement) => {
|
||||
const tab = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Response");
|
||||
await act(async () => {
|
||||
tab?.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
tab?.click();
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
$resourcesCache.set(previousCache);
|
||||
$previewFormExchanges.set(new Map());
|
||||
$resourcePreviewExchanges.set(new Map());
|
||||
$instances.set(new Map());
|
||||
$dataSources.set(new Map());
|
||||
$pages.set(undefined);
|
||||
$selectedPageId.set(undefined);
|
||||
selectInstance(undefined);
|
||||
$livePreviewFormValues.set(new Map());
|
||||
$livePreviewBrowserInfo.set(new Map());
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("Form context Preview shows live data and server browser info", async () => {
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const formDataVariable = {
|
||||
id: "form-data",
|
||||
type: "parameter" as const,
|
||||
name: "formData",
|
||||
scopeInstanceId: "form",
|
||||
};
|
||||
const browserInfoVariable = {
|
||||
id: "browser-info",
|
||||
type: "parameter" as const,
|
||||
name: "browserInfo",
|
||||
scopeInstanceId: "form",
|
||||
};
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[formDataVariable.id, formDataVariable],
|
||||
[browserInfoVariable.id, browserInfoVariable],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "form" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["form", "collection[one]", "root"]);
|
||||
$livePreviewFormValues.set(
|
||||
new Map([
|
||||
[
|
||||
"form,collection[one],root",
|
||||
{ email: "", message: "live Preview value" },
|
||||
],
|
||||
[
|
||||
"form,collection[two],root",
|
||||
{ email: "other@example.com", message: "another occurrence" },
|
||||
],
|
||||
])
|
||||
);
|
||||
recordPreviewBrowserInfo("form", {
|
||||
ip: "203.0.113.10",
|
||||
userAgent: "Preview browser",
|
||||
language: "en-GB",
|
||||
referrer: "https://builder.example/project",
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<ParameterVariablePreview variable={formDataVariable} />
|
||||
<ParameterVariablePreview variable={browserInfoVariable} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await expect.poll(() => container.textContent).toContain('"email": ""');
|
||||
expect(container.textContent).toContain('"message": "live Preview value"');
|
||||
expect(container.textContent).toContain('"ip": "203.0.113.10"');
|
||||
expect(container.textContent).toContain(
|
||||
'"referrer": "https://builder.example/project"'
|
||||
);
|
||||
expect(container.textContent).toContain('"userAgent": "Preview browser"');
|
||||
});
|
||||
|
||||
test("preview-only resources show Load data without an empty Request tab", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
let loads = 0;
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variableValue={undefined}
|
||||
showEmptyLoadButton
|
||||
onLoadData={() => {
|
||||
loads += 1;
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll('[role="tab"]'),
|
||||
(tab) => tab.textContent
|
||||
)
|
||||
).toEqual(["Preview", "Diagnostics"]);
|
||||
const loadButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((button) => button.textContent === "Load data");
|
||||
expect(loadButton).toBeDefined();
|
||||
await act(async () => loadButton?.click());
|
||||
expect(loads).toBe(1);
|
||||
});
|
||||
|
||||
test("Resource inspector can load the actual Request from an empty Request tab", async () => {
|
||||
const request: ResourceRequest = {
|
||||
name: "Request",
|
||||
method: "get",
|
||||
url: "https://example.com",
|
||||
headers: [],
|
||||
searchParams: [],
|
||||
};
|
||||
const key = getResourceKey(request);
|
||||
$resourcesCache.set(
|
||||
new Map([
|
||||
[
|
||||
key,
|
||||
{
|
||||
ok: true,
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
data: { message: "created response" },
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
let loads = 0;
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
showEmptyLoadButton
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={request}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={false}
|
||||
onLoadData={() => {
|
||||
loads += 1;
|
||||
}}
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll('[role="tab"]'),
|
||||
(tab) => tab.textContent
|
||||
)
|
||||
).toEqual(["Request", "Response", "Diagnostics"]);
|
||||
await openResponseTab(container);
|
||||
await expect.poll(() => container.textContent).toContain("201");
|
||||
expect(container.textContent).toContain("Created");
|
||||
expect(container.textContent).toContain("created response");
|
||||
const requestTab = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Request");
|
||||
expect(requestTab).toBeDefined();
|
||||
await act(async () => {
|
||||
requestTab?.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
requestTab?.click();
|
||||
});
|
||||
const loadButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((button) => button.textContent === "Load data");
|
||||
expect(loadButton).toBeDefined();
|
||||
expect(container.textContent).not.toContain("Loading...");
|
||||
expect(container.textContent).not.toContain("null");
|
||||
expect(container.textContent).not.toContain("example.com");
|
||||
expect(loads).toBe(0);
|
||||
await act(async () => loadButton?.click());
|
||||
expect(loads).toBe(1);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
showEmptyLoadButton
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={request}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={true}
|
||||
onLoadData={() => {
|
||||
loads += 1;
|
||||
}}
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[role="status"]')?.getAttribute("aria-label")
|
||||
).toBe("Loading request…");
|
||||
await act(async () =>
|
||||
recordResourcePreviewExchange(key, {
|
||||
resourceId: key,
|
||||
resourceName: "Request",
|
||||
kind: "http",
|
||||
request: {
|
||||
method: "GET",
|
||||
url: "https://example.com/actual-request",
|
||||
headers: [{ name: "accept", value: "application/json" }],
|
||||
body: undefined,
|
||||
truncated: false,
|
||||
},
|
||||
response: {
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
headers: [],
|
||||
body: { message: "created response" },
|
||||
truncated: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
showEmptyLoadButton
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={request}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={false}
|
||||
onLoadData={() => {
|
||||
loads += 1;
|
||||
}}
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await expect.poll(() => container.textContent).toContain("actual-request");
|
||||
expect(container.textContent).toContain('"accept"');
|
||||
expect(container.textContent).not.toContain("Load data");
|
||||
const responseTab = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Response");
|
||||
await act(async () => {
|
||||
responseTab?.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
responseTab?.click();
|
||||
});
|
||||
await expect.poll(() => container.textContent).toContain('"status": 201');
|
||||
expect(container.textContent).toContain("created response");
|
||||
});
|
||||
|
||||
test("explicit Resource reload shows the captured request and response headers", async () => {
|
||||
const request: ResourceRequest = {
|
||||
name: "Contact webhook",
|
||||
method: "post",
|
||||
url: "https://example.com/contacts",
|
||||
headers: [],
|
||||
searchParams: [],
|
||||
body: { email: "person@example.com" },
|
||||
};
|
||||
const key = getResourceKey(request);
|
||||
$previewFormExchanges.set(
|
||||
new Map([
|
||||
[
|
||||
key,
|
||||
{
|
||||
formId: "form",
|
||||
revision: 0,
|
||||
attempts: [
|
||||
{
|
||||
resourceId: key,
|
||||
resourceName: "Contact webhook",
|
||||
kind: "http",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://old.example/contacts",
|
||||
headers: [],
|
||||
body: { email: "stale@example.com" },
|
||||
truncated: false,
|
||||
},
|
||||
response: {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [],
|
||||
body: { accepted: true },
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
recordResourcePreviewExchange(key, {
|
||||
resourceId: key,
|
||||
resourceName: "Contact webhook",
|
||||
kind: "http",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: request.url,
|
||||
headers: [{ name: "content-type", value: "application/json" }],
|
||||
body: { email: "person@example.com" },
|
||||
truncated: false,
|
||||
},
|
||||
response: {
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
url: request.url,
|
||||
headers: [{ name: "x-request-id", value: "request-123" }],
|
||||
body: { accepted: true },
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variable={{
|
||||
id: "contact-webhook-variable",
|
||||
type: "resource",
|
||||
name: "Contact webhook",
|
||||
resourceId: key,
|
||||
}}
|
||||
showEmptyLoadButton
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={request}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={false}
|
||||
onLoadData={() => {}}
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const requestTab = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Request");
|
||||
expect(requestTab).not.toBeNull();
|
||||
await act(async () => {
|
||||
requestTab?.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
requestTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(container.textContent).toContain('"method": "POST"');
|
||||
expect(container.textContent).toContain('"content-type"');
|
||||
expect(container.textContent).toContain('"email": "person@example.com"');
|
||||
expect(container.textContent).not.toContain("stale@example.com");
|
||||
expect(container.textContent).not.toContain("old.example");
|
||||
});
|
||||
|
||||
test.each(["submission", "reload"] as const)(
|
||||
"Form Resource preview prefers the latest %s exchange",
|
||||
async (latest) => {
|
||||
const request: ResourceRequest = {
|
||||
name: "Contact webhook",
|
||||
method: "post",
|
||||
url: "https://example.com/contacts",
|
||||
headers: [],
|
||||
searchParams: [],
|
||||
body: undefined,
|
||||
};
|
||||
const key = getResourceKey(request);
|
||||
const exchange = {
|
||||
resourceId: key,
|
||||
resourceName: "Contact webhook",
|
||||
kind: "http" as const,
|
||||
request: {
|
||||
method: "POST",
|
||||
url: request.url,
|
||||
headers: [],
|
||||
body: null,
|
||||
truncated: false,
|
||||
},
|
||||
response: {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [],
|
||||
body: { source: "submission" },
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
const recordSubmission = () =>
|
||||
recordPreviewFormExchanges("form", [exchange]);
|
||||
const recordReload = () =>
|
||||
recordResourcePreviewExchange(key, {
|
||||
...exchange,
|
||||
response: {
|
||||
...exchange.response,
|
||||
status: 201,
|
||||
body: { source: "reload" },
|
||||
},
|
||||
});
|
||||
if (latest === "submission") {
|
||||
recordReload();
|
||||
recordSubmission();
|
||||
} else {
|
||||
recordSubmission();
|
||||
recordReload();
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variable={{
|
||||
id: "contact-webhook-variable",
|
||||
type: "resource",
|
||||
name: "Contact webhook",
|
||||
resourceId: key,
|
||||
}}
|
||||
variableValue={request}
|
||||
inspectSubmission
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await openResponseTab(container);
|
||||
await expect
|
||||
.poll(() => container.textContent)
|
||||
.toContain(`"source": "${latest}"`);
|
||||
expect(container.textContent).not.toContain(
|
||||
`"source": "${latest === "submission" ? "reload" : "submission"}"`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(["http", "email"] as const)(
|
||||
"%s inspector shows actual submitted requests and response headers across retry attempts without loading",
|
||||
async (kind) => {
|
||||
const exchange = {
|
||||
resourceId: "submitted-resource",
|
||||
resourceName: "Submitted resource",
|
||||
kind,
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://example.com/submitted",
|
||||
headers: [{ name: "Content-Type", value: "application/json" }],
|
||||
body:
|
||||
kind === "email"
|
||||
? {
|
||||
subject: "actual entered value",
|
||||
body: "logical Email body",
|
||||
recipients: [{ address: "ada@example.com" }],
|
||||
}
|
||||
: { entered: "actual entered value" },
|
||||
truncated: false,
|
||||
},
|
||||
response: {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: [{ name: "Retry-After", value: "1" }],
|
||||
body: { error: "retry response" },
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
recordPreviewFormExchanges("form", [
|
||||
exchange,
|
||||
{
|
||||
...exchange,
|
||||
response: {
|
||||
...exchange.response,
|
||||
status: 201,
|
||||
statusText: "Created",
|
||||
url: "https://example.com/final",
|
||||
headers: [{ name: "Content-Type", value: "application/json" }],
|
||||
body: { accepted: true },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
let loads = 0;
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variable={{
|
||||
id: "variable",
|
||||
type: "resource",
|
||||
name: "Request",
|
||||
resourceId: "submitted-resource",
|
||||
}}
|
||||
showEmptyLoadButton={kind === "http"}
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={undefined}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={false}
|
||||
onLoadData={() => {
|
||||
loads += 1;
|
||||
}}
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const tabs = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
);
|
||||
expect(tabs.map((tab) => tab.textContent)).toEqual([
|
||||
"Request",
|
||||
"Response",
|
||||
"Diagnostics",
|
||||
]);
|
||||
expect(tabs[0].getAttribute("data-state")).toBe("active");
|
||||
await openResponseTab(container);
|
||||
await expect.poll(() => container.textContent).toContain("Retry-After");
|
||||
expect(container.textContent).toContain("Service Unavailable");
|
||||
expect(container.textContent).toContain("Created");
|
||||
expect(container.textContent).toContain('"status": 201');
|
||||
expect(container.textContent).toContain('"accepted": true');
|
||||
expect(container.textContent).toContain("https://example.com/final");
|
||||
await act(async () => {
|
||||
tabs[0].dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
tabs[0].click();
|
||||
});
|
||||
await expect
|
||||
.poll(() => container.textContent)
|
||||
.toContain("actual entered value");
|
||||
expect(container.textContent).toContain(
|
||||
'"resourceId": "submitted-resource"'
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
'"resourceName": "Submitted resource"'
|
||||
);
|
||||
expect(container.textContent).toContain("https://example.com/submitted");
|
||||
expect(container.textContent).toContain("POST");
|
||||
expect(container.textContent).toContain("application/json");
|
||||
if (kind === "email") {
|
||||
expect(container.textContent).toContain("logical Email body");
|
||||
expect(container.textContent).toContain("ada@example.com");
|
||||
expect(container.textContent).not.toContain("Load data");
|
||||
}
|
||||
await act(async () => {
|
||||
tabs[0].dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
tabs[0].click();
|
||||
});
|
||||
await expect
|
||||
.poll(() => container.textContent)
|
||||
.toContain('"resourceName": "Submitted resource"');
|
||||
expect(container.textContent).toContain(
|
||||
'"resourceId": "submitted-resource"'
|
||||
);
|
||||
expect(loads).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
test("Email Request loads only a local preview until an actual submission exists", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
let localLoads = 0;
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variable={{
|
||||
id: "email-variable",
|
||||
type: "resource",
|
||||
name: "Email",
|
||||
resourceId: "unsent-email",
|
||||
}}
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
emptyPreviewMessage="Submit this Form in Preview to see the email service response."
|
||||
variableValue={undefined}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={false}
|
||||
onLoadData={() => {
|
||||
localLoads += 1;
|
||||
}}
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll('[role="tab"]'),
|
||||
(tab) => tab.textContent
|
||||
)
|
||||
).toEqual(["Request", "Response", "Diagnostics"]);
|
||||
expect(
|
||||
container.querySelector('[role="tab"][data-state="active"]')?.textContent
|
||||
).toBe("Request");
|
||||
await openResponseTab(container);
|
||||
expect(container.textContent).not.toContain("Email delivery is available");
|
||||
expect(container.textContent).toContain(
|
||||
"Submit this Form in Preview to see the email service response."
|
||||
);
|
||||
const requestTab = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Request");
|
||||
await act(async () => {
|
||||
requestTab?.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
requestTab?.click();
|
||||
});
|
||||
const loadButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button")
|
||||
).find((button) => button.textContent === "Load data");
|
||||
expect(loadButton).toBeDefined();
|
||||
await act(async () => loadButton?.click());
|
||||
expect(localLoads).toBe(1);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variable={{
|
||||
id: "email-variable",
|
||||
type: "resource",
|
||||
name: "Email",
|
||||
resourceId: "unsent-email",
|
||||
}}
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={undefined}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={false}
|
||||
onLoadData={() => {
|
||||
localLoads += 1;
|
||||
}}
|
||||
requestSnapshot={{
|
||||
preview: { to: [], subject: "Receipt", fromName: "Site Owner" },
|
||||
}}
|
||||
emptyPreviewMessage="Submit this Form in Preview to see the email service response."
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(container.textContent).toContain('"preview"');
|
||||
expect(container.textContent).toContain('"subject": "Receipt"');
|
||||
const responseTab = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Response");
|
||||
await act(async () => {
|
||||
responseTab?.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
responseTab?.click();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"Submit this Form in Preview to see the email service response."
|
||||
);
|
||||
expect($previewFormExchanges.get().has("unsent-email")).toBe(false);
|
||||
});
|
||||
|
||||
test("Email inspector shows raw HTTP response and failed delivery diagnostics separately", async () => {
|
||||
recordPreviewFormExchanges("form", [
|
||||
{
|
||||
resourceId: "email-resource",
|
||||
resourceName: "Receipt",
|
||||
kind: "email",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://webstudio-email-service.wstd.workers.dev/trpc/email.send",
|
||||
headers: [{ name: "content-type", value: "application/json" }],
|
||||
body: { subject: "Receipt", body: "Submitted" },
|
||||
truncated: false,
|
||||
},
|
||||
response: {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [{ name: "x-email-service-id", value: "request-id" }],
|
||||
body: {
|
||||
error: { code: "DELIVERY_FAILED", message: "Delivery rejected" },
|
||||
},
|
||||
truncated: false,
|
||||
},
|
||||
outcome: {
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: "Email service returned an invalid response",
|
||||
body: {
|
||||
ok: false,
|
||||
error: { code: "EMAIL_SERVICE_ERROR" },
|
||||
},
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<FormResourcePreview
|
||||
variable={{
|
||||
id: "email-variable",
|
||||
type: "resource",
|
||||
name: "Receipt",
|
||||
resourceId: "email-resource",
|
||||
}}
|
||||
inspectSubmission
|
||||
alwaysShowRequestTab
|
||||
variableValue={undefined}
|
||||
showSavedResourceRequest={false}
|
||||
isComputingRequest={false}
|
||||
onLoadData={() => {}}
|
||||
queryActive={false}
|
||||
queryPending={false}
|
||||
queryContainerRef={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
|
||||
await openResponseTab(container);
|
||||
expect(container.textContent).toContain('"status": 200');
|
||||
expect(container.textContent).toContain("Delivery rejected");
|
||||
expect(container.textContent).toContain("request-id");
|
||||
expect(container.textContent).toContain('"ok": false');
|
||||
const diagnostics = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
).find((tab) => tab.textContent === "Diagnostics");
|
||||
await act(async () => {
|
||||
diagnostics?.dispatchEvent(
|
||||
new MouseEvent("mousedown", { bubbles: true, button: 0 })
|
||||
);
|
||||
diagnostics?.click();
|
||||
});
|
||||
await expect.poll(() => container.textContent).toContain("502");
|
||||
expect(container.textContent).toContain("EMAIL_SERVICE_ERROR");
|
||||
expect(container.textContent).toContain(
|
||||
"Email service returned an invalid response"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,761 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import { CollapsibleProvider } from "~/builder/shared/collapsible-section";
|
||||
import {
|
||||
$selectedInstanceSelector,
|
||||
$selectedPageId,
|
||||
selectInstance,
|
||||
} from "~/shared/nano-states";
|
||||
import { encodeDataSourceVariable, ROOT_INSTANCE_ID } from "@webstudio-is/sdk";
|
||||
import { $pages, $props, $resources } from "~/shared/sync/data-stores";
|
||||
import { registerContainers } from "~/shared/sync/sync-stores";
|
||||
import { FormActionControl } from "./controls/form-action";
|
||||
import { $variableToFocus, $variableToOpen } from "./variable-navigation";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import { $instances, $dataSources } from "~/shared/sync/data-stores";
|
||||
import { __testing__, VariablesSection } from "./variables-section";
|
||||
import { __testing__ as popoverTesting } from "./variable-popover";
|
||||
import {
|
||||
$livePreviewBrowserInfo,
|
||||
recordPreviewBrowserInfo,
|
||||
} from "~/shared/preview-form-values";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root | undefined;
|
||||
registerContainers();
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
vi.restoreAllMocks();
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
$variableToFocus.set(undefined);
|
||||
$variableToOpen.set(undefined);
|
||||
selectInstance(undefined);
|
||||
$pages.set(undefined);
|
||||
$props.set(new Map());
|
||||
$resources.set(new Map());
|
||||
$instances.set(new Map());
|
||||
$dataSources.set(new Map());
|
||||
$livePreviewBrowserInfo.set(new Map());
|
||||
});
|
||||
const setup = () => {
|
||||
const parent = {
|
||||
id: "parent",
|
||||
type: "instance" as const,
|
||||
component: "Body",
|
||||
children: [{ type: "id" as const, value: "child" }],
|
||||
};
|
||||
const child = {
|
||||
id: "child",
|
||||
type: "instance" as const,
|
||||
component: "Box",
|
||||
children: [],
|
||||
};
|
||||
const ancestor = {
|
||||
id: "ancestor",
|
||||
type: "variable" as const,
|
||||
scopeInstanceId: "parent",
|
||||
name: "Color",
|
||||
value: { type: "string" as const, value: "blue" },
|
||||
};
|
||||
const local = {
|
||||
...ancestor,
|
||||
id: "local",
|
||||
scopeInstanceId: "child",
|
||||
value: { type: "string" as const, value: "red" },
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[ancestor.id, ancestor],
|
||||
[local.id, local],
|
||||
])
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
return { container, local };
|
||||
};
|
||||
|
||||
test("Option-click local variable label opens Delete confirmation without editing", async () => {
|
||||
const { container, local } = setup();
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "parent" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["child"]);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.VariablesItem
|
||||
variable={local}
|
||||
source="local"
|
||||
index={0}
|
||||
value="red"
|
||||
usageCount={2}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const label = container.querySelector("label")!;
|
||||
await act(async () => {
|
||||
label.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, altKey: true })
|
||||
);
|
||||
});
|
||||
expect(document.body.textContent).toContain("Delete confirmation");
|
||||
expect(document.body.textContent).toContain(
|
||||
'Delete "Color" variable from the project? It is used in 2 expressions.'
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("Edit variable");
|
||||
const deleteButton = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')
|
||||
).find((button) => button.textContent?.trim() === "Delete");
|
||||
expect(deleteButton).not.toBeUndefined();
|
||||
await act(async () => await userEvent.click(deleteButton!));
|
||||
await expect.poll(() => $dataSources.get().has(local.id)).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["formState", "results", "errors"] as const)(
|
||||
"Option-click cannot delete required Form variable %s",
|
||||
async (name) => {
|
||||
const variable = {
|
||||
id: `form-${name}`,
|
||||
type: "variable" as const,
|
||||
name,
|
||||
scopeInstanceId: "form",
|
||||
value:
|
||||
name === "formState"
|
||||
? ({ type: "string", value: "initial" } as const)
|
||||
: ({ type: "json", value: [] } as const),
|
||||
};
|
||||
const reference = encodeDataSourceVariable(variable.id);
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance" as const,
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "form" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["form"]);
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
$props.set(
|
||||
new Map([
|
||||
[
|
||||
"form-state-binding",
|
||||
name === "formState"
|
||||
? {
|
||||
id: "form-state-binding",
|
||||
instanceId: "form",
|
||||
name: "state",
|
||||
type: "expression",
|
||||
value: reference,
|
||||
}
|
||||
: {
|
||||
id: "form-state-binding",
|
||||
instanceId: "form",
|
||||
name: "onResultChange",
|
||||
type: "action",
|
||||
value: [
|
||||
{
|
||||
type: "execute",
|
||||
args: ["result"],
|
||||
code: `({ ${name}: ${reference} = result.${name} })`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.VariablesItem
|
||||
variable={variable}
|
||||
source="local"
|
||||
index={0}
|
||||
value={undefined}
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const label = container.querySelector("label")!;
|
||||
await act(async () =>
|
||||
label.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, altKey: true })
|
||||
)
|
||||
);
|
||||
|
||||
expect($dataSources.get().has(variable.id)).toBe(true);
|
||||
expect(document.body.textContent).not.toContain("Delete confirmation");
|
||||
}
|
||||
);
|
||||
|
||||
test("keeps a variable highlighted while its edit dialog is open", async () => {
|
||||
const { container, local } = setup();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.VariablesItem
|
||||
variable={local}
|
||||
source="local"
|
||||
index={0}
|
||||
value="red"
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const row = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Variable Color"]'
|
||||
)!;
|
||||
await act(async () => await userEvent.click(row));
|
||||
await expect.poll(() => row.getAttribute("data-state")).toBe("open");
|
||||
});
|
||||
|
||||
test("a variable without an instance scope is labeled as coming from Global root", async () => {
|
||||
const { container, local } = setup();
|
||||
const globalVariable = {
|
||||
...local,
|
||||
id: "global-variable",
|
||||
scopeInstanceId: undefined,
|
||||
name: "Ghost URL",
|
||||
};
|
||||
$dataSources.set(new Map([[globalVariable.id, globalVariable]]));
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<__testing__.VariablesItem
|
||||
variable={globalVariable}
|
||||
source="remote"
|
||||
index={0}
|
||||
value="https://example.com"
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(
|
||||
async () => await userEvent.hover(container.querySelector("label")!)
|
||||
);
|
||||
expect(document.body.textContent).toContain("Global root");
|
||||
expect(document.body.textContent).not.toContain("System");
|
||||
});
|
||||
|
||||
test("Variables list preview uses server browser info after Preview submission", async () => {
|
||||
const { container } = setup();
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance",
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const variable = {
|
||||
id: "browser-info",
|
||||
type: "parameter" as const,
|
||||
name: "browserInfo",
|
||||
scopeInstanceId: "form",
|
||||
};
|
||||
recordPreviewBrowserInfo("form", {
|
||||
ip: "203.0.113.10",
|
||||
referrer: "https://builder.example/project",
|
||||
});
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.VariablesItem
|
||||
variable={variable}
|
||||
source="local"
|
||||
index={0}
|
||||
value={undefined}
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const preview = container.querySelector<HTMLSpanElement>("span[title]");
|
||||
expect(preview?.title).toContain("203.0.113.10");
|
||||
expect(preview?.title).toContain("builder.example/project");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["formData", "Submitted field values from this Form, keyed by input name."],
|
||||
[
|
||||
"browserInfo",
|
||||
"Visitor IP address, browser, language, and referrer available to this Form.",
|
||||
],
|
||||
["formState", "The current Form state: initial, success, or error."],
|
||||
[
|
||||
"results",
|
||||
"Responses from selected actions, in order. Each includes the resource name, HTTP status code, and response body.",
|
||||
],
|
||||
[
|
||||
"errors",
|
||||
"Errors from failed actions, in order. Each includes the resource name, HTTP status code, response body, and message.",
|
||||
],
|
||||
] as const)(
|
||||
"shows the approved %s Form variable description",
|
||||
async (name, description) => {
|
||||
const { container } = setup();
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"form",
|
||||
{
|
||||
id: "form",
|
||||
type: "instance" as const,
|
||||
component: "Form",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const variable =
|
||||
name === "formData" || name === "browserInfo"
|
||||
? {
|
||||
id: `form-${name}`,
|
||||
type: "parameter" as const,
|
||||
name,
|
||||
scopeInstanceId: "form",
|
||||
}
|
||||
: {
|
||||
id: `form-${name}`,
|
||||
type: "variable" as const,
|
||||
name,
|
||||
scopeInstanceId: "form",
|
||||
value: { type: "json" as const, value: {} },
|
||||
};
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<__testing__.VariablesItem
|
||||
variable={variable}
|
||||
source="local"
|
||||
index={0}
|
||||
value={undefined}
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(
|
||||
async () => await userEvent.hover(container.querySelector("label")!)
|
||||
);
|
||||
expect(document.body.textContent).toContain(description);
|
||||
const tooltipContent = document.querySelector<HTMLElement>(
|
||||
"[data-side][data-align]"
|
||||
);
|
||||
expect(tooltipContent).not.toBeNull();
|
||||
expect(getComputedStyle(tooltipContent!).maxWidth).toBe("224px");
|
||||
}
|
||||
);
|
||||
|
||||
test("clicking an ancestor source label selects and focuses its defining instance", async () => {
|
||||
const { container } = setup();
|
||||
const ancestor = $dataSources.get().get("ancestor")!;
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "parent" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["child"]);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<CollapsibleProvider initialOpen="Variables">
|
||||
<__testing__.VariablesItem
|
||||
variable={ancestor}
|
||||
source="remote"
|
||||
index={0}
|
||||
value="blue"
|
||||
usageCount={0}
|
||||
/>
|
||||
</CollapsibleProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const variableLabel = Array.from(container.querySelectorAll("label")).find(
|
||||
(label) => label.textContent?.trim() === "Color"
|
||||
)!;
|
||||
await act(async () => await userEvent.hover(variableLabel));
|
||||
await act(
|
||||
async () => await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
);
|
||||
expect(document.body.textContent).toContain("Value comes from");
|
||||
expect(
|
||||
document.querySelector('[role="tooltip"] [aria-label="Static variable"]')
|
||||
).not.toBeNull();
|
||||
const tooltipButtons = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('[role="tooltip"] button')
|
||||
);
|
||||
const sourceButton = tooltipButtons.find(
|
||||
(button) => button.textContent?.trim() === "Body"
|
||||
);
|
||||
expect(sourceButton).not.toBeUndefined();
|
||||
expect(sourceButton?.closest('[role="tooltip"]')?.textContent).toContain(
|
||||
"Value comes from"
|
||||
);
|
||||
await act(async () =>
|
||||
sourceButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }))
|
||||
);
|
||||
expect($selectedInstanceSelector.get()?.[0]).toBe("parent");
|
||||
await expect.poll(() => $variableToFocus.get()).toBeUndefined();
|
||||
await expect
|
||||
.poll(() => document.activeElement?.getAttribute("aria-label"))
|
||||
.toBe("Variable Color");
|
||||
});
|
||||
|
||||
test("clicking a Global root source selects and focuses Global root on the current page", async () => {
|
||||
const { container, local } = setup();
|
||||
const globalVariable = {
|
||||
...local,
|
||||
id: "global-variable",
|
||||
scopeInstanceId: undefined,
|
||||
name: "Global Color",
|
||||
value: { type: "string" as const, value: "green" },
|
||||
};
|
||||
$dataSources.set(new Map([[globalVariable.id, globalVariable]]));
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "parent" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["child"]);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<__testing__.VariablesItem
|
||||
variable={globalVariable}
|
||||
source="remote"
|
||||
index={0}
|
||||
value="green"
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const variableLabel = Array.from(container.querySelectorAll("label")).find(
|
||||
(label) => label.textContent?.trim() === "Global Color"
|
||||
)!;
|
||||
await act(async () => await userEvent.hover(variableLabel));
|
||||
const globalRootButton = await vi.waitFor(() => {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('[role="tooltip"] button')
|
||||
).find((element) => element.textContent?.trim() === "Global root");
|
||||
expect(button).toBeDefined();
|
||||
return button!;
|
||||
});
|
||||
expect(document.querySelector('[role="tooltip"]')?.textContent).toContain(
|
||||
"Value comes from"
|
||||
);
|
||||
await act(async () => {
|
||||
globalRootButton.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, button: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
expect($selectedPageId.get()).toBe("home");
|
||||
expect($selectedInstanceSelector.get()?.[0]).toBe(ROOT_INSTANCE_ID);
|
||||
await expect.poll(() => $variableToFocus.get()).toBeUndefined();
|
||||
expect(document.activeElement?.getAttribute("aria-label")).toBe(
|
||||
"Variable Global Color"
|
||||
);
|
||||
});
|
||||
|
||||
test("name shadow warning appears for an existing shadow and renamed ancestor name", async () => {
|
||||
const { container, local } = setup();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<popoverTesting.NameField variable={local} defaultValue="Color" />
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<popoverTesting.NameField
|
||||
key="rename"
|
||||
variable={{ ...local, name: "Other" }}
|
||||
defaultValue="Other"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const input = container.querySelector("input")!;
|
||||
await act(async () => await userEvent.fill(input, "Color"));
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
expect(input.validity.valid).toBe(true);
|
||||
await act(async () => await userEvent.fill(input, "Unique"));
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.VariablesItem
|
||||
variable={local}
|
||||
source="local"
|
||||
index={0}
|
||||
value="red"
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("svg")).some(
|
||||
(icon) => icon.getAttribute("color") === "var(--foreground-warning)"
|
||||
)
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<__testing__.VariablesItem
|
||||
variable={{ ...local, name: "Unique" }}
|
||||
source="local"
|
||||
index={0}
|
||||
value="red"
|
||||
usageCount={0}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
expect(
|
||||
container.querySelector('svg[color="var(--foreground-warning)"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("clicking an Action opens its Resource editor and highlights its Variables row", async () => {
|
||||
const { container } = setup();
|
||||
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
|
||||
const resourceVariable = {
|
||||
id: "request-variable",
|
||||
scopeInstanceId: "child",
|
||||
type: "resource" as const,
|
||||
name: "Request",
|
||||
resourceId: "request",
|
||||
};
|
||||
$dataSources.set(new Map([[resourceVariable.id, resourceVariable]]));
|
||||
$resources.set(
|
||||
new Map([
|
||||
[
|
||||
"request",
|
||||
{
|
||||
id: "request",
|
||||
name: "request",
|
||||
method: "post",
|
||||
url: '"https://example.com"',
|
||||
headers: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "parent" }));
|
||||
$selectedPageId.set("home");
|
||||
selectInstance(["child"]);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<CollapsibleProvider initialOpen="Variables">
|
||||
<FormActionControl
|
||||
instanceId="child"
|
||||
prop={{
|
||||
id: "action",
|
||||
instanceId: "child",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [{ dataSourceId: resourceVariable.id, enabled: true }],
|
||||
}}
|
||||
onChange={() => {}}
|
||||
propName="action"
|
||||
computedValue={[]}
|
||||
meta={{
|
||||
type: "json",
|
||||
control: "form-action",
|
||||
required: false,
|
||||
}}
|
||||
/>
|
||||
<VariablesSection />
|
||||
</CollapsibleProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
const row = () =>
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Variable Request"]'
|
||||
);
|
||||
const action = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Action Request"]'
|
||||
)!;
|
||||
const variablesSectionToggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[data-state="open"]'
|
||||
)!;
|
||||
expect($selectedInstanceSelector.get()?.[0]).toBe("child");
|
||||
await act(async () => await userEvent.click(variablesSectionToggle));
|
||||
expect(variablesSectionToggle.getAttribute("data-state")).toBe("closed");
|
||||
await act(async () => await userEvent.hover(action));
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector("[data-drag-handle]")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect($variableToFocus.get()).toBeUndefined();
|
||||
expect($variableToOpen.get()).toBeUndefined();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
await act(
|
||||
async () =>
|
||||
await userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Disable action Request"]'
|
||||
)!
|
||||
)
|
||||
);
|
||||
expect($variableToFocus.get()).toBeUndefined();
|
||||
expect($variableToOpen.get()).toBeUndefined();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
await act(
|
||||
async () =>
|
||||
await userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove action Request"]'
|
||||
)!
|
||||
)
|
||||
);
|
||||
expect($variableToFocus.get()).toBeUndefined();
|
||||
expect($variableToOpen.get()).toBeUndefined();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
await act(async () => await userEvent.click(action));
|
||||
expect(document.body.textContent).toContain("Edit variable");
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
expect(row()?.getAttribute("data-state")).toBe("open");
|
||||
expect(variablesSectionToggle.getAttribute("data-state")).toBe("open");
|
||||
expect($selectedInstanceSelector.get()?.[0]).toBe("child");
|
||||
expect($selectedPageId.get()).toBe("home");
|
||||
expect($variableToOpen.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an Action Resource unavailable in Variables does not open a stale dialog", async () => {
|
||||
const { container } = setup();
|
||||
const parentResource = {
|
||||
id: "parent-resource",
|
||||
scopeInstanceId: "parent",
|
||||
type: "resource" as const,
|
||||
name: "Request",
|
||||
resourceId: "parent-request",
|
||||
};
|
||||
const localResource = {
|
||||
...parentResource,
|
||||
id: "local-resource",
|
||||
scopeInstanceId: "child",
|
||||
resourceId: "local-request",
|
||||
};
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[parentResource.id, parentResource],
|
||||
[localResource.id, localResource],
|
||||
])
|
||||
);
|
||||
$resources.set(
|
||||
new Map([
|
||||
[
|
||||
parentResource.resourceId,
|
||||
{
|
||||
id: parentResource.resourceId,
|
||||
name: "parent-request",
|
||||
method: "post",
|
||||
url: '"https://example.com"',
|
||||
headers: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
localResource.resourceId,
|
||||
{
|
||||
id: localResource.resourceId,
|
||||
name: "local-request",
|
||||
method: "post",
|
||||
url: '"https://example.com"',
|
||||
headers: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$pages.set(createDefaultPages({ rootInstanceId: "parent" }));
|
||||
$selectedPageId.set("home");
|
||||
$props.set(new Map());
|
||||
selectInstance(["child"]);
|
||||
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<TooltipProvider>
|
||||
<CollapsibleProvider initialOpen="Variables">
|
||||
<FormActionControl
|
||||
instanceId="child"
|
||||
propName="action"
|
||||
prop={{
|
||||
id: "action",
|
||||
instanceId: "child",
|
||||
name: "action",
|
||||
type: "json",
|
||||
value: [{ dataSourceId: parentResource.id, enabled: true }],
|
||||
}}
|
||||
computedValue={[]}
|
||||
meta={{
|
||||
type: "json",
|
||||
control: "form-action",
|
||||
required: false,
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<VariablesSection />
|
||||
</CollapsibleProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
const sectionButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[data-state="open"]'
|
||||
)!;
|
||||
await act(async () => await userEvent.click(sectionButton));
|
||||
expect(sectionButton.getAttribute("data-state")).toBe("closed");
|
||||
|
||||
await act(
|
||||
async () =>
|
||||
await userEvent.click(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Action Request"]'
|
||||
)!
|
||||
)
|
||||
);
|
||||
|
||||
expect($variableToFocus.get()).toBeUndefined();
|
||||
expect($variableToOpen.get()).toBeUndefined();
|
||||
expect(sectionButton.getAttribute("data-state")).toBe("closed");
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
@@ -1,27 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
$livePreviewFormValues,
|
||||
$livePreviewBrowserInfo,
|
||||
} from "~/shared/preview-form-values";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { computed } from "nanostores";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
css,
|
||||
CssValueListArrowFocus,
|
||||
CssValueListItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
cssVar,
|
||||
InteractiveListArrowFocus,
|
||||
InteractiveListItem,
|
||||
Flex,
|
||||
Label,
|
||||
SectionTitle,
|
||||
SectionTitleButton,
|
||||
SectionTitleLabel,
|
||||
SmallIconButton,
|
||||
Text,
|
||||
Tooltip,
|
||||
Kbd,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { EllipsesIcon, PlusIcon } from "@webstudio-is/icons";
|
||||
import type { DataSource } from "@webstudio-is/sdk";
|
||||
import { AlertIcon, PlusIcon, TrashIcon } from "@webstudio-is/icons";
|
||||
import { ROOT_INSTANCE_ID, type DataSource } from "@webstudio-is/sdk";
|
||||
import { $variableValuesByInstanceSelector } from "~/shared/nano-states";
|
||||
import { $dataSources } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
@@ -36,10 +38,22 @@ import {
|
||||
} from "~/builder/shared/collapsible-section";
|
||||
import { formatValuePreview } from "~/builder/shared/expression-editor";
|
||||
import { VariablePopoverTrigger } from "./variable-popover";
|
||||
import {
|
||||
canDeleteVariable,
|
||||
VariableContextMenu,
|
||||
VariableMenu,
|
||||
} from "./variable-menu";
|
||||
import {
|
||||
$variableToFocus,
|
||||
$variableToOpen,
|
||||
showVariableAtSource,
|
||||
} from "./variable-navigation";
|
||||
import { StyleSourceBadge } from "../style-panel/style-source";
|
||||
import { resolveFormParameterPreview } from "./form-context-preview";
|
||||
import {
|
||||
$selectedInstance,
|
||||
$selectedInstanceSelector,
|
||||
$selectedInstanceKeyWithRoot,
|
||||
$selectedPage,
|
||||
} from "~/shared/nano-states";
|
||||
import {
|
||||
findAvailableVariables,
|
||||
@@ -101,14 +115,14 @@ const EmptyVariables = () => (
|
||||
<Flex direction="column" gap="2">
|
||||
<Flex justify="center" align="center">
|
||||
<Text variant="labels" align="center">
|
||||
No data variables created
|
||||
No variables created
|
||||
<br /> on this instance
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex justify="center" align="center">
|
||||
<VariablePopoverTrigger>
|
||||
<Button color="primary" type="button" prefix={<PlusIcon />}>
|
||||
Create data variable
|
||||
Create variable
|
||||
</Button>
|
||||
</VariablePopoverTrigger>
|
||||
</Flex>
|
||||
@@ -131,12 +145,23 @@ const getVariableBadge = (variable: DataSource) => {
|
||||
}
|
||||
if (variable.type === "resource") {
|
||||
return {
|
||||
label: "Dynamic data variable",
|
||||
label: "Dynamic variable",
|
||||
text: "D",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formVariableDescriptions: Record<string, string> = {
|
||||
formData: "Submitted field values from this Form, keyed by input name.",
|
||||
browserInfo:
|
||||
"Visitor IP address, browser, language, and referrer available to this Form.",
|
||||
formState: "The current Form state: initial, success, or error.",
|
||||
results:
|
||||
"Responses from selected actions, in order. Each includes the resource name, HTTP status code, and response body.",
|
||||
errors:
|
||||
"Errors from failed actions, in order. Each includes the resource name, HTTP status code, response body, and message.",
|
||||
};
|
||||
|
||||
const DataVariableBadge = ({ variable }: { variable: DataSource }) => {
|
||||
const badge = getVariableBadge(variable);
|
||||
if (badge === undefined) {
|
||||
@@ -155,14 +180,100 @@ const VariablesItem = ({
|
||||
index,
|
||||
value,
|
||||
usageCount,
|
||||
isOpen = true,
|
||||
}: {
|
||||
variable: DataSource;
|
||||
source: "local" | "remote";
|
||||
index: number;
|
||||
value: unknown;
|
||||
usageCount: number;
|
||||
isOpen?: boolean;
|
||||
}) => {
|
||||
const selectedPage = useStore($selectedPage);
|
||||
const variableToFocus = useStore($variableToFocus);
|
||||
const rowRef = useRef<HTMLButtonElement>(null);
|
||||
const [isVariableDialogOpen, setIsVariableDialogOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isOpen || variableToFocus?.id !== variable.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let focusRequestId: number | undefined;
|
||||
const scrollRequestId = requestAnimationFrame(() => {
|
||||
const row = rowRef.current;
|
||||
if (
|
||||
row === null ||
|
||||
!row.isConnected ||
|
||||
$variableToFocus.get()?.id !== variable.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
focusRequestId = requestAnimationFrame(() => {
|
||||
const currentRow = rowRef.current;
|
||||
if (
|
||||
currentRow === null ||
|
||||
!currentRow.isConnected ||
|
||||
$variableToFocus.get()?.id !== variable.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentRow.focus({ preventScroll: true });
|
||||
$variableToFocus.set(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(scrollRequestId);
|
||||
if (focusRequestId !== undefined) {
|
||||
cancelAnimationFrame(focusRequestId);
|
||||
}
|
||||
};
|
||||
}, [isOpen, variableToFocus, variable.id]);
|
||||
const instances = useStore($instances);
|
||||
const liveFormValues = useStore($livePreviewFormValues);
|
||||
const liveBrowserInfo = useStore($livePreviewBrowserInfo);
|
||||
const selectedInstanceSelector = useStore($selectedInstanceSelector);
|
||||
const dataSources = useStore($dataSources);
|
||||
const valueSourceId = variable.scopeInstanceId ?? ROOT_INSTANCE_ID;
|
||||
const valueSource = instances.get(valueSourceId);
|
||||
const formVariableDescription =
|
||||
valueSource?.component === "Form"
|
||||
? formVariableDescriptions[variable.name]
|
||||
: undefined;
|
||||
const valueSourceName =
|
||||
valueSourceId === ROOT_INSTANCE_ID
|
||||
? "Global root"
|
||||
: (valueSource?.label ?? valueSource?.component ?? "System");
|
||||
const shadowed =
|
||||
source === "local" && variable.scopeInstanceId
|
||||
? findAvailableVariables({
|
||||
startingInstanceId: variable.scopeInstanceId,
|
||||
instances,
|
||||
dataSources: new Map(
|
||||
[...dataSources].filter(
|
||||
([, other]) => other.scopeInstanceId !== variable.scopeInstanceId
|
||||
)
|
||||
),
|
||||
}).find(
|
||||
(other) =>
|
||||
other.scopeInstanceId !== variable.scopeInstanceId &&
|
||||
other.name === variable.name
|
||||
)
|
||||
: undefined;
|
||||
value =
|
||||
resolveFormParameterPreview(variable, {
|
||||
instances,
|
||||
selector: selectedInstanceSelector,
|
||||
liveFormValues,
|
||||
liveBrowserInfo,
|
||||
})?.value ?? value;
|
||||
const canDelete = canDeleteVariable(variable, source === "local");
|
||||
const requestDelete = () =>
|
||||
setVariableToDelete({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
usages: usageCount,
|
||||
});
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [variableToDelete, setVariableToDelete] = useState<{
|
||||
id: string;
|
||||
@@ -170,73 +281,131 @@ const VariablesItem = ({
|
||||
usages: number;
|
||||
}>();
|
||||
return (
|
||||
<VariablePopoverTrigger key={variable.id} variable={variable}>
|
||||
<CssValueListItem
|
||||
<VariablePopoverTrigger
|
||||
key={variable.id}
|
||||
variable={variable}
|
||||
onOpenChange={setIsVariableDialogOpen}
|
||||
>
|
||||
<InteractiveListItem
|
||||
ref={rowRef}
|
||||
aria-label={`Variable ${variable.name}`}
|
||||
id={variable.id}
|
||||
index={index}
|
||||
label={
|
||||
<Flex align="center">
|
||||
<Label tag="label" color={source}>
|
||||
{variable.name}
|
||||
</Label>
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
triggerProps={{
|
||||
onClick: (event) => {
|
||||
if (event.altKey && canDelete) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
requestDelete();
|
||||
}
|
||||
},
|
||||
}}
|
||||
content={
|
||||
<Flex direction="column" gap="2">
|
||||
<Text variant="labels">{variable.name}</Text>
|
||||
<Flex align="center" gap="1">
|
||||
<Text>
|
||||
{variable.type === "variable"
|
||||
? `${
|
||||
variable.value.type === "json"
|
||||
? "JSON"
|
||||
: variable.value.type
|
||||
} · Static`
|
||||
: variable.type === "resource"
|
||||
? "Resource · Dynamic"
|
||||
: "JSON · Dynamic parameter"}
|
||||
</Text>
|
||||
<DataVariableBadge variable={variable} />
|
||||
</Flex>
|
||||
{formVariableDescription && (
|
||||
<Text>{formVariableDescription}</Text>
|
||||
)}
|
||||
<Text color="moreSubtle">Value comes from</Text>
|
||||
<Flex gap="1" wrap="wrap">
|
||||
{source === "local" && (
|
||||
<StyleSourceBadge source="local" variant="small">
|
||||
Local
|
||||
</StyleSourceBadge>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
border: 0,
|
||||
padding: 0,
|
||||
background: "transparent",
|
||||
display: "inline-flex",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
showVariableAtSource(variable.id, valueSourceId);
|
||||
}}
|
||||
>
|
||||
<StyleSourceBadge source="instance" variant="small">
|
||||
{valueSourceName}
|
||||
</StyleSourceBadge>
|
||||
</button>
|
||||
</Flex>
|
||||
{canDelete && (
|
||||
<Button
|
||||
css={{ marginTop: theme.spacing[2] }}
|
||||
color="neutral-destructive"
|
||||
prefix={<TrashIcon />}
|
||||
suffix={
|
||||
<Kbd value={["alt", "click"]} color="moreSubtle" />
|
||||
}
|
||||
onClick={requestDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Label tag="label" color={source}>
|
||||
{variable.name}
|
||||
</Label>
|
||||
</Tooltip>
|
||||
{shadowed && (
|
||||
<Tooltip
|
||||
content={`This variable shadows ${shadowed.name} from ${
|
||||
instances.get(shadowed.scopeInstanceId ?? "")?.label ??
|
||||
instances.get(shadowed.scopeInstanceId ?? "")?.component ??
|
||||
"an ancestor"
|
||||
}. Delete the local variable to reveal it.`}
|
||||
>
|
||||
<AlertIcon color={cssVar("--foreground-warning")} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{value !== undefined && (
|
||||
<span className={variableLabelStyle.toString()}>
|
||||
<span
|
||||
className={variableLabelStyle.toString()}
|
||||
title={
|
||||
variable.type === "parameter" &&
|
||||
variable.name === "browserInfo"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
||||
{formatValuePreview(value)}
|
||||
</span>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
data-state={isMenuOpen ? "open" : undefined}
|
||||
data-state={isMenuOpen || isVariableDialogOpen ? "open" : undefined}
|
||||
suffix={<DataVariableBadge variable={variable} />}
|
||||
buttons={
|
||||
<>
|
||||
{((source === "local" && variable.type !== "parameter") ||
|
||||
(source === "local" &&
|
||||
variable.id === selectedPage?.systemDataSourceId)) && (
|
||||
<DropdownMenu modal onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* a11y is completely broken here
|
||||
focus is not restored to button invoker
|
||||
@todo fix it eventually and consider restoring from closed value preview dialog
|
||||
*/}
|
||||
<SmallIconButton
|
||||
tabIndex={-1}
|
||||
aria-label="Open variable menu"
|
||||
icon={<EllipsesIcon />}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
css={{ width: theme.spacing[28] }}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{source === "local" && variable.type !== "parameter" && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setVariableToDelete({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
usages: usageCount,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete {usageCount > 0 && `(${usageCount} bindings)`}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{source === "local" &&
|
||||
variable.id === selectedPage?.systemDataSourceId && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
deleteDataVariable(variable.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<VariableMenu
|
||||
variable={variable}
|
||||
canDelete={canDelete}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
/>
|
||||
|
||||
<DeleteDataVariableDialog
|
||||
variable={variableToDelete}
|
||||
@@ -255,7 +424,7 @@ const VariablesItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const VariablesList = () => {
|
||||
const VariablesList = ({ isOpen }: { isOpen: boolean }) => {
|
||||
const instance = useStore($selectedInstance);
|
||||
const availableVariables = useStore($availableVariables);
|
||||
const variableValues = useStore($instanceVariableValues);
|
||||
@@ -266,7 +435,7 @@ const VariablesList = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<CssValueListArrowFocus>
|
||||
<InteractiveListArrowFocus>
|
||||
{/* local variables should be ordered first to not block tab to first item */}
|
||||
{availableVariables.map((variable, index) => (
|
||||
<VariablesItem
|
||||
@@ -278,51 +447,99 @@ const VariablesList = () => {
|
||||
variable={variable}
|
||||
index={index}
|
||||
usageCount={usedVariables.get(variable.id) ?? 0}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
))}
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
);
|
||||
};
|
||||
|
||||
const label = "Data variables";
|
||||
const label = "Variables";
|
||||
|
||||
export const VariablesSection = () => {
|
||||
const variableToFocus = useStore($variableToFocus);
|
||||
const variableToOpen = useStore($variableToOpen);
|
||||
const availableVariables = useStore($availableVariables);
|
||||
const selectedInstance = useStore($selectedInstance);
|
||||
const selectedScopeId = selectedInstance?.id ?? ROOT_INSTANCE_ID;
|
||||
const [isOpen, setIsOpen] = useOpenState(label);
|
||||
return (
|
||||
<CollapsibleSectionRoot
|
||||
label={label}
|
||||
fullWidth={true}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
trigger={
|
||||
<SectionTitle
|
||||
suffix={
|
||||
<VariablePopoverTrigger>
|
||||
<SectionTitleButton
|
||||
type="button"
|
||||
aria-label="Add data variable"
|
||||
prefix={<PlusIcon />}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
// open panel when adding a new variable
|
||||
onClick={() => {
|
||||
if (isOpen === false) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</VariablePopoverTrigger>
|
||||
}
|
||||
>
|
||||
<SectionTitleLabel>Data variables</SectionTitleLabel>
|
||||
</SectionTitle>
|
||||
useEffect(() => {
|
||||
if (
|
||||
variableToOpen !== undefined &&
|
||||
availableVariables.some(({ id }) => id === variableToOpen.id)
|
||||
) {
|
||||
if (isOpen === false) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
>
|
||||
{/* prevent applyig gap to list items */}
|
||||
<div>
|
||||
<VariablesList />
|
||||
</div>
|
||||
</CollapsibleSectionRoot>
|
||||
} else if (
|
||||
variableToOpen !== undefined &&
|
||||
!availableVariables.some(({ id }) => id === variableToOpen.id)
|
||||
) {
|
||||
$variableToOpen.set(undefined);
|
||||
}
|
||||
if (variableToFocus === undefined) {
|
||||
return;
|
||||
}
|
||||
if (variableToFocus.scopeInstanceId !== selectedScopeId) {
|
||||
return;
|
||||
}
|
||||
if (availableVariables.some(({ id }) => id === variableToFocus.id)) {
|
||||
if (isOpen === false) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
} else {
|
||||
$variableToFocus.set(undefined);
|
||||
$variableToOpen.set(undefined);
|
||||
}
|
||||
}, [
|
||||
availableVariables,
|
||||
isOpen,
|
||||
selectedScopeId,
|
||||
setIsOpen,
|
||||
variableToFocus,
|
||||
variableToOpen,
|
||||
]);
|
||||
return (
|
||||
<VariableContextMenu>
|
||||
<CollapsibleSectionRoot
|
||||
label={label}
|
||||
fullWidth={true}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
trigger={
|
||||
<SectionTitle
|
||||
suffix={
|
||||
<Flex align="center">
|
||||
<VariablePopoverTrigger>
|
||||
<SectionTitleButton
|
||||
type="button"
|
||||
aria-label="Add variable"
|
||||
prefix={<PlusIcon />}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
// open panel when adding a new variable
|
||||
onClick={() => {
|
||||
if (isOpen === false) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</VariablePopoverTrigger>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<SectionTitleLabel>Variables</SectionTitleLabel>
|
||||
</SectionTitle>
|
||||
}
|
||||
>
|
||||
{/* prevent applyig gap to list items */}
|
||||
<div>
|
||||
<VariablesList isOpen={isOpen} />
|
||||
</div>
|
||||
</CollapsibleSectionRoot>
|
||||
</VariableContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const __testing__ = { VariablesItem };
|
||||
+4
-7
@@ -3,8 +3,7 @@ import {
|
||||
type CssProperty,
|
||||
type StyleValue,
|
||||
} from "@webstudio-is/css-engine";
|
||||
import { Tooltip, Flex, Text, cssVar } from "@webstudio-is/design-system";
|
||||
import { InfoCircleIcon } from "@webstudio-is/icons";
|
||||
import { InfoTooltip, Flex, Text } from "@webstudio-is/design-system";
|
||||
import { humanizeString } from "~/shared/string-utils";
|
||||
import { RepeatedStyleSection } from "../../shared/style-section";
|
||||
import { FilterSectionContent } from "../../shared/filter-content";
|
||||
@@ -66,8 +65,8 @@ export const Section = () => {
|
||||
);
|
||||
}}
|
||||
tooltip={
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About backdrop filters"
|
||||
content={
|
||||
<Flex gap="2" direction="column">
|
||||
<Text variant="regularBold">{label}</Text>
|
||||
@@ -80,9 +79,7 @@ export const Section = () => {
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon color={cssVar("--foreground-secondary")} />
|
||||
</Tooltip>
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Flex, Tooltip, Text, cssVar } from "@webstudio-is/design-system";
|
||||
import { InfoCircleIcon } from "@webstudio-is/icons";
|
||||
import { Flex, InfoTooltip, Text } from "@webstudio-is/design-system";
|
||||
import {
|
||||
toValue,
|
||||
type CssProperty,
|
||||
@@ -63,8 +62,8 @@ export const Section = () => {
|
||||
);
|
||||
}}
|
||||
tooltip={
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About filters"
|
||||
content={
|
||||
<Flex gap="2" direction="column">
|
||||
<Text variant="regularBold">{label}</Text>
|
||||
@@ -77,9 +76,7 @@ export const Section = () => {
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon color={cssVar("--foreground-secondary")} />
|
||||
</Tooltip>
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
Label,
|
||||
InputField,
|
||||
Grid,
|
||||
CssValueListItem,
|
||||
CssValueListArrowFocus,
|
||||
InteractiveListItem,
|
||||
InteractiveListArrowFocus,
|
||||
SmallIconButton,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { PlusIcon, MinusIcon } from "@webstudio-is/icons";
|
||||
@@ -627,7 +627,7 @@ export const GridAreas = () => {
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<CssValueListArrowFocus>
|
||||
<InteractiveListArrowFocus>
|
||||
<Flex direction="column">
|
||||
{areas.length === 0 && (
|
||||
<PanelContent as={Text} color="subtle" align="center">
|
||||
@@ -660,7 +660,7 @@ export const GridAreas = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CssValueListItem
|
||||
<InteractiveListItem
|
||||
id={String(index)}
|
||||
index={index}
|
||||
onMouseEnter={() => setHoveredAreaIndex(index)}
|
||||
@@ -687,7 +687,7 @@ export const GridAreas = () => {
|
||||
</FloatingPanel>
|
||||
))}
|
||||
</Flex>
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
</CollapsibleSectionRoot>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -7,8 +7,8 @@ import {
|
||||
FloatingPanel,
|
||||
IconButton,
|
||||
Label,
|
||||
CssValueListItem,
|
||||
CssValueListArrowFocus,
|
||||
InteractiveListItem,
|
||||
InteractiveListArrowFocus,
|
||||
SmallIconButton,
|
||||
useSortable,
|
||||
Button,
|
||||
@@ -234,7 +234,7 @@ const TrackItem = ({
|
||||
open={isEditing}
|
||||
onOpenChange={onEditingChange}
|
||||
>
|
||||
<CssValueListItem
|
||||
<InteractiveListItem
|
||||
id={id}
|
||||
draggable={!isAuto}
|
||||
active={dragItemId === id}
|
||||
@@ -407,7 +407,7 @@ const TrackEditor = ({
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<CssValueListArrowFocus dragItemId={dragItemId}>
|
||||
<InteractiveListArrowFocus dragItemId={dragItemId}>
|
||||
<Flex direction="column" ref={sortableRefCallback}>
|
||||
{tracks.length === 0 && (
|
||||
<PanelContent as={Text} color="subtle" align="center">
|
||||
@@ -445,7 +445,7 @@ const TrackEditor = ({
|
||||
})}
|
||||
{placementIndicator}
|
||||
</Flex>
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
</CollapsibleSectionRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@webstudio-is/css-data";
|
||||
import {
|
||||
Box,
|
||||
CssValueListArrowFocus,
|
||||
CssValueListItem,
|
||||
InteractiveListArrowFocus,
|
||||
InteractiveListItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -278,7 +278,7 @@ export const Section = () => {
|
||||
}
|
||||
>
|
||||
{isAnyTransformPropertyAdded && (
|
||||
<CssValueListArrowFocus>
|
||||
<InteractiveListArrowFocus>
|
||||
<Flex direction="column">
|
||||
{transformPanels.map(
|
||||
(panel, index) =>
|
||||
@@ -290,7 +290,7 @@ export const Section = () => {
|
||||
)
|
||||
)}
|
||||
</Flex>
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
)}
|
||||
</CollapsibleSectionRoot>
|
||||
);
|
||||
@@ -329,7 +329,7 @@ const TransformSection = ({
|
||||
</PanelContent>
|
||||
}
|
||||
>
|
||||
<CssValueListItem
|
||||
<InteractiveListItem
|
||||
id={panel}
|
||||
index={index}
|
||||
hidden={value.hidden}
|
||||
@@ -363,7 +363,7 @@ const TransformSection = ({
|
||||
/>
|
||||
</>
|
||||
}
|
||||
></CssValueListItem>
|
||||
></InteractiveListItem>
|
||||
</FloatingPanel>
|
||||
);
|
||||
};
|
||||
+4
-8
@@ -9,17 +9,15 @@ import {
|
||||
PanelContent,
|
||||
Flex,
|
||||
Label,
|
||||
InfoTooltip,
|
||||
TextArea,
|
||||
theme,
|
||||
textVariants,
|
||||
Separator,
|
||||
Tooltip,
|
||||
Text,
|
||||
Grid,
|
||||
toast,
|
||||
cssVar,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { InfoCircleIcon } from "@webstudio-is/icons";
|
||||
import { propertiesData, propertyDescriptions } from "@webstudio-is/css-data";
|
||||
import { type IntermediateStyleValue } from "../../shared/css-value-input";
|
||||
import { CssValueInputContainer } from "../../shared/css-value-input";
|
||||
@@ -257,8 +255,8 @@ export const TransitionContent = ({ index }: { index: number }) => {
|
||||
<Label>
|
||||
<Flex align="center" gap="1">
|
||||
Code
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About transition CSS code"
|
||||
content={
|
||||
<Text>
|
||||
Paste CSS code for a transition or part of a transition, for
|
||||
@@ -268,9 +266,7 @@ export const TransitionContent = ({ index }: { index: number }) => {
|
||||
<Text variant="monoBold">opacity 200ms ease 0s</Text>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon color={cssVar("--foreground-secondary")} />
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
</Label>
|
||||
<TextArea
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { parseCssValue, propertiesData } from "@webstudio-is/css-data";
|
||||
import { EyeClosedIcon, EyeOpenIcon, MinusIcon } from "@webstudio-is/icons";
|
||||
import {
|
||||
CssValueListArrowFocus,
|
||||
CssValueListItem,
|
||||
InteractiveListArrowFocus,
|
||||
InteractiveListItem,
|
||||
Flex,
|
||||
Label,
|
||||
SmallIconButton,
|
||||
@@ -423,7 +423,7 @@ export const RepeatedStyle = (props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<CssValueListArrowFocus dragItemId={dragItemId}>
|
||||
<InteractiveListArrowFocus dragItemId={dragItemId}>
|
||||
<Flex direction="column" ref={sortableRefCallback}>
|
||||
{primaryItems.map((primaryItem, index) => {
|
||||
const id = String(index);
|
||||
@@ -458,7 +458,7 @@ export const RepeatedStyle = (props: {
|
||||
titleSuffix={panelTitleSuffix}
|
||||
offset={floatingPanelOffset}
|
||||
>
|
||||
<CssValueListItem
|
||||
<InteractiveListItem
|
||||
id={id}
|
||||
draggable={readonly === false}
|
||||
active={dragItemId === id}
|
||||
@@ -497,6 +497,6 @@ export const RepeatedStyle = (props: {
|
||||
})}
|
||||
{placementIndicator}
|
||||
</Flex>
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PanelContent,
|
||||
Flex,
|
||||
Grid,
|
||||
InfoTooltip,
|
||||
Label,
|
||||
Separator,
|
||||
Text,
|
||||
@@ -26,13 +27,8 @@ import {
|
||||
ToggleGroupButton,
|
||||
Tooltip,
|
||||
toast,
|
||||
cssVar,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
InfoCircleIcon,
|
||||
ShadowInsetIcon,
|
||||
ShadowNormalIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import { ShadowInsetIcon, ShadowNormalIcon } from "@webstudio-is/icons";
|
||||
import { humanizeString } from "~/shared/string-utils";
|
||||
import { PropertyInlineLabel } from "../property-label";
|
||||
import type { IntermediateStyleValue } from "./css-value-input";
|
||||
@@ -419,8 +415,8 @@ export const ShadowContent = ({
|
||||
<Label>
|
||||
<Flex align={"center"} gap={1}>
|
||||
Code
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About shadow CSS code"
|
||||
content={
|
||||
<Text>
|
||||
Paste a {property} CSS code without the property name, for
|
||||
@@ -431,9 +427,7 @@ export const ShadowContent = ({
|
||||
</Text>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon color={cssVar("--foreground-secondary")} />
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
</Label>
|
||||
<CssFragmentEditor
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Grid,
|
||||
IconButton,
|
||||
InputErrorsTooltip,
|
||||
InfoTooltip,
|
||||
InputField,
|
||||
Label,
|
||||
Popover,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
CopyIcon,
|
||||
DimensionsIcon,
|
||||
DownloadIcon,
|
||||
InfoCircleIcon,
|
||||
ListViewIcon,
|
||||
PageIcon,
|
||||
RefreshCcwIcon,
|
||||
@@ -595,12 +595,10 @@ const AssetSettingsContent = ({
|
||||
css={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
Description
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About asset description"
|
||||
content="The description is used as the default “alt” text for the image."
|
||||
>
|
||||
<InfoCircleIcon color={cssVar("--foreground-secondary")} />
|
||||
</Tooltip>
|
||||
/>
|
||||
</Label>
|
||||
<TextArea
|
||||
id="asset-manager-description"
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Flex,
|
||||
Grid,
|
||||
InputField,
|
||||
InfoTooltip,
|
||||
Label,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
InfoCircleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
ListViewIcon,
|
||||
@@ -904,16 +904,10 @@ export const CollectionSettingsDialog = ({
|
||||
{fieldsError}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About collection fields"
|
||||
content="Define the information editors fill in for every entry."
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
aria-label="About collection fields"
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<Button
|
||||
css={{ flexShrink: 0 }}
|
||||
@@ -1108,16 +1102,10 @@ export const CollectionSettingsDialog = ({
|
||||
>
|
||||
Field key
|
||||
</Label>
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About field key"
|
||||
content="Stored in the entry’s frontmatter. Used to connect this field to your page."
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
aria-label="About field key"
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<InputField
|
||||
@@ -1188,20 +1176,14 @@ export const CollectionSettingsDialog = ({
|
||||
<Flex gap={1} align="center">
|
||||
<Label>Type</Label>
|
||||
{protectedField && (
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About field type"
|
||||
content={
|
||||
requiredField
|
||||
? "The slug identifies each entry and is always required."
|
||||
: "This text field is used to generate the entry slug."
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
aria-label="About field type"
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<Select
|
||||
@@ -1258,16 +1240,10 @@ export const CollectionSettingsDialog = ({
|
||||
>
|
||||
<Flex gap={1} align="center">
|
||||
<Label>Generate from</Label>
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About slug generation"
|
||||
content="The slug becomes the MDX filename. It is generated from this field when editors create an entry."
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
aria-label="About slug generation"
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<Select<{ key: string; label: string }>
|
||||
aria-label="Generate slug from"
|
||||
@@ -1511,16 +1487,10 @@ export const CollectionSettingsDialog = ({
|
||||
>
|
||||
<Flex gap={1} align="center">
|
||||
<Text variant="titles">Entry template</Text>
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About entry template"
|
||||
content="Every new entry starts as a copy of this template. Add headings, placeholder text, and default field values so editors have a consistent starting point. Changes to the template only affect future entries."
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
aria-label="About entry template"
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<Grid gap={1} css={{ maxWidth: 320 }}>
|
||||
<Label htmlFor="collection-template-name">
|
||||
@@ -1575,16 +1545,10 @@ export const CollectionSettingsDialog = ({
|
||||
>
|
||||
<Flex gap={1} align="center">
|
||||
<Text variant="titles">Entry page</Text>
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About entry page"
|
||||
content="Choose the dynamic page that displays entries from this collection. Editors can then open an entry on the canvas from its menu or settings. The page must have one URL parameter."
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
aria-label="About entry page"
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</Flex>
|
||||
<Grid gap={1} css={{ maxWidth: 400 }}>
|
||||
<ResettableLabel
|
||||
|
||||
@@ -7,32 +7,27 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
DotIcon,
|
||||
InfoCircleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import { DotIcon, PlusIcon, TrashIcon } from "@webstudio-is/icons";
|
||||
import {
|
||||
PanelContent,
|
||||
Box,
|
||||
Button,
|
||||
cssVar,
|
||||
declareCssVar,
|
||||
CssValueListArrowFocus,
|
||||
CssValueListItem,
|
||||
InteractiveListArrowFocus,
|
||||
InteractiveListItem,
|
||||
DialogTitleActions,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
FloatingPanel,
|
||||
Flex,
|
||||
Label,
|
||||
ScrollArea,
|
||||
ScrollAreaNative,
|
||||
SmallIconButton,
|
||||
Text,
|
||||
Tooltip,
|
||||
InputErrorsTooltip,
|
||||
InfoTooltip,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { getExpressionIdentifiers } from "@webstudio-is/expression";
|
||||
@@ -96,27 +91,27 @@ const BindingPanel = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
<ScrollAreaNative
|
||||
css={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: theme.spacing[30],
|
||||
// The floating panel includes a border, so its content is narrower
|
||||
// than the sidebar width. A fixed sidebar width overflows the dialog
|
||||
// itself and makes its title scroll horizontally.
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box css={{ paddingBottom: theme.spacing[5] }}>
|
||||
<PanelContent as={Flex} gap="1">
|
||||
<Text variant="labels">Variables</Text>
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About available variables"
|
||||
content={
|
||||
"Click on the available variables in this scope to insert them into the Expression Editor."
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</PanelContent>
|
||||
{scopeEntries.length === 0 && (
|
||||
<Flex justify="center" align="center" css={{ py: theme.spacing[5] }}>
|
||||
@@ -126,7 +121,7 @@ const BindingPanel = ({
|
||||
</Flex>
|
||||
)}
|
||||
<ScrollAreaNative css={{ maxHeight: theme.spacing[25] }}>
|
||||
<CssValueListArrowFocus>
|
||||
<InteractiveListArrowFocus>
|
||||
{scopeEntries.map(([identifier, value], index) => {
|
||||
const name = aliases.get(identifier);
|
||||
const label =
|
||||
@@ -134,7 +129,7 @@ const BindingPanel = ({
|
||||
? name
|
||||
: `${name}: ${formatValuePreview(value)}`;
|
||||
return (
|
||||
<CssValueListItem
|
||||
<InteractiveListItem
|
||||
key={identifier}
|
||||
id={identifier}
|
||||
index={index}
|
||||
@@ -157,13 +152,13 @@ const BindingPanel = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CssValueListArrowFocus>
|
||||
</InteractiveListArrowFocus>
|
||||
</ScrollAreaNative>
|
||||
</Box>
|
||||
<PanelContent as={Flex} gap="1">
|
||||
<Text variant="labels">Expression editor</Text>
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label="About the expression editor"
|
||||
content={
|
||||
<Text>
|
||||
Use JavaScript syntax to access variables along with comparison
|
||||
@@ -173,12 +168,7 @@ const BindingPanel = ({
|
||||
<Text variant="mono">Variable.nested.value</Text>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color={cssVar("--foreground-secondary")}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</PanelContent>
|
||||
<PanelContent as={Box} css={{ pt: 0 }}>
|
||||
<InputErrorsTooltip
|
||||
@@ -206,7 +196,7 @@ const BindingPanel = ({
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</PanelContent>
|
||||
</ScrollArea>
|
||||
</ScrollAreaNative>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -118,10 +118,7 @@ export const validateDataVariableName = (
|
||||
const dataSources = $dataSources.get();
|
||||
const currentVariable = variableId ? dataSources.get(variableId) : undefined;
|
||||
const actualScopeInstanceId =
|
||||
scopeInstanceId ??
|
||||
(currentVariable?.type === "variable"
|
||||
? currentVariable.scopeInstanceId
|
||||
: undefined);
|
||||
scopeInstanceId ?? currentVariable?.scopeInstanceId;
|
||||
return validateDataVariableNameWithSources({
|
||||
dataSources: dataSources.values(),
|
||||
name,
|
||||
@@ -269,14 +266,14 @@ export const DeleteUnusedDataVariablesDialog = () => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Delete unused data variables</DialogTitle>
|
||||
<DialogTitle>Delete unused variables</DialogTitle>
|
||||
<PanelContent as={Flex} gap="3" direction="column">
|
||||
{unusedVariables.length === 0 ? (
|
||||
<Text>There are no unused data variables to delete.</Text>
|
||||
<Text>There are no unused variables to delete.</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text>
|
||||
Delete {unusedVariables.length} unused data{" "}
|
||||
Delete {unusedVariables.length} unused{" "}
|
||||
{unusedVariables.length === 1 ? "variable" : "variables"} from
|
||||
the project?
|
||||
</Text>
|
||||
@@ -304,10 +301,10 @@ export const DeleteUnusedDataVariablesDialog = () => {
|
||||
const deletedCount = deleteUnusedDataVariables();
|
||||
handleClose();
|
||||
if (deletedCount === 0) {
|
||||
toast.info("No unused data variables to delete");
|
||||
toast.info("No unused variables to delete");
|
||||
} else {
|
||||
toast.success(
|
||||
`Deleted ${deletedCount} unused data ${deletedCount === 1 ? "variable" : "variables"}`
|
||||
`Deleted ${deletedCount} unused ${deletedCount === 1 ? "variable" : "variables"}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -569,6 +569,7 @@ export const ExpressionEditor = ({
|
||||
chromeless = false,
|
||||
autoFocus = false,
|
||||
readOnly = false,
|
||||
showLineNumbers = false,
|
||||
value,
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
@@ -588,6 +589,7 @@ export const ExpressionEditor = ({
|
||||
chromeless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
readOnly?: boolean;
|
||||
showLineNumbers?: boolean;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
onChangeComplete: (value: string) => void;
|
||||
@@ -649,13 +651,18 @@ export const ExpressionEditor = ({
|
||||
keymap.of([...closeBracketsKeymap, ...completionKeymap]),
|
||||
expressionLinter,
|
||||
linterTooltipTheme,
|
||||
...(showLineNumbers ? [lineNumbers()] : []),
|
||||
...(size === "full" ? [fullSizeEditorTheme] : []),
|
||||
],
|
||||
[scopeWithUnsetVariables, aliasesWithUnsetVariables, size]
|
||||
[scopeWithUnsetVariables, aliasesWithUnsetVariables, size, showLineNumbers]
|
||||
);
|
||||
const dialogExtensions = useMemo(
|
||||
() => [...extensions, lineNumbers(), foldGutterExtension],
|
||||
[extensions]
|
||||
() => [
|
||||
...extensions,
|
||||
...(showLineNumbers ? [] : [lineNumbers()]),
|
||||
foldGutterExtension,
|
||||
],
|
||||
[extensions, showLineNumbers]
|
||||
);
|
||||
|
||||
// prevent clicking on autocomplete options propagating to body
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useSearchFieldKeys,
|
||||
findNextListItemIndex,
|
||||
Tooltip,
|
||||
InfoTooltip,
|
||||
Text,
|
||||
Link,
|
||||
Flex,
|
||||
@@ -139,8 +140,8 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
|
||||
item.type === "uploaded" ? (
|
||||
renderMenu(index)
|
||||
) : itemProps.state === "selected" && item.description ? (
|
||||
<Tooltip
|
||||
variant="wrapped"
|
||||
<InfoTooltip
|
||||
label={`About ${item.label}`}
|
||||
content={
|
||||
<Flex
|
||||
direction="column"
|
||||
@@ -162,12 +163,7 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
|
||||
<Text>{item.description}</Text>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<InfoCircleIcon
|
||||
tabIndex={0}
|
||||
color={cssVar("--foreground-secondary")}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
import {
|
||||
registerComponentLibrary,
|
||||
$registeredComponents,
|
||||
subscribeComponentHooks,
|
||||
subscribeResourceRequestPlan,
|
||||
$isPreviewMode,
|
||||
$isDesignMode,
|
||||
@@ -281,8 +280,6 @@ export const Canvas = () => {
|
||||
// from the cache synchronized by the Builder.
|
||||
useEffect(() => subscribeResourceRequestPlan(() => {}), []);
|
||||
|
||||
useEffect(subscribeComponentHooks, []);
|
||||
|
||||
useEffect(subscribeCommands, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -308,7 +305,7 @@ export const Canvas = () => {
|
||||
|
||||
useEffect(() => startPointerTracking(), []);
|
||||
|
||||
useHashLinkSync();
|
||||
useHashLinkSync(rootInstanceId);
|
||||
|
||||
useEffect(subscribeInterceptedEvents, []);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { $variableValuesByInstanceSelector } from "~/shared/nano-states";
|
||||
import { getInstanceKey } from "~/shared/nano-states/instances";
|
||||
import {
|
||||
createInstanceChildrenElements,
|
||||
createManagedFormErrorElements,
|
||||
type WebstudioComponentProps,
|
||||
} from "./elements";
|
||||
|
||||
@@ -106,3 +107,61 @@ test("updates expressions when async scoped values resolve", async () => {
|
||||
container.remove();
|
||||
$variableValuesByInstanceSelector.set(new Map());
|
||||
});
|
||||
|
||||
test("saved Form error rendering follows one and multiple live runtime errors with escaped text", async () => {
|
||||
const selector = ["saved-error", "saved-form"];
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
$variableValuesByInstanceSelector.set(
|
||||
new Map([
|
||||
[
|
||||
getInstanceKey([...selector, ROOT_INSTANCE_ID]),
|
||||
new Map([
|
||||
["saved-errors", [{ message: "Enable at least one action" }]],
|
||||
]),
|
||||
],
|
||||
])
|
||||
);
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<div className="authored-style">
|
||||
{createManagedFormErrorElements(
|
||||
"$ws$dataSource$saved__DASH__errors",
|
||||
selector
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
await expect
|
||||
.poll(() => container.textContent)
|
||||
.toBe("Enable at least one action");
|
||||
await act(async () =>
|
||||
$variableValuesByInstanceSelector.set(
|
||||
new Map([
|
||||
[
|
||||
getInstanceKey([...selector, ROOT_INSTANCE_ID]),
|
||||
new Map([
|
||||
[
|
||||
"saved-errors",
|
||||
[
|
||||
{ message: "First failure" },
|
||||
{ message: "<script>escaped failure</script>" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
],
|
||||
])
|
||||
)
|
||||
);
|
||||
await expect.poll(() => container.textContent).toContain("First failure");
|
||||
expect(container.textContent).toContain("<script>escaped failure</script>");
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(container.querySelector(".authored-style")).not.toBeNull();
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
$variableValuesByInstanceSelector.set(new Map());
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { formatManagedFormErrors } from "@webstudio-is/sdk";
|
||||
import type { Instance, Instances } from "@webstudio-is/sdk";
|
||||
import type { Components } from "@webstudio-is/react-sdk";
|
||||
import { renderText as renderTextValue } from "@webstudio-is/react-sdk/runtime";
|
||||
@@ -70,10 +71,12 @@ const renderText = (text: string): Array<JSX.Element> => {
|
||||
};
|
||||
|
||||
const AsyncTextExpression = ({
|
||||
managedFormErrors = false,
|
||||
expression,
|
||||
instanceSelector,
|
||||
}: {
|
||||
expression: string;
|
||||
managedFormErrors?: boolean;
|
||||
instanceSelector: InstanceSelector;
|
||||
}) => {
|
||||
const variableValuesByInstanceSelector = useStore(
|
||||
@@ -94,9 +97,24 @@ const AsyncTextExpression = ({
|
||||
[expression, variableValues],
|
||||
undefined
|
||||
);
|
||||
return renderText(String(renderTextValue(value)));
|
||||
return renderText(
|
||||
managedFormErrors
|
||||
? formatManagedFormErrors(value)
|
||||
: String(renderTextValue(value))
|
||||
);
|
||||
};
|
||||
|
||||
export const createManagedFormErrorElements = (
|
||||
expression: string,
|
||||
instanceSelector: InstanceSelector
|
||||
) => (
|
||||
<AsyncTextExpression
|
||||
expression={expression}
|
||||
instanceSelector={instanceSelector}
|
||||
managedFormErrors
|
||||
/>
|
||||
);
|
||||
|
||||
export const createInstanceChildrenElements = ({
|
||||
instances,
|
||||
instanceSelector,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { navigatePreviewFormSuccess } from "./form-success-redirect";
|
||||
|
||||
test("internal Preview success redirects switch pages", () => {
|
||||
const navigateInternal = vi.fn(() => true);
|
||||
const navigateExternal = vi.fn();
|
||||
const redirected = navigatePreviewFormSuccess(
|
||||
"/thanks?sent=1#done",
|
||||
"https://builder.example/contact",
|
||||
navigateInternal,
|
||||
navigateExternal
|
||||
);
|
||||
expect(navigateInternal).toHaveBeenCalledWith("/thanks?sent=1#done");
|
||||
expect(navigateExternal).not.toHaveBeenCalled();
|
||||
expect(redirected).toBe(true);
|
||||
});
|
||||
|
||||
test("relative Preview success redirect uses the Preview page path", () => {
|
||||
const navigateInternal = vi.fn(() => true);
|
||||
const navigateExternal = vi.fn();
|
||||
navigatePreviewFormSuccess(
|
||||
"thanks",
|
||||
"https://webstudio.local/contact?source=preview",
|
||||
navigateInternal,
|
||||
navigateExternal
|
||||
);
|
||||
expect(navigateInternal).toHaveBeenCalledExactlyOnceWith("/thanks");
|
||||
expect(navigateExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("unmatched internal Preview redirect reports that navigation did not happen", () => {
|
||||
const navigateInternal = vi.fn(() => false);
|
||||
const navigateExternal = vi.fn();
|
||||
expect(
|
||||
navigatePreviewFormSuccess(
|
||||
"/missing",
|
||||
"https://webstudio.local/contact",
|
||||
navigateInternal,
|
||||
navigateExternal
|
||||
)
|
||||
).toBe(false);
|
||||
expect(navigateInternal).toHaveBeenCalledExactlyOnceWith("/missing");
|
||||
expect(navigateExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
"https://other.example/thanks",
|
||||
"mailto:team@example.com",
|
||||
"tel:+15550123",
|
||||
])(
|
||||
"external Preview success redirect %s uses current-frame navigation",
|
||||
(target) => {
|
||||
const navigateInternal = vi.fn(() => true);
|
||||
const navigateExternal = vi.fn();
|
||||
const redirected = navigatePreviewFormSuccess(
|
||||
target,
|
||||
"https://builder.example/contact",
|
||||
navigateInternal,
|
||||
navigateExternal
|
||||
);
|
||||
expect(navigateInternal).not.toHaveBeenCalled();
|
||||
expect(navigateExternal).toHaveBeenCalledWith(target);
|
||||
expect(redirected).toBe(true);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Navigate in the current frame so async Form submissions are never popup-blocked. */
|
||||
export const navigatePreviewFormSuccess = (
|
||||
destination: string,
|
||||
currentHref: string,
|
||||
navigateInternal: (path: string) => boolean,
|
||||
navigateExternal: (href: string) => void
|
||||
) => {
|
||||
const current = new URL(currentHref);
|
||||
const target = new URL(destination, current);
|
||||
if (target.origin === current.origin) {
|
||||
return navigateInternal(target.pathname + target.search + target.hash);
|
||||
}
|
||||
navigateExternal(target.href);
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { appendSystemSearch, type System } from "@webstudio-is/sdk";
|
||||
|
||||
export const getPreviewCurrentUrl = (
|
||||
currentSystem: Pick<System, "pathname" | "search">,
|
||||
hash: string
|
||||
) => {
|
||||
// Preview renders inside the builder canvas route, so window.location points
|
||||
// at the builder shell, not the page being previewed. Recreate the page URL
|
||||
// from the selected page system data so :local-link state matches preview
|
||||
// navigation, including query params and hash-only links.
|
||||
const currentUrl = new URL(currentSystem.pathname, "https://webstudio.local");
|
||||
const searchParams = new URLSearchParams();
|
||||
appendSystemSearch(searchParams, currentSystem.search);
|
||||
currentUrl.search = searchParams.toString();
|
||||
currentUrl.hash = hash;
|
||||
return currentUrl;
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { $builderMode, $isPreviewMode } from "~/shared/nano-states";
|
||||
import { __testing__ } from "./webstudio-component";
|
||||
import { subscribe } from "~/shared/pubsub";
|
||||
import { encodeDataVariableId, type Instance } from "@webstudio-is/sdk";
|
||||
import { $dataSources, $instances, $props } from "~/shared/sync/data-stores";
|
||||
import { $variableValuesByInstanceSelector } from "~/shared/nano-states";
|
||||
import { WebstudioComponentPreview } from "./webstudio-component";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root | undefined;
|
||||
const initialInstances = $instances.get();
|
||||
const initialDataSources = $dataSources.get();
|
||||
const initialProps = $props.get();
|
||||
const initialVariableValues = $variableValuesByInstanceSelector.get();
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
$builderMode.set("design");
|
||||
$instances.set(initialInstances);
|
||||
$dataSources.set(initialDataSources);
|
||||
$props.set(initialProps);
|
||||
$variableValuesByInstanceSelector.set(initialVariableValues);
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"managed Form state resets after Preview outcome %s and remains initial on re-entry",
|
||||
async (success) => {
|
||||
$builderMode.set("preview");
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const submit = vi.fn(async () => ({
|
||||
success,
|
||||
status: success ? 200 : 422,
|
||||
results: [],
|
||||
errors: success
|
||||
? []
|
||||
: [{ status: 422, body: null, message: "Fixture failure" }],
|
||||
}));
|
||||
const Harness = () => {
|
||||
const isPreviewMode = useStore($isPreviewMode);
|
||||
const [state, setState] = useState<"initial" | "success" | "error">(
|
||||
"initial"
|
||||
);
|
||||
if (!isPreviewMode) {
|
||||
return <div data-renderer="canvas" />;
|
||||
}
|
||||
return (
|
||||
<__testing__.PreviewResourceForm
|
||||
action={[{ dataSourceId: "request", enabled: true }]}
|
||||
state={state}
|
||||
onStateChange={setState}
|
||||
onManagedSubmit={submit}
|
||||
>
|
||||
<button type="submit">Send</button>
|
||||
</__testing__.PreviewResourceForm>
|
||||
);
|
||||
};
|
||||
await act(async () => root?.render(<Harness />));
|
||||
await act(async () => container.querySelector("button")?.click());
|
||||
await expect
|
||||
.poll(() => container.querySelector("form")?.getAttribute("data-state"))
|
||||
.toBe(success ? "success" : "error");
|
||||
await act(async () => $builderMode.set("design"));
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
expect(container.querySelector('[data-renderer="canvas"]')).not.toBeNull();
|
||||
await act(async () => $builderMode.set("preview"));
|
||||
expect(container.querySelector("form")?.getAttribute("data-state")).toBe(
|
||||
"initial"
|
||||
);
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"uncontrolled managed Form resets after renderer switch from Preview outcome %s",
|
||||
async (success) => {
|
||||
$builderMode.set("preview");
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const submit = vi.fn(async () => ({
|
||||
success,
|
||||
status: success ? 200 : 422,
|
||||
results: [],
|
||||
errors: success
|
||||
? []
|
||||
: [{ status: 422, body: null, message: "Fixture failure" }],
|
||||
}));
|
||||
const Harness = () => {
|
||||
const isPreviewMode = useStore($isPreviewMode);
|
||||
return isPreviewMode ? (
|
||||
<__testing__.PreviewResourceForm
|
||||
action={[{ dataSourceId: "request", enabled: true }]}
|
||||
onManagedSubmit={submit}
|
||||
>
|
||||
<button type="submit">Send</button>
|
||||
</__testing__.PreviewResourceForm>
|
||||
) : (
|
||||
<div data-renderer="canvas" />
|
||||
);
|
||||
};
|
||||
await act(async () => root?.render(<Harness />));
|
||||
await act(async () => container.querySelector("button")?.click());
|
||||
await expect
|
||||
.poll(() => container.querySelector("form")?.getAttribute("data-state"))
|
||||
.toBe(success ? "success" : "error");
|
||||
await act(async () => $builderMode.set("design"));
|
||||
expect(container.querySelector("form")).toBeNull();
|
||||
await act(async () => $builderMode.set("preview"));
|
||||
expect(container.querySelector("form")?.getAttribute("data-state")).toBe(
|
||||
null
|
||||
);
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
test("Preview publishes live unsent DOM values as input/change events occur", async () => {
|
||||
const values: Array<Record<string, unknown> | null> = [];
|
||||
const cleanup = subscribe("previewFormValues", (event) =>
|
||||
values.push(event.values)
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const submit = vi.fn();
|
||||
try {
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<__testing__.PreviewResourceForm
|
||||
data-ws-managed-form-id="live-form"
|
||||
action={[{ dataSourceId: "request", enabled: true }]}
|
||||
onManagedSubmit={submit}
|
||||
>
|
||||
<input name="text" />
|
||||
<select name="choice">
|
||||
<option value="first">First</option>
|
||||
<option value="second">Second</option>
|
||||
</select>
|
||||
<input name="flag" type="checkbox" value="yes" />
|
||||
</__testing__.PreviewResourceForm>
|
||||
)
|
||||
);
|
||||
await expect
|
||||
.poll(() => values.at(-1))
|
||||
.toEqual({ text: "", choice: "first", flag: [] });
|
||||
const input = container.querySelector<HTMLInputElement>('[name="text"]')!;
|
||||
const select = container.querySelector(
|
||||
'[name="choice"]'
|
||||
) as unknown as HTMLSelectElement;
|
||||
const checkbox =
|
||||
container.querySelector<HTMLInputElement>('[name="flag"]')!;
|
||||
await act(async () => {
|
||||
input.value = "edited";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
select.value = "second";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await expect
|
||||
.poll(() => values.at(-1))
|
||||
.toEqual({ text: "edited", choice: "second", flag: ["yes"] });
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
[[{ message: "First" }, { message: "Second" }], "FirstSecond"],
|
||||
[{ message: "Malformed" }, ""],
|
||||
[[null, { message: 42 }, { message: "Valid" }], "Valid"],
|
||||
] as const)(
|
||||
"saved error placeholder renders bound result errors in Preview",
|
||||
async (errors, expected) => {
|
||||
const slot: Instance = {
|
||||
type: "instance" as const,
|
||||
id: "feedback",
|
||||
component: "ws:element",
|
||||
tag: "div",
|
||||
label: "Renamed feedback",
|
||||
children: [
|
||||
{
|
||||
type: "text" as const,
|
||||
value: "Sorry, something went wrong.",
|
||||
placeholder: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const form: Instance = {
|
||||
type: "instance" as const,
|
||||
id: "form",
|
||||
component: "Form",
|
||||
children: [{ type: "id" as const, value: slot.id }],
|
||||
};
|
||||
$instances.set(
|
||||
new Map([
|
||||
[form.id, form],
|
||||
[slot.id, slot],
|
||||
])
|
||||
);
|
||||
$dataSources.set(
|
||||
new Map([
|
||||
[
|
||||
"renamed-errors",
|
||||
{
|
||||
id: "renamed-errors",
|
||||
name: "customErrors",
|
||||
type: "variable" as const,
|
||||
scopeInstanceId: form.id,
|
||||
value: { type: "json" as const, value: [] },
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$props.set(
|
||||
new Map([
|
||||
[
|
||||
"result-action",
|
||||
{
|
||||
id: "result-action",
|
||||
instanceId: form.id,
|
||||
name: "onResultChange",
|
||||
type: "action" as const,
|
||||
value: [
|
||||
{
|
||||
type: "execute" as const,
|
||||
args: ["result"],
|
||||
code: `${encodeDataVariableId("renamed-errors")} = result.errors`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root?.render(
|
||||
<WebstudioComponentPreview
|
||||
instance={slot}
|
||||
instanceSelector={[slot.id]}
|
||||
components={new Map()}
|
||||
/>
|
||||
)
|
||||
);
|
||||
await act(async () =>
|
||||
$variableValuesByInstanceSelector.set(
|
||||
new Map([
|
||||
[JSON.stringify([slot.id]), new Map([["renamed-errors", errors]])],
|
||||
])
|
||||
)
|
||||
);
|
||||
await expect.poll(() => container.textContent).toBe(expected);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ComponentProps,
|
||||
type ElementRef,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { mergeRefs } from "@react-aria/utils";
|
||||
import { selectorIdAttribute } from "@webstudio-is/react-sdk";
|
||||
import { Form as ResourceForm } from "@webstudio-is/sdk-components-react/components";
|
||||
import type { submitManagedForm } from "@webstudio-is/sdk-components-react";
|
||||
import type { ManagedFormResponse } from "@webstudio-is/sdk/runtime";
|
||||
import { $selectedPageHash } from "~/shared/nano-states/pages";
|
||||
import { $currentSystem } from "~/shared/system";
|
||||
import { publish } from "~/shared/pubsub";
|
||||
import { readPreviewFormValues } from "~/shared/preview-form-values";
|
||||
import { submitPreviewForm } from "~/shared/preview-form-bridge";
|
||||
import { switchPageAndUpdateSystem } from "~/canvas/interceptor";
|
||||
import { navigatePreviewFormSuccess } from "./form-success-redirect";
|
||||
import { getPreviewCurrentUrl } from "./preview-current-url";
|
||||
|
||||
export const submitManagedFormFromPreview = (
|
||||
managedFormId: string,
|
||||
values: Parameters<typeof submitManagedForm>[0]["values"],
|
||||
signal: AbortSignal
|
||||
) => {
|
||||
const currentUrl = getPreviewCurrentUrl(
|
||||
$currentSystem.get(),
|
||||
$selectedPageHash.get().hash
|
||||
);
|
||||
// The authenticated parent waits for durable saves before posting the draft.
|
||||
return submitPreviewForm({
|
||||
values,
|
||||
managedFormId,
|
||||
path: currentUrl.pathname + currentUrl.search,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const PreviewResourceForm = forwardRef<
|
||||
ElementRef<typeof ResourceForm>,
|
||||
ComponentProps<typeof ResourceForm>
|
||||
>((props, ref) => {
|
||||
const system = useStore($currentSystem);
|
||||
const { hash } = useStore($selectedPageHash);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const onStateChangeRef = useRef(props.onStateChange);
|
||||
onStateChangeRef.current = props.onStateChange;
|
||||
const formSelector = (props as Record<string, unknown>)[selectorIdAttribute];
|
||||
useEffect(() => () => onStateChangeRef.current?.("initial"), []);
|
||||
const formId = props["data-ws-managed-form-id"];
|
||||
useEffect(() => {
|
||||
const form = formRef.current;
|
||||
if (!form || !formId) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const send = () =>
|
||||
queueMicrotask(() => {
|
||||
if (active) {
|
||||
publish({
|
||||
type: "previewFormValues",
|
||||
payload: {
|
||||
selector: String(formSelector),
|
||||
values: readPreviewFormValues(form),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
form.addEventListener("input", send);
|
||||
form.addEventListener("change", send);
|
||||
const observer = new MutationObserver(send);
|
||||
observer.observe(form, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: [
|
||||
"value",
|
||||
"checked",
|
||||
"selected",
|
||||
"disabled",
|
||||
"name",
|
||||
"type",
|
||||
],
|
||||
});
|
||||
send();
|
||||
return () => {
|
||||
active = false;
|
||||
observer.disconnect();
|
||||
form.removeEventListener("input", send);
|
||||
form.removeEventListener("change", send);
|
||||
publish({
|
||||
type: "previewFormValues",
|
||||
payload: { selector: String(formSelector), values: null },
|
||||
});
|
||||
};
|
||||
}, [formId, formSelector]);
|
||||
return (
|
||||
<ResourceForm
|
||||
{...props}
|
||||
ref={mergeRefs(ref, formRef)}
|
||||
navigationToken={getPreviewCurrentUrl(system, hash).href}
|
||||
onQueryNavigate={(url) => {
|
||||
// The Form element's relative action resolves against the Builder
|
||||
// canvas URL. Apply its submitted query to the previewed page instead.
|
||||
const submittedUrl = new URL(url, window.location.href);
|
||||
const previewUrl = getPreviewCurrentUrl(system, hash);
|
||||
switchPageAndUpdateSystem(
|
||||
`${previewUrl.pathname}${submittedUrl.search}${submittedUrl.hash}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const getPreviewResourceFormProps = (formId: string) => {
|
||||
const getPreviewUrl = () =>
|
||||
getPreviewCurrentUrl($currentSystem.get(), $selectedPageHash.get().hash);
|
||||
return {
|
||||
"data-ws-managed-form-id": formId,
|
||||
getRedirectBaseUrl: () => getPreviewUrl().href,
|
||||
onManagedSubmit: (
|
||||
values: Parameters<typeof submitManagedForm>[0]["values"],
|
||||
signal: AbortSignal
|
||||
): Promise<ManagedFormResponse> =>
|
||||
submitManagedFormFromPreview(formId, values, signal),
|
||||
onSuccessRedirect: (destination: string) =>
|
||||
navigatePreviewFormSuccess(
|
||||
destination,
|
||||
getPreviewUrl().href,
|
||||
(path) => switchPageAndUpdateSystem(path, { includeFallback: false }),
|
||||
(href) => window.location.assign(href)
|
||||
),
|
||||
};
|
||||
};
|
||||
Loaded 100 of 596 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user