Skip to content

Commit bf6abe7

Browse files
authored
feat: Manage data variables (webstudio-is#5488)
ref webstudio-is#4608 ## Description 1. What is this PR about (link the issue and add a short description) ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent b2b423c commit bf6abe7

18 files changed

Lines changed: 1019 additions & 294 deletions

apps/builder/app/builder/features/command-panel/command-panel.tsx

Lines changed: 14 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,13 @@ import {
1616
$commandSearch,
1717
closeCommandPanel,
1818
} from "./command-state";
19-
import {
20-
$allOptions,
21-
groups,
22-
type ComponentOption,
23-
type TagOption,
24-
type BreakpointOption,
25-
type PageOption,
26-
type CommandOption,
27-
type TokenOption,
28-
} from "./groups";
19+
import { $allOptions, groups, type Option } from "./groups";
20+
21+
const renderGroup = (type: Option["type"], matches: Option[]): JSX.Element => {
22+
const Group = groups[type];
23+
// Type assertion is safe here because matches are filtered by type before calling renderGroup
24+
return <Group key={type} options={matches as never} />;
25+
};
2926

3027
const CommandDialogContent = () => {
3128
const search = useStore($commandSearch);
@@ -40,7 +37,10 @@ const CommandDialogContent = () => {
4037
});
4138
}
4239
}
43-
const matchGroups = mapGroupBy(matches, (match) => match.type);
40+
const matchGroups = mapGroupBy<Option, Option["type"]>(
41+
matches,
42+
(match) => match.type
43+
);
4444
return (
4545
<>
4646
<CommandInput
@@ -50,57 +50,9 @@ const CommandDialogContent = () => {
5050
<Flex direction="column" css={{ maxHeight: 300 }}>
5151
<ScrollArea>
5252
<CommandList>
53-
{Array.from(matchGroups).map(([groupType, matches]) => {
54-
if (groupType === "component") {
55-
return (
56-
<groups.component
57-
key={groupType}
58-
options={matches as ComponentOption[]}
59-
/>
60-
);
61-
}
62-
if (groupType === "tag") {
63-
return (
64-
<groups.tag
65-
key={groupType}
66-
options={matches as TagOption[]}
67-
/>
68-
);
69-
}
70-
if (groupType === "breakpoint") {
71-
return (
72-
<groups.breakpoint
73-
key={groupType}
74-
options={matches as BreakpointOption[]}
75-
/>
76-
);
77-
}
78-
if (groupType === "page") {
79-
return (
80-
<groups.page
81-
key={groupType}
82-
options={matches as PageOption[]}
83-
/>
84-
);
85-
}
86-
if (groupType === "command") {
87-
return (
88-
<groups.command
89-
key={groupType}
90-
options={matches as CommandOption[]}
91-
/>
92-
);
93-
}
94-
if (groupType === "token") {
95-
return (
96-
<groups.token
97-
key={groupType}
98-
options={matches as TokenOption[]}
99-
/>
100-
);
101-
}
102-
groupType satisfies never;
103-
})}
53+
{Array.from(matchGroups).map(([type, matches]) =>
54+
renderGroup(type, matches)
55+
)}
10456
</CommandList>
10557
</ScrollArea>
10658
</Flex>

apps/builder/app/builder/features/command-panel/groups/breakpoint-group.tsx renamed to apps/builder/app/builder/features/command-panel/groups/breakpoints-group.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import {
1515
} from "~/shared/nano-states";
1616
import { setCanvasWidth } from "~/builder/features/breakpoints";
1717
import { closeCommandPanel } from "../command-state";
18+
import type { BaseOption } from "../shared/types";
1819

19-
export type BreakpointOption = {
20-
terms: string[];
20+
export type BreakpointOption = BaseOption & {
2121
type: "breakpoint";
2222
breakpoint: Breakpoint;
23-
shortcut: string;
23+
keys: string[];
2424
};
2525

2626
export const $breakpointOptions = computed(
@@ -41,7 +41,7 @@ export const $breakpointOptions = computed(
4141
terms: ["breakpoints", breakpoint.label, width],
4242
type: "breakpoint",
4343
breakpoint,
44-
shortcut: (index + 1).toString(),
44+
keys: [(index + 1).toString()],
4545
});
4646
}
4747
return breakpointOptions;
@@ -59,7 +59,7 @@ const getBreakpointLabel = (breakpoint: Breakpoint) => {
5959
return `${breakpoint.label}: ${label}`;
6060
};
6161

62-
export const BreakpointGroup = ({
62+
export const BreakpointsGroup = ({
6363
options,
6464
}: {
6565
options: BreakpointOption[];
@@ -70,7 +70,7 @@ export const BreakpointGroup = ({
7070
heading={<CommandGroupHeading>Breakpoints</CommandGroupHeading>}
7171
actions={["select"]}
7272
>
73-
{options.map(({ breakpoint, shortcut }) => (
73+
{options.map(({ breakpoint, keys }) => (
7474
<CommandItem
7575
key={breakpoint.id}
7676
// preserve selected state when rerender
@@ -84,7 +84,7 @@ export const BreakpointGroup = ({
8484
<Text variant="labelsTitleCase">
8585
{getBreakpointLabel(breakpoint)}
8686
</Text>
87-
<Kbd value={[shortcut]} />
87+
<Kbd value={keys} />
8888
</CommandItem>
8989
))}
9090
</CommandGroup>

apps/builder/app/builder/features/command-panel/groups/command-group.tsx renamed to apps/builder/app/builder/features/command-panel/groups/commands-group.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { $commandMetas } from "~/shared/commands-emitter";
1010
import { emitCommand } from "~/builder/shared/commands";
1111
import { humanizeString } from "~/shared/string-utils";
1212
import { closeCommandPanel } from "../command-state";
13+
import type { BaseOption } from "../shared/types";
1314

14-
export type CommandOption = {
15-
terms: string[];
15+
export type CommandOption = BaseOption & {
1616
type: "command";
1717
name: string;
1818
label: string;

apps/builder/app/builder/features/command-panel/groups/component-group.tsx renamed to apps/builder/app/builder/features/command-panel/groups/components-group.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ import {
2929
InstanceIcon,
3030
} from "~/builder/shared/instance-label";
3131
import { closeCommandPanel } from "../command-state";
32+
import type { BaseOption } from "../shared/types";
3233

33-
export type ComponentOption = {
34-
terms: string[];
34+
export type ComponentOption = BaseOption & {
3535
type: "component";
3636
component: string;
3737
label: string;
@@ -110,7 +110,11 @@ export const $componentOptions = computed(
110110
}
111111
);
112112

113-
export const ComponentGroup = ({ options }: { options: ComponentOption[] }) => {
113+
export const ComponentsGroup = ({
114+
options,
115+
}: {
116+
options: ComponentOption[];
117+
}) => {
114118
return (
115119
<CommandGroup
116120
name="component"
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { useState } from "react";
2+
import { computed } from "nanostores";
3+
import {
4+
CommandGroup,
5+
CommandGroupHeading,
6+
CommandItem,
7+
Text,
8+
toast,
9+
useSelectedAction,
10+
} from "@webstudio-is/design-system";
11+
import { $instances, $dataSources } from "~/shared/nano-states";
12+
import { selectInstance as selectInstanceBySelector } from "~/shared/awareness";
13+
import { $activeInspectorPanel } from "~/builder/shared/nano-states";
14+
import { $commandContent, closeCommandPanel } from "../command-state";
15+
import { InstanceList, selectInstance } from "../shared/instance-list";
16+
import { deleteVariableMutable } from "~/shared/data-variables";
17+
import { updateWebstudioData } from "~/shared/instance-utils";
18+
import {
19+
DeleteDataVariableDialog,
20+
RenameDataVariableDialog,
21+
$usedVariablesInInstances,
22+
} from "~/builder/shared/data-variable-utils";
23+
import type { BaseOption } from "../shared/types";
24+
25+
export type DataVariableOption = BaseOption & {
26+
type: "dataVariable";
27+
id: string;
28+
name: string;
29+
instanceId: string;
30+
usages: number;
31+
};
32+
33+
export const $dataVariableOptions = computed(
34+
[$dataSources, $instances, $usedVariablesInInstances],
35+
(dataSources, instances, usedInInstances) => {
36+
const dataVariableOptions: DataVariableOption[] = [];
37+
38+
for (const dataSource of dataSources.values()) {
39+
if (
40+
dataSource.type === "variable" &&
41+
dataSource.scopeInstanceId !== undefined
42+
) {
43+
const instance = instances.get(dataSource.scopeInstanceId);
44+
if (instance) {
45+
const usages = usedInInstances.get(dataSource.id)?.size ?? 0;
46+
dataVariableOptions.push({
47+
terms: ["variable", "variables", "data", dataSource.name],
48+
type: "dataVariable",
49+
id: dataSource.id,
50+
name: dataSource.name,
51+
instanceId: dataSource.scopeInstanceId,
52+
usages,
53+
});
54+
}
55+
}
56+
}
57+
58+
return dataVariableOptions;
59+
}
60+
);
61+
62+
const DataVariableInstances = ({ variableId }: { variableId: string }) => {
63+
const usedInInstances = $usedVariablesInInstances.get();
64+
const instanceIds = usedInInstances.get(variableId) ?? new Set();
65+
66+
return (
67+
<InstanceList
68+
instanceIds={instanceIds}
69+
onSelect={(instanceId) => {
70+
selectInstance(instanceId);
71+
$activeInspectorPanel.set("settings");
72+
}}
73+
/>
74+
);
75+
};
76+
77+
export const DataVariablesGroup = ({
78+
options,
79+
}: {
80+
options: DataVariableOption[];
81+
}) => {
82+
const action = useSelectedAction();
83+
const [variableToRename, setVariableToRename] =
84+
useState<DataVariableOption>();
85+
const [variableToDelete, setVariableToDelete] =
86+
useState<DataVariableOption>();
87+
88+
const handleSelect = (option: DataVariableOption) => {
89+
if (action === "find") {
90+
if (option.usages > 0) {
91+
$commandContent.set(<DataVariableInstances variableId={option.id} />);
92+
} else {
93+
toast.error("Variable is not used in any instance");
94+
}
95+
return;
96+
}
97+
98+
if (action === "rename") {
99+
setVariableToRename(option);
100+
return;
101+
}
102+
103+
if (action === "delete") {
104+
setVariableToDelete(option);
105+
return;
106+
}
107+
108+
closeCommandPanel();
109+
110+
// Find the instance selector
111+
const instanceSelector: string[] = [option.instanceId];
112+
113+
// Select the instance
114+
selectInstanceBySelector(instanceSelector);
115+
116+
// Switch to settings tab
117+
$activeInspectorPanel.set("settings");
118+
};
119+
120+
return (
121+
<>
122+
<CommandGroup
123+
name="dataVariable"
124+
heading={<CommandGroupHeading>Data variables</CommandGroupHeading>}
125+
actions={["find", "select", "rename", "delete"]}
126+
>
127+
{options.map((option) => (
128+
<CommandItem
129+
key={option.id}
130+
value={option.id}
131+
onSelect={() => handleSelect(option)}
132+
>
133+
<Text variant="labelsSentenceCase">
134+
{option.name}{" "}
135+
<Text as="span" color="moreSubtle">
136+
{option.usages === 0
137+
? "unused"
138+
: `${option.usages} ${option.usages === 1 ? "usage" : "usages"}`}
139+
</Text>
140+
</Text>
141+
</CommandItem>
142+
))}
143+
</CommandGroup>
144+
<RenameDataVariableDialog
145+
variable={variableToRename}
146+
onClose={() => {
147+
setVariableToRename(undefined);
148+
}}
149+
onConfirm={(_variableId, newName) => {
150+
toast.success(
151+
`Variable renamed from "${variableToRename?.name}" to "${newName}"`
152+
);
153+
setVariableToRename(undefined);
154+
}}
155+
/>
156+
<DeleteDataVariableDialog
157+
variable={variableToDelete}
158+
onClose={() => {
159+
setVariableToDelete(undefined);
160+
}}
161+
onConfirm={(variableId) => {
162+
updateWebstudioData((data) => {
163+
deleteVariableMutable(data, variableId);
164+
});
165+
toast.success(`Variable "${variableToDelete?.name}" deleted`);
166+
setVariableToDelete(undefined);
167+
}}
168+
/>
169+
</>
170+
);
171+
};

0 commit comments

Comments
 (0)