From c2d1179f101b60da43566634ee325d8191a0b30e Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Thu, 2 Nov 2023 16:26:49 +0300 Subject: [PATCH] feat: add variables panel (#2548) Ref https://github.com/webstudio-is/webstudio/issues/2532 Here added popover panel to maintain variables. For now only create/edit/delete. Will add bindings support in another PR. Can be tested in storybook "Variables" component. Screenshot 2023-11-02 at 02 36 29 Screenshot 2023-11-02 at 02 36 37
Screenshot 2023-11-02 at 02 37 21 Screenshot 2023-11-02 at 02 38 42 --- .../settings-panel/variables.stories.tsx | 38 ++ .../features/settings-panel/variables.tsx | 380 ++++++++++++++++++ .../src/components/css-value-list-item.tsx | 6 - .../src/components/floating-panel-popover.tsx | 11 +- 4 files changed, 426 insertions(+), 9 deletions(-) create mode 100644 apps/builder/app/builder/features/settings-panel/variables.stories.tsx create mode 100644 apps/builder/app/builder/features/settings-panel/variables.tsx diff --git a/apps/builder/app/builder/features/settings-panel/variables.stories.tsx b/apps/builder/app/builder/features/settings-panel/variables.stories.tsx new file mode 100644 index 0000000000..46da2d2aec --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/variables.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CodeEditor as CodeEditorComponent } from "./code-editor"; +import { + Button, + FloatingPanelPopover, + FloatingPanelPopoverContent, + FloatingPanelPopoverTrigger, +} from "@webstudio-is/design-system"; +import { VariablesPanel } from "./variables"; +import { selectedInstanceSelectorStore } from "~/shared/nano-states"; +import { registerContainers } from "~/shared/sync"; +import { useState } from "react"; + +export default { + title: "Builder/Variables", + component: CodeEditorComponent, +} satisfies Meta; + +registerContainers(); +selectedInstanceSelectorStore.set(["root"]); + +const VariablesPopover = () => { + const [isOpen, setIsOpen] = useState(true); + return ( + + + + + + + + + ); +}; + +export const Variables: StoryObj = { + render: () => , +}; diff --git a/apps/builder/app/builder/features/settings-panel/variables.tsx b/apps/builder/app/builder/features/settings-panel/variables.tsx new file mode 100644 index 0000000000..a930ceb1a5 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/variables.tsx @@ -0,0 +1,380 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import { nanoid } from "nanoid"; +import { computed } from "nanostores"; +import { useStore } from "@nanostores/react"; +import { + Button, + CssValueListArrowFocus, + CssValueListItem, + Flex, + FloatingPanelPopoverTitle, + InputErrorsTooltip, + InputField, + Label, + ScrollArea, + SmallIconButton, + Text, + Tooltip, + theme, +} from "@webstudio-is/design-system"; +import { MenuIcon, MinusIcon, PlusIcon } from "@webstudio-is/icons"; +import type { DataSource } from "@webstudio-is/sdk"; +import { validateExpression } from "@webstudio-is/react-sdk"; +import { + dataSourcesStore, + selectedInstanceSelectorStore, +} from "~/shared/nano-states"; +import { serverSyncStore } from "~/shared/sync"; +import { CodeEditor } from "./code-editor"; + +type VariableDataSource = Extract; + +/** + * convert value expression to js value + * validating out accessing any identifier + */ +const parseVariableValue = (code: string) => { + const result: { value?: unknown; error?: string } = {}; + const ids = new Set(); + try { + validateExpression(code, { + transformIdentifier: (id) => { + ids.add(id); + return id; + }, + }); + } catch (error) { + result.error = (error as Error).message; + return result; + } + if (ids.size === 0) { + try { + result.value = eval(code); + } catch (error) { + result.error = `Parse Error: ${(error as Error).message}`; + } + } else { + const idsList = Array.from(ids).join(", "); + result.error = `Cannot use variables ${idsList} as variable value`; + } + return result; +}; + +const saveVariable = ( + dataSourceId: string = nanoid(), + name: string, + valueString: string +): undefined | { error?: string } => { + const { value, error } = parseVariableValue(valueString); + if (error !== undefined) { + return { error }; + } + + const instanceSelector = selectedInstanceSelectorStore.get(); + if (instanceSelector === undefined) { + return; + } + const [instanceId] = instanceSelector; + serverSyncStore.createTransaction([dataSourcesStore], (dataSources) => { + let variableValue: VariableDataSource["value"] = { + type: "string", + value: "", + }; + if (typeof value === "string") { + variableValue = { type: "string", value }; + } + if (typeof value === "number") { + variableValue = { type: "number", value }; + } + if (typeof value === "boolean") { + variableValue = { type: "boolean", value }; + } + dataSources.set(dataSourceId, { + id: dataSourceId, + // preserve existing instance scope when edit + scopeInstanceId: + dataSources.get(dataSourceId)?.scopeInstanceId ?? instanceId, + name, + type: "variable", + value: variableValue, + }); + }); +}; + +const VariablePanel = ({ + variable, + onBack, +}: { + variable?: VariableDataSource; + onBack: () => void; +}) => { + // variable value cannot have an access to other variables + const variables = useMemo(() => new Map(), []); + const nameId = useId(); + const [name, setName] = useState(variable?.name ?? ""); + const [nameErrors, setNameErrors] = useState(); + const [value, setValue] = useState( + JSON.stringify(variable?.value.value ?? "") + ); + const [valueErrors, setValueErrors] = useState(); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // prevent closing popover + event.preventDefault(); + onBack(); + } + }; + document.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + document.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [onBack]); + + return ( + + + + + + setName(event.target.value)} + /> + + + + + +
+ +
+
+
+ + + + +
+
+ ); +}; + +const selectedInstanceVariables = computed( + [selectedInstanceSelectorStore, dataSourcesStore], + (instanceSelector, dataSources) => { + const matchedVariables: VariableDataSource[] = []; + if (instanceSelector === undefined) { + return matchedVariables; + } + for (const dataSource of dataSources.values()) { + if ( + dataSource.type === "variable" && + dataSource.scopeInstanceId !== undefined && + instanceSelector.includes(dataSource.scopeInstanceId) + ) { + matchedVariables.push(dataSource); + } + } + return matchedVariables; + } +); + +const EmptyList = ({ onAdd }: { onAdd: () => void }) => { + return ( + + + No variables yet + + + + + + ); +}; + +const deleteVariable = (variable: VariableDataSource) => { + // @todo prevent delete when variable is used + serverSyncStore.createTransaction([dataSourcesStore], (dataSources) => { + dataSources.delete(variable.id); + }); +}; + +const ListItem = ({ + index, + selectedId, + variable, + onSelect, + onEdit, +}: { + index: number; + selectedId: undefined | string; + variable: VariableDataSource; + onSelect: (variableId: DataSource["id"]) => void; + onEdit: (variable: VariableDataSource) => void; +}) => { + return ( + + {variable.name}: {JSON.stringify(variable.value.value)} + + } + id={variable.id} + index={index} + active={selectedId === variable.id} + buttons={ + <> + + } + onClick={() => onEdit(variable)} + /> + + + } + onClick={() => deleteVariable(variable)} + /> + + + } + onClick={() => onSelect(variable.id)} + /> + ); +}; + +const ListPanel = ({ + onAdd, + onEdit, +}: { + onAdd: () => void; + onEdit: (variable: VariableDataSource) => void; +}) => { + const matchedVariables = useStore(selectedInstanceVariables); + const [selectedId, setSelectedId] = useState(undefined); + return ( + + {matchedVariables.length === 0 ? ( + + ) : ( + + {matchedVariables.map((variable, index) => ( + + ))} + + )} + + ); +}; + +export const VariablesPanel = () => { + const [view, setView] = useState< + | { name: "list" } + | { name: "add" } + | { name: "edit"; variable: VariableDataSource } + >({ name: "list" }); + if (view.name === "add") { + return ( + <> + setView({ name: "list" })} /> + {/* put after content to avoid auto focusing "Close" button */} + New Variable + + ); + } + if (view.name === "edit") { + return ( + <> + setView({ name: "list" })} + /> + {/* put after content to avoid auto focusing "Close" button */} + Edit Variable + + ); + } + return ( + <> + setView({ name: "add" })} + onEdit={(variable) => setView({ name: "edit", variable })} + /> + {/* put after content to avoid auto focusing "New variable" button */} + +