Files
Oleg Isonen 33f7b350bc fix: Improve Form setup and email settings guidance (#6536)
## User-facing features and how to test them

- Project Settings → Emails → Reply to has an info tooltip explaining
how its display name is used in visitor confirmation subjects and
messages, and when “Site Owner” is shown. Test it by hovering or
focusing the info icon.
- When a Form has no resources available for its Action, clicking the
Action + button explains that a resource must first be created in
Variables. Test it with a Form that has no action-capable resources,
then create a resource and verify it can be added to the Action.
- Success redirect is shown inside the Action control only when Resource
actions are connected. Test by switching between navigation mode and a
Form with a Resource action; the redirect field should appear only for
Resource actions, and its saved value should return when a Resource
action is added again.
- The Action control no longer offers Disable/Enable. Connected Resource
actions run in their configured order. The Form Action model has no
`enabled` field; older saved JSON with that extra key is read as an
ordinary connected action.

## Verification

- Focused UI test for the Reply to tooltip.
- Focused UI test for the empty Action resource tooltip.
- Manually verify Success redirect visibility and value retention while
switching modes.
- Manually verify all connected Actions execute in order, including
older saved Actions carrying an ignored `enabled` key.


## Review follow-up

- Address all current open code review comments on this PR, verify the
fixes, and reply to each addressed thread before resolving it.

## CI repair follow-up

- Inspect the latest failing checks on this PR, fix the causes on the
existing branch, and verify the affected checks locally and in CI.

## Follow-up audit (2026-10-14)

- Audited head `8fb0b44`, all inline review threads and the PR
conversation. The earlier Reply to tooltip comment was answered and
resolved. Two P1 comments remained: required boolean `enabled`
validation in CLI evaluations, and execution of saved disabled actions.
- `builder-e2e (shard-6)` failed because Preview returned `Disabled`
between `First` and `Last` in the configured failure results test. The
failure matches the security review finding. `builder-e2e (shard-2)` was
still in progress at audit start; the staging deployment workflow was
cancelled.
- The follow-up filters saved disabled actions from execution and
restores boolean `enabled` validation in both CLI evaluations. Builder
labels saved disabled actions. Focused SDK (55), CLI (222), and Builder
(45) tests, plus all three affected package typechecks, passed locally.
- Pushed `7c2b90e082`; replied to both P1 review threads, then resolved
them. Its main CI run passed all 25 test/check jobs, including all six
Builder E2E shards and the previously failing shard 6.
- A later P2 review identified stale CLI/MCP guidance. Commit
`160a9d1707` corrects the source and generated docs; focused docs tests
(12) and CLI typecheck pass locally. Replied to and resolved that
thread. On this latest head, the main CI run completed successfully: 27
check jobs passed, including all six Builder E2E shards; one
visual-regression job was skipped. All three required checks passed. The
automatically triggered staging deployment workflow was cancelled under
the no-deployment instruction.


## Input type default follow-up

- Show `text` as selected in the Props panel for both the HTML `input`
element and the Input component when their `type` prop is unset,
matching their rendered default. Verify both variants in the Builder and
retain explicit type choices.


## Current review-thread follow-up (2026-10-14)

- Recheck every unresolved review thread against the current PR head,
make and validate any required fixes, and reply with the resolution
before closing each thread. Keep this work on the existing PR branch; do
not deploy.


## Input type metadata follow-up (2026-10-14)

- Move the unset input `type` display default into component/element
property metadata for both the Input component and native HTML `input`
element. Remove the Props-panel special case, keep the implicit default
out of saved props, and cover both variants with regression tests.
- Run focused checks, push the fix to this PR, and cancel any
automatically triggered staging deployment workflow. Do not deploy
manually.


## Props metadata selector simplification follow-up (2026-10-15)

- Refactor `$selectedInstancePropsMetas` after the input default
metadata change. Preserve the current metadata, ordering, and attribute
override behavior while simplifying the selector's structure and
abstraction boundaries.
- Run focused checks, commit and push to this PR branch. Cancel any
automatically triggered staging deployment; do not deploy manually.

## Latest review follow-up (2026-10-15)

- Audit the latest PR head, all unresolved inline review threads, and
general PR comments. Address actionable feedback on this branch, run
focused validation, then reply and resolve each addressed thread. Cancel
any automatically triggered staging deployment; do not deploy manually.


## Form Action model decision (2026-10-15)

- Remove the Form Action `enabled` field completely from the type,
serialization, validation, execution, fixtures, and current docs. Accept
older saved JSON containing that extra key by ignoring it, including
`enabled: false`; action presence and valid IDs determine execution.
- Update the CLI evaluation to assert intended action IDs and order
without using `enabled`. Address the latest P2 review finding against
this model. Run focused checks, push on this PR branch, and cancel any
automatic staging deployment. No manual deployment.


## Action control visual follow-up (2026-10-15)

- Match the Form Action plus button to the established Variables section
plus button style and implementation where appropriate. Keep the
existing icon.
- Give the Action resource dropdown a stable width comparable to the
Variables resource dropdown, including when available resource names are
short.
- Add focused UI coverage, validate locally, commit and push to this PR
branch, and cancel any automatic staging deployment. Do not deploy
manually.


## Action plus hover follow-up (2026-10-15)

- Match the Action `+` button's hover and focus styling exactly to the
plus buttons in panels such as Variables. Compare their actual
implementations, preserve the existing icon, and add focused UI coverage
where appropriate.
- Run focused checks, commit and push to this PR branch. Cancel and
confirm any automatically triggered staging workflow; do not deploy
manually.


## Current CI fix (2026-10-15)

- Inspect the latest check rollup and failing job logs on the current PR
head. Fix all current failures on this branch, run focused local
validation, push the changes, and monitor the new head until checks
complete.
- Cancel and confirm any automatically triggered staging deployment. Do
not deploy manually.
2026-10-15 17:25:22 +02:00

506 lines
14 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import hash from "@emotion/hash";
import {
coreMetas,
createScope,
elementComponent,
type Instance,
type Instances,
type Prop,
type Props,
} from "@webstudio-is/sdk";
import { generateWebstudioComponent } from "@webstudio-is/react-sdk";
import {
findByClasses,
findByTags,
getAttr,
getTextContent,
loadHtmlIndices,
loadSvgSinglePage,
parseHtml,
} from "./crawler";
import { possibleStandardNames } from "./possible-standard-names";
import { ignoredTags } from "./overrides";
const validHtmlAttributes = new Set<string>();
type Attribute = {
name: string;
description: string;
required?: boolean;
type: "string" | "boolean" | "number" | "select" | "url";
options?: string[];
defaultValue?: string;
};
const overrides: Record<string, Record<string, false | Partial<Attribute>>> = {
"*": {
// react has own opinions about it
style: false,
// specific to input in react types
enterkeyhint: false,
inert: false,
popover: false,
writingsuggestions: false,
hidden: {
// "until-found"; "hidden"; the empty string
type: "boolean",
options: undefined,
},
},
a: {
href: { type: "url", required: true },
target: { required: true },
download: { type: "boolean", required: true },
},
blockquote: {
cite: { required: true },
},
form: {
action: { required: true },
method: { required: true },
enctype: { required: true },
},
area: {
ping: false,
},
button: {
type: { required: true },
command: false,
commandfor: false,
popovertarget: false,
popovertargetaction: false,
},
label: {
for: { required: true },
},
dialog: {
closedby: false,
},
img: {
src: { required: true },
alt: { required: true },
width: { required: true },
height: { required: true },
ismap: false,
},
input: {
name: { required: true },
value: { required: true },
checked: { required: true },
type: { required: true, defaultValue: "text" },
placeholder: { required: true },
required: { required: true },
autofocus: { required: true },
alpha: false,
colorspace: false,
// react types have it only in textarea
dirname: false,
popovertarget: false,
popovertargetaction: false,
},
textarea: {
name: { required: true },
placeholder: { required: true },
required: { required: true },
autofocus: { required: true },
},
select: {
name: { required: true },
required: { required: true },
autofocus: { required: true },
// mutltiple mode is not considered accessible
// and we cannot express it in builder so easier to remove
multiple: false,
},
option: {
label: { required: true },
value: { required: true },
disabled: { required: true },
// enforce fake value attribute on select element
selected: false,
},
};
// Crawl WHATWG HTML.
const html = await loadHtmlIndices();
const document = parseHtml(html);
const table = findByTags(document, "table").find(
(table) => getAttr(table, "id")?.value === "attributes-1"
);
const [tbody] = findByTags(table, "tbody");
const rows = findByTags(tbody, "tr");
const attributesByTag: Record<string, Attribute[]> = {};
// textarea does not have value attribute and text content is used as initial value
// introduce fake value attribute to manage initial state similar to input
attributesByTag.textarea = [
{
name: "value",
description: "Value of the form control",
type: "string",
required: true,
},
];
// select does not have value attribute and selected options are used as initial value
// introduce fake value attribute to manage initial state similar to input
attributesByTag.select = [
{
name: "value",
description: "Value of the form control",
type: "string",
required: true,
},
];
for (const row of rows) {
const attribute = getTextContent(row.childNodes[0]).trim();
const elements = getTextContent(row.childNodes[1]).trim();
const description = getTextContent(row.childNodes[2]).trim();
const tags = /HTML elements/.test(elements)
? ["*"]
: elements.split(/;/g).map((d) => d.replace(/\([^)]+\)/g, "").trim());
let value = getTextContent(row.childNodes[3]).trim().toLowerCase();
if (value.endsWith(";")) {
value = value.slice(0, -1);
}
let possibleOptions = value
.split(/\s*;\s*/)
.filter((item) => item.startsWith('"') && item.endsWith('"'))
.map((item) => item.slice(1, -1));
if (attribute === "target" || attribute === "formtarget") {
possibleOptions = ["_blank", "_self", "_parent", "_top"];
}
if (value.includes("input type keyword")) {
possibleOptions = [
"hidden",
"text",
"search",
"tel",
"url",
"email",
"password",
"date",
"month",
"week",
"time",
"datetime-local",
"number",
"range",
"color",
"checkbox",
"radio",
"file",
"submit",
"image",
"reset",
"button",
];
}
let type: "string" | "boolean" | "number" | "select" = "string";
let options: undefined | string[];
if (possibleOptions.length > 0) {
type = "select";
options = possibleOptions;
} else if (value.includes("boolean attribute")) {
type = "boolean";
} else if (
(value.includes("number") || value.includes("integer")) &&
!value.includes("list") &&
!value.includes("string")
) {
type = "number";
}
validHtmlAttributes.add(attribute);
for (let tag of tags) {
tag = tag.toLowerCase().trim();
if (/custom elements/i.test(tag)) {
continue;
}
if (ignoredTags.includes(tag)) {
continue;
}
if (!attributesByTag[tag]) {
attributesByTag[tag] = [];
}
const attributes = attributesByTag[tag];
if (!attributes.some((item) => item.name === attribute)) {
const override = overrides[tag]?.[attribute];
if (override !== false) {
attributes.push({
name: attribute,
description,
type,
options,
...override,
});
}
}
}
}
{
const svg = await loadSvgSinglePage();
const document = parseHtml(svg);
const attributeOptions = new Map<string, string[]>();
// find all property definition and extract there keywords
for (const propdef of findByClasses(document, "propdef")) {
let options: undefined | string[];
for (const row of findByTags(propdef, "tr")) {
const [nameNode, valueNode] = row.childNodes;
const name = getTextContent(nameNode);
const list = getTextContent(valueNode)
.trim()
.split(/\s+\|\s+/);
if (
name.toLowerCase().includes("value") &&
list.every((item) => item.match(/^[a-zA-Z-]+$/))
) {
options = list;
}
}
for (const propNameNode of findByClasses(propdef, "propdef-title")) {
const propName = getTextContent(propNameNode).slice(1, -1);
if (options) {
attributeOptions.set(propName, options);
}
}
}
for (const summary of findByClasses(document, "element-summary")) {
const [tag] = findByClasses(summary, "element-summary-name").map((item) =>
getTextContent(item).slice(1, -1)
);
// ignore existing
if (attributesByTag[tag] || ignoredTags.includes(tag)) {
continue;
}
const attributes = new Set<string>();
const [dl] = findByTags(summary, "dl");
for (let index = 0; index < dl.childNodes.length; index += 1) {
const child = dl.childNodes[index];
if (getTextContent(child).toLowerCase().includes("attributes")) {
const dd = dl.childNodes[index + 1];
for (const attrNameNode of findByClasses(dd, "attr-name")) {
const attrName = getTextContent(attrNameNode).slice(1, -1);
// skip events
if (attrName.startsWith("on") || attrName === "style") {
continue;
}
validHtmlAttributes.add(attrName);
attributes.add(attrName);
}
}
}
attributesByTag[tag] = Array.from(attributes)
.sort()
.map((name) => {
let options = attributeOptions.get(name);
if (name === "externalResourcesRequired") {
options = ["true", "false"];
}
if (name === "accumulate") {
options = ["none", "sum"];
}
if (name === "additive") {
options = ["replace", "sum"];
}
if (name === "preserveAlpha") {
options = ["true", "false"];
}
if (options) {
return { name, description: "", type: "select", options };
}
return { name, description: "", type: "string" };
});
}
}
// sort tags and attributes
const tags = Object.keys(attributesByTag).sort();
const attributesByHash = new Map<string, Attribute>();
const reusableAttributesByHash = new Map<string, Attribute>();
for (const tag of tags) {
const attributes = attributesByTag[tag];
delete attributesByTag[tag];
attributes.sort((left, right) => left.name.localeCompare(right.name));
if (attributes.length > 0) {
for (const attribute of attributes) {
const attributeHash = hash(JSON.stringify(attribute));
if (attributesByHash.has(attributeHash)) {
reusableAttributesByHash.set(attributeHash, attribute);
} else {
attributesByHash.set(attributeHash, attribute);
}
}
attributesByTag[tag] = attributes;
}
}
let attributesContent = `type Attribute = {
name: string,
description: string,
required?: boolean,
type: 'string' | 'boolean' | 'number' | 'select' | 'url',
options?: string[],
defaultValue?: string
}
`;
const attributeVariableByHash = new Map<string, string>();
for (const [key, attribute] of reusableAttributesByHash) {
const normalizedName = attribute.name
.replaceAll("-", "_")
.replaceAll(":", "_");
const variableName = `attribute_${normalizedName}_${key}`;
attributeVariableByHash.set(key, variableName);
attributesContent += `const ${variableName}: Attribute = ${JSON.stringify(attribute, null, 2)};\n\n`;
}
const serializableAttributesByTag: Record<
string,
Array<string | Attribute>
> = {};
for (const tag of tags) {
const attributes = attributesByTag[tag];
serializableAttributesByTag[tag] = attributes.map((attribute) => {
const key = hash(JSON.stringify(attribute));
const variableName = attributeVariableByHash.get(key);
if (variableName) {
return variableName;
}
return attribute;
});
}
attributesContent += `
export const attributesByTag: Record<string, undefined | Attribute[]> = ${JSON.stringify(serializableAttributesByTag, null, 2)};
`;
for (const variableName of attributeVariableByHash.values()) {
attributesContent = attributesContent.replaceAll(
`"${variableName}"`,
variableName
);
}
await mkdir("./src/__generated__", { recursive: true });
await writeFile("./src/__generated__/attributes.ts", attributesContent);
// generate jsx for testing react types
const instances: Instances = new Map();
const props: Props = new Map();
let id = 0;
const getId = () => {
id += 1;
return id.toString();
};
const body: Instance = {
type: "instance",
id: getId(),
component: elementComponent,
tag: "body",
children: [],
};
instances.set(body.id, body);
for (const entry of Object.entries(attributesByTag)) {
let [tag] = entry;
const [_tag, attributes] = entry;
if (tag === "*") {
tag = "div";
}
const instance: Instance = {
type: "instance",
id: getId(),
component: elementComponent,
tag,
children: [],
};
body.children.push({ type: "id", value: instance.id });
instances.set(instance.id, instance);
for (const { name, type, options } of attributes) {
const id = getId();
const instanceId = instance.id;
if (type === "string" || type === "url") {
const prop: Prop = { id, instanceId, type: "string", name, value: "" };
props.set(prop.id, prop);
continue;
}
if (type === "boolean") {
const prop: Prop = { id, instanceId, type, name, value: true };
props.set(prop.id, prop);
continue;
}
if (type === "select") {
const prop: Prop = {
id,
instanceId,
type: "string",
name,
value: options?.[0] ?? "",
};
props.set(prop.id, prop);
continue;
}
if (type === "number") {
const prop: Prop = {
id,
instanceId,
type: "number",
name,
value: 0,
};
props.set(prop.id, prop);
continue;
}
(type) satisfies never;
throw Error(`Unknown attribute ${name} with type ${type}`);
}
}
await mkdir("./src/__generated__", { recursive: true });
await writeFile(
"./src/__generated__/attributes-jsx-test.tsx",
generateWebstudioComponent({
name: "Page",
scope: createScope(),
metas: new Map(Object.entries(coreMetas)),
instances,
props,
dataSources: new Map(),
rootInstanceId: body.id,
classesMap: new Map(),
parameters: [],
}) + "export { Page }"
);
// react does not have this one
possibleStandardNames["dirname"] = "dirName";
const standardAttributesToReactProps: Record<string, string> = {};
const reactPropsToStandardAttributes: Record<string, string> = {};
for (const [htmlAttribute, reactProperty] of Object.entries(
possibleStandardNames
)) {
if (
validHtmlAttributes.has(htmlAttribute) &&
htmlAttribute !== reactProperty
) {
standardAttributesToReactProps[htmlAttribute] = reactProperty;
reactPropsToStandardAttributes[reactProperty] = htmlAttribute;
}
}
let standardAttributesContent = "";
standardAttributesContent += `export const standardAttributesToReactProps: Record<string, string> = ${JSON.stringify(standardAttributesToReactProps, null, 2)};\n\n`;
standardAttributesContent += `export const reactPropsToStandardAttributes: Record<string, string> = ${JSON.stringify(reactPropsToStandardAttributes, null, 2)};\n`;
await mkdir("../content-engine/src/__generated__", { recursive: true });
await writeFile(
"../content-engine/src/__generated__/standard-attributes.ts",
standardAttributesContent
);