Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,28 +94,27 @@ describe("canWrapInstance for components", () => {
expect(result).toBe(true);
});

test("should reject invalid wrapping (text in CodeText)", () => {
test("should allow wrapping text in a legacy CodeText", () => {
$instances.set(
renderData(
<$.Body ws:id="body">
<$.Box ws:id="box"></$.Box>
<$.Text ws:id="text">Hello</$.Text>
</$.Body>
).instances
);
selectInstance(["box", "body"]);
selectInstance(["text", "body"]);

// CodeText only accepts text content, not boxes
const result = canWrapInstance(
"box",
["box", "body"],
"text",
["text", "body"],
"body",
"CodeText",
undefined,
$instances.get(),
$props.get(),
$registeredComponentMetas.get()
);
expect(result).toBe(false);
expect(result).toBe(true);
});
});

Expand Down
112 changes: 78 additions & 34 deletions apps/builder/app/builder/features/publish/publish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ import {
type PrePublishAuditFinding,
} from "@webstudio-is/project-build/runtime";

const PrePublishAuditError = ({
const PrePublishAuditMessage = ({
finding,
}: {
finding: PrePublishAuditFinding;
Expand Down Expand Up @@ -164,7 +164,7 @@ const PrePublishAuditError = ({
);
};

const getPrePublishAuditError = () => {
const getPrePublishAuditMessages = () => {
const findings = runPrePublishAudit({
pages: $pages.get(),
instances: $instances.get(),
Expand All @@ -173,8 +173,14 @@ const getPrePublishAuditError = () => {
resources: $resources.get(),
metas: $registeredComponentMetas.get(),
});
const finding = findings.find(({ severity }) => severity === "error");
return finding && <PrePublishAuditError finding={finding} />;
const getMessage = (severity: PrePublishAuditFinding["severity"]) => {
const finding = findings.find((item) => item.severity === severity);
return finding && <PrePublishAuditMessage finding={finding} />;
};
return {
error: getMessage("error"),
warning: getMessage("warning"),
};
};

type ChangeProjectDomainProps = {
Expand Down Expand Up @@ -463,6 +469,9 @@ const Publish = ({
const [publishError, setPublishError] = useState<
undefined | JSX.Element | string
>();
const [publishWarning, setPublishWarning] = useState<
undefined | JSX.Element | string
>();
const [isPublishing, setIsPublishing] = useOptimistic(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const [hasSelectedDomains, setHasSelectedDomains] = useState(false);
Expand Down Expand Up @@ -511,27 +520,7 @@ const Publish = ({
};
}, [project.domain]);

const handlePublish = async (formData: FormData) => {
setPublishError(undefined);

const auditError = getPrePublishAuditError();
if (auditError !== undefined) {
toast.error(auditError);
setPublishError(auditError);
return;
}

// Custom domain checkboxes are disabled on free plan so they are never
// submitted — only the staging (wstd.io) domain can appear in formData.
const domains = formData
.getAll(domainToPublishName)
.map((domainEntry) => domainEntry.toString());

if (domains.length === 0) {
toast.error("Please select at least one domain to publish");
return;
}

const publish = async (domains: string[]) => {
setIsPublishing(true);

const publishResult = await nativeClient.domain.publish.mutate({
Expand Down Expand Up @@ -628,6 +617,38 @@ const Publish = ({
}
};

const handlePublish = (formData: FormData) => {
setPublishError(undefined);
setPublishWarning(undefined);

const { error: auditError, warning: auditWarning } =
getPrePublishAuditMessages();
if (auditError !== undefined) {
toast.error(auditError);
setPublishError(auditError);
return;
}
if (auditWarning !== undefined) {
toast.warn(auditWarning);
setPublishWarning(auditWarning);
}

// Custom domain checkboxes are disabled on free plan so they are never
// submitted — only the staging (wstd.io) domain can appear in formData.
const domains = formData
.getAll(domainToPublishName)
.map((domainEntry) => domainEntry.toString());

if (domains.length === 0) {
toast.error("Please select at least one domain to publish");
return;
}

startTransition(async () => {
await publish(domains);
});
};

const hasPendingState = project.latestBuildVirtual
? getPublishStatusAndText(project.latestBuildVirtual).status === "PENDING"
: false;
Expand All @@ -639,6 +660,11 @@ const Publish = ({
return (
<Flex gap={2} shrink={false} direction={"column"}>
{publishError && <Text color="destructive">{publishError}</Text>}
{publishWarning && (
<PanelBanner variant="warning">
<Text>{publishWarning}</Text>
</PanelBanner>
)}

<Tooltip
content={
Expand All @@ -651,7 +677,13 @@ const Publish = ({
>
<Button
ref={buttonRef}
formAction={handlePublish}
type="button"
onClick={() => {
const form = buttonRef.current?.closest("form");
if (form) {
handlePublish(new FormData(form));
}
}}
color="positive"
state={showPendingState ? "pending" : undefined}
disabled={
Expand Down Expand Up @@ -712,6 +744,7 @@ const PublishStatic = ({
const project = useStore($project);
const [_, startTransition] = useTransition();
const [publishError, setPublishError] = useState<JSX.Element | string>();
const [publishWarning, setPublishWarning] = useState<JSX.Element | string>();

if (project == null) {
throw new Error("Project not found");
Expand All @@ -729,6 +762,11 @@ const PublishStatic = ({
return (
<Flex gap={2} shrink={false} direction={"column"}>
{publishError && <Text color="destructive">{publishError}</Text>}
{publishWarning && (
<PanelBanner variant="warning">
<Text>{publishWarning}</Text>
</PanelBanner>
)}
{status === "FAILED" && <Text color="destructive">{statusText}</Text>}

<Tooltip
Expand All @@ -739,16 +777,22 @@ const PublishStatic = ({
color="positive"
state={isPublishInProgress ? "pending" : undefined}
onClick={() => {
setPublishError(undefined);
setPublishWarning(undefined);
const { error: auditError, warning: auditWarning } =
getPrePublishAuditMessages();
if (auditError !== undefined) {
toast.error(auditError);
setPublishError(auditError);
return;
}
if (auditWarning !== undefined) {
toast.warn(auditWarning);
setPublishWarning(auditWarning);
}

startTransition(async () => {
try {
setPublishError(undefined);
const auditError = getPrePublishAuditError();
if (auditError !== undefined) {
toast.error(auditError);
setPublishError(auditError);
return;
}

setIsPendingOptimistic(true);

const result = await nativeClient.domain.publish.mutate({
Expand Down
24 changes: 23 additions & 1 deletion apps/builder/app/shared/copy-paste/fragment-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { WebstudioFragment } from "@webstudio-is/sdk";
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime";
import {
breakpointPasteLimitWarning,
type FragmentContentModelWarning,
} from "@webstudio-is/project-build/runtime";
import {
insertWebstudioFragmentAt,
type Insertable,
Expand All @@ -10,6 +13,23 @@ import { resolveFragmentTokenConflicts } from "../resolve-token-conflicts";
export const hasFragmentData = (fragment: WebstudioFragment) =>
fragment.children.length > 0 || fragment.styleSources.length > 0;

export const reportFragmentContentModelWarnings = (
warnings: FragmentContentModelWarning[]
) => {
const [firstWarning] = warnings;
if (firstWarning === undefined) {
return;
}
const remainingCount = warnings.length - 1;
const suffix =
remainingCount === 0
? ""
: ` (${remainingCount} more validation ${remainingCount === 1 ? "warning" : "warnings"})`;
builderApi.toast.warn(
`Pasted with warning: ${firstWarning.message}${suffix}`
);
};

export const insertFragmentWithBreakpointWarning = async (
fragment: WebstudioFragment,
insertable?: Insertable
Expand All @@ -19,6 +39,8 @@ export const insertFragmentWithBreakpointWarning = async (
return false;
}
return insertWebstudioFragmentAt(fragment, insertable, conflictResolution, {
allowContentModelWarnings: true,
onContentModelWarnings: reportFragmentContentModelWarnings,
onBreakpointLimitMerge: () => {
builderApi.toast.warn(breakpointPasteLimitWarning);
},
Expand Down
35 changes: 35 additions & 0 deletions apps/builder/app/shared/copy-paste/plugin-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
expectSlotsShareFragment,
} from "../slot-test-utils";
import { pasteHandled } from "./copy-paste";
import { initBuilderApi } from "../builder-api";

const expectString = expect.any(String) as unknown as string;

Expand Down Expand Up @@ -204,6 +205,40 @@ describe("copy and cut guards", () => {
]);
});

test("pastes a legacy invalid subtree with a warning", async () => {
$instances.set(
toMap([
createInstance("body0", "Body", [
{ type: "id", value: "legacy-button" },
]),
{
...createInstance("legacy-button", elementComponent, [
{ type: "id", value: "legacy-heading" },
]),
tag: "button",
},
{
...createInstance("legacy-heading", elementComponent, []),
tag: "h3",
},
] satisfies Instance[])
);
selectInstance(["legacy-button", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["body0"]);
initBuilderApi();
const previousWarn = window.__webstudio__$__builderApi.toast.warn;
const warn = vi.fn();
window.__webstudio__$__builderApi.toast.warn = warn;

expect(await instanceText.onPaste?.(clipboardData)).toEqual(pasteHandled);
expect($instances.get().get("body0")?.children).toHaveLength(2);
expect(warn).toHaveBeenCalledWith(
"Pasted with warning: Placing <h3> element inside a <button> violates HTML spec."
);
window.__webstudio__$__builderApi.toast.warn = previousWarn;
});

test("sanitizes multi-root clipboard root ids before paste", async () => {
$instances.set(
toMap([
Expand Down
12 changes: 11 additions & 1 deletion apps/builder/app/shared/copy-paste/plugin-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
findSafeFragmentPasteTarget,
getCommonAncestorSelector,
getPasteRootInstanceIds,
getFragmentContentModelWarnings,
mergeWebstudioFragments,
} from "@webstudio-is/project-build/runtime";
import {
Expand Down Expand Up @@ -37,13 +38,15 @@ import {
clearInstanceSelection,
$selectedInstancePath,
$selectedInstanceSelector,
$registeredComponentMetas,
selectInstances,
} from "~/shared/nano-states";
import { getInstancePath } from "@webstudio-is/project-build/runtime";
import { builderApi } from "../builder-api";
import { pasteHandled, pasteIgnored, type Plugin } from "./copy-paste";
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime";
import { resolveFragmentTokenConflicts } from "../resolve-token-conflicts";
import { reportFragmentContentModelWarnings } from "./fragment-utils";

const invalidPasteDataMessage =
"Could not paste Webstudio instance data. The clipboard data appears to be incomplete or invalid.";
Expand Down Expand Up @@ -156,7 +159,9 @@ const findPasteTargetForFragment = (
): undefined | Insertable => {
const instances = $instances.get();

insertable = findClosestInsertable(fragment, insertable);
insertable = findClosestInsertable(fragment, insertable, {
allowContentModelWarnings: true,
});
if (insertable === undefined) {
return;
}
Expand Down Expand Up @@ -208,6 +213,10 @@ const insertPastedFragment = async ({
selectRootInstances: (rootInstanceIds: Instance["id"][]) => void;
}) => {
try {
const contentModelWarnings = getFragmentContentModelWarnings({
fragment,
metas: $registeredComponentMetas.get(),
});
const conflictResolution = await resolveFragmentTokenConflicts(fragment);
if (conflictResolution === "cancel") {
return;
Expand All @@ -231,6 +240,7 @@ const insertPastedFragment = async ({
if (result?.result.didMergeBreakpointsDueToLimit === true) {
toast.warn(breakpointPasteLimitWarning);
}
reportFragmentContentModelWarnings(contentModelWarnings);
selectRootInstances(rootInstanceIds);
} catch (error) {
// User cancelled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ const handlePasteWebflow = async (clipboardData: string) => {
}
fragment = await denormalizeSrcProps(fragment);

const insertable = findClosestInsertable(fragment);
const insertable = findClosestInsertable(fragment, undefined, {
allowContentModelWarnings: true,
});
if (insertable === undefined) {
return pasteHandled;
}
Expand Down
Loading
Loading