mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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. <img width="252" alt="Screenshot 2023-11-02 at 02 36 29" src="https://github.com/webstudio-is/webstudio/assets/5635476/16ac71fe-17a0-4d5b-878c-aa48c829ef2c"> <img width="254" alt="Screenshot 2023-11-02 at 02 36 37" src="https://github.com/webstudio-is/webstudio/assets/5635476/75335775-bfd1-4322-9bdd-416834d8ab68"> <br /> <img width="251" alt="Screenshot 2023-11-02 at 02 37 21" src="https://github.com/webstudio-is/webstudio/assets/5635476/0c26d307-ee8f-480e-9850-616a8d893bdf"> <img width="248" alt="Screenshot 2023-11-02 at 02 38 42" src="https://github.com/webstudio-is/webstudio/assets/5635476/db9a724a-ad19-4c31-963a-657b2cba561c">
This commit is contained in:
1 parent
2fe575ad2c
commit
c2d1179f10
4 files changed
+426
-9
No files matched your search
@@ -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 (
|
||||
<FloatingPanelPopover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<FloatingPanelPopoverTrigger asChild>
|
||||
<Button>Open variables panel</Button>
|
||||
</FloatingPanelPopoverTrigger>
|
||||
<FloatingPanelPopoverContent align="start">
|
||||
<VariablesPanel />
|
||||
</FloatingPanelPopoverContent>
|
||||
</FloatingPanelPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export const Variables: StoryObj = {
|
||||
render: () => <VariablesPopover />,
|
||||
};
|
||||
@@ -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<DataSource, { type: "variable" }>;
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
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<undefined | string[]>();
|
||||
const [value, setValue] = useState(
|
||||
JSON.stringify(variable?.value.value ?? "")
|
||||
);
|
||||
const [valueErrors, setValueErrors] = useState<undefined | string[]>();
|
||||
|
||||
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 (
|
||||
<ScrollArea
|
||||
css={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: theme.spacing[30],
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
css={{
|
||||
overflow: "hidden",
|
||||
p: theme.spacing[9],
|
||||
gap: theme.spacing[5],
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
|
||||
<Label htmlFor={nameId}>Name</Label>
|
||||
<InputErrorsTooltip errors={nameErrors}>
|
||||
<InputField
|
||||
id={nameId}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</Flex>
|
||||
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
|
||||
<Label>Value</Label>
|
||||
<InputErrorsTooltip errors={valueErrors}>
|
||||
<div>
|
||||
<CodeEditor
|
||||
variables={variables}
|
||||
defaultValue={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
</InputErrorsTooltip>
|
||||
</Flex>
|
||||
<Flex justify="end" css={{ gap: theme.spacing[5] }}>
|
||||
<Button color="neutral" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (/[a-zA-z_]+[a-zA-z0-9_]*/.test(name) === false) {
|
||||
setNameErrors([
|
||||
`Variable names must start with letter or underscore (_) and can include numbers.`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const result = saveVariable(variable?.id, name, value);
|
||||
if (result?.error !== undefined) {
|
||||
setValueErrors([result.error]);
|
||||
return;
|
||||
}
|
||||
onBack();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Flex direction="column" css={{ gap: theme.spacing[5] }}>
|
||||
<Flex justify="center" align="center" css={{ height: theme.spacing[13] }}>
|
||||
<Text variant="labelsSentenceCase">No variables yet</Text>
|
||||
</Flex>
|
||||
<Flex justify="center" align="center" css={{ height: theme.spacing[13] }}>
|
||||
<Button prefix={<PlusIcon />} onClick={onAdd}>
|
||||
Create variable
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<CssValueListItem
|
||||
label={
|
||||
<Label truncate>
|
||||
{variable.name}: {JSON.stringify(variable.value.value)}
|
||||
</Label>
|
||||
}
|
||||
id={variable.id}
|
||||
index={index}
|
||||
active={selectedId === variable.id}
|
||||
buttons={
|
||||
<>
|
||||
<Tooltip content="Edit variable" side="bottom">
|
||||
<SmallIconButton
|
||||
tabIndex={-1}
|
||||
aria-label="Edit variable"
|
||||
icon={<MenuIcon />}
|
||||
onClick={() => onEdit(variable)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Delete variable" side="bottom">
|
||||
<SmallIconButton
|
||||
tabIndex={-1}
|
||||
aria-label="Delete variable"
|
||||
variant="destructive"
|
||||
icon={<MinusIcon />}
|
||||
onClick={() => deleteVariable(variable)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
onClick={() => onSelect(variable.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ListPanel = ({
|
||||
onAdd,
|
||||
onEdit,
|
||||
}: {
|
||||
onAdd: () => void;
|
||||
onEdit: (variable: VariableDataSource) => void;
|
||||
}) => {
|
||||
const matchedVariables = useStore(selectedInstanceVariables);
|
||||
const [selectedId, setSelectedId] = useState<undefined | string>(undefined);
|
||||
return (
|
||||
<ScrollArea
|
||||
css={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: theme.spacing[30],
|
||||
padding: `${theme.spacing[5]} 0 ${theme.spacing[9]}`,
|
||||
}}
|
||||
>
|
||||
{matchedVariables.length === 0 ? (
|
||||
<EmptyList onAdd={onAdd} />
|
||||
) : (
|
||||
<CssValueListArrowFocus>
|
||||
{matchedVariables.map((variable, index) => (
|
||||
<ListItem
|
||||
key={variable.id}
|
||||
index={index}
|
||||
variable={variable}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</CssValueListArrowFocus>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariablesPanel = () => {
|
||||
const [view, setView] = useState<
|
||||
| { name: "list" }
|
||||
| { name: "add" }
|
||||
| { name: "edit"; variable: VariableDataSource }
|
||||
>({ name: "list" });
|
||||
if (view.name === "add") {
|
||||
return (
|
||||
<>
|
||||
<VariablePanel onBack={() => setView({ name: "list" })} />
|
||||
{/* put after content to avoid auto focusing "Close" button */}
|
||||
<FloatingPanelPopoverTitle>New Variable</FloatingPanelPopoverTitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (view.name === "edit") {
|
||||
return (
|
||||
<>
|
||||
<VariablePanel
|
||||
variable={view.variable}
|
||||
onBack={() => setView({ name: "list" })}
|
||||
/>
|
||||
{/* put after content to avoid auto focusing "Close" button */}
|
||||
<FloatingPanelPopoverTitle>Edit Variable</FloatingPanelPopoverTitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ListPanel
|
||||
onAdd={() => setView({ name: "add" })}
|
||||
onEdit={(variable) => setView({ name: "edit", variable })}
|
||||
/>
|
||||
{/* put after content to avoid auto focusing "New variable" button */}
|
||||
<FloatingPanelPopoverTitle
|
||||
actions={
|
||||
<Tooltip content="New variable" side="bottom">
|
||||
<Button
|
||||
aria-label="New variable"
|
||||
prefix={<PlusIcon />}
|
||||
color="ghost"
|
||||
onClick={() => setView({ name: "add" })}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
Variables
|
||||
</FloatingPanelPopoverTitle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -40,7 +40,6 @@ const IconButtonsWrapper = styled(Flex, {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
paddingRight: sharedPaddingRight,
|
||||
visibility: "hidden",
|
||||
});
|
||||
|
||||
const FakeIconButtonsWrapper = styled(Flex, {
|
||||
@@ -118,11 +117,6 @@ const ItemWrapper = styled("div", {
|
||||
visibility: "visible",
|
||||
},
|
||||
},
|
||||
"&:hover, &[data-active=true], &:focus-within": {
|
||||
[`& ${IconButtonsWrapper}`]: {
|
||||
visibility: "visible",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const FakeSmallButton = styled("div", {
|
||||
|
||||
@@ -45,17 +45,22 @@ FloatingPanelPopoverContent.displayName = "FloatingPanelPopoverContent";
|
||||
|
||||
export const FloatingPanelPopoverTitle = ({
|
||||
children,
|
||||
actions,
|
||||
closeLabel = "Close dialog",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
closeLabel?: string;
|
||||
}) => (
|
||||
<TitleSlot>
|
||||
<PanelTitle
|
||||
suffix={
|
||||
<Primitive.Close asChild>
|
||||
<CloseButton aria-label={closeLabel} />
|
||||
</Primitive.Close>
|
||||
<>
|
||||
{actions}
|
||||
<Primitive.Close asChild>
|
||||
<CloseButton aria-label={closeLabel} />
|
||||
</Primitive.Close>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in new issue
Block a user