diff --git a/apps/builder/app/builder/features/inspector/inspector.tsx b/apps/builder/app/builder/features/inspector/inspector.tsx index 7da5d045deba..004fb9ff2bef 100644 --- a/apps/builder/app/builder/features/inspector/inspector.tsx +++ b/apps/builder/app/builder/features/inspector/inspector.tsx @@ -2,7 +2,6 @@ import { useRef } from "react"; import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import type { Instance } from "@webstudio-is/sdk"; -import { rootComponent } from "@webstudio-is/sdk"; import { theme, PanelTabs, @@ -20,7 +19,7 @@ import { FloatingPanelProvider, } from "@webstudio-is/design-system"; import { ModeMenu, StylePanel } from "~/builder/features/style-panel"; -import { SettingsPanelContainer } from "~/builder/features/settings-panel"; +import { SettingsPanel } from "~/builder/features/settings-panel"; import { $registeredComponentMetas, $dragAndDropState, @@ -93,6 +92,7 @@ export const Inspector = ({ navigatorLayout }: InspectorProps) => { type PanelName = "style" | "settings"; const availablePanels = new Set(); + availablePanels.add("settings"); if ( // forbid styling body in xml document documentType === "html" && @@ -102,11 +102,6 @@ export const Inspector = ({ navigatorLayout }: InspectorProps) => { ) { availablePanels.add("style"); } - // @todo hide root component settings until - // global data sources are implemented - if (selectedInstance.component !== rootComponent) { - availablePanels.add("settings"); - } return ( { > - (); for (const [dataSourceId, value] of values) { const dataSource = dataSources.get(dataSourceId); diff --git a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx index 6ac01b769c1b..11d7b8ec0f7f 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -55,7 +55,7 @@ import { import { parseCurl, type CurlRequest } from "./curl"; import { $selectedInstance, - $selectedInstanceKey, + $selectedInstanceKeyWithRoot, $selectedPage, } from "~/shared/awareness"; import { updateWebstudioData } from "~/shared/instance-utils"; @@ -384,7 +384,7 @@ const $hiddenDataSourceIds = computed( const $selectedInstanceScope = computed( [ - $selectedInstanceKey, + $selectedInstanceKeyWithRoot, $variableValuesByInstanceSelector, $dataSources, $hiddenDataSourceIds, diff --git a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx index 511657d9330f..15d1ba227be3 100644 --- a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx @@ -16,7 +16,7 @@ import { useStore } from "@nanostores/react"; import cmsUpgradeBanner from "./cms-upgrade-banner.svg?url"; import { $isDesignMode, $userPlanFeatures } from "~/shared/nano-states"; -export const SettingsPanelContainer = ({ +export const SettingsPanel = ({ selectedInstance, }: { selectedInstance: Instance; diff --git a/apps/builder/app/builder/features/settings-panel/shared.tsx b/apps/builder/app/builder/features/settings-panel/shared.tsx index 065a3583fb23..e74bcad9d2bd 100644 --- a/apps/builder/app/builder/features/settings-panel/shared.tsx +++ b/apps/builder/app/builder/features/settings-panel/shared.tsx @@ -36,7 +36,7 @@ import { } from "~/shared/nano-states"; import type { BindingVariant } from "~/builder/shared/binding-popover"; import { humanizeString } from "~/shared/string-utils"; -import { $selectedInstanceKey } from "~/shared/awareness"; +import { $selectedInstanceKeyWithRoot } from "~/shared/awareness"; export type PropValue = | { type: "number"; value: number } @@ -314,7 +314,11 @@ export const Row = ({ ); export const $selectedInstanceScope = computed( - [$selectedInstanceKey, $variableValuesByInstanceSelector, $dataSources], + [ + $selectedInstanceKeyWithRoot, + $variableValuesByInstanceSelector, + $dataSources, + ], (instanceKey, variableValuesByInstanceSelector, dataSources) => { const scope: Record = {}; const aliases = new Map(); diff --git a/apps/builder/app/builder/features/settings-panel/variables-section.tsx b/apps/builder/app/builder/features/settings-panel/variables-section.tsx index a9e5c4e55dc0..c5778c9ac946 100644 --- a/apps/builder/app/builder/features/settings-panel/variables-section.tsx +++ b/apps/builder/app/builder/features/settings-panel/variables-section.tsx @@ -51,45 +51,40 @@ import { } from "./variable-popover"; import { $selectedInstance, - $selectedInstanceKey, - $selectedInstancePath, + $selectedInstanceKeyWithRoot, $selectedPage, } from "~/shared/awareness"; import { updateWebstudioData } from "~/shared/instance-utils"; -import { deleteVariableMutable } from "~/shared/data-variables"; +import { + deleteVariableMutable, + findAvailableVariables, +} from "~/shared/data-variables"; /** * find variables defined specifically on this selected instance */ const $availableVariables = computed( - [$selectedInstancePath, $dataSources], - (instancePath, dataSources) => { - if (instancePath === undefined) { + [$selectedInstance, $instances, $dataSources], + (selectedInstance, instances, dataSources) => { + if (selectedInstance === undefined) { return []; } - const [{ instanceSelector }] = instancePath; - const [selectedInstanceId] = instanceSelector; - const availableVariables = new Map(); - // order from ancestor to descendant - // so descendants can override ancestor variables - for (const { instance } of instancePath.slice().reverse()) { - for (const dataSource of dataSources.values()) { - if (dataSource.scopeInstanceId === instance.id) { - availableVariables.set(dataSource.name, dataSource); - } - } - } + const availableVariables = findAvailableVariables({ + startingInstanceId: selectedInstance.id, + instances, + dataSources, + }); // order local variables first return Array.from(availableVariables.values()).sort((left, right) => { - const leftRank = left.scopeInstanceId === selectedInstanceId ? 0 : 1; - const rightRank = right.scopeInstanceId === selectedInstanceId ? 0 : 1; + const leftRank = left.scopeInstanceId === selectedInstance.id ? 0 : 1; + const rightRank = right.scopeInstanceId === selectedInstance.id ? 0 : 1; return leftRank - rightRank; }); } ); const $instanceVariableValues = computed( - [$selectedInstanceKey, $variableValuesByInstanceSelector], + [$selectedInstanceKeyWithRoot, $variableValuesByInstanceSelector], (instanceKey, variableValuesByInstanceSelector) => variableValuesByInstanceSelector.get(instanceKey ?? "") ?? new Map() diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts index 42895b04d50b..07afc73ef731 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts @@ -2,10 +2,10 @@ import type { Instance, Instances } from "@webstudio-is/sdk"; import { blockTemplateComponent } from "@webstudio-is/sdk"; import { shallowEqual } from "shallow-equal"; import { selectInstance } from "~/shared/awareness"; +import { findAvailableVariables } from "~/shared/data-variables"; import { extractWebstudioFragment, findAllEditableInstanceSelector, - findAvailableDataSources, getWebstudioData, insertInstanceChildrenMutable, insertWebstudioFragmentCopy, @@ -107,11 +107,10 @@ export const insertListItemAt = (listItemSelector: InstanceSelector) => { const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - target.parentSelector - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: target.parentSelector[0], + }), }); const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); if (newRootInstanceId === undefined) { @@ -170,11 +169,10 @@ export const insertTemplateAt = ( const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - target.parentSelector - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: target.parentSelector[0], + }), }); const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); if (newRootInstanceId === undefined) { diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 346c7a3c23ab..b7aa0c1f9856 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -20,7 +20,6 @@ import { } from "~/shared/breakpoints"; import { deleteInstanceMutable, - findAvailableDataSources, extractWebstudioFragment, insertWebstudioFragmentCopy, updateWebstudioData, @@ -44,6 +43,7 @@ import { isTreeMatching, } from "~/shared/matcher"; import { getSetting, setSetting } from "./client-settings"; +import { findAvailableVariables } from "~/shared/data-variables"; const makeBreakpointCommand = ( name: CommandName, @@ -424,11 +424,10 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - parentItem.instanceSelector - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: parentItem.instanceSelector[0], + }), }); const newRootInstanceId = newInstanceIds.get( selectedItem.instance.id diff --git a/apps/builder/app/shared/awareness.ts b/apps/builder/app/shared/awareness.ts index 0303c18245f6..ea5fee5cd11b 100644 --- a/apps/builder/app/shared/awareness.ts +++ b/apps/builder/app/shared/awareness.ts @@ -72,6 +72,19 @@ export const getInstanceKey = < ): (InstanceSelector extends undefined ? undefined : never) | string => JSON.stringify(instanceSelector); +export const $selectedInstanceKeyWithRoot = computed( + $awareness, + (awareness) => { + const instanceSelector = awareness?.instanceSelector; + if (instanceSelector) { + if (instanceSelector[0] === ROOT_INSTANCE_ID) { + return getInstanceKey(instanceSelector); + } + return getInstanceKey([...instanceSelector, ROOT_INSTANCE_ID]); + } + } +); + export const $selectedInstanceKey = computed($awareness, (awareness) => getInstanceKey(awareness?.instanceSelector) ); diff --git a/apps/builder/app/shared/copy-paste.test.tsx b/apps/builder/app/shared/copy-paste.test.tsx index c4fe0b053176..8a4a75d27eb8 100644 --- a/apps/builder/app/shared/copy-paste.test.tsx +++ b/apps/builder/app/shared/copy-paste.test.tsx @@ -24,10 +24,10 @@ import { import type { Project } from "@webstudio-is/project"; import { extractWebstudioFragment, - findAvailableDataSources, insertWebstudioFragmentCopy, } from "./instance-utils"; import { $project } from "./nano-states"; +import { findAvailableVariables } from "./data-variables"; $project.set({ id: "current_project" } as Project); @@ -121,13 +121,13 @@ test("insert instances with slots", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(data.instances.size).toEqual(4); insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(data.instances.size).toEqual(5); expect(Array.from(data.instances.values())).toEqual([ @@ -156,7 +156,7 @@ test("insert instances with multiple roots", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(data.instances.size).toEqual(5); }); @@ -177,7 +177,7 @@ test("should add :root local styles", () => { insertWebstudioFragmentCopy({ data: newProject, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(toCss(newProject)).toEqual( stripIndent(` @@ -215,7 +215,7 @@ test("should merge :root local styles", () => { insertWebstudioFragmentCopy({ data: newProject, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(toCss(newProject)).toEqual( stripIndent(` @@ -244,7 +244,7 @@ test("should copy local styles of duplicated instance", () => { insertWebstudioFragmentCopy({ data: project, fragment, - availableDataSources: new Set(), + availableVariables: [], }); const newInstanceId = Array.from(project.instances.keys()).at(-1); expect(toCss(project)).toEqual( @@ -309,7 +309,7 @@ describe("props", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.props.values())).toEqual([ expect.objectContaining({ @@ -337,7 +337,7 @@ describe("props", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.props.values())).toEqual([ expect.objectContaining({ @@ -429,7 +429,7 @@ describe("variables", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); const [newDataSourceId] = data.dataSources.keys(); expect(Array.from(data.dataSources.values())).toEqual([ @@ -480,7 +480,7 @@ describe("variables", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ @@ -529,11 +529,10 @@ describe("variables", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - ["bodyId"] - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: "bodyId", + }), }); const newInstanceId = Array.from(data.instances.keys()).at(-1) ?? ""; expect(newInstanceId).not.toEqual("boxId"); @@ -626,11 +625,10 @@ describe("resources", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - ["bodyId"] - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: "bodyId", + }), }); const newInstanceId = Array.from(data.instances.keys()).at(-1); expect(newInstanceId).not.toEqual("boxId"); @@ -715,11 +713,10 @@ describe("resources", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - ["bodyId"] - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: "bodyId", + }), }); const newInstanceId = Array.from(data.instances.keys()).at(-1); expect(newInstanceId).not.toEqual("boxId"); @@ -762,7 +759,7 @@ describe("resources", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); const [newPropResourceId, newVariableResourceId] = data.resources.keys(); const [newBoxVariableId] = data.dataSources.keys(); @@ -828,7 +825,7 @@ describe("resources", () => { insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ diff --git a/apps/builder/app/shared/copy-paste/plugin-instance.ts b/apps/builder/app/shared/copy-paste/plugin-instance.ts index c9ae48c63b2f..2fecef9404ab 100644 --- a/apps/builder/app/shared/copy-paste/plugin-instance.ts +++ b/apps/builder/app/shared/copy-paste/plugin-instance.ts @@ -16,7 +16,6 @@ import { import type { InstanceSelector, DroppableTarget } from "../tree-utils"; import { deleteInstanceMutable, - findAvailableDataSources, extractWebstudioFragment, insertWebstudioFragmentCopy, updateWebstudioData, @@ -26,6 +25,7 @@ import { } from "../instance-utils"; import { isInstanceDetachable } from "../matcher"; import { $selectedInstancePath } from "../awareness"; +import { findAvailableVariables } from "../data-variables"; const version = "@webstudio/instance/v0.1"; @@ -172,11 +172,10 @@ export const onPaste = (clipboardData: string) => { const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - pasteTarget.parentSelector - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: pasteTarget.parentSelector[0], + }), }); const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); if (newRootInstanceId === undefined) { diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts index b1448d4ddf0c..3380c3b7af0c 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.ts @@ -1,6 +1,5 @@ import type { Instance, WebstudioFragment } from "@webstudio-is/sdk"; import { - findAvailableDataSources, findClosestInsertable, insertInstanceChildrenMutable, insertWebstudioFragmentCopy, @@ -19,6 +18,7 @@ import { addStyles } from "./styles"; import { builderApi } from "~/shared/builder-api"; import { denormalizeSrcProps } from "../asset-upload"; import { nanoHash } from "~/shared/nano-hash"; +import { findAvailableVariables } from "~/shared/data-variables"; const { toast } = builderApi; @@ -186,11 +186,10 @@ export const onPaste = async (clipboardData: string) => { const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - insertable.parentSelector - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: insertable.parentSelector[0], + }), }); const children = fragment.children diff --git a/apps/builder/app/shared/data-variables.test.tsx b/apps/builder/app/shared/data-variables.test.tsx index 672527af1318..ccce7602738e 100644 --- a/apps/builder/app/shared/data-variables.test.tsx +++ b/apps/builder/app/shared/data-variables.test.tsx @@ -6,8 +6,9 @@ import { renderData, ResourceValue, Variable, + ws, } from "@webstudio-is/template"; -import { encodeDataVariableId } from "@webstudio-is/sdk"; +import { encodeDataVariableId, ROOT_INSTANCE_ID } from "@webstudio-is/sdk"; import { computeExpression, decodeDataVariableName, @@ -17,6 +18,7 @@ import { rebindTreeVariablesMutable, unsetExpressionVariables, deleteVariableMutable, + findAvailableVariables, } from "./data-variables"; test("encode data variable name when necessary", () => { @@ -43,6 +45,80 @@ test("dencode data variable name with dollar sign", () => { expect(decodeDataVariableName("$my$Variable")).toEqual("$my$Variable"); }); +test("find available variables", () => { + const bodyVariable = new Variable("bodyVariable", ""); + const boxVariable = new Variable("boxVariable", ""); + const data = renderData( + <$.Body ws:id="bodyId" vars={expression`${bodyVariable}`}> + <$.Box ws:id="boxId" vars={expression`${boxVariable}`}> + + ); + expect( + findAvailableVariables({ ...data, startingInstanceId: "boxId" }) + ).toEqual([ + expect.objectContaining({ name: "bodyVariable" }), + expect.objectContaining({ name: "boxVariable" }), + ]); +}); + +test("find masked variables", () => { + const bodyVariable = new Variable("myVariable", ""); + const boxVariable = new Variable("myVariable", ""); + const data = renderData( + <$.Body ws:id="bodyId" vars={expression`${bodyVariable}`}> + <$.Box ws:id="boxId" vars={expression`${boxVariable}`}> + + ); + expect( + findAvailableVariables({ ...data, startingInstanceId: "boxId" }) + ).toEqual([ + expect.objectContaining({ scopeInstanceId: "boxId", name: "myVariable" }), + ]); +}); + +test("find global variables", () => { + const globalVariable = new Variable("globalVariable", ""); + const boxVariable = new Variable("boxVariable", ""); + const data = renderData( + + <$.Body ws:id="bodyId"> + <$.Box ws:id="boxId" vars={expression`${boxVariable}`}> + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + expect( + findAvailableVariables({ ...data, startingInstanceId: "boxId" }) + ).toEqual([ + expect.objectContaining({ name: "globalVariable" }), + expect.objectContaining({ name: "boxVariable" }), + ]); +}); + +test("find global variables in slots", () => { + const globalVariable = new Variable("globalVariable", ""); + const bodyVariable = new Variable("bodyVariable", ""); + const boxVariable = new Variable("boxVariable", ""); + const data = renderData( + + <$.Body ws:id="bodyId" vars={expression`${bodyVariable}`}> + <$.Slot ws:id="slotId"> + <$.Fragment ws:id="fragmentId"> + <$.Box ws:id="boxId" vars={expression`${boxVariable}`}> + + + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + expect( + findAvailableVariables({ ...data, startingInstanceId: "boxId" }) + ).toEqual([ + expect.objectContaining({ name: "globalVariable" }), + expect.objectContaining({ name: "boxVariable" }), + ]); +}); + test("unset expression variables", () => { expect( unsetExpressionVariables({ diff --git a/apps/builder/app/shared/data-variables.ts b/apps/builder/app/shared/data-variables.ts index 0d2c9d0052fa..4d6961e108b6 100644 --- a/apps/builder/app/shared/data-variables.ts +++ b/apps/builder/app/shared/data-variables.ts @@ -7,6 +7,7 @@ import { type Resource, type Resources, type WebstudioData, + ROOT_INSTANCE_ID, decodeDataVariableId, encodeDataVariableId, findTreeInstanceIdsExcludingSlotDescendants, @@ -209,6 +210,8 @@ const findMaskedVariablesByInstanceId = ({ instanceIdsPath.push(currentId); currentId = parentInstanceById.get(currentId); } + // allow accessing global variables everywhere + instanceIdsPath.push(ROOT_INSTANCE_ID); const maskedVariables = new Map(); // start from the root to descendant // so child variables override parent variables @@ -222,6 +225,30 @@ const findMaskedVariablesByInstanceId = ({ return maskedVariables; }; +export const findAvailableVariables = ({ + startingInstanceId, + instances, + dataSources, +}: { + startingInstanceId: Instance["id"]; + instances: Instances; + dataSources: DataSources; +}) => { + const maskedVariables = findMaskedVariablesByInstanceId({ + startingInstanceId, + instances, + dataSources, + }); + const availableVariables: DataSource[] = []; + for (const dataSourceId of maskedVariables.values()) { + const dataSource = dataSources.get(dataSourceId); + if (dataSource) { + availableVariables.push(dataSource); + } + } + return availableVariables; +}; + const traverseExpressions = ({ startingInstanceId, instances, diff --git a/apps/builder/app/shared/instance-utils.test.tsx b/apps/builder/app/shared/instance-utils.test.tsx index 9e0070b1e950..4e4bb74ce176 100644 --- a/apps/builder/app/shared/instance-utils.test.tsx +++ b/apps/builder/app/shared/instance-utils.test.tsx @@ -1065,7 +1065,7 @@ describe("insert webstudio fragment copy", () => { ...emptyFragment, assets: [createImageAsset("asset1", "name", "another_project")], }, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.assets.values())).toEqual([ createImageAsset("asset1", "name", "current_project"), @@ -1083,7 +1083,7 @@ describe("insert webstudio fragment copy", () => { createImageAsset("asset2", "another_name", "another_project"), ], }, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.assets.values())).toEqual([ // preserve any user changes @@ -1117,7 +1117,7 @@ describe("insert webstudio fragment copy", () => { }, ], }, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.breakpoints.values())).toEqual([ { id: "existing_base", label: "base" }, @@ -1156,7 +1156,7 @@ describe("insert webstudio fragment copy", () => { }, ], }, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.styleSources.values())).toEqual([ { id: "token1", type: "token", name: "oldLabel" }, @@ -1208,7 +1208,7 @@ describe("insert webstudio fragment copy", () => { }, ], }, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.styleSourceSelections.values())).toEqual([ { @@ -1273,7 +1273,7 @@ describe("insert webstudio fragment copy", () => { }, ], }, - availableDataSources: new Set(), + availableVariables: [], }); expect(Array.from(data.styleSourceSelections.values())).toEqual([ { instanceId: "fragment", values: ["localId", "tokenId"] }, diff --git a/apps/builder/app/shared/instance-utils.ts b/apps/builder/app/shared/instance-utils.ts index dd764142c118..36ec04252181 100644 --- a/apps/builder/app/shared/instance-utils.ts +++ b/apps/builder/app/shared/instance-utils.ts @@ -70,6 +70,7 @@ import { findClosestInstanceMatchingFragment, } from "./matcher"; import { + findAvailableVariables, restoreExpressionVariables, unsetExpressionVariables, } from "./data-variables"; @@ -310,11 +311,10 @@ export const insertWebstudioFragmentAt = ( const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - insertable.parentSelector - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: insertable.parentSelector[0], + }), }); children = fragment.children.map((child) => { if (child.type === "id") { @@ -377,11 +377,10 @@ export const reparentInstance = ( const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, - availableDataSources: findAvailableDataSources( - data.dataSources, - data.instances, - reparentDropTarget.parentSelector - ), + availableVariables: findAvailableVariables({ + ...data, + startingInstanceId: reparentDropTarget.parentSelector[0], + }), }); const newRootInstanceId = newInstanceIds.get(rootInstanceId); if (newRootInstanceId === undefined) { @@ -720,29 +719,6 @@ export const extractWebstudioFragment = ( }; }; -export const findAvailableDataSources = ( - dataSources: DataSources, - instances: Instances, - instanceSelector: InstanceSelector -) => { - // inline data sources not scoped to current portal - const instanceIds = new Set(); - for (const instanceId of instanceSelector) { - const instance = instances.get(instanceId); - if (instance?.component === portalComponent) { - break; - } - instanceIds.add(instanceId); - } - const availableDataSources = new Set(); - for (const { id, scopeInstanceId } of dataSources.values()) { - if (scopeInstanceId && instanceIds.has(scopeInstanceId)) { - availableDataSources.add(id); - } - } - return availableDataSources; -}; - const replaceDataSources = ( code: string, replacements: Map @@ -764,11 +740,11 @@ const replaceDataSources = ( export const insertWebstudioFragmentCopy = ({ data, fragment, - availableDataSources, + availableVariables, }: { data: Omit; fragment: WebstudioFragment; - availableDataSources: Set; + availableVariables: DataSource[]; }) => { const newInstanceIds = new Map(); const newDataSourceIds = new Map(); @@ -980,11 +956,8 @@ export const insertWebstudioFragmentCopy = ({ newInstanceIds.set(ROOT_INSTANCE_ID, ROOT_INSTANCE_ID); const maskedIdByName = new Map(); - for (const dataSourceId of availableDataSources) { - const dataSource = dataSources.get(dataSourceId); - if (dataSource) { - maskedIdByName.set(dataSource.name, dataSource.id); - } + for (const dataSource of availableVariables) { + maskedIdByName.set(dataSource.name, dataSource.id); } const newResourceIds = new Map(); for (let dataSource of fragment.dataSources) { diff --git a/apps/builder/app/shared/nano-states/props.test.tsx b/apps/builder/app/shared/nano-states/props.test.tsx index f6df474b1d90..1d46279e4121 100644 --- a/apps/builder/app/shared/nano-states/props.test.tsx +++ b/apps/builder/app/shared/nano-states/props.test.tsx @@ -5,6 +5,7 @@ import { setEnv } from "@webstudio-is/feature-flags"; import { DataSource, type Instance, + ROOT_INSTANCE_ID, collectionComponent, } from "@webstudio-is/sdk"; import { textContentAttribute } from "@webstudio-is/react-sdk"; @@ -16,7 +17,7 @@ import { import { $pages } from "./pages"; import { $assets, $dataSources, $props, $resources } from "./misc"; import { $dataSourceVariables, $resourceValues } from "./variables"; -import { $awareness } from "../awareness"; +import { $awareness, getInstanceKey } from "../awareness"; import { $, expression, @@ -592,6 +593,31 @@ test("use default system values in props", () => { cleanStores($propValuesByInstanceSelector); }); +test("compute props with global variables", () => { + const rootVariable = new Variable("rootVariable", "root value"); + const data = renderData( + + <$.Body ws:id="bodyId"> + <$.Box ws:id="boxId" data-value={expression`${rootVariable}`}> + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + $instances.set(data.instances); + $dataSources.set(data.dataSources); + $props.set(data.props); + selectPageRoot("bodyId"); + expect($propValuesByInstanceSelector.get()).toEqual( + new Map([ + [JSON.stringify(["bodyId"]), new Map()], + [ + JSON.stringify(["boxId", "bodyId"]), + new Map([["data-value", "root value"]]), + ], + ]) + ); +}); + test("compute variable values for root", () => { const bodyVariable = new Variable("bodyVariable", "initial"); const data = renderData( @@ -605,8 +631,9 @@ test("compute variable values for root", () => { $dataSourceVariables.set(new Map([[dataSourceId, "success"]])); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([[dataSourceId, "success"]]), ], ]) @@ -638,19 +665,20 @@ test("nest variable values from root to current instance", () => { ); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([[bodyVariableId, "bodyValue"]]), ], [ - JSON.stringify(["boxId", "bodyId"]), + JSON.stringify(["boxId", "bodyId", ROOT_INSTANCE_ID]), new Map([ [bodyVariableId, "bodyValue"], [boxVariableId, "boxValue"], ]), ], [ - JSON.stringify(["textId", "bodyId"]), + JSON.stringify(["textId", "bodyId", ROOT_INSTANCE_ID]), new Map([ [bodyVariableId, "bodyValue"], [textVariableId, "textValue"], @@ -686,30 +714,49 @@ test("compute item values for collection", () => { $dataSourceVariables.set(new Map([])); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ - [JSON.stringify(["bodyId"]), new Map()], + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], + [JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["boxId", "collectionId[0]", "collectionId", "bodyId"]), + JSON.stringify([ + "boxId", + "collectionId[0]", + "collectionId", + "bodyId", + ROOT_INSTANCE_ID, + ]), new Map([ [dataVariableId, ["apple", "banana", "orange"]], [itemParameterId, "apple"], ]), ], [ - JSON.stringify(["boxId", "collectionId[1]", "collectionId", "bodyId"]), + JSON.stringify([ + "boxId", + "collectionId[1]", + "collectionId", + "bodyId", + ROOT_INSTANCE_ID, + ]), new Map([ [dataVariableId, ["apple", "banana", "orange"]], [itemParameterId, "banana"], ]), ], [ - JSON.stringify(["boxId", "collectionId[2]", "collectionId", "bodyId"]), + JSON.stringify([ + "boxId", + "collectionId[2]", + "collectionId", + "bodyId", + ROOT_INSTANCE_ID, + ]), new Map([ [dataVariableId, ["apple", "banana", "orange"]], [itemParameterId, "orange"], ]), ], [ - JSON.stringify(["collectionId", "bodyId"]), + JSON.stringify(["collectionId", "bodyId", ROOT_INSTANCE_ID]), new Map([ [dataVariableId, ["apple", "banana", "orange"]], ]), @@ -736,8 +783,9 @@ test("compute resource variable values", () => { $resourceValues.set(new Map([[resourceId, "my-value"]])); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([[resourceVariableId, "my-value"]]), ], ]) @@ -765,23 +813,30 @@ test("stop variables lookup outside of slots", () => { selectPageRoot("bodyId"); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([[bodyVariableId, "body"]]), ], [ - JSON.stringify(["slotId", "bodyId"]), + JSON.stringify(["slotId", "bodyId", ROOT_INSTANCE_ID]), new Map([ [bodyVariableId, "body"], [slotVariableId, "slot"], ]), ], [ - JSON.stringify(["fragmentId", "slotId", "bodyId"]), + JSON.stringify(["fragmentId", "slotId", "bodyId", ROOT_INSTANCE_ID]), new Map(), ], [ - JSON.stringify(["boxId", "fragmentId", "slotId", "bodyId"]), + JSON.stringify([ + "boxId", + "fragmentId", + "slotId", + "bodyId", + ROOT_INSTANCE_ID, + ]), new Map([[boxVariableId, "box"]]), ], ]) @@ -808,8 +863,9 @@ test("compute parameter and resource variables without values to make it availab selectPageRoot("bodyId"); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([ [resourceVariableId, undefined], [parameterVariableId, undefined], @@ -831,8 +887,9 @@ test("prefill default system variable value", () => { selectPageRoot("bodyId", systemId); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([ [ systemId, @@ -847,8 +904,9 @@ test("prefill default system variable value", () => { ); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([ [ systemId, @@ -879,14 +937,94 @@ test("mask variables with the same name in nested scope", () => { selectPageRoot("bodyId"); expect($variableValuesByInstanceSelector.get()).toEqual( new Map([ + [JSON.stringify([ROOT_INSTANCE_ID]), new Map()], [ - JSON.stringify(["bodyId"]), + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), new Map([[bodyVariableId, "body"]]), ], [ - JSON.stringify(["boxId", "bodyId"]), + JSON.stringify(["boxId", "bodyId", ROOT_INSTANCE_ID]), new Map([[boxVariableId, "box"]]), ], ]) ); }); + +test("inherit variables from global root", () => { + const rootVariable = new Variable("rootVariable", "root"); + const boxVariable = new Variable("myVariable", "box"); + const data = renderData( + + <$.Body ws:id="bodyId"> + <$.Box ws:id="boxId" vars={expression`${boxVariable}`}> + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + $instances.set(data.instances); + $dataSources.set(data.dataSources); + $props.set(data.props); + const [rootVariableId, boxVariableId] = data.dataSources.keys(); + selectPageRoot("bodyId"); + expect($variableValuesByInstanceSelector.get()).toEqual( + new Map([ + [ + JSON.stringify([ROOT_INSTANCE_ID]), + new Map([[rootVariableId, "root"]]), + ], + [ + JSON.stringify(["bodyId", ROOT_INSTANCE_ID]), + new Map([[rootVariableId, "root"]]), + ], + [ + JSON.stringify(["boxId", "bodyId", ROOT_INSTANCE_ID]), + new Map([ + [rootVariableId, "root"], + [boxVariableId, "box"], + ]), + ], + ]) + ); +}); + +test("inherit variables from global root inside slots", () => { + const rootVariable = new Variable("rootVariable", "root"); + const bodyVariable = new Variable("bodyVariable", "body"); + const boxVariable = new Variable("myVariable", "box"); + const data = renderData( + + <$.Body ws:id="bodyId" vars={expression`${bodyVariable}`}> + <$.Slot ws:id="slotId"> + <$.Fragment ws:id="fragmentId"> + <$.Box ws:id="boxId" vars={expression`${boxVariable}`}> + + + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + $instances.set(data.instances); + $dataSources.set(data.dataSources); + $props.set(data.props); + const [rootVariableId, _bodyVariableId, boxVariableId] = + data.dataSources.keys(); + selectPageRoot("bodyId"); + expect( + $variableValuesByInstanceSelector + .get() + .get( + getInstanceKey([ + "boxId", + "fragmentId", + "slotId", + "bodyId", + ROOT_INSTANCE_ID, + ]) + ) + ).toEqual( + new Map([ + [rootVariableId, "root"], + [boxVariableId, "box"], + ]) + ); +}); diff --git a/apps/builder/app/shared/nano-states/props.ts b/apps/builder/app/shared/nano-states/props.ts index dfdedf8990f2..221af295a4ac 100644 --- a/apps/builder/app/shared/nano-states/props.ts +++ b/apps/builder/app/shared/nano-states/props.ts @@ -14,9 +14,9 @@ import { transpileExpression, collectionComponent, portalComponent, + ROOT_INSTANCE_ID, } from "@webstudio-is/sdk"; import { normalizeProps, textContentAttribute } from "@webstudio-is/react-sdk"; -import { isFeatureEnabled } from "@webstudio-is/feature-flags"; import { mapGroupBy } from "~/shared/shim"; import { $instances } from "./instances"; import { @@ -226,6 +226,7 @@ export const $propValuesByInstanceSelector = computed( assets, uploadingFilesDataStore ) => { + // already includes global variables const variableValues = new Map(unscopedVariableValues); let propsList = Array.from(props.values()); @@ -414,16 +415,15 @@ export const $variableValuesByInstanceSelector = computed( if (page === undefined) { return variableValuesByInstanceSelector; } - const traverseInstances = ( + + const collectVariables = ( instanceSelector: InstanceSelector, parentVariableValues = new Map(), parentVariableNames = new Map() ) => { const [instanceId] = instanceSelector; - const instance = instances.get(instanceId); - const variableNames = new Map(parentVariableNames); - let variableValues = new Map(parentVariableValues); + const variableValues = new Map(parentVariableValues); variableValuesByInstanceSelector.set( getInstanceKey(instanceSelector), variableValues @@ -431,12 +431,6 @@ export const $variableValuesByInstanceSelector = computed( const variables = variablesByInstanceId.get(instanceId); if (variables) { for (const variable of variables) { - if ( - variable.id === page.systemDataSourceId && - isFeatureEnabled("filters") === false - ) { - continue; - } // delete previous variable with the same name // because it is masked and no longer available variableValues.delete(variableNames.get(variable.name) ?? ""); @@ -461,7 +455,21 @@ export const $variableValuesByInstanceSelector = computed( } } } + return { variableValues, variableNames }; + }; + + const traverseInstances = ( + instanceSelector: InstanceSelector, + parentVariableValues = new Map(), + parentVariableNames = new Map() + ) => { + let { variableValues, variableNames } = collectVariables( + instanceSelector, + parentVariableValues, + parentVariableNames + ); + const [instanceId] = instanceSelector; const propValues = new Map(); const props = propsByInstanceId.get(instanceId); const parameters = new Map(); @@ -489,6 +497,7 @@ export const $variableValuesByInstanceSelector = computed( } } + const instance = instances.get(instanceId); if (instance === undefined) { return; } @@ -520,7 +529,9 @@ export const $variableValuesByInstanceSelector = computed( } // reset values for slot children to let slots behave as isolated components if (instance.component === portalComponent) { - variableValues = new Map(); + // allow accessing global variables in slots + variableValues = globalVariableValues; + variableNames = globalVariableNames; } for (const child of instance.children) { if (child.type === "id") { @@ -532,7 +543,15 @@ export const $variableValuesByInstanceSelector = computed( } } }; - traverseInstances([page.rootInstanceId]); + const { + variableValues: globalVariableValues, + variableNames: globalVariableNames, + } = collectVariables([ROOT_INSTANCE_ID]); + traverseInstances( + [page.rootInstanceId, ROOT_INSTANCE_ID], + globalVariableValues, + globalVariableNames + ); return variableValuesByInstanceSelector; } ); diff --git a/apps/builder/app/shared/page-utils.ts b/apps/builder/app/shared/page-utils.ts index fe4f1d688922..d65cbfd47d72 100644 --- a/apps/builder/app/shared/page-utils.ts +++ b/apps/builder/app/shared/page-utils.ts @@ -16,6 +16,7 @@ import { extractWebstudioFragment, insertWebstudioFragmentCopy, } from "./instance-utils"; +import { findAvailableVariables } from "./data-variables"; const deduplicateName = ( pages: Pages, @@ -99,13 +100,19 @@ export const insertPageCopyMutable = ({ insertWebstudioFragmentCopy({ data: target.data, fragment: extractWebstudioFragment(source.data, ROOT_INSTANCE_ID), - availableDataSources: new Set(), + availableVariables: findAvailableVariables({ + ...target.data, + startingInstanceId: ROOT_INSTANCE_ID, + }), }); // copy paste page body const { newInstanceIds, newDataSourceIds } = insertWebstudioFragmentCopy({ data: target.data, fragment: extractWebstudioFragment(source.data, page.rootInstanceId), - availableDataSources: new Set(), + availableVariables: findAvailableVariables({ + ...target.data, + startingInstanceId: ROOT_INSTANCE_ID, + }), }); const newPageId = nanoid(); const newRootInstanceId = diff --git a/packages/react-sdk/src/component-generator.test.tsx b/packages/react-sdk/src/component-generator.test.tsx index 711234f7625e..9b319e718a92 100644 --- a/packages/react-sdk/src/component-generator.test.tsx +++ b/packages/react-sdk/src/component-generator.test.tsx @@ -1,7 +1,7 @@ import ts from "typescript"; import { expect, test } from "vitest"; import stripIndent from "strip-indent"; -import { createScope } from "@webstudio-is/sdk"; +import { createScope, ROOT_INSTANCE_ID } from "@webstudio-is/sdk"; import { $, ActionValue, @@ -1099,3 +1099,72 @@ test("generate unset variables as undefined", () => { ) ); }); + +test("generate global variables", () => { + const rootVariable = new Variable("rootVariable", "root"); + const data = renderData( + + <$.Body ws:id="body"> + <$.Box>{expression`${rootVariable}`} + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + expect( + generateWebstudioComponent({ + classesMap: new Map(), + scope: createScope(), + name: "Page", + rootInstanceId: "body", + parameters: [], + indexesWithinAncestors: new Map(), + ...data, + }) + ).toEqual( + validateJSX( + clear(` + const Page = () => { + let [rootVariable, set$rootVariable] = useVariableState("root") + return + + {rootVariable} + + + } + `) + ) + ); +}); + +test("ignore unused global variables", () => { + const rootVariable = new Variable("rootVariable", "root"); + const data = renderData( + + <$.Body ws:id="body"> + <$.Box> + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + expect( + generateWebstudioComponent({ + classesMap: new Map(), + scope: createScope(), + name: "Page", + rootInstanceId: "body", + parameters: [], + indexesWithinAncestors: new Map(), + ...data, + }) + ).toEqual( + validateJSX( + clear(` + const Page = () => { + return + + + } + `) + ) + ); +});