mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: Preserve template validation and navigation layout when merging main
This commit is contained in:
commit
9bc31ca679
7 files changed
+361
-23
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { createAssetFolderFixture } from "@webstudio-is/sdk/testing";
|
||||
import { $assets, $assetFolders } from "~/shared/sync/data-stores";
|
||||
import { $authPermit } from "~/shared/nano-states";
|
||||
import { createAssetManagerTestRenderer } from "~/builder/shared/asset-manager/test-utils";
|
||||
import { SelectAsset } from "./select-asset";
|
||||
|
||||
const renderer = createAssetManagerTestRenderer();
|
||||
const asset: Asset = {
|
||||
id: "image",
|
||||
projectId: "project",
|
||||
name: "image.svg",
|
||||
filename: "A long image name that should not widen the source picker",
|
||||
format: "svg",
|
||||
type: "image",
|
||||
size: 100,
|
||||
meta: { width: 100, height: 100 },
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
$authPermit.set("build");
|
||||
$assets.set(new Map([[asset.id, asset]]));
|
||||
$assetFolders.set(new Map());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
renderer.cleanup();
|
||||
$assets.set(new Map());
|
||||
$assetFolders.set(new Map());
|
||||
});
|
||||
|
||||
const openPicker = async (onChange = vi.fn()) => {
|
||||
renderer.render(
|
||||
<TooltipProvider>
|
||||
<div data-floating-panel-container style={{ width: 260, height: 600 }}>
|
||||
<SelectAsset accept="image/*" onChange={onChange} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
await act(async () => {
|
||||
await page.getByRole("button", { name: "Choose source" }).click();
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole("dialog", { name: "Images" }))
|
||||
.toBeVisible();
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
const thumbnail = dialog.querySelector<HTMLButtonElement>(
|
||||
"[data-asset-manager-thumbnail-button]"
|
||||
)!;
|
||||
// Let opening layout and the initial resize observer delivery finish.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
});
|
||||
return { dialog, thumbnail, onChange };
|
||||
};
|
||||
|
||||
test.each([false, true])(
|
||||
"keeps the source picker width when selecting an image (nested: %s)",
|
||||
async (nested) => {
|
||||
if (nested) {
|
||||
const parent = createAssetFolderFixture({
|
||||
id: "parent",
|
||||
name: "A long parent folder name",
|
||||
});
|
||||
const folder = createAssetFolderFixture({
|
||||
id: "folder",
|
||||
parentId: parent.id,
|
||||
name: "A long nested folder name",
|
||||
});
|
||||
$assetFolders.set(
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[folder.id, folder],
|
||||
])
|
||||
);
|
||||
$assets.set(new Map([[asset.id, { ...asset, folderId: folder.id }]]));
|
||||
}
|
||||
const { dialog } = await openPicker();
|
||||
if (nested) {
|
||||
await act(async () => {
|
||||
await page.getByRole("textbox", { name: "Search" }).fill("image");
|
||||
});
|
||||
}
|
||||
const thumbnail = dialog.querySelector<HTMLButtonElement>(
|
||||
"[data-asset-manager-thumbnail-button]"
|
||||
)!;
|
||||
const width = dialog.getBoundingClientRect().width;
|
||||
expect(width).toBe(240);
|
||||
act(() => thumbnail.focus());
|
||||
await new Promise(requestAnimationFrame);
|
||||
expect(dialog.getBoundingClientRect().width).toBe(width);
|
||||
}
|
||||
);
|
||||
|
||||
test("chooses an image with a quick thumbnail click", async () => {
|
||||
const { thumbnail, onChange } = await openPicker();
|
||||
const preview = thumbnail.firstElementChild!;
|
||||
// Allow a frame between press and release, as with a normal mouse click.
|
||||
await act(async () => {
|
||||
await page.elementLocator(preview).click({ delay: 80 });
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith(asset.id);
|
||||
await expect
|
||||
.element(page.getByRole("dialog", { name: "Images" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import { css, theme } from "../stitches.config";
|
||||
import { theme } from "../stitches.config";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitleActions,
|
||||
@@ -130,11 +130,6 @@ type FloatingPanelProps = {
|
||||
closeOnInteractOutside?: boolean;
|
||||
};
|
||||
|
||||
const contentStyle = css({
|
||||
width: theme.sizes.sidebarWidth,
|
||||
overflow: "auto",
|
||||
});
|
||||
|
||||
const defaultOffset: OffsetOptions = { mainAxis: 0, crossAxis: 0 };
|
||||
|
||||
export const FloatingPanel = ({
|
||||
@@ -313,7 +308,7 @@ export const FloatingPanel = ({
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className={contentStyle()}
|
||||
css={{ width: theme.sizes.sidebarWidth, overflow: "auto" }}
|
||||
width={width}
|
||||
height={height}
|
||||
{...position}
|
||||
|
||||
@@ -23,6 +23,32 @@ const pressArrow = (element: HTMLElement, key: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
test("keeps navigation labels left aligned beside their icons", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
act(() =>
|
||||
root?.render(
|
||||
<InsetList css={{ width: 220 }}>
|
||||
<InsetListItem asChild>
|
||||
<a href="#projects">
|
||||
<svg width="16" height="16" />
|
||||
<span>Projects</span>
|
||||
</a>
|
||||
</InsetListItem>
|
||||
<InsetListItem>Settings</InsetListItem>
|
||||
</InsetList>
|
||||
)
|
||||
);
|
||||
const link = container.querySelector("a")!;
|
||||
const icon = link.querySelector("svg")!.getBoundingClientRect();
|
||||
const label = link.querySelector("span")!.getBoundingClientRect();
|
||||
expect(label.left - icon.right).toBeLessThanOrEqual(8);
|
||||
expect(getComputedStyle(container.querySelector("button")!).textAlign).toBe(
|
||||
"left"
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["{Enter}", " "])("activates list items with %s", async (key) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { GearIcon } from "@webstudio-is/icons";
|
||||
import { Grid } from "./grid";
|
||||
import { InsetList, InsetListItem } from "./inset-list";
|
||||
import { List, ListItem } from "./primitives/list";
|
||||
@@ -54,6 +55,19 @@ export const SupportingText = () => (
|
||||
</StorySection>
|
||||
);
|
||||
|
||||
export const NavigationWithIcons = () => (
|
||||
<StorySection title="Navigation with icons">
|
||||
<InsetList css={{ width: 220 }}>
|
||||
{items.map((item) => (
|
||||
<InsetListItem key={item}>
|
||||
<GearIcon />
|
||||
<Text variant="labels">{item}</Text>
|
||||
</InsetListItem>
|
||||
))}
|
||||
</InsetList>
|
||||
</StorySection>
|
||||
);
|
||||
|
||||
export const Columns = () => (
|
||||
<StorySection title="Columns">
|
||||
<List asChild aria-label="Projects">
|
||||
|
||||
@@ -15,11 +15,11 @@ export const InsetList = styled("div", {
|
||||
padding: theme.spacing[3],
|
||||
});
|
||||
|
||||
const insetListItemStyle = css({
|
||||
const insetListItemStyle = css(buttonStyle, {
|
||||
height: "auto",
|
||||
minHeight: theme.spacing[13],
|
||||
padding: theme.spacing[3],
|
||||
justifyContent: "stretch",
|
||||
justifyContent: "start",
|
||||
textAlign: "left",
|
||||
overflow: "hidden",
|
||||
gap: theme.spacing[5],
|
||||
@@ -52,9 +52,10 @@ export const InsetListItem = forwardRef(
|
||||
{...props}
|
||||
ref={ref}
|
||||
data-state={dataState ?? "auto"}
|
||||
className={buttonStyle({
|
||||
className={insetListItemStyle({
|
||||
color: "ghost",
|
||||
className: insetListItemStyle({ className, css: cssProp }),
|
||||
className,
|
||||
css: cssProp,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -62,6 +62,33 @@ test.each(["body", "templates", "list-item"])(
|
||||
}
|
||||
);
|
||||
|
||||
test.each(["list", "templates", "box", "paragraph"])(
|
||||
"does not inherit a description-list parent into a template from %s",
|
||||
(start) => {
|
||||
const data = renderData(
|
||||
<ws.element ws:tag="dl" ws:id="list">
|
||||
<ws.block ws:id="block">
|
||||
<BlockTemplate ws:id="templates">
|
||||
<ws.element ws:tag="div" ws:id="box">
|
||||
<ws.element ws:tag="p" ws:id="paragraph">
|
||||
Content
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
</BlockTemplate>
|
||||
</ws.block>
|
||||
</ws.element>
|
||||
);
|
||||
const selector = ["paragraph", "box", "templates", "block", "list"];
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...data,
|
||||
metas: defaultMetas,
|
||||
instanceSelector: selector.slice(selector.indexOf(start)),
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(["template", "body"])(
|
||||
"still rejects invalid list nesting in %s content",
|
||||
(location) => {
|
||||
@@ -209,6 +236,149 @@ test("none category accepted by parent by tag", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("description lists", () => {
|
||||
test("does not accept an internal context marker as an HTML tag", () => {
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...renderData(
|
||||
<ws.element ws:tag="dl" ws:id="listId">
|
||||
<ws.element ws:tag="dl-child" />
|
||||
</ws.element>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceSelector: ["listId"],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["listId"],
|
||||
["groupId", "listId"],
|
||||
["termId", "groupId", "listId"],
|
||||
["detailId", "groupId", "listId"],
|
||||
])("accepts div groups when validating from %j", (...instanceSelector) => {
|
||||
const errors: string[] = [];
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...renderData(
|
||||
<ws.element ws:tag="dl" ws:id="listId">
|
||||
<ws.element ws:tag="div" ws:id="groupId">
|
||||
<ws.element ws:tag="dt" ws:id="termId">
|
||||
Company
|
||||
</ws.element>
|
||||
<ws.element ws:tag="dd" ws:id="detailId">
|
||||
XXX
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
<ws.element ws:tag="div">
|
||||
<ws.element ws:tag="dt">Tax ID (NIP EU)</ws.element>
|
||||
<ws.element ws:tag="dd">XXX</ws.element>
|
||||
</ws.element>
|
||||
<ws.element ws:tag="div">
|
||||
<ws.element ws:tag="dt">Address</ws.element>
|
||||
<ws.element ws:tag="dd">...</ws.element>
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceSelector,
|
||||
onError: (message) => errors.push(message),
|
||||
})
|
||||
).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("accepts div groups through components without HTML tags", () => {
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...renderData(
|
||||
<ws.element ws:tag="dl" ws:id="listId">
|
||||
<Fragment>
|
||||
<ws.element ws:tag="div">
|
||||
<Slot>
|
||||
<ws.element ws:tag="dt">Company</ws.element>
|
||||
<ws.element ws:tag="dd">XXX</ws.element>
|
||||
</Slot>
|
||||
</ws.element>
|
||||
</Fragment>
|
||||
</ws.element>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceSelector: ["listId"],
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects flow content directly inside a description-list group", () => {
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...renderData(
|
||||
<ws.element ws:tag="dl" ws:id="listId">
|
||||
<ws.element ws:tag="div">
|
||||
<ws.element ws:tag="p">Company</ws.element>
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceSelector: ["listId"],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["dt", "dd"])("keeps %s restricted to description lists", (tag) => {
|
||||
for (const [ancestor, parent] of [
|
||||
["div", "div"],
|
||||
["dl", "div"],
|
||||
["dl", "a"],
|
||||
["dl", "section"],
|
||||
]) {
|
||||
const data = renderData(
|
||||
<ws.element ws:tag={ancestor} ws:id="rootId">
|
||||
<ws.element ws:tag={parent} ws:id="groupId">
|
||||
<ws.element ws:tag="div" ws:id="wrapperId">
|
||||
<ws.element ws:tag={tag} ws:id="childId">
|
||||
XXX
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
);
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...data,
|
||||
metas: defaultMetas,
|
||||
instanceSelector: ["childId", "wrapperId", "groupId", "rootId"],
|
||||
})
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...renderData(
|
||||
<ws.element ws:tag="dl" ws:id="listId">
|
||||
<ws.element ws:tag="div">
|
||||
<ws.element ws:tag={tag}>XXX</ws.element>
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceSelector: ["listId"],
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
...renderData(
|
||||
<ws.element ws:tag="dl" ws:id="listId">
|
||||
<ws.element ws:tag="dt">Company</ws.element>
|
||||
<ws.element ws:tag="dd">XXX</ws.element>
|
||||
</ws.element>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceSelector: ["listId"],
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("none category prevents unacceptable parent", () => {
|
||||
expect(
|
||||
isTreeSatisfyingContentModel({
|
||||
|
||||
@@ -119,7 +119,8 @@ const isTagSatisfyingContentModel = ({
|
||||
*/
|
||||
const getElementChildren = (
|
||||
tag: undefined | string,
|
||||
allowedCategories: undefined | string[]
|
||||
allowedCategories: undefined | string[],
|
||||
parentTag: undefined | string
|
||||
) => {
|
||||
// A transparent component without a known parent imposes no constraint.
|
||||
if (tag === undefined && allowedCategories === undefined) {
|
||||
@@ -138,6 +139,10 @@ const getElementChildren = (
|
||||
category === "transparent" ? inheritedCategories : category
|
||||
);
|
||||
}
|
||||
// A div directly under dl contains name-value groups instead of flow content.
|
||||
if (tag === "div" && parentTag === "dl") {
|
||||
elementChildren = ["dt", "dd", "script-supporting elements"];
|
||||
}
|
||||
// introduce custom non-interactive category to restrict nesting interactive elements
|
||||
// like button > button or a > input
|
||||
if (
|
||||
@@ -187,6 +192,7 @@ const computeAllowedCategories = ({
|
||||
}) => {
|
||||
let instance: undefined | Instance;
|
||||
let allowedCategories: undefined | string[];
|
||||
let parentTag: undefined | string;
|
||||
// skip selected instance for which these constraints are computed
|
||||
for (const instanceId of instanceSelector.slice(1).reverse()) {
|
||||
instance = instances.get(instanceId);
|
||||
@@ -197,12 +203,15 @@ const computeAllowedCategories = ({
|
||||
// Template roots are reusable definitions, not children of the page HTML.
|
||||
if (instance.component === blockTemplateComponent) {
|
||||
allowedCategories = undefined;
|
||||
parentTag = undefined;
|
||||
continue;
|
||||
}
|
||||
const tag = getTag({ instance, metas, props, htmlTagsByInstanceId });
|
||||
allowedCategories = getElementChildren(tag, allowedCategories);
|
||||
allowedCategories = getElementChildren(tag, allowedCategories, parentTag);
|
||||
// Tagless components do not change the rendered HTML parent.
|
||||
parentTag = tag ?? parentTag;
|
||||
}
|
||||
return allowedCategories;
|
||||
return { allowedCategories, parentTag };
|
||||
};
|
||||
|
||||
const findHtmlConstraintInstance = ({
|
||||
@@ -223,6 +232,7 @@ const findHtmlConstraintInstance = ({
|
||||
component: Instance["component"];
|
||||
}) => {
|
||||
let allowedCategories: undefined | string[];
|
||||
let parentTag: undefined | string;
|
||||
let wasSatisfying = true;
|
||||
let constraintInstance: undefined | Instance;
|
||||
|
||||
@@ -233,6 +243,7 @@ const findHtmlConstraintInstance = ({
|
||||
}
|
||||
if (ancestor.component === blockTemplateComponent) {
|
||||
allowedCategories = undefined;
|
||||
parentTag = undefined;
|
||||
constraintInstance = undefined;
|
||||
wasSatisfying = true;
|
||||
continue;
|
||||
@@ -243,7 +254,12 @@ const findHtmlConstraintInstance = ({
|
||||
props,
|
||||
htmlTagsByInstanceId,
|
||||
});
|
||||
allowedCategories = getElementChildren(ancestorTag, allowedCategories);
|
||||
allowedCategories = getElementChildren(
|
||||
ancestorTag,
|
||||
allowedCategories,
|
||||
parentTag
|
||||
);
|
||||
parentTag = ancestorTag ?? parentTag;
|
||||
const isSatisfying = isTagSatisfyingContentModel({
|
||||
tag,
|
||||
component,
|
||||
@@ -429,6 +445,7 @@ export const isTreeSatisfyingContentModel = ({
|
||||
htmlTagsByInstanceId = getHtmlTagsFromProps(props),
|
||||
onError,
|
||||
_allowedCategories: allowedCategories,
|
||||
_parentTag: parentTag,
|
||||
_allowedAncestorCategories: allowedAncestorCategories,
|
||||
_allowedParentCategories: allowedParentCategories,
|
||||
}: {
|
||||
@@ -439,17 +456,20 @@ export const isTreeSatisfyingContentModel = ({
|
||||
htmlTagsByInstanceId?: HtmlTagsByInstanceId;
|
||||
onError?: (message: string, instanceSelector: InstanceSelector) => void;
|
||||
_allowedCategories?: string[];
|
||||
_parentTag?: string;
|
||||
_allowedAncestorCategories?: string[];
|
||||
_allowedParentCategories?: string[];
|
||||
}): boolean => {
|
||||
// compute constraints only when not passed from parent
|
||||
allowedCategories ??= computeAllowedCategories({
|
||||
instanceSelector,
|
||||
instances,
|
||||
props,
|
||||
metas,
|
||||
htmlTagsByInstanceId,
|
||||
});
|
||||
if (allowedCategories === undefined) {
|
||||
({ allowedCategories, parentTag } = computeAllowedCategories({
|
||||
instanceSelector,
|
||||
instances,
|
||||
props,
|
||||
metas,
|
||||
htmlTagsByInstanceId,
|
||||
}));
|
||||
}
|
||||
allowedParentCategories ??= getAllowedParentCategories({
|
||||
instanceSelector,
|
||||
instances,
|
||||
@@ -547,7 +567,7 @@ export const isTreeSatisfyingContentModel = ({
|
||||
isSatisfying = false;
|
||||
}
|
||||
const contentModel = getComponentContentModel(metas.get(instance.component));
|
||||
allowedCategories = getElementChildren(tag, allowedCategories);
|
||||
allowedCategories = getElementChildren(tag, allowedCategories, parentTag);
|
||||
allowedParentCategories = contentModel.children;
|
||||
if (contentModel.descendants) {
|
||||
allowedAncestorCategories ??= [];
|
||||
@@ -569,6 +589,7 @@ export const isTreeSatisfyingContentModel = ({
|
||||
instance.component === blockTemplateComponent
|
||||
? undefined
|
||||
: allowedCategories,
|
||||
_parentTag: tag ?? parentTag,
|
||||
_allowedParentCategories: allowedParentCategories,
|
||||
_allowedAncestorCategories: allowedAncestorCategories,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user