Files
Oleg Isonen 9a8ae2dc8f feat: Customize Form progress feedback (#6606)
Closes #6605

New Forms show editable feedback while Resource actions run. The
`formProgress` variable starts with `status: "idle"`, `loaded: 0`,
`total: 0`, and `files: []`. It becomes pending during submission, then
returns to idle. The existing `formState` still reports only initial,
success, or error.

The Progress container says “Submitting…” and adds measured
whole-request bytes when the total is known. Files include ID, field
name, name, size, and type. The browser cannot measure reliable per-file
transfer bytes in the single multipart request. The binding editor
suggests one example file to reveal those fields; the example never
enters the actual Form value. Saved Forms retain their existing bindings
and submission message.

## Checklist

- [x] Keep the existing Form outcome variable and add upload activity,
byte counts, and file details.
- [x] Show zero byte defaults on insertion, new submissions,
cancellation, and Preview reset without fake runtime files.
- [x] Suggest file fields in Form and Resource bindings while
evaluation, reset, rendering, and stored values use real data.
- [x] Preserve Builder Preview headers, authentication, response checks,
and cancellation.
- [x] Keep existing success/error feedback and saved-Form fallback.
- [x] Reset external progress when an active published Form unmounts.
- [x] Update Form, Variables, and MCP/CLI guides; clarify the older
screenshot and final-only simulation. Generated MCP docs check passes.
- [x] Audit Form and Variables docs against final progress behavior;
clarify the older Variables screenshot.
- [x] Cover lifecycle, transport, repeated submission, cancellation,
unmount, Preview, template, MCP binding/style, and editor completion
with focused tests.
- [x] Run relevant typechecks, lint, formatting, fixture generation, and
independent review.
- [x] Required CI checks pass on the latest commit.

## Verification

Local focused Form browser tests (25), Form template tests (12), Builder
Preview and Form context tests (14), and mounted editor example/reset
tests (6) pass. Builder, SDK React, SDK registry, and project-build
typechecks pass, as do lint, formatting, and fixture generation. The
fixture sync regenerated two unrelated linked-project site paths; those
outputs were restored before commit. Independent review found no
remaining issues. All latest-commit checks passed; E2E shard 9 passed on
a targeted rerun after its two Form tests also passed locally with 11
related tests.

This follow-up adds 162 net non-test lines, more than the initial
estimate. The extra code identifies the actual callback-bound variable
by ID, even when renamed, and gives autocomplete a separate example
scope so it cannot affect actual values. One provider covers Form fields
and Resource editors. No schema or package was added.

The existing Variables screenshot predates `formProgress`; its
explanatory text is current. The React Router fixture's built browser
client grew by 4 KiB on Linux CI (376 → 380 KiB), and its checked limit
matches that measured output. Multipart transfer remains one whole
request.

Browser submission supplies live progress. MCP can discover
`formProgress`, bind the Progress container, and style it; server
simulation reports final outcomes and does not measure browser uploads.
2026-10-26 14:38:12 +01:00

77 lines
2.3 KiB
TypeScript

import { toast } from "@webstudio-is/design-system";
import {
apiClientHeader,
apiClientVersionHeader,
getApiCompatibilityPayload,
} from "@webstudio-is/trpc-interface/api-compatibility";
import { csrfToken } from "./csrf.client";
import { $authToken } from "./nano-states/misc";
import { publicStaticEnv } from "~/env/env.static";
/**
* To avoid fetch interception from the canvas, i.e., `globalThis.fetch = () => console.log('INTERCEPTED');`,
*/
const _fetch = globalThis.fetch;
/** Apply the Builder request headers to both fetch and Form uploads. */
export const getBuilderRequestHeaders = (initial?: HeadersInit) => {
if (csrfToken === undefined) {
toast.error("CSRF token is not set.");
throw new Error("CSRF token is not set.");
}
const headers = new Headers(initial);
headers.set("X-CSRF-Token", csrfToken);
headers.set(apiClientHeader, "browser");
headers.set(apiClientVersionHeader, publicStaticEnv.VERSION);
const authToken = $authToken.get();
// Do not override the existing x-auth-token header.
// As some mutations are queue based and they need to be authenticated with the same token as in the queue.
if (authToken !== undefined && headers.get("x-auth-token") === null) {
headers.set("x-auth-token", authToken);
}
return headers;
};
export const inspectBuilderResponse = async (response: Response) => {
if (response.ok === false) {
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/json")) {
const body: unknown = await response
.clone()
.json()
.catch(() => undefined);
const payload = getApiCompatibilityPayload(body);
if (payload?.action.type === "reloadBrowser") {
toast.error(payload.message, {
id: "api-compatibility",
duration: Number.POSITIVE_INFINITY,
});
throw new Error(payload.message, { cause: payload });
}
}
}
};
export const fetch: typeof globalThis.fetch = async (
requestInfo,
requestInit
) => {
const headers = getBuilderRequestHeaders(requestInit?.headers);
const modifiedInit: RequestInit = {
...requestInit,
cache: requestInit?.cache ?? "no-store",
headers,
};
const response = await _fetch(requestInfo, modifiedInit);
await inspectBuilderResponse(response);
return response;
};