mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: enforce MDX templates across content editing
This commit is contained in:
1 parent
2b8195692e
commit
c3702a7238
36 files changed
+1388
-357
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
elementComponent,
|
||||
contentBlockSourceProp,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $flatTree, __testing__ } from "./navigator-tree";
|
||||
import {
|
||||
@@ -11,10 +12,11 @@ import {
|
||||
$selectedPageId,
|
||||
selectInstances,
|
||||
} from "~/shared/nano-states";
|
||||
import { $instances, $pages } from "~/shared/sync/data-stores";
|
||||
import { $instances, $pages, $props } from "~/shared/sync/data-stores";
|
||||
import { $externalContentRoots } from "~/shared/external-content-mutations";
|
||||
|
||||
const {
|
||||
getMdxContentSource,
|
||||
commitNavigatorDrop,
|
||||
getFocusSelectionSkipCountAfterPointerDown,
|
||||
getBuilderDropTarget,
|
||||
@@ -26,6 +28,102 @@ const {
|
||||
shouldSelectOnPointerDown,
|
||||
} = __testing__;
|
||||
|
||||
test("identifies only the connected MDX Body, including an empty Body", () => {
|
||||
const previousRoots = $externalContentRoots.get();
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
id: "block",
|
||||
type: "instance",
|
||||
component: blockComponent,
|
||||
children: [
|
||||
{ type: "id", value: "body" },
|
||||
{ type: "id", value: "shell" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"body",
|
||||
{
|
||||
id: "body",
|
||||
type: "instance",
|
||||
component: blockBodyComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
"shell",
|
||||
{
|
||||
id: "shell",
|
||||
type: "instance",
|
||||
component: elementComponent,
|
||||
tag: "div",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$props.set(new Map());
|
||||
expect(getMdxContentSource(["body", "block"])).toBeUndefined();
|
||||
$props.set(
|
||||
new Map([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: contentBlockSourceProp,
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
try {
|
||||
expect(getMdxContentSource(["body", "block"])).toEqual({
|
||||
assetId: "article",
|
||||
});
|
||||
expect(getMdxContentSource(["shell", "block"])).toBeUndefined();
|
||||
expect(getMdxContentSource(["block"])).toBeUndefined();
|
||||
$props.set(
|
||||
new Map([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: contentBlockSourceProp,
|
||||
type: "expression",
|
||||
value: "collection.data._id",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
$externalContentRoots.set(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
{
|
||||
blockInstanceId: "block",
|
||||
contentInstanceId: "body",
|
||||
assetId: "article",
|
||||
instanceIds: new Set(),
|
||||
mutationRevision: 0,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
expect(getMdxContentSource(["body", "block"])).toEqual({
|
||||
assetId: "article",
|
||||
});
|
||||
} finally {
|
||||
$props.set(new Map());
|
||||
$externalContentRoots.set(previousRoots);
|
||||
}
|
||||
});
|
||||
|
||||
const createTreeItem = ({
|
||||
parentComponent = "Box",
|
||||
}: { parentComponent?: string } = {}): Parameters<
|
||||
|
||||
@@ -8,6 +8,10 @@ import { mergeRefs } from "@react-aria/utils";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
Box,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
cssVar,
|
||||
keyframes,
|
||||
ScrollArea,
|
||||
@@ -28,6 +32,8 @@ import {
|
||||
ROOT_INSTANCE_ID,
|
||||
collectionComponent,
|
||||
blockBodyComponent,
|
||||
contentBlockSourceProp,
|
||||
getContentBlockSource,
|
||||
blockComponent,
|
||||
rootComponent,
|
||||
blockTemplateComponent,
|
||||
@@ -40,6 +46,8 @@ import {
|
||||
EyeClosedIcon,
|
||||
EyeOpenIcon,
|
||||
InfoCircleIcon,
|
||||
MarkdownEmbedIcon,
|
||||
SettingsIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import {
|
||||
$allSelectedInstanceSelectors,
|
||||
@@ -85,12 +93,81 @@ import {
|
||||
externalContentInstanceNameMessage,
|
||||
isExternalContentInstance,
|
||||
resolveExternalContentOccurrence,
|
||||
findExternalContentRoot,
|
||||
} from "~/shared/external-content-mutations";
|
||||
import {
|
||||
getInstanceLabel,
|
||||
InstanceIcon,
|
||||
} from "~/builder/shared/instance-label";
|
||||
import { InstanceContextMenu } from "~/builder/shared/instance-context-menu";
|
||||
import { TextFileEditor } from "../text-file-editor/text-file-editor";
|
||||
|
||||
const getMdxContentSource = (selector: InstanceSelector) => {
|
||||
const instances = $instances.get();
|
||||
if (instances.get(selector[0])?.component !== blockBodyComponent) {
|
||||
return;
|
||||
}
|
||||
const blockIndex = selector.findIndex(
|
||||
(id) => instances.get(id)?.component === blockComponent
|
||||
);
|
||||
if (blockIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const blockSelector = selector.slice(blockIndex);
|
||||
const root =
|
||||
Array.from($externalContentRoots.get().values()).find(
|
||||
(root) =>
|
||||
root.contentInstanceId === selector[0] &&
|
||||
root.blockInstanceId === blockSelector[0]
|
||||
) ??
|
||||
findExternalContentRoot(
|
||||
$externalContentRoots.get(),
|
||||
blockSelector[0],
|
||||
JSON.stringify(blockSelector)
|
||||
);
|
||||
const source = getContentBlockSource({
|
||||
blockInstanceId: root?.sourceBlockInstanceId ?? blockSelector[0],
|
||||
props: $props.get().values(),
|
||||
});
|
||||
if (source === undefined) {
|
||||
return;
|
||||
}
|
||||
const value = $propValuesByInstanceSelector
|
||||
.get()
|
||||
.get(getInstanceKey(blockSelector))
|
||||
?.get(contentBlockSourceProp);
|
||||
return {
|
||||
assetId:
|
||||
source.type === "asset"
|
||||
? source.assetId
|
||||
: (root?.assetId ??
|
||||
(typeof value === "string" && value !== "" ? value : undefined)),
|
||||
};
|
||||
};
|
||||
|
||||
const MdxContentMenu = ({ assetId }: { assetId?: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SmallIconButton
|
||||
aria-label="MDX content settings"
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={!assetId} onSelect={() => setOpen(true)}>
|
||||
Open MDX file
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{open && assetId && (
|
||||
<TextFileEditor assetId={assetId} onOpenChange={setOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TreeItemAncestor =
|
||||
| undefined
|
||||
@@ -540,11 +617,13 @@ const EditableTreeNodeLabel = styled("div", {
|
||||
|
||||
const TreeNodeContent = ({
|
||||
instance,
|
||||
mdx = false,
|
||||
isEditing,
|
||||
isNameEditable,
|
||||
onIsEditingChange,
|
||||
}: {
|
||||
instance: Instance;
|
||||
mdx?: boolean;
|
||||
isEditing: boolean;
|
||||
isNameEditable: boolean;
|
||||
onIsEditingChange: (isEditing: boolean) => void;
|
||||
@@ -552,7 +631,7 @@ const TreeNodeContent = ({
|
||||
const editableRef = useRef<HTMLDivElement | null>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const label = getInstanceLabel(instance);
|
||||
const label = mdx ? "MDX content" : getInstanceLabel(instance);
|
||||
const { ref, handlers } = useContentEditable({
|
||||
value: label,
|
||||
isEditable: isNameEditable,
|
||||
@@ -581,9 +660,19 @@ const TreeNodeContent = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<TreeNodeLabel prefix={<InstanceIcon instance={instance} />}>
|
||||
<TreeNodeLabel
|
||||
prefix={
|
||||
mdx ? <MarkdownEmbedIcon /> : <InstanceIcon instance={instance} />
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
content={isNameEditable ? error : externalContentInstanceNameMessage}
|
||||
content={
|
||||
mdx
|
||||
? "Content inside this region is saved to the connected MDX file. Other instances in this Content Block are saved in the project."
|
||||
: isNameEditable
|
||||
? error
|
||||
: externalContentInstanceNameMessage
|
||||
}
|
||||
delayDuration={0}
|
||||
>
|
||||
<EditableTreeNodeLabel
|
||||
@@ -1081,6 +1170,7 @@ export const NavigatorTree = () => {
|
||||
const isAnimating = isAnimationSelected || isAnimationPinned;
|
||||
|
||||
const meta = metas.get(item.instance.component);
|
||||
const mdxSource = getMdxContentSource(item.selector);
|
||||
|
||||
if (meta === undefined) {
|
||||
return;
|
||||
@@ -1124,7 +1214,7 @@ export const NavigatorTree = () => {
|
||||
isSelectedDescendant={isSelectedDescendantItem}
|
||||
isHighlighted={hoveredKey === key || dropTargetKey === key}
|
||||
isExpanded={item.isExpanded}
|
||||
isActionVisible={isAnimating}
|
||||
isActionVisible={isAnimating || mdxSource !== undefined}
|
||||
onExpand={(isExpanded, all) =>
|
||||
handleExpand(item, isExpanded, all)
|
||||
}
|
||||
@@ -1160,16 +1250,22 @@ export const NavigatorTree = () => {
|
||||
onKeyUp: handleKeyUp,
|
||||
}}
|
||||
action={
|
||||
<ShowToggle
|
||||
instance={item.instance}
|
||||
value={show}
|
||||
isAnimating={isAnimating}
|
||||
/>
|
||||
mdxSource ? (
|
||||
<MdxContentMenu assetId={mdxSource.assetId} />
|
||||
) : (
|
||||
<ShowToggle
|
||||
instance={item.instance}
|
||||
value={show}
|
||||
isAnimating={isAnimating}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<TreeNodeContent
|
||||
instance={item.instance}
|
||||
mdx={mdxSource !== undefined}
|
||||
isNameEditable={
|
||||
mdxSource === undefined &&
|
||||
isExternalContentInstance(
|
||||
externalContentRoots,
|
||||
item.instance.id
|
||||
@@ -1199,6 +1295,7 @@ export const NavigatorTree = () => {
|
||||
};
|
||||
|
||||
export const __testing__ = {
|
||||
getMdxContentSource,
|
||||
commitNavigatorDrop,
|
||||
getFocusSelectionSkipCountAfterPointerDown,
|
||||
getBuilderDropTarget,
|
||||
|
||||
+93
-3
@@ -6,6 +6,9 @@ import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { $assets } from "~/shared/sync/data-stores";
|
||||
import { ContentBlockSourceControl } from "./content-block-source";
|
||||
import { $builderMode } from "~/shared/nano-states";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
@@ -256,6 +259,95 @@ test("shows only a full-width bindable connect button without a source", () => {
|
||||
expect(document.body.textContent).not.toContain("Create MDX file");
|
||||
});
|
||||
|
||||
test("validates source bindings only after a source is configured", () => {
|
||||
const previousMode = $builderMode.get();
|
||||
$builderMode.set("design");
|
||||
try {
|
||||
for (const source of [
|
||||
undefined,
|
||||
{ type: "expression" as const, value: '\"\"' },
|
||||
undefined,
|
||||
]) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ContentBlockSourceControl
|
||||
source={source}
|
||||
readOnly={false}
|
||||
onRequestSource={async () => ({ status: "applied" })}
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
const binding = container.querySelector(
|
||||
"button[data-variant] [data-variant]"
|
||||
);
|
||||
expect(binding).not.toBeNull();
|
||||
expect(binding?.getAttribute("data-variant")).toBe(
|
||||
source === undefined ? "default" : "error"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
$builderMode.set(previousMode);
|
||||
}
|
||||
});
|
||||
|
||||
test("marks rejected bindings on the expression input with an error tooltip", async () => {
|
||||
const previousMode = $builderMode.get();
|
||||
$builderMode.set("design");
|
||||
const onRequestSource = vi.fn(async () => ({
|
||||
status: "blocked" as const,
|
||||
message: "Content source does not resolve to an MDX Asset",
|
||||
}));
|
||||
try {
|
||||
act(() =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ContentBlockSourceControl
|
||||
readOnly={false}
|
||||
onRequestSource={onRequestSource}
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
);
|
||||
await act(async () =>
|
||||
userEvent.click(container.querySelector("button[data-variant]")!)
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector(".cm-content")).not.toBeNull()
|
||||
);
|
||||
const editor = document.querySelector<HTMLElement>(".cm-content")!;
|
||||
const view = EditorView.findFromDOM(editor)!;
|
||||
await act(async () => {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: '"not-an-asset"',
|
||||
},
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
editor.blur();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onRequestSource).toHaveBeenCalledOnce();
|
||||
expect(editor.closest('[data-invalid="true"]')).not.toBeNull();
|
||||
expect(document.querySelector('[role="dialog"] [role="alert"]')).toBeNull();
|
||||
expect(container.querySelector('fieldset [role="alert"]')).toBeNull();
|
||||
await act(async () => userEvent.hover(editor));
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(
|
||||
"Content source does not resolve to an MDX Asset"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
$builderMode.set(previousMode);
|
||||
}
|
||||
});
|
||||
|
||||
test("asks for confirmation before connecting over existing content", async () => {
|
||||
const onRequestSource = vi
|
||||
.fn()
|
||||
@@ -294,9 +386,7 @@ test("asks for confirmation before connecting over existing content", async () =
|
||||
expect(document.body.textContent).toContain(
|
||||
"The MDX file will not be changed."
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Unexpected closing tag. Line 3, column 2."
|
||||
);
|
||||
expect(document.querySelector('[role="dialog"] [role="status"]')).toBeNull();
|
||||
const dialogButtons = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>("button")
|
||||
)
|
||||
|
||||
+12
-29
@@ -43,24 +43,17 @@ type ContentBlockSourceActionResult =
|
||||
diagnostics?: readonly ContentBlockDiagnostic[];
|
||||
}>;
|
||||
|
||||
type PendingSource = Readonly<{
|
||||
source: ContentBlockSource;
|
||||
diagnostics: readonly ContentBlockDiagnostic[];
|
||||
}>;
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
error instanceof Error ? error.message : fallback;
|
||||
|
||||
const ConnectSourceDialog = ({
|
||||
disabled,
|
||||
error,
|
||||
diagnostics,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
error?: string;
|
||||
diagnostics: readonly ContentBlockDiagnostic[];
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) => (
|
||||
@@ -86,17 +79,6 @@ const ConnectSourceDialog = ({
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<Text
|
||||
key={JSON.stringify(diagnostic)}
|
||||
role="status"
|
||||
color="subtle"
|
||||
variant="tiny"
|
||||
css={{ paddingInline: theme.panel.paddingInline }}
|
||||
>
|
||||
{formatContentBlockDiagnostic(diagnostic)}
|
||||
</Text>
|
||||
))}
|
||||
<DialogActions>
|
||||
<Button disabled={disabled} onClick={onConfirm}>
|
||||
Connect
|
||||
@@ -146,10 +128,11 @@ export const ContentBlockSourceControl = ({
|
||||
}) => Promise<ContentBlockSourceActionResult>;
|
||||
onOpen: (assetId: string) => void;
|
||||
}) => {
|
||||
const [pendingSource, setPendingSource] = useState<PendingSource>();
|
||||
const [pendingSource, setPendingSource] = useState<ContentBlockSource>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [localError, setLocalError] = useState<string>();
|
||||
const [bindingError, setBindingError] = useState<string>();
|
||||
const isDisabled = disabled || loading || busy;
|
||||
const isSourceMutationDisabled = readOnly || isDisabled;
|
||||
const connected = source !== undefined;
|
||||
@@ -175,6 +158,7 @@ export const ContentBlockSourceControl = ({
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setLocalError(undefined);
|
||||
setBindingError(undefined);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -190,25 +174,24 @@ export const ContentBlockSourceControl = ({
|
||||
if (readOnly || beginOperation() === false) {
|
||||
return;
|
||||
}
|
||||
const setRequestError =
|
||||
requestedSource.type === "expression" ? setBindingError : setLocalError;
|
||||
try {
|
||||
const result = await onRequestSource({
|
||||
source: requestedSource,
|
||||
confirmed,
|
||||
});
|
||||
if (result.status === "requires-confirmation") {
|
||||
setPendingSource({
|
||||
source: requestedSource,
|
||||
diagnostics: result.diagnostics ?? [],
|
||||
});
|
||||
setPendingSource(requestedSource);
|
||||
return;
|
||||
}
|
||||
if (result.status === "blocked" || result.status === "partial") {
|
||||
setLocalError(result.message);
|
||||
setRequestError(result.message);
|
||||
return;
|
||||
}
|
||||
setPendingSource(undefined);
|
||||
} catch (error) {
|
||||
setLocalError(getErrorMessage(error, "Unable to change source"));
|
||||
setRequestError(getErrorMessage(error, "Unable to change source"));
|
||||
} finally {
|
||||
finishOperation();
|
||||
}
|
||||
@@ -222,9 +205,10 @@ export const ContentBlockSourceControl = ({
|
||||
showBinding={readOnly === false}
|
||||
value={resolvedAsset?.id}
|
||||
validate={(value) =>
|
||||
typeof value === "string" && value !== ""
|
||||
bindingError ??
|
||||
(!connected || (typeof value === "string" && value !== "")
|
||||
? undefined
|
||||
: "Content source must resolve to an Asset ID"
|
||||
: "Content source must resolve to an Asset ID")
|
||||
}
|
||||
onChangeValue={(value) => {
|
||||
if (typeof value === "string" && value !== "") {
|
||||
@@ -365,9 +349,8 @@ export const ContentBlockSourceControl = ({
|
||||
<ConnectSourceDialog
|
||||
disabled={isSourceMutationDisabled}
|
||||
error={localError ?? error}
|
||||
diagnostics={pendingSource.diagnostics}
|
||||
onClose={() => setPendingSource(undefined)}
|
||||
onConfirm={() => void requestSource(pendingSource.source, true)}
|
||||
onConfirm={() => void requestSource(pendingSource, true)}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
@@ -11,81 +11,121 @@ import {
|
||||
type Prop,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $instances, $props } from "~/shared/sync/data-stores";
|
||||
import { $externalContentRoots } from "~/shared/external-content-mutations";
|
||||
import { __testing__ } from "./text-file-editor";
|
||||
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
||||
|
||||
const { getMdxCompletionComponents } = __testing__;
|
||||
|
||||
afterEach(() => {
|
||||
$instances.set(new Map());
|
||||
$props.set(new Map());
|
||||
$externalContentRoots.set(new Map());
|
||||
});
|
||||
|
||||
test("derives MDX completions from template names instead of labels", () => {
|
||||
const instances = new Map<string, Instance>([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "templates" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
{
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [{ type: "id", value: "promotion-card" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"promotion-card",
|
||||
{
|
||||
type: "instance",
|
||||
id: "promotion-card",
|
||||
component: elementComponent,
|
||||
tag: "section",
|
||||
name: "PromotionCard",
|
||||
label: "Heading 1",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]);
|
||||
const props = new Map<string, Prop>([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
test.each(["direct", "dynamic"])(
|
||||
"derives MDX completions from template names for a %s source",
|
||||
(sourceType) => {
|
||||
const instances = new Map<string, Instance>([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "templates" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
{
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [{ type: "id", value: "promotion-card" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"promotion-card",
|
||||
{
|
||||
type: "instance",
|
||||
id: "promotion-card",
|
||||
component: elementComponent,
|
||||
tag: "section",
|
||||
name: "PromotionCard",
|
||||
label: "Heading 1",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]);
|
||||
const props = new Map<string, Prop>([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: contentBlockSourceProp,
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"title",
|
||||
{
|
||||
id: "title",
|
||||
instanceId: "promotion-card",
|
||||
name: "title",
|
||||
type: "string",
|
||||
value: "Launch offer",
|
||||
},
|
||||
],
|
||||
]);
|
||||
$instances.set(instances);
|
||||
$props.set(props);
|
||||
if (sourceType === "dynamic") {
|
||||
props.set("source", {
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: contentBlockSourceProp,
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
[
|
||||
"title",
|
||||
{
|
||||
id: "title",
|
||||
instanceId: "promotion-card",
|
||||
name: "title",
|
||||
type: "string",
|
||||
value: "Launch offer",
|
||||
},
|
||||
],
|
||||
]);
|
||||
$instances.set(instances);
|
||||
$props.set(props);
|
||||
type: "expression",
|
||||
value: "collection.data._id",
|
||||
});
|
||||
$externalContentRoots.set(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
{
|
||||
sourceBlockInstanceId: "block",
|
||||
blockInstanceId: "scoped-block",
|
||||
assetId: "article",
|
||||
instanceIds: new Set(),
|
||||
mutationRevision: 0,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const completions = getMdxCompletionComponents({
|
||||
assetId: "article",
|
||||
metas: new Map(Object.entries(coreMetas)),
|
||||
});
|
||||
const promotionCard = completions.find(
|
||||
({ name }) => name === "PromotionCard"
|
||||
);
|
||||
|
||||
expect(promotionCard?.props).toContainEqual({ name: "title" });
|
||||
expect(completions.some(({ name }) => name === "Heading 1")).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
test("does not suggest registered custom components without templates", () => {
|
||||
const completions = getMdxCompletionComponents({
|
||||
assetId: "article",
|
||||
metas: new Map(Object.entries(coreMetas)),
|
||||
metas: componentMetas,
|
||||
});
|
||||
const promotionCard = completions.find(
|
||||
({ name }) => name === "PromotionCard"
|
||||
);
|
||||
|
||||
expect(promotionCard?.props).toContainEqual({ name: "title" });
|
||||
expect(completions.some(({ name }) => name === "Heading 1")).toBe(false);
|
||||
expect(completions.map(({ name }) => name).sort()).toEqual([
|
||||
"CodeText",
|
||||
"Image",
|
||||
]);
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { getJsxPropName } from "@webstudio-is/content-engine/jsx-attributes";
|
||||
import {
|
||||
contentBlockMdxTemplateDescriptors,
|
||||
getAssetUrl,
|
||||
getComponentJsxName,
|
||||
getContentBlockTemplateName,
|
||||
@@ -126,6 +127,21 @@ const getStaticMdxCompletionProps = (
|
||||
];
|
||||
});
|
||||
|
||||
const getMdxEditorSourceBlockInstanceIds = (assetId: string) =>
|
||||
Array.from(
|
||||
new Set([
|
||||
...getMdxAssetSourceBlockInstanceIds({
|
||||
assetId,
|
||||
state: readBuilderStateStores(),
|
||||
}),
|
||||
...Array.from($externalContentRoots.get().values()).flatMap((root) =>
|
||||
root.assetId === assetId
|
||||
? [root.sourceBlockInstanceId ?? root.blockInstanceId]
|
||||
: []
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
const getMdxCompletionComponents = ({
|
||||
assetId,
|
||||
metas,
|
||||
@@ -136,6 +152,14 @@ const getMdxCompletionComponents = ({
|
||||
const components = new Map<string, MdxCompletionComponent>();
|
||||
const componentIds = Array.from(metas.keys());
|
||||
for (const [component, meta] of metas) {
|
||||
if (
|
||||
contentBlockMdxTemplateDescriptors.some(
|
||||
(descriptor) =>
|
||||
descriptor.kind === "component" && descriptor.component === component
|
||||
) === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const name = getComponentJsxName({
|
||||
component,
|
||||
components: componentIds,
|
||||
@@ -167,10 +191,7 @@ const getMdxCompletionComponents = ({
|
||||
propsByInstanceId.set(prop.instanceId, props);
|
||||
}
|
||||
const templates = new Map<string, MdxCompletionComponent>();
|
||||
for (const blockInstanceId of getMdxAssetSourceBlockInstanceIds({
|
||||
assetId,
|
||||
state,
|
||||
})) {
|
||||
for (const blockInstanceId of getMdxEditorSourceBlockInstanceIds(assetId)) {
|
||||
for (const [template] of findBlockTemplates({
|
||||
anchor: [blockInstanceId],
|
||||
instances,
|
||||
@@ -687,6 +708,7 @@ export const TextFileEditor = ({
|
||||
inspectMdxAssetSource({
|
||||
source,
|
||||
assetId,
|
||||
sourceBlockInstanceIds: getMdxEditorSourceBlockInstanceIds(assetId),
|
||||
state: readBuilderStateStores(),
|
||||
metas: registeredComponentMetas,
|
||||
projectId,
|
||||
|
||||
@@ -302,7 +302,7 @@ describe("text file assets", () => {
|
||||
{
|
||||
from: 0,
|
||||
to: 6,
|
||||
severity: "warning",
|
||||
severity: "error",
|
||||
source: "unresolved-template",
|
||||
message:
|
||||
'Template "Card" is not available and was skipped. Line 1, column 1.',
|
||||
|
||||
@@ -240,7 +240,10 @@ export const getTextFileEditorDiagnostics = async ({
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
severity: diagnostic.severity,
|
||||
severity:
|
||||
diagnostic.code === "unresolved-template"
|
||||
? ("error" as const)
|
||||
: diagnostic.severity,
|
||||
source: diagnostic.code,
|
||||
message:
|
||||
"message" in diagnostic
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SmallIconButton,
|
||||
Text,
|
||||
Tooltip,
|
||||
InputErrorsTooltip,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { getExpressionIdentifiers } from "@webstudio-is/expression";
|
||||
@@ -179,26 +180,30 @@ const BindingPanel = ({
|
||||
</Tooltip>
|
||||
</PanelContent>
|
||||
<PanelContent as={Box} css={{ pt: 0 }}>
|
||||
<ExpressionEditor
|
||||
editorApiRef={editorApiRef}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
color={
|
||||
(touched && errorsCount > 0) || valueError !== undefined
|
||||
? "error"
|
||||
: undefined
|
||||
}
|
||||
autoFocus={true}
|
||||
value={expression}
|
||||
onChange={(value) => {
|
||||
updateExpression(value);
|
||||
setTouched(false);
|
||||
}}
|
||||
onChangeComplete={() => {
|
||||
onSave(expression, errorsCount > 0);
|
||||
setTouched(true);
|
||||
}}
|
||||
/>
|
||||
<InputErrorsTooltip
|
||||
errors={valueError === undefined ? undefined : [valueError]}
|
||||
>
|
||||
<ExpressionEditor
|
||||
editorApiRef={editorApiRef}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
color={
|
||||
(touched && errorsCount > 0) || valueError !== undefined
|
||||
? "error"
|
||||
: undefined
|
||||
}
|
||||
autoFocus={true}
|
||||
value={expression}
|
||||
onChange={(value) => {
|
||||
updateExpression(value);
|
||||
setTouched(false);
|
||||
}}
|
||||
onChangeComplete={() => {
|
||||
onSave(expression, errorsCount > 0);
|
||||
setTouched(true);
|
||||
}}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</PanelContent>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import {
|
||||
$commandMetas,
|
||||
createCommandsEmitter,
|
||||
} from "~/shared/commands-emitter";
|
||||
import type { QueryDefinition } from "@webstudio-is/query-builder";
|
||||
import { encodeDataSourceVariable, type DataSource } from "@webstudio-is/sdk";
|
||||
import { $dataSources } from "~/shared/sync/data-stores";
|
||||
@@ -53,58 +58,86 @@ const renderQueryBuilder = <Query extends Record<string, unknown>>({
|
||||
return container;
|
||||
};
|
||||
|
||||
test("shows public variable names without changing the stored expression", () => {
|
||||
const value = {
|
||||
where: {
|
||||
all: [
|
||||
test.each(["Backspace", "Delete"])(
|
||||
"protects a bound filter from the %s instance shortcut",
|
||||
async (key) => {
|
||||
const value = {
|
||||
where: {
|
||||
all: [
|
||||
{
|
||||
field: ["path"],
|
||||
operator: "eq",
|
||||
value: "$ws$system.pathname",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const capabilities = {
|
||||
version: 1,
|
||||
fields: [{ path: ["path"], label: "Path", types: ["string"] }],
|
||||
operators: [
|
||||
{
|
||||
field: ["path"],
|
||||
operator: "eq",
|
||||
value: "$ws$system.pathname",
|
||||
value: "eq",
|
||||
label: "Equals",
|
||||
types: ["string"],
|
||||
input: { control: "expression", defaultValue: '""' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const capabilities = {
|
||||
version: 1,
|
||||
fields: [{ path: ["path"], label: "Path", types: ["string"] }],
|
||||
operators: [
|
||||
{
|
||||
value: "eq",
|
||||
label: "Equals",
|
||||
types: ["string"],
|
||||
input: { control: "expression", defaultValue: '""' },
|
||||
source: {
|
||||
fieldPathSchema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
minItems: 1,
|
||||
},
|
||||
controls: [
|
||||
{
|
||||
type: "filter",
|
||||
key: "where",
|
||||
label: "Filters",
|
||||
defaultValue: { all: [] },
|
||||
combinators: ["all"],
|
||||
limits: { conditions: 8, depth: 3 },
|
||||
defaultCondition: { field: ["path"], operator: "eq" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
source: {
|
||||
fieldPathSchema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
minItems: 1,
|
||||
},
|
||||
controls: [
|
||||
} as const satisfies QueryDefinition<string, string>;
|
||||
|
||||
const container = renderQueryBuilder({ value, capabilities });
|
||||
const editor = container.querySelector(".cm-content");
|
||||
const source = Array.from(container.querySelectorAll(".cm-content")).at(-1);
|
||||
|
||||
expect(editor?.textContent).toBe('"/blog/article"');
|
||||
expect(source?.textContent).toContain("system.pathname");
|
||||
expect(source?.textContent).not.toContain("$ws$system.pathname");
|
||||
expect(value.where.all[0].value).toBe("$ws$system.pathname");
|
||||
const handler = vi.fn();
|
||||
const previousCommands = $commandMetas.get();
|
||||
const { subscribeCommands } = createCommandsEmitter({
|
||||
source: "builder",
|
||||
commands: [
|
||||
{
|
||||
type: "filter",
|
||||
key: "where",
|
||||
label: "Filters",
|
||||
defaultValue: { all: [] },
|
||||
combinators: ["all"],
|
||||
limits: { conditions: 8, depth: 3 },
|
||||
defaultCondition: { field: ["path"], operator: "eq" },
|
||||
name: "deleteTestInstance",
|
||||
defaultHotkeys: [key],
|
||||
disableOnInputLikeControls: true,
|
||||
handler,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const satisfies QueryDefinition<string, string>;
|
||||
|
||||
const container = renderQueryBuilder({ value, capabilities });
|
||||
const editor = container.querySelector(".cm-content");
|
||||
const source = Array.from(container.querySelectorAll(".cm-content")).at(-1);
|
||||
|
||||
expect(editor?.textContent).toBe('"/blog/article"');
|
||||
expect(source?.textContent).toContain("system.pathname");
|
||||
expect(source?.textContent).not.toContain("$ws$system.pathname");
|
||||
expect(value.where.all[0].value).toBe("$ws$system.pathname");
|
||||
});
|
||||
});
|
||||
const unsubscribe = subscribeCommands();
|
||||
try {
|
||||
await userEvent.click(editor as HTMLElement);
|
||||
await userEvent.keyboard(`{${key}}`);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
(editor as HTMLElement).blur();
|
||||
await userEvent.keyboard(`{${key}}`);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
$commandMetas.set(previousCommands);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("shows an evaluated value for a bound number input", () => {
|
||||
const capabilities = {
|
||||
|
||||
@@ -426,6 +426,9 @@ export const EditorContent = ({
|
||||
},
|
||||
]),
|
||||
EditorView.lineWrapping,
|
||||
// Read-only editors must keep keyboard focus instead of leaving
|
||||
// destructive Builder shortcuts active after clicking their content.
|
||||
EditorView.contentAttributes.of({ tabindex: "0" }),
|
||||
...(ariaLabel === undefined
|
||||
? []
|
||||
: [EditorView.contentAttributes.of({ "aria-label": ariaLabel })]),
|
||||
|
||||
@@ -16,8 +16,8 @@ export type CommandMeta<CommandName extends string> = {
|
||||
/** listen hotkeys only locally without sharing with other apps */
|
||||
disableHotkeyOutsideApp?: boolean;
|
||||
/**
|
||||
* input, select and textarea, content editable and role=option used in Radix will not invoke command when hotkey is hit
|
||||
* with the exception when default event behavior is prevented
|
||||
* Inputs, text editors (including read-only ones), and Radix options
|
||||
* will not invoke the command when its hotkey is pressed.
|
||||
**/
|
||||
disableOnInputLikeControls?: boolean;
|
||||
/**
|
||||
@@ -176,6 +176,7 @@ export const createCommandsEmitter = <CommandName extends string>({
|
||||
element.tagName.toLowerCase()
|
||||
) ||
|
||||
element.isContentEditable ||
|
||||
element.closest('[role="textbox"]') !== null ||
|
||||
// Detect Radix select, dropdown and co.
|
||||
element.getAttribute("role") === "option");
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ registerContainers();
|
||||
test("places unresolved-template placeholders at their authored nesting point", async () => {
|
||||
const source =
|
||||
'<ws.element ws:tag="section">Before<ws.element ws:name="Missing" />After</ws.element>';
|
||||
let storedSource = source;
|
||||
const writes: string[] = [];
|
||||
const sourceAsset = {
|
||||
...asset,
|
||||
size: new TextEncoder().encode(source).byteLength,
|
||||
@@ -82,10 +84,17 @@ test("places unresolved-template placeholders at their authored nesting point",
|
||||
readContent: async () => ({
|
||||
asset: sourceAsset,
|
||||
data: (async function* () {
|
||||
yield new TextEncoder().encode(source);
|
||||
yield new TextEncoder().encode(storedSource);
|
||||
})(),
|
||||
}),
|
||||
updateContent: async () => sourceAsset,
|
||||
updateContent: async ({ data }) => {
|
||||
storedSource = await new Response(data).text();
|
||||
writes.push(storedSource);
|
||||
return {
|
||||
...sourceAsset,
|
||||
size: new TextEncoder().encode(storedSource).byteLength,
|
||||
};
|
||||
},
|
||||
},
|
||||
authorize: () => true,
|
||||
});
|
||||
@@ -155,6 +164,17 @@ test("places unresolved-template placeholders at their authored nesting point",
|
||||
renderScope: '["block"]',
|
||||
})
|
||||
).toEqual([{ type: "id", value: section.id }]);
|
||||
executeRuntimeMutation({
|
||||
id: "instances.deleteBySelector",
|
||||
input: { instanceSelector: [missing.id, section.id, "block"] },
|
||||
});
|
||||
await flushExternalContentAsset({
|
||||
projectId: "project",
|
||||
assetId: sourceAsset.id,
|
||||
});
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(storedSource).toBe("<section>BeforeAfter</section>\n");
|
||||
expect($instances.get().has(missing.id)).toBe(false);
|
||||
});
|
||||
|
||||
test("re-resolves existing Markdown when a matching template is added", async () => {
|
||||
|
||||
@@ -927,20 +927,15 @@ const getSession = (projectId: string) => {
|
||||
|
||||
const extractRootFragment = ({
|
||||
contentInstanceId,
|
||||
children: rootChildren,
|
||||
transientInstanceIds,
|
||||
children,
|
||||
}: {
|
||||
contentInstanceId: string;
|
||||
children: WebstudioFragment["children"];
|
||||
transientInstanceIds: ReadonlySet<string>;
|
||||
}) => {
|
||||
const data = getWebstudioData();
|
||||
if (data.instances.has(contentInstanceId) === false) {
|
||||
throw new Error("Connected Content Block no longer exists");
|
||||
}
|
||||
const isPersistentChild = (child: WebstudioFragment["children"][number]) =>
|
||||
child.type !== "id" || transientInstanceIds.has(child.value) === false;
|
||||
const children = rootChildren.filter(isPersistentChild);
|
||||
const rootIds = children.flatMap((child) =>
|
||||
child.type === "id" ? [child.value] : []
|
||||
);
|
||||
@@ -951,12 +946,6 @@ const extractRootFragment = ({
|
||||
return {
|
||||
...fragment,
|
||||
children,
|
||||
instances: fragment.instances
|
||||
.filter(({ id }) => transientInstanceIds.has(id) === false)
|
||||
.map((instance) => ({
|
||||
...instance,
|
||||
children: instance.children.filter(isPersistentChild),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -964,26 +953,8 @@ const extractCurrentFragment = (entry: RootEntry) =>
|
||||
extractRootFragment({
|
||||
contentInstanceId: entry.contentInstanceId,
|
||||
children: getAuthoredChildren(entry),
|
||||
transientInstanceIds: entry.transientInstanceIds,
|
||||
});
|
||||
|
||||
const captureInstalledFragment = (
|
||||
entry: RootEntry,
|
||||
fragment: WebstudioFragment
|
||||
): WebstudioFragment => {
|
||||
const data = getWebstudioData();
|
||||
const transientInstances = Array.from(entry.transientInstanceIds).flatMap(
|
||||
(id) => {
|
||||
const instance = data.instances.get(id);
|
||||
return instance === undefined ? [] : [instance];
|
||||
}
|
||||
);
|
||||
return {
|
||||
...fragment,
|
||||
instances: [...fragment.instances, ...transientInstances],
|
||||
};
|
||||
};
|
||||
|
||||
const getInsertedTemplates = (
|
||||
entry: RootEntry,
|
||||
fragment: WebstudioFragment
|
||||
@@ -1111,7 +1082,7 @@ subscribeExternalContentMutations((rootKeys) => {
|
||||
const insertedTemplates = getInsertedTemplates(entry, fragment);
|
||||
const authoredRoot = entry.root;
|
||||
const saveRevision = ++entry.saveRevision;
|
||||
entry.installedFragment = captureInstalledFragment(entry, fragment);
|
||||
entry.installedFragment = fragment;
|
||||
registerMutationRoot(entry, entry.installedFragment);
|
||||
void enqueueAssetUpdate({
|
||||
projectId: entry.projectId,
|
||||
@@ -1793,7 +1764,6 @@ export const getExternalContentRootSnapshot = ({
|
||||
getWebstudioData().instances.get(
|
||||
root.contentInstanceId ?? root.blockInstanceId
|
||||
)?.children ?? [],
|
||||
transientInstanceIds: root.transientInstanceIds ?? new Set(),
|
||||
}),
|
||||
identity: root.identity,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type PageTemplate,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import { findCycles } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
findCycles,
|
||||
extractWebstudioFragment,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import type { BuilderRuntimeOperationInput } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
executeRuntimeMutation,
|
||||
@@ -108,6 +111,130 @@ const createImageAsset = (id: string): Asset => ({
|
||||
});
|
||||
|
||||
describe("data store helpers", () => {
|
||||
test.each([
|
||||
"move",
|
||||
"reparent",
|
||||
"paste",
|
||||
"nested paste",
|
||||
"aliased template paste",
|
||||
"duplicate",
|
||||
"sequence",
|
||||
])(
|
||||
"rejects %s of an unsupported component into MDX before committing",
|
||||
(operation) => {
|
||||
setBaseStores();
|
||||
const accordion = "@webstudio-is/sdk-components-react-radix:Accordion";
|
||||
$instances.set(
|
||||
new Map([
|
||||
[
|
||||
"body",
|
||||
createInstance("body", "Body", [
|
||||
{ type: "id", value: "block" },
|
||||
{ type: "id", value: "accordion" },
|
||||
]),
|
||||
],
|
||||
[
|
||||
"block",
|
||||
createInstance("block", blockComponent, [
|
||||
{ type: "id", value: "templates" },
|
||||
]),
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
createInstance("templates", blockTemplateComponent, []),
|
||||
],
|
||||
["accordion", createInstance("accordion", accordion, [])],
|
||||
])
|
||||
);
|
||||
$externalContentRoots.set(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
{
|
||||
blockInstanceId: "block",
|
||||
instanceIds: new Set(),
|
||||
mutationRevision: 0,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
if (operation === "aliased template paste") {
|
||||
$instances.get().set("accordion-template", {
|
||||
...createInstance("accordion-template", accordion, []),
|
||||
name: "FAQ",
|
||||
});
|
||||
$instances.get().get("templates")!.children = [
|
||||
{ type: "id", value: "accordion-template" },
|
||||
];
|
||||
}
|
||||
if (operation === "duplicate") {
|
||||
$instances.get().get("body")!.children = [
|
||||
{ type: "id", value: "block" },
|
||||
];
|
||||
$instances
|
||||
.get()
|
||||
.get("block")!
|
||||
.children.push({ type: "id", value: "accordion" });
|
||||
$externalContentRoots.get().get("root")!.instanceIds = new Set([
|
||||
"accordion",
|
||||
]);
|
||||
}
|
||||
const before = structuredClone(getWebstudioData());
|
||||
const move = {
|
||||
id: "instances.move",
|
||||
input: {
|
||||
moves: [{ instanceId: "accordion", parentInstanceId: "block" }],
|
||||
},
|
||||
} satisfies RuntimeMutationOperation;
|
||||
expect(() => {
|
||||
if (operation === "move") {
|
||||
executeRuntimeMutation(move);
|
||||
} else if (operation === "reparent") {
|
||||
executeRuntimeMutation({
|
||||
id: "instances.reparent",
|
||||
input: {
|
||||
sourceInstanceSelector: ["accordion", "body"],
|
||||
dropTarget: {
|
||||
parentSelector: ["block", "body"],
|
||||
position: "end",
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
operation === "paste" ||
|
||||
operation === "nested paste" ||
|
||||
operation === "aliased template paste"
|
||||
) {
|
||||
const fragment = extractWebstudioFragment(before, "accordion");
|
||||
if (operation === "nested paste") {
|
||||
fragment.instances.push({
|
||||
...createInstance("wrapper", elementComponent, fragment.children),
|
||||
tag: "div",
|
||||
});
|
||||
fragment.children = [{ type: "id", value: "wrapper" }];
|
||||
}
|
||||
executeRuntimeMutation({
|
||||
id: "instances.insertFragment",
|
||||
input: {
|
||||
parentInstanceId: "block",
|
||||
fragment,
|
||||
},
|
||||
});
|
||||
} else if (operation === "duplicate") {
|
||||
executeRuntimeMutation({
|
||||
id: "instances.duplicateAfterItself",
|
||||
input: { sourceInstanceId: "accordion", parentInstanceId: "block" },
|
||||
});
|
||||
} else {
|
||||
executeRuntimeMutationSequence([move]);
|
||||
}
|
||||
}).toThrow("MDX");
|
||||
expect(getWebstudioData()).toEqual(before);
|
||||
expect(serverSyncStore.popAll()).toEqual([]);
|
||||
expect(externalContentSyncStore.popAll()).toEqual([]);
|
||||
expect($externalContentRoots.get().get("root")?.mutationRevision).toBe(0);
|
||||
}
|
||||
);
|
||||
test("publishes external children only after their instances exist", () => {
|
||||
setBaseStores();
|
||||
$instances.set(
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
// transaction boundaries. Put generic store reads/writes and content-mode data
|
||||
// guards here, not tree-shape mutations.
|
||||
import { toast } from "@webstudio-is/design-system";
|
||||
import { type WebstudioData, isPageTemplate } from "@webstudio-is/sdk";
|
||||
import {
|
||||
type WebstudioData,
|
||||
isPageTemplate,
|
||||
blockTemplateComponent,
|
||||
contentBlockMdxTemplateDescriptors,
|
||||
elementComponent,
|
||||
findTreeInstanceIds,
|
||||
getContentBlockTemplateName,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
BuilderRuntimeError,
|
||||
blockTemplateNameConfirmationInput,
|
||||
builderRuntimeContext,
|
||||
executeBuilderRuntimeOperation,
|
||||
createRuntimeMutationAccumulator,
|
||||
findBlockTemplates,
|
||||
type BuilderRuntimeContext,
|
||||
type BuilderRuntimeMutation,
|
||||
type BuilderRuntimeOperationInput,
|
||||
@@ -186,6 +195,83 @@ const commitRuntimeMutation = <Mutation extends BuilderRuntimeMutation>(
|
||||
const afterData = applyBuilderPatchTransactions(data, [
|
||||
{ id: "external-content-persistence-plan", payload },
|
||||
]).state as ReturnType<typeof getWebstudioData>;
|
||||
for (const key of affectedRootKeys) {
|
||||
const root = roots.get(key);
|
||||
if (root === undefined) {
|
||||
continue;
|
||||
}
|
||||
const templates =
|
||||
findBlockTemplates({
|
||||
anchor: [root.sourceBlockInstanceId ?? root.blockInstanceId],
|
||||
instances: afterData.instances,
|
||||
}) ?? [];
|
||||
const templateInstanceIds = new Set<string>();
|
||||
const matchTemplate = (instanceId: string, templateId: string) => {
|
||||
const instance = afterData.instances.get(instanceId);
|
||||
const template = afterData.instances.get(templateId);
|
||||
if (
|
||||
instance === undefined ||
|
||||
template === undefined ||
|
||||
instance.component !== template.component ||
|
||||
instance.tag !== template.tag ||
|
||||
templateInstanceIds.has(instanceId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
templateInstanceIds.add(instanceId);
|
||||
instance.children.forEach((child, index) => {
|
||||
const templateChild = template.children[index];
|
||||
if (child.type === "id" && templateChild?.type === "id") {
|
||||
matchTemplate(child.value, templateChild.value);
|
||||
}
|
||||
});
|
||||
};
|
||||
const content = afterData.instances.get(
|
||||
root.contentInstanceId ?? root.blockInstanceId
|
||||
);
|
||||
for (const child of content?.children ?? []) {
|
||||
if (
|
||||
child.type !== "id" ||
|
||||
afterData.instances.get(child.value)?.component ===
|
||||
blockTemplateComponent
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const id of findTreeInstanceIds(afterData.instances, child.value)) {
|
||||
const instance = afterData.instances.get(id);
|
||||
if (instance !== undefined) {
|
||||
const matching = templates.filter(
|
||||
([template]) =>
|
||||
getContentBlockTemplateName(template) ===
|
||||
getContentBlockTemplateName(instance)
|
||||
);
|
||||
if (matching.length === 1) {
|
||||
matchTemplate(id, matching[0][0].id);
|
||||
}
|
||||
}
|
||||
if (
|
||||
instance === undefined ||
|
||||
(root.instanceIds.has(id) &&
|
||||
data.instances.get(id)?.component === instance.component) ||
|
||||
instance.component === elementComponent ||
|
||||
contentBlockMdxTemplateDescriptors.some(
|
||||
(descriptor) =>
|
||||
descriptor.kind === "component" &&
|
||||
descriptor.component === instance.component
|
||||
) ||
|
||||
templateInstanceIds.has(id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const error = new BuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"This component is not in this Content Block’s Templates and cannot be added to MDX content. Add it to Templates first, or keep it outside MDX content."
|
||||
);
|
||||
toast.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const afterOwnership = getExternalContentOwnershipFromState({
|
||||
state: afterData,
|
||||
roots,
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
} from "@webstudio-is/template";
|
||||
import * as defaultMetas from "@webstudio-is/sdk-components-react/metas";
|
||||
import type { WebstudioData, WebstudioFragment } from "@webstudio-is/sdk";
|
||||
import { coreMetas, elementComponent } from "@webstudio-is/sdk";
|
||||
import {
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
coreMetas,
|
||||
elementComponent,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
$registeredComponentMetas,
|
||||
$registeredTemplates,
|
||||
@@ -803,6 +808,51 @@ describe("insert webstudio component at", () => {
|
||||
children: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
test("reports rejected MDX insertion without changing instances", async () => {
|
||||
const instances = $instances.get();
|
||||
instances.get("bodyId")!.children = [{ type: "id", value: "block" }];
|
||||
instances.set("block", {
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "mdx" }],
|
||||
});
|
||||
instances.set("mdx", {
|
||||
type: "instance",
|
||||
id: "mdx",
|
||||
component: blockBodyComponent,
|
||||
children: [],
|
||||
});
|
||||
$props.set(
|
||||
new Map([
|
||||
[
|
||||
"source",
|
||||
{
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "article",
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
const original = structuredClone(instances);
|
||||
const notify = vi.spyOn(toast, "error");
|
||||
try {
|
||||
expect(
|
||||
await insertWebstudioComponentAt("Form", {
|
||||
parentSelector: ["mdx", "block", "bodyId"],
|
||||
position: "end",
|
||||
})
|
||||
).toBe(false);
|
||||
expect(notify).toHaveBeenCalledOnce();
|
||||
expect($instances.get()).toEqual(original);
|
||||
} finally {
|
||||
notify.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("find closest insertable", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
builderRuntimeContext,
|
||||
BuilderRuntimeError,
|
||||
createComponentTemplateFragment,
|
||||
getFragmentContentModelWarnings,
|
||||
type FragmentContentModelWarning,
|
||||
@@ -137,15 +138,26 @@ export const insertWebstudioComponentAt = (
|
||||
if (target === undefined) {
|
||||
return false;
|
||||
}
|
||||
const result = executeRuntimeMutation({
|
||||
id: "instances.insertComponent",
|
||||
input: {
|
||||
parentInstanceId: target.parentInstanceId,
|
||||
component,
|
||||
tag: target.tag,
|
||||
insertIndex: target.insertIndex,
|
||||
},
|
||||
});
|
||||
let result: ReturnType<
|
||||
typeof executeRuntimeMutation<"instances.insertComponent">
|
||||
>;
|
||||
try {
|
||||
result = executeRuntimeMutation({
|
||||
id: "instances.insertComponent",
|
||||
input: {
|
||||
parentInstanceId: target.parentInstanceId,
|
||||
component,
|
||||
tag: target.tag,
|
||||
insertIndex: target.insertIndex,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BuilderRuntimeError) {
|
||||
toast.error(error.message);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const newInstanceId = result?.result.rootInstanceIds[0];
|
||||
if (result !== undefined && newInstanceId !== undefined) {
|
||||
const parentSelector =
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
$resourcePerformanceCache,
|
||||
$resourcesCache,
|
||||
getResourceKey,
|
||||
invalidateAssets,
|
||||
loadResourceDiagnostics,
|
||||
preloadResources,
|
||||
} from "./resources";
|
||||
@@ -143,6 +144,57 @@ test("reloads an invalidated cached request", async () => {
|
||||
expect($resourcesCache.get().get(key)).toEqual({ data: "second" });
|
||||
});
|
||||
|
||||
test.each(["updated", "empty", "failed"])(
|
||||
"keeps Assets visible during refresh until the %s response settles",
|
||||
async (outcome) => {
|
||||
const request: ResourceRequest = {
|
||||
name: "assets",
|
||||
method: "post",
|
||||
url: "/$resources/assets",
|
||||
searchParams: [],
|
||||
headers: [],
|
||||
body: { query: {} },
|
||||
};
|
||||
const key = getResourceKey(request);
|
||||
const original = { data: [{ _id: "article", title: "Before" }] };
|
||||
queueResources([request]);
|
||||
await loadResources(async () => Response.json([[key, original]]));
|
||||
const values: unknown[] = [];
|
||||
const unsubscribe = $resourcesCache.listen((cache) =>
|
||||
values.push(cache.get(key))
|
||||
);
|
||||
let respond: (response: Response) => void = () => {};
|
||||
const requestFetch = vi.fn<typeof globalThis.fetch>(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
respond = resolve;
|
||||
})
|
||||
);
|
||||
try {
|
||||
invalidateAssets(requestFetch);
|
||||
expect(requestFetch).toHaveBeenCalledOnce();
|
||||
expect($pendingResourceKeys.get().has(key)).toBe(true);
|
||||
expect($resourcesCache.get().get(key)).toEqual(original);
|
||||
const updated = {
|
||||
data: outcome === "empty" ? [] : [{ _id: "article", title: "After" }],
|
||||
};
|
||||
respond(
|
||||
outcome === "failed"
|
||||
? new Response(null, { status: 503 })
|
||||
: Response.json([[key, updated]])
|
||||
);
|
||||
await vi.waitFor(() => expect($hasPendingResources.get()).toBe(false));
|
||||
expect($resourcesCache.get().get(key)).toEqual(
|
||||
outcome === "failed" ? original : updated
|
||||
);
|
||||
expect(values).not.toContain(undefined);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
respond(Response.json([]));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("replaces an invalidated in-flight request without caching stale data", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request: ResourceRequest = {
|
||||
|
||||
@@ -239,7 +239,11 @@ export const preloadResources = (resources: readonly ResourceRequest[]) => {
|
||||
|
||||
const invalidateAndQueueResource = (resource: ResourceRequest) => {
|
||||
const key = getResourceKey(resource);
|
||||
cache.delete(key);
|
||||
// Asset saves refresh collection bindings without unmounting their content.
|
||||
// Keep the last result until the replacement arrives, including an empty result.
|
||||
if (isAssetsResourceRequest(resource) === false) {
|
||||
cache.delete(key);
|
||||
}
|
||||
invalidateRequestState(key);
|
||||
pending.delete(key);
|
||||
knownRequests.set(key, resource);
|
||||
@@ -397,7 +401,7 @@ export const loadResourceDiagnostics = (
|
||||
* Invalidate the assets system resource.
|
||||
* Call this when assets are uploaded, deleted, or modified to refresh expressions using assets.
|
||||
*/
|
||||
export const invalidateAssets = () => {
|
||||
export const invalidateAssets = (requestFetch: typeof fetch = fetch) => {
|
||||
for (const request of knownRequests.values()) {
|
||||
if (isAssetsResourceRequest(request) === false) {
|
||||
continue;
|
||||
@@ -407,7 +411,7 @@ export const invalidateAssets = () => {
|
||||
abortObsoleteBatches();
|
||||
updateCache();
|
||||
updatePending();
|
||||
startLoading();
|
||||
startLoading(requestFetch);
|
||||
};
|
||||
|
||||
export const computeResourceRequest = (
|
||||
|
||||
@@ -20,6 +20,10 @@ Next is a breakdown of Content Block by mode:
|
||||
|
||||
## Content Block in Design mode
|
||||
|
||||
Without an MDX source, add content directly inside the Content Block, alongside **Templates**. No Body wrapper is needed. **Templates** defines the reusable designs that editors can insert in Content mode. Adding a one-off content instance does not add it to the editor's insertion menu. Add a design to Templates only when editors should be able to insert more instances of it.
|
||||
|
||||
Templates are not required for ordinary Markdown content. Custom components referenced by JSX in MDX must match a template in that Content Block; being registered in the Builder is not enough.
|
||||
|
||||
Sometimes providing team members or clients the ability to edit existing content doesn’t help them accomplish everything they need.
|
||||
|
||||
Instead, they may want to add new content without asking you.
|
||||
@@ -53,32 +57,38 @@ Reusable top-level templates appear in Content mode like this. Structural and in
|
||||
<div><figure><img src="../../.gitbook/assets/templates-design-mode.png" alt="Templates in Design mode"><figcaption><p>Templates in Design mode</p></figcaption></figure> <figure><img src="../../.gitbook/assets/templates-content-mode.png" alt="Template in Content mode"><figcaption><p>Template in Content mode</p></figcaption></figure></div>
|
||||
|
||||
|
||||
Each time they insert a template, its copy appears inside the Content Block's Body outlet, alongside any initial body content. The Templates container remains protected source material.
|
||||
Each time they insert a template, its copy appears alongside the existing content: directly inside an unconnected Content Block, or inside **MDX content** when a file is connected. The Templates container remains protected source material.
|
||||
|
||||
### Step 3: Add an initial setup (optional)
|
||||
|
||||
Optionally, you can add instances inside the Content Block's Body outlet.
|
||||
Optionally, add instances directly inside the Content Block. If the block is connected to MDX, its file content appears inside **MDX content** instead.
|
||||
|
||||
<figure><img src="../../.gitbook/assets/startingpoint-content-block.png" alt="Initial Feature instances inside a Content Block Body outlet" width="357"><figcaption><p>The Feature instances are provided as a starting point</p></figcaption></figure>
|
||||
|
||||
|
||||
Doing so will provide an initial setup for editors.
|
||||
|
||||
Editors can delete children of the Body outlet. They cannot delete the designed shell, Templates container, templates, or nested instances independently.
|
||||
Editors can delete top-level content instances. In MDX-connected blocks, these are the children of **MDX content**, not the surrounding designed shell. They cannot delete the Templates container, templates, or nested instances independently.
|
||||
|
||||
### Store content in an MDX file
|
||||
|
||||
Connect a `.mdx` file when the Content Block's body should live in Assets instead of the project's regular instance data. The designed shell and Templates list remain in the project. Markdown `.md` files cannot be connected to a Content Block.
|
||||
|
||||
To insert a custom component such as Accordion into **MDX content**, first add its design to **Templates**, then insert it from the template picker. Inserting custom components directly from the Add panel into MDX content is blocked. Basic Markdown elements, images, and code blocks can still be inserted directly. Place designer-owned, one-off components outside **MDX content**; they remain in the project and are not saved to the file.
|
||||
|
||||
Moving, pasting, or duplicating content into an MDX region also checks the destination's Templates. Unsupported components are rejected before the tree changes, including components nested inside a pasted container.
|
||||
|
||||
#### Prepare the Content Block
|
||||
|
||||
1. Add the Content Block and design its shell in Design mode.
|
||||
2. Place the **Body** outlet where the article body should render.
|
||||
2. After connecting a file, place the **MDX content** outlet where the article body should render.
|
||||
3. Keep exactly one direct **Templates** container. Style the standard document elements already provided inside it.
|
||||
4. Add any reusable custom content to **Templates**.
|
||||
5. Give every custom top-level template a unique instance name. Use a JSX-compatible name such as `PromotionCard` when you want component-style JSX.
|
||||
|
||||
New Content Blocks already include a Body outlet and direct templates for every supported Markdown element, including headings, paragraphs, marks, links, images, quotes, lists, task controls, code, separators, and tables. When you connect an older Content Block without a Body outlet, Webstudio adds it automatically.
|
||||
New Content Blocks include direct templates for every supported Markdown element, including headings, paragraphs, marks, links, images, quotes, lists, task controls, code, separators, and tables. They do not include a Body outlet. Connecting an MDX file adds the outlet automatically. Existing blocks with a Body outlet remain supported without changing their saved structure.
|
||||
|
||||
When a file is connected, the Navigator labels its Body outlet **MDX content** and shows an MDX icon, even when the region is empty. Content inside that region is saved to the file; instances outside it remain in the project. Use the region's **MDX content settings** menu and choose **Open MDX file** to edit the connected file directly.
|
||||
|
||||
#### Create and connect the file
|
||||
|
||||
@@ -148,9 +158,13 @@ Regular document content stays Markdown and uses the matching standard templates
|
||||
</PromotionCard>
|
||||
```
|
||||
|
||||
The JSX name first matches the stable **Name** of a unique top-level template in the Content Block's Templates list. **Name** is a JavaScript identifier and is separate from the optional **Label** shown in the canvas. If no template has that name, Webstudio uses the exact registered component with that exported name. A new template gets its default name from its root component or HTML tag, and duplicate defaults get deterministic numeric suffixes.
|
||||
The JSX name matches the stable **Name** of a unique top-level template in the Content Block's Templates list. **Name** is a JavaScript identifier and is separate from the optional **Label** shown in the canvas. If no template has that name, the custom component is unresolved and is not rendered. Its JSX remains in the file so adding the matching template can resolve it later. A new template gets its default name from its root component or HTML tag, and duplicate defaults get deterministic numeric suffixes.
|
||||
|
||||
In the MDX editor, type `<` to autocomplete registered components and templates connected to the file. Inside a JSX tag, autocomplete suggests its supported properties and available property values.
|
||||
In the MDX editor, type `<` to autocomplete templates connected to the file and built-in Image and CodeText components. Inside a JSX tag, autocomplete suggests its supported properties and available property values.
|
||||
|
||||
Missing template references are marked as errors at their JSX source range. Hover the underline to read the explanation. For dynamic sources, the editor also uses currently rendered Content Blocks to find the file's templates.
|
||||
|
||||
The Navigator shows an unresolved reference as **Missing template: Name**. Delete that item to remove its JSX, including its children, from the MDX file. Leaving the item in place preserves its source until the template is available.
|
||||
|
||||
When two component libraries export the same name, the core component keeps the plain identifier and the namespaced component gets a stable library prefix, such as `Checkbox` and `RadixCheckbox`. Component discovery reports the exact JSX identifier to use.
|
||||
|
||||
|
||||
@@ -25,6 +25,61 @@ afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("keeps the first and subsequent drag positions when only the title must remain visible", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<Dialog open draggable>
|
||||
<DialogContent width={640} height={480} aria-describedby={undefined}>
|
||||
<DialogTitle>MDX editor</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
const title = dialog.querySelector<HTMLElement>('[draggable="true"]')!;
|
||||
for (const top of [window.innerHeight - 150, window.innerHeight - 100]) {
|
||||
const initial = dialog.getBoundingClientRect();
|
||||
const dataTransfer = new DataTransfer();
|
||||
await act(async () => {
|
||||
title.dispatchEvent(
|
||||
new DragEvent("dragstart", {
|
||||
bubbles: true,
|
||||
clientX: initial.x + 20,
|
||||
clientY: initial.y + 20,
|
||||
dataTransfer,
|
||||
})
|
||||
);
|
||||
title.dispatchEvent(
|
||||
new DragEvent("drag", {
|
||||
bubbles: true,
|
||||
clientX: initial.x + 20,
|
||||
clientY: top + 20,
|
||||
dataTransfer,
|
||||
})
|
||||
);
|
||||
title.dispatchEvent(
|
||||
new DragEvent("dragend", { bubbles: true, dataTransfer })
|
||||
);
|
||||
});
|
||||
expect(dialog.getBoundingClientRect().top).toBe(top);
|
||||
expect(dialog.getBoundingClientRect().height).toBe(initial.height);
|
||||
}
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<Dialog open draggable>
|
||||
<DialogContent width={300} height={150} aria-describedby={undefined}>
|
||||
<DialogTitle>MDX editor</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
expect(dialog.getBoundingClientRect().width).toBe(300);
|
||||
expect(dialog.getBoundingClientRect().height).toBe(150);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"contains Escape within the dialog (dismissal prevented: %s)",
|
||||
async (preventDismissal) => {
|
||||
|
||||
@@ -367,18 +367,30 @@ const useDraggable = ({
|
||||
|
||||
if (isMaximized === false) {
|
||||
if (x !== undefined && y !== undefined) {
|
||||
const draggedRect = lastDragDataRef.current?.rect;
|
||||
// Get actual rendered dimensions if width/height not specified
|
||||
const actualWidth = width ?? ref.current?.offsetWidth ?? bounds.width;
|
||||
const actualWidth =
|
||||
width ??
|
||||
draggedRect?.width ??
|
||||
ref.current?.offsetWidth ??
|
||||
bounds.width;
|
||||
const actualHeight =
|
||||
height ?? ref.current?.offsetHeight ?? bounds.height;
|
||||
height ??
|
||||
draggedRect?.height ??
|
||||
ref.current?.offsetHeight ??
|
||||
bounds.height;
|
||||
|
||||
const constrained = applyBoundaries(
|
||||
x,
|
||||
y,
|
||||
actualWidth,
|
||||
actualHeight,
|
||||
// After dragging, keep the title reachable without snapping the
|
||||
// entire dialog back inside the boundary.
|
||||
draggedRect
|
||||
? Math.min(actualHeight, DIALOG_TITLE_HEIGHT)
|
||||
: actualHeight,
|
||||
bounds,
|
||||
boundaryTolerance
|
||||
draggedRect ? undefined : boundaryTolerance
|
||||
);
|
||||
|
||||
style.left = constrained.x;
|
||||
@@ -387,7 +399,7 @@ const useDraggable = ({
|
||||
style.width = constrained.width;
|
||||
}
|
||||
if (height !== undefined) {
|
||||
style.height = constrained.height;
|
||||
style.height = draggedRect ? actualHeight : constrained.height;
|
||||
}
|
||||
style.right = "auto";
|
||||
style.bottom = "auto";
|
||||
@@ -525,14 +537,12 @@ const useDraggable = ({
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
// Apply constraints to snap to the closest valid position
|
||||
let constrainedX = Math.max(rect.x, bounds.x);
|
||||
constrainedX = Math.min(constrainedX, bounds.x + bounds.width - rect.width);
|
||||
let constrainedY = Math.max(rect.y, bounds.y);
|
||||
// Keep at least title visible at the bottom
|
||||
constrainedY = Math.min(
|
||||
constrainedY,
|
||||
bounds.y + bounds.height - DIALOG_TITLE_HEIGHT
|
||||
const { x: constrainedX, y: constrainedY } = applyBoundaries(
|
||||
rect.x,
|
||||
rect.y,
|
||||
rect.width,
|
||||
Math.min(rect.height, DIALOG_TITLE_HEIGHT),
|
||||
bounds
|
||||
);
|
||||
|
||||
setX(constrainedX);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
blockTemplateComponent,
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
collectionComponent,
|
||||
elementComponent,
|
||||
encodeDataVariableId,
|
||||
@@ -116,6 +118,96 @@ const createIdFactory = () => {
|
||||
return () => `generated-${index++}`;
|
||||
};
|
||||
|
||||
test("rejects Add-panel components inside MDX before changing state, but allows shell and unconnected content", () => {
|
||||
const block: Instance = {
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [
|
||||
{ type: "id", value: "mdx" },
|
||||
{ type: "id", value: "shell" },
|
||||
],
|
||||
};
|
||||
const state = createState(block);
|
||||
state.instances.set("mdx", {
|
||||
type: "instance",
|
||||
id: "mdx",
|
||||
component: blockBodyComponent,
|
||||
children: [{ type: "id", value: "paragraph" }],
|
||||
});
|
||||
state.instances.set("paragraph", {
|
||||
type: "instance",
|
||||
id: "paragraph",
|
||||
component: elementComponent,
|
||||
tag: "p",
|
||||
children: [],
|
||||
});
|
||||
state.instances.set("shell", {
|
||||
type: "instance",
|
||||
id: "shell",
|
||||
component: elementComponent,
|
||||
tag: "div",
|
||||
children: [],
|
||||
});
|
||||
state.props.set("source", {
|
||||
id: "source",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "article",
|
||||
});
|
||||
const original = structuredClone(state);
|
||||
const component = "@webstudio-is/sdk-components-react-radix:Accordion";
|
||||
for (const parentInstanceId of ["mdx", "paragraph"]) {
|
||||
expect(() =>
|
||||
insertComponent(
|
||||
state,
|
||||
{ parentInstanceId, component },
|
||||
{ createId: createIdFactory() }
|
||||
)
|
||||
).toThrow("template picker");
|
||||
expect(state).toEqual(original);
|
||||
}
|
||||
expect(
|
||||
insertComponent(
|
||||
state,
|
||||
{ parentInstanceId: "shell", component },
|
||||
{ createId: createIdFactory() }
|
||||
).result.rootInstanceIds
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
insertComponent(
|
||||
state,
|
||||
{ parentInstanceId: "mdx", component: elementComponent, tag: "p" },
|
||||
{ createId: createIdFactory() }
|
||||
).result.rootInstanceIds
|
||||
).toHaveLength(1);
|
||||
state.instances.set("templates", {
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [],
|
||||
});
|
||||
block.children = [{ type: "id", value: "templates" }];
|
||||
state.instances = new Map(state.instances);
|
||||
expect(
|
||||
insertComponent(
|
||||
state,
|
||||
{ parentInstanceId: "templates", component },
|
||||
{ createId: createIdFactory() }
|
||||
).result.rootInstanceIds
|
||||
).toHaveLength(1);
|
||||
block.children.push({ type: "id", value: "mdx" });
|
||||
state.props.delete("source");
|
||||
expect(
|
||||
insertComponent(
|
||||
state,
|
||||
{ parentInstanceId: "mdx", component },
|
||||
{ createId: createIdFactory() }
|
||||
).result.rootInstanceIds
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("assigns a unique name when inserting into a Templates list", async () => {
|
||||
const parent: Instance = {
|
||||
type: "instance",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import {
|
||||
blockTemplateComponent,
|
||||
blockBodyComponent,
|
||||
blockComponent,
|
||||
getContentBlockSource,
|
||||
findContentBlockBodyContainers,
|
||||
assignUniqueBlockTemplateNamesMutable,
|
||||
elementComponent,
|
||||
instanceComponent,
|
||||
@@ -71,6 +75,7 @@ import {
|
||||
} from "./matcher";
|
||||
import { z } from "zod";
|
||||
import { getBlockTemplateNameConfirmation } from "./block";
|
||||
import { hasMdxComponentAdapter } from "./mdx-component-adapters";
|
||||
|
||||
const conflictResolutionInput = z
|
||||
.enum(["ours", "theirs", "merge"])
|
||||
@@ -1031,6 +1036,48 @@ export const insertComponent = (
|
||||
return throwBuilderRuntimeError("NOT_FOUND", "Instance not found");
|
||||
}
|
||||
|
||||
if (
|
||||
input.component !== elementComponent &&
|
||||
!hasMdxComponentAdapter(input.component)
|
||||
) {
|
||||
const { instanceSelector } = findPageAndSelectorByInstanceId(
|
||||
mutationState.pages,
|
||||
mutationState.instances,
|
||||
parent.id
|
||||
);
|
||||
let insideBody = false;
|
||||
for (const id of instanceSelector) {
|
||||
const ancestor = mutationState.instances.get(id);
|
||||
if (ancestor?.component === blockTemplateComponent) {
|
||||
break;
|
||||
}
|
||||
if (ancestor?.component === blockBodyComponent) {
|
||||
insideBody = true;
|
||||
}
|
||||
if (ancestor?.component !== blockComponent) {
|
||||
continue;
|
||||
}
|
||||
const source = getContentBlockSource({
|
||||
blockInstanceId: id,
|
||||
props: mutationState.props.values(),
|
||||
});
|
||||
if (
|
||||
source !== undefined &&
|
||||
(insideBody ||
|
||||
findContentBlockBodyContainers({
|
||||
blockInstance: ancestor,
|
||||
instances: mutationState.instances,
|
||||
}).length === 0)
|
||||
) {
|
||||
return throwBuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"To add this component to MDX content, add it to this Content Block’s Templates and insert it from the template picker. You can add a one-off instance outside MDX content."
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const templates = getComponentTemplates();
|
||||
const template = templates.get(input.component);
|
||||
const meta = componentMetas.get(input.component);
|
||||
|
||||
@@ -117,6 +117,38 @@ const createFixture = () => {
|
||||
};
|
||||
|
||||
describe("createContentBlockApplication", () => {
|
||||
test("inspects unresolved JSX in a dynamically resolved source context", async () => {
|
||||
const { state } = createFixture();
|
||||
state.props?.set("src", {
|
||||
id: "src",
|
||||
instanceId: "block",
|
||||
name: "src",
|
||||
type: "expression",
|
||||
value: "collection.data._id",
|
||||
});
|
||||
expect(
|
||||
getMdxAssetSourceBlockInstanceIds({ assetId: "asset", state })
|
||||
).toEqual([]);
|
||||
const diagnostics = await inspectMdxAssetSource({
|
||||
source: "<Accordion />",
|
||||
assetId: "asset",
|
||||
state,
|
||||
metas: componentMetas,
|
||||
projectId: "project",
|
||||
sourceBlockInstanceIds: ["block"],
|
||||
});
|
||||
expect(diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "unresolved-template",
|
||||
templateName: "Accordion",
|
||||
blockInstanceId: "block",
|
||||
sourceRange: expect.objectContaining({
|
||||
start: expect.objectContaining({ offset: 0 }),
|
||||
end: expect.objectContaining({ offset: 13 }),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
test("inspects an Asset in each resolvable Content Block context", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.state.dataSources?.set("selectedAsset", {
|
||||
|
||||
@@ -95,12 +95,15 @@ export const inspectMdxAssetSource = async ({
|
||||
state,
|
||||
metas,
|
||||
projectId,
|
||||
sourceBlockInstanceIds = [],
|
||||
}: {
|
||||
source: string;
|
||||
assetId: string;
|
||||
state: BuilderState;
|
||||
metas: Map<string, WsComponentMeta>;
|
||||
projectId: string;
|
||||
/** Source blocks resolved by the caller's live rendering context. */
|
||||
sourceBlockInstanceIds?: readonly string[];
|
||||
}) => {
|
||||
const validation = await validateTextAssetSource({ source, format: "mdx" });
|
||||
if (validation.format !== "mdx") {
|
||||
@@ -127,10 +130,11 @@ export const inspectMdxAssetSource = async ({
|
||||
return diagnostics;
|
||||
}
|
||||
const data = getData(state);
|
||||
for (const blockInstanceId of getMdxAssetSourceBlockInstanceIds({
|
||||
assetId,
|
||||
state,
|
||||
})) {
|
||||
const blockInstanceIds = new Set([
|
||||
...getMdxAssetSourceBlockInstanceIds({ assetId, state }),
|
||||
...sourceBlockInstanceIds,
|
||||
]);
|
||||
for (const blockInstanceId of blockInstanceIds) {
|
||||
const identity = createContentBlockExternalContentIdentity({
|
||||
blockInstanceId,
|
||||
asset,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type WebstudioFragment,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
adoptMdxAuthoredContentFragment,
|
||||
materializeMdxAuthoredContent,
|
||||
MdxAuthoredContentConflictError,
|
||||
rebaseMdxAuthoredContent,
|
||||
@@ -78,7 +79,7 @@ const createCodeTextFragment = (theme = "github-light"): WebstudioFragment => ({
|
||||
});
|
||||
|
||||
describe("MDX authored content", () => {
|
||||
test("materializes and round-trips exact registered component JSX", async () => {
|
||||
test("does not materialize registered JSX without a resolved template", async () => {
|
||||
const source =
|
||||
"<PromotionCard><Heading>Launch offer</Heading></PromotionCard>\n";
|
||||
const document = await parseMdxDocument({ source });
|
||||
@@ -93,19 +94,13 @@ describe("MDX authored content", () => {
|
||||
metas,
|
||||
});
|
||||
|
||||
expect(root.fragment.instances).toEqual([
|
||||
expect.objectContaining({ component: "Heading" }),
|
||||
expect.objectContaining({
|
||||
component: "PromotionCard",
|
||||
children: [{ type: "id", value: expect.any(String) }],
|
||||
}),
|
||||
]);
|
||||
expect(root.fragment.instances).toEqual([]);
|
||||
await expect(
|
||||
serializeMdxAuthoredContent({ root, fragment: root.fragment })
|
||||
).resolves.toBe(source);
|
||||
});
|
||||
|
||||
test("materializes static props on an exact registered component", async () => {
|
||||
test("does not materialize props on registered JSX without a template", async () => {
|
||||
const source = '<Heading tag="h2">Test</Heading>\n';
|
||||
const document = await parseMdxDocument({ source });
|
||||
const root = materializeMdxAuthoredContent({
|
||||
@@ -115,12 +110,8 @@ describe("MDX authored content", () => {
|
||||
metas: componentMetas,
|
||||
});
|
||||
|
||||
expect(root.fragment.instances).toEqual([
|
||||
expect.objectContaining({ component: "Heading" }),
|
||||
]);
|
||||
expect(root.fragment.props).toEqual([
|
||||
expect.objectContaining({ name: "tag", type: "string", value: "h2" }),
|
||||
]);
|
||||
expect(root.fragment.instances).toEqual([]);
|
||||
expect(root.fragment.props).toEqual([]);
|
||||
await expect(
|
||||
serializeMdxAuthoredContent({ root, fragment: root.fragment })
|
||||
).resolves.toBe(source);
|
||||
@@ -1776,6 +1767,52 @@ describe("MDX authored content", () => {
|
||||
{ type: "id", value: "missing" },
|
||||
{ type: "text", value: "After" },
|
||||
]);
|
||||
expect(
|
||||
reconcileMdxAuthoredContent({ root, fragment: root.fragment }).children
|
||||
).toMatchObject([
|
||||
{
|
||||
type: "element",
|
||||
children:
|
||||
document.children[0].type === "element"
|
||||
? document.children[0].children
|
||||
: [],
|
||||
},
|
||||
]);
|
||||
const live = structuredClone(root.fragment);
|
||||
for (const instance of live.instances) {
|
||||
if (instance.id === "missing") {
|
||||
instance.id = "live-missing";
|
||||
}
|
||||
instance.children = instance.children.map((child) =>
|
||||
child.type === "id" && child.value === "missing"
|
||||
? { ...child, value: "live-missing" }
|
||||
: child
|
||||
);
|
||||
}
|
||||
const adopted = adoptMdxAuthoredContentFragment({ root, fragment: live });
|
||||
expect(adopted.provenance.unresolvedTemplates[0].markerId).toBe(
|
||||
"live-missing"
|
||||
);
|
||||
const next = structuredClone(adopted.fragment);
|
||||
next.instances = next.instances.filter(({ id }) => id !== "live-missing");
|
||||
for (const instance of next.instances) {
|
||||
instance.children = instance.children.filter(
|
||||
(child) => child.type !== "id" || child.value !== "live-missing"
|
||||
);
|
||||
}
|
||||
const result = reconcileMdxAuthoredContent({
|
||||
root: adopted,
|
||||
fragment: next,
|
||||
});
|
||||
expect(result.children).toMatchObject([
|
||||
{
|
||||
type: "element",
|
||||
children: [
|
||||
{ type: "text", value: "Before" },
|
||||
{ type: "text", value: "After" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an inline comment anchored when adjacent text is deleted", async () => {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "@webstudio-is/content-engine/jsx-attributes";
|
||||
import {
|
||||
elementComponent,
|
||||
getComponentByJsxName,
|
||||
getContentBlockMdxTemplateDescriptor,
|
||||
getDefaultContentBlockTemplateName,
|
||||
getStyleDeclKey,
|
||||
@@ -1265,30 +1264,15 @@ export const materializeMdxAuthoredContent = ({
|
||||
children.push({ type: "id", value: rootId });
|
||||
continue;
|
||||
}
|
||||
const registeredComponent =
|
||||
node.type === "template" && node.syntax === "jsx" && metas !== undefined
|
||||
? getComponentByJsxName({
|
||||
name: node.name,
|
||||
components: metas.keys(),
|
||||
})
|
||||
: undefined;
|
||||
const materializedComponent = materializeMdxComponent(
|
||||
node,
|
||||
registeredComponent
|
||||
);
|
||||
const materializedComponent = materializeMdxComponent(node);
|
||||
if (materializedComponent !== undefined) {
|
||||
const isDirectRegisteredComponent =
|
||||
"authoredChildren" in materializedComponent;
|
||||
const createId = createMdxScopeIdGenerator({ identity, path });
|
||||
const instanceId = createId();
|
||||
const instance: Instance = {
|
||||
type: "instance",
|
||||
id: instanceId,
|
||||
component: materializedComponent.component,
|
||||
children:
|
||||
"authoredChildren" in materializedComponent
|
||||
? visit(node.children, path)
|
||||
: materializedComponent.children,
|
||||
children: materializedComponent.children,
|
||||
};
|
||||
fragment.instances.push(instance);
|
||||
const componentData =
|
||||
@@ -1450,19 +1434,8 @@ export const materializeMdxAuthoredContent = ({
|
||||
assetId: assetReference?.assetId,
|
||||
});
|
||||
} else {
|
||||
const declaredType = metas?.get(instance.component)?.props?.[
|
||||
instanceProp.name
|
||||
]?.type;
|
||||
const directBinding =
|
||||
isDirectRegisteredComponent &&
|
||||
(declaredType === "string" ||
|
||||
declaredType === "number" ||
|
||||
declaredType === "boolean")
|
||||
? parseMdxStaticProp({ prop: instanceProp, type: declaredType })
|
||||
: undefined;
|
||||
const binding =
|
||||
directBinding ??
|
||||
(assetReference === undefined
|
||||
assetReference === undefined
|
||||
? getMdxPropBinding({
|
||||
capabilities: componentCapabilities,
|
||||
instance,
|
||||
@@ -1476,7 +1449,7 @@ export const materializeMdxAuthoredContent = ({
|
||||
jsxPropName,
|
||||
}).editable
|
||||
? { type: "asset" as const, value: assetReference.assetId }
|
||||
: undefined);
|
||||
: undefined;
|
||||
if (binding === undefined) {
|
||||
const eligibility = getMdxPropEligibility({
|
||||
capabilities: componentCapabilities,
|
||||
@@ -1759,6 +1732,12 @@ export const adoptMdxAuthoredContentFragment = ({
|
||||
fragment,
|
||||
provenance: {
|
||||
...root.provenance,
|
||||
unresolvedTemplates: root.provenance.unresolvedTemplates.map(
|
||||
(reference) => ({
|
||||
...reference,
|
||||
markerId: instanceIds.get(reference.markerId) ?? reference.markerId,
|
||||
})
|
||||
),
|
||||
nodes: root.provenance.nodes.map((node) =>
|
||||
node.type === "element"
|
||||
? {
|
||||
@@ -1994,6 +1973,14 @@ export const reconcileMdxAuthoredContent = ({
|
||||
const originalInstanceById = new Map(
|
||||
root.fragment.instances.map((instance) => [instance.id, instance])
|
||||
);
|
||||
const unresolvedById = new Map(
|
||||
root.provenance.unresolvedTemplates
|
||||
.filter(({ markerId }) => originalInstanceById.has(markerId))
|
||||
.map((reference) => [reference.markerId, reference])
|
||||
);
|
||||
const unresolvedPaths = new Set(
|
||||
Array.from(unresolvedById.values(), ({ path }) => pathKey(path))
|
||||
);
|
||||
const indexPropsByInstanceId = (props: readonly Prop[]) => {
|
||||
const index = new Map<Instance["id"], Prop[]>();
|
||||
for (const prop of props) {
|
||||
@@ -2295,6 +2282,20 @@ export const reconcileMdxAuthoredContent = ({
|
||||
if (instance === undefined) {
|
||||
throw new Error(`Authored MDX instance "${instanceId}" is missing`);
|
||||
}
|
||||
const unresolved = unresolvedById.get(instanceId);
|
||||
if (unresolved !== undefined) {
|
||||
if (serializedInstanceIds.has(instanceId)) {
|
||||
throw new Error(`Authored MDX instance "${instanceId}" is reused`);
|
||||
}
|
||||
const original = originalNodeByPath.get(pathKey(unresolved.path));
|
||||
if (original === undefined) {
|
||||
throw new Error(
|
||||
"Missing template placeholder has no authored MDX node"
|
||||
);
|
||||
}
|
||||
serializedInstanceIds.add(instanceId);
|
||||
return original;
|
||||
}
|
||||
const provenance = provenanceById.get(instanceId);
|
||||
if (provenance?.type === "template") {
|
||||
if (serializedInstanceIds.has(instanceId)) {
|
||||
@@ -3031,7 +3032,9 @@ export const reconcileMdxAuthoredContent = ({
|
||||
}
|
||||
const provenance = provenanceById.get(child.value);
|
||||
const authoredPath =
|
||||
provenance?.path ?? overlaidDescendantById.get(child.value)?.path;
|
||||
provenance?.path ??
|
||||
overlaidDescendantById.get(child.value)?.path ??
|
||||
unresolvedById.get(child.value)?.path;
|
||||
return {
|
||||
key:
|
||||
authoredPath === undefined
|
||||
@@ -3057,7 +3060,10 @@ export const reconcileMdxAuthoredContent = ({
|
||||
const key =
|
||||
node.type === "text"
|
||||
? `text:${index}`
|
||||
: provenance === undefined && isOverlaidDescendant === false
|
||||
: provenance === undefined &&
|
||||
isOverlaidDescendant === false &&
|
||||
(originalPath === undefined ||
|
||||
unresolvedPaths.has(originalPath) === false)
|
||||
? undefined
|
||||
: `node:${originalPath}`;
|
||||
if (key !== undefined && surviving.has(key)) {
|
||||
|
||||
@@ -537,10 +537,7 @@ export const serializeMdxComponentFallback = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const materializeMdxComponent = (
|
||||
node: MdxAuthoredNode,
|
||||
registeredComponent?: Instance["component"]
|
||||
) => {
|
||||
export const materializeMdxComponent = (node: MdxAuthoredNode) => {
|
||||
if (node.type === "template") {
|
||||
if (node.syntax !== "jsx") {
|
||||
return;
|
||||
@@ -550,14 +547,6 @@ export const materializeMdxComponent = (
|
||||
if (component !== undefined && adapter !== undefined) {
|
||||
return { ...component, component: adapter.component };
|
||||
}
|
||||
if (registeredComponent !== undefined) {
|
||||
return {
|
||||
component: registeredComponent,
|
||||
children: [] as Instance["children"],
|
||||
props: materializeNamedProps(node.props),
|
||||
authoredChildren: true as const,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const adapter of componentAdapters.values()) {
|
||||
|
||||
@@ -384,7 +384,7 @@ describe("resolveMdxTemplates", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolves templates nested inside a direct registered component", async () => {
|
||||
test("requires a template even for registered JSX and does not render its descendants", async () => {
|
||||
const instances = createInstances();
|
||||
instances.set(
|
||||
"heading-2",
|
||||
@@ -395,32 +395,33 @@ describe("resolveMdxTemplates", () => {
|
||||
value: "heading-2",
|
||||
});
|
||||
const document = await parseMdxDocument({
|
||||
source: "<Box><h2>Nested</h2><Missing /></Box>\n",
|
||||
source: "<Accordion><h2>Nested</h2><Missing /></Accordion>\n",
|
||||
});
|
||||
|
||||
const result = resolveMdxTemplates({
|
||||
document,
|
||||
identity,
|
||||
instances,
|
||||
metas: new Map([...metas, ["Box", { label: "Box" }]]),
|
||||
metas: new Map([
|
||||
...metas,
|
||||
[
|
||||
"@webstudio-is/sdk-components-react-radix:Accordion",
|
||||
{ label: "Accordion" },
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.references).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "resolved-template",
|
||||
path: [0, 0],
|
||||
templateInstanceId: "heading-2",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "unresolved-template",
|
||||
path: [0, 1],
|
||||
templateName: "Missing",
|
||||
path: [0],
|
||||
templateName: "Accordion",
|
||||
}),
|
||||
]);
|
||||
expect(result.diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "unresolved-template",
|
||||
templateName: "Missing",
|
||||
templateName: "Accordion",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
import {
|
||||
findContentBlockTemplateContainers,
|
||||
getContentBlockTemplateName,
|
||||
getComponentByJsxName,
|
||||
getHtmlTagsFromProps,
|
||||
type ContentBlockDiagnostic,
|
||||
type ContentBlockExternalContentIdentity,
|
||||
@@ -268,16 +267,6 @@ export const resolveMdxTemplates = ({
|
||||
if (standard?.key.startsWith("component:") === true) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
node.syntax === "jsx" &&
|
||||
getComponentByJsxName({
|
||||
name: node.name,
|
||||
components: metas.keys(),
|
||||
}) !== undefined
|
||||
) {
|
||||
visit(node.children, path);
|
||||
continue;
|
||||
}
|
||||
references.push({
|
||||
type: "unresolved-template",
|
||||
path,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import {
|
||||
createStructuredQuery,
|
||||
type QueryDefinition,
|
||||
@@ -481,4 +482,38 @@ describe("structured query builder", () => {
|
||||
expect(screen.getByText("File content")).toBeTruthy();
|
||||
expect(screen.getByRole("separator")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("keeps a long Output menu within the viewport and scrolls to its last option", async () => {
|
||||
const capabilities = {
|
||||
...selectionCapabilities,
|
||||
fields: Array.from({ length: 100 }, (_, index) => ({
|
||||
path: [`field${index}`],
|
||||
label: `Field ${index}`,
|
||||
types: ["string" as const],
|
||||
})),
|
||||
};
|
||||
render(
|
||||
<StructuredQueryBuilder
|
||||
value={createStructuredQuery(capabilities)}
|
||||
capabilities={capabilities}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText("Add output"));
|
||||
const menu = screen.getByRole("menu");
|
||||
await expect
|
||||
.poll(() => menu.getBoundingClientRect().top)
|
||||
.toBeGreaterThanOrEqual(0);
|
||||
expect(menu.getBoundingClientRect().bottom).toBeLessThanOrEqual(
|
||||
window.innerHeight
|
||||
);
|
||||
expect(menu.scrollHeight).toBeGreaterThan(menu.clientHeight);
|
||||
await userEvent.keyboard("{End}");
|
||||
const lastOption = screen.getByRole("menuitem", { name: "Field 99" });
|
||||
expect(document.activeElement).toBe(lastOption);
|
||||
await expect.poll(() => menu.scrollTop).toBeGreaterThan(0);
|
||||
expect(lastOption.getBoundingClientRect().bottom).toBeLessThanOrEqual(
|
||||
menu.getBoundingClientRect().bottom
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -833,7 +833,15 @@ const QueryParameters = ({
|
||||
icon={<PlusIcon />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
css={{
|
||||
maxHeight:
|
||||
"var(--radix-dropdown-menu-content-available-height)",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{selectionParameters.map((parameter, index) => (
|
||||
<Fragment key={parameter.key}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
blockComponent,
|
||||
blockBodyComponent,
|
||||
blockTemplateComponent,
|
||||
contentBlockMdxTemplateDescriptors,
|
||||
getContentBlockMdxTemplateDescriptor,
|
||||
@@ -33,6 +34,24 @@ const expectCodeTextDefaultsToStayImplicit = (
|
||||
).toEqual([]);
|
||||
};
|
||||
|
||||
test("creates unconnected Content Blocks with direct content and no MDX Body wrapper", () => {
|
||||
const fragment = renderCoreTemplate(coreTemplates[blockComponent]);
|
||||
expect(
|
||||
fragment.instances.some(({ component }) => component === blockBodyComponent)
|
||||
).toBe(false);
|
||||
const block = fragment.instances.find(
|
||||
({ component }) => component === blockComponent
|
||||
)!;
|
||||
const children = block.children.map((child) =>
|
||||
fragment.instances.find(({ id }) => id === child.value)
|
||||
);
|
||||
expect(children[0]?.component).toBe(blockTemplateComponent);
|
||||
expect(children.slice(1).map((instance) => instance?.tag)).toEqual([
|
||||
"p",
|
||||
"ul",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps Code Text defaults out of standalone and Content Block props", () => {
|
||||
expectCodeTextDefaultsToStayImplicit(coreTemplates.code_text);
|
||||
expectCodeTextDefaultsToStayImplicit(coreTemplates[blockComponent]);
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
import { componentsById } from "./components";
|
||||
|
||||
const BlockTemplate = ws.blockTemplate;
|
||||
const BlockBody = ws.contentBlockBody;
|
||||
const blockDocument = new Parameter(contentBlockDocumentProp);
|
||||
|
||||
const listItemMdxTemplateDescriptor = contentBlockMdxTemplateDescriptors.find(
|
||||
@@ -105,29 +104,27 @@ const blockMeta: TemplateMeta = {
|
||||
{ label: "Templates" },
|
||||
<BlockTemplate>{contentBlockDefaultTemplates}</BlockTemplate>
|
||||
)}
|
||||
<BlockBody>
|
||||
<p>
|
||||
The Content Block component designates regions on the page where
|
||||
pre-styled instances can be inserted in{" "}
|
||||
<a href="https://wstd.us/content-block">Content mode</a>.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
In Content mode, you can edit content inside this Content Block and
|
||||
add new instances predefined in templates. Content outside Content
|
||||
Blocks is read-only.
|
||||
</li>
|
||||
<li>
|
||||
To predefine instances for insertion in Content mode, switch to
|
||||
Design mode and add them to the Templates container.
|
||||
</li>
|
||||
<li>
|
||||
To insert predefined instances in Content mode, click the + button
|
||||
while hovering over the Content Block on the canvas and choose an
|
||||
instance from the list.
|
||||
</li>
|
||||
</ul>
|
||||
</BlockBody>
|
||||
<p>
|
||||
The Content Block component designates regions on the page where
|
||||
pre-styled instances can be inserted in{" "}
|
||||
<a href="https://wstd.us/content-block">Content mode</a>.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
In Content mode, you can edit content inside this Content Block and
|
||||
add new instances predefined in templates. Content outside Content
|
||||
Blocks is read-only.
|
||||
</li>
|
||||
<li>
|
||||
To predefine instances for insertion in Content mode, switch to Design
|
||||
mode and add them to the Templates container.
|
||||
</li>
|
||||
<li>
|
||||
To insert predefined instances in Content mode, click the + button
|
||||
while hovering over the Content Block on the canvas and choose an
|
||||
instance from the list.
|
||||
</li>
|
||||
</ul>
|
||||
</ws.block>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user