Skip to content

Commit 8b526dc

Browse files
authored
refactor: store webhook form action as resource (#4983)
Ref #4093 Here's refactoring to unlock resource UI in webhook form Need to test in builder and on published site 1. Create webhook form in main and try to update it on this branch. So legacy text prop should automatically migrate to resource. 2. New webhook form should create resource when type anything in action prop
1 parent fcb4e25 commit 8b526dc

File tree

35 files changed

+241
-144
lines changed

35 files changed

+241
-144
lines changed

apps/builder/app/builder/features/settings-panel/controls/combined.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { UrlControl } from "./url";
1010
import type { ControlProps } from "../shared";
1111
import { JsonControl } from "./json";
1212
import { TextContent } from "./text-content";
13+
import { ResourceControl } from "./resource-control";
1314

1415
export const renderControl = ({
1516
meta,
@@ -45,6 +46,10 @@ export const renderControl = ({
4546
return <TextControl key={key} meta={meta} prop={prop} {...rest} />;
4647
}
4748

49+
if (meta.control === "resource") {
50+
return <ResourceControl key={key} meta={meta} prop={prop} {...rest} />;
51+
}
52+
4853
if (meta.control === "code") {
4954
return <CodeControl key={key} meta={meta} prop={prop} {...rest} />;
5055
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useId } from "react";
2+
import { useStore } from "@nanostores/react";
3+
import { InputField } from "@webstudio-is/design-system";
4+
import { isLiteralExpression, Resource, type Prop } from "@webstudio-is/sdk";
5+
import {
6+
BindingControl,
7+
BindingPopover,
8+
type BindingVariant,
9+
} from "~/builder/shared/binding-popover";
10+
import {
11+
type ControlProps,
12+
useLocalValue,
13+
ResponsiveLayout,
14+
Label,
15+
humanizeAttribute,
16+
} from "../shared";
17+
import { $resources } from "~/shared/nano-states";
18+
import { $selectedInstanceResourceScope } from "../resource-panel";
19+
import { computeExpression } from "~/shared/data-variables";
20+
import { updateWebstudioData } from "~/shared/instance-utils";
21+
import { nanoid } from "nanoid";
22+
23+
export const ResourceControl = ({
24+
meta,
25+
prop,
26+
propName,
27+
instanceId,
28+
deletable,
29+
}: ControlProps<"resource">) => {
30+
const resources = useStore($resources);
31+
const { variableValues, scope, aliases } = useStore(
32+
$selectedInstanceResourceScope
33+
);
34+
35+
let computedValue: unknown;
36+
let expression: string = JSON.stringify("");
37+
if (prop?.type === "string") {
38+
expression = JSON.stringify(prop.value);
39+
computedValue = prop.value;
40+
}
41+
if (prop?.type === "expression") {
42+
expression = prop.value;
43+
computedValue = computeExpression(prop.value, variableValues);
44+
}
45+
if (prop?.type === "resource") {
46+
const resource = resources.get(prop.value);
47+
if (resource) {
48+
expression = resource.url;
49+
computedValue = computeExpression(resource.url, variableValues);
50+
}
51+
}
52+
53+
const updateResourceUrl = (urlExpression: string) => {
54+
updateWebstudioData((data) => {
55+
if (prop?.type === "resource") {
56+
const resource = data.resources.get(prop.value);
57+
if (resource) {
58+
resource.url = urlExpression;
59+
}
60+
} else {
61+
let method: Resource["method"] = "post";
62+
for (const prop of data.props.values()) {
63+
if (
64+
prop.instanceId === instanceId &&
65+
prop.type === "string" &&
66+
prop.name === "method"
67+
) {
68+
const value = prop.value.toLowerCase();
69+
if (
70+
value === "get" ||
71+
value === "post" ||
72+
value === "put" ||
73+
value === "delete"
74+
) {
75+
method = value;
76+
}
77+
break;
78+
}
79+
}
80+
81+
const newResource: Resource = {
82+
id: nanoid(),
83+
name: propName,
84+
url: urlExpression,
85+
method,
86+
headers: [{ name: "Content-Type", value: `"application/json"` }],
87+
};
88+
const newProp: Prop = {
89+
id: prop?.id ?? nanoid(),
90+
instanceId,
91+
name: propName,
92+
type: "resource",
93+
value: newResource.id,
94+
};
95+
data.props.set(newProp.id, newProp);
96+
data.resources.set(newResource.id, newResource);
97+
}
98+
});
99+
};
100+
101+
const deletePropAndResource = () => {
102+
updateWebstudioData((data) => {
103+
if (prop?.type === "resource") {
104+
data.resources.delete(prop.value);
105+
}
106+
if (prop) {
107+
data.props.delete(prop.id);
108+
}
109+
});
110+
};
111+
112+
const id = useId();
113+
const label = humanizeAttribute(meta.label || propName);
114+
let variant: BindingVariant = "bound";
115+
let readOnly = true;
116+
if (isLiteralExpression(expression)) {
117+
variant = "default";
118+
readOnly = false;
119+
}
120+
const localValue = useLocalValue(String(computedValue ?? ""), (value) =>
121+
updateResourceUrl(JSON.stringify(value))
122+
);
123+
124+
return (
125+
<ResponsiveLayout
126+
label={
127+
<Label htmlFor={id} description={meta.description} readOnly={readOnly}>
128+
{label}
129+
</Label>
130+
}
131+
deletable={deletable}
132+
onDelete={deletePropAndResource}
133+
>
134+
<BindingControl>
135+
<InputField
136+
id={id}
137+
disabled={readOnly}
138+
value={localValue.value}
139+
onChange={(event) => localValue.set(event.target.value)}
140+
onBlur={localValue.save}
141+
onSubmit={localValue.save}
142+
/>
143+
<BindingPopover
144+
scope={scope}
145+
aliases={aliases}
146+
validate={(value) => {
147+
if (value !== undefined && typeof value !== "string") {
148+
return `${label} expects a string value`;
149+
}
150+
}}
151+
variant={variant}
152+
value={expression}
153+
onChange={(newExpression) => updateResourceUrl(newExpression)}
154+
onRemove={(evaluatedValue) =>
155+
updateResourceUrl(JSON.stringify(String(evaluatedValue)))
156+
}
157+
/>
158+
</BindingControl>
159+
</ResponsiveLayout>
160+
);
161+
};

apps/builder/app/builder/features/settings-panel/controls/text.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useId, useRef } from "react";
1+
import { useId } from "react";
22
import { useStore } from "@nanostores/react";
33
import { TextArea } from "@webstudio-is/design-system";
44
import {
@@ -22,7 +22,6 @@ export const TextControl = ({
2222
propName,
2323
deletable,
2424
computedValue,
25-
autoFocus,
2625
onChange,
2726
onDelete,
2827
}: ControlProps<"text">) => {
@@ -35,24 +34,16 @@ export const TextControl = ({
3534
});
3635
const id = useId();
3736
const label = humanizeAttribute(meta.label || propName);
38-
const textAreaRef = useRef<HTMLTextAreaElement>(null);
3937
const { scope, aliases } = useStore($selectedInstanceScope);
4038
const expression =
4139
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
4240
const { overwritable, variant } = useBindingState(
4341
prop?.type === "expression" ? prop.value : undefined
4442
);
4543

46-
useEffect(() => {
47-
if (autoFocus) {
48-
textAreaRef.current?.focus();
49-
}
50-
}, [autoFocus]);
51-
5244
const input = (
5345
<BindingControl>
5446
<TextArea
55-
ref={textAreaRef}
5647
id={id}
5748
disabled={overwritable === false}
5849
autoGrow

apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,9 @@ const matchOrSuggestToCreate = (
5959
const renderProperty = (
6060
{ propsLogic: logic, propValues, component, instanceId }: PropsSectionProps,
6161
{ prop, propName, meta }: PropAndMeta,
62-
{ deletable, autoFocus }: { deletable?: boolean; autoFocus?: boolean } = {}
62+
{ deletable }: { deletable?: boolean } = {}
6363
) =>
6464
renderControl({
65-
autoFocus,
6665
key: propName,
6766
instanceId,
6867
meta,

apps/builder/app/builder/features/settings-panel/resource-panel.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { useStore } from "@nanostores/react";
1414
import type { DataSource, Resource } from "@webstudio-is/sdk";
1515
import {
16-
encodeDataSourceVariable,
16+
encodeDataVariableId,
1717
generateObjectExpression,
1818
isLiteralExpression,
1919
parseObjectExpression,
@@ -383,7 +383,7 @@ const $hiddenDataSourceIds = computed(
383383
}
384384
);
385385

386-
const $selectedInstanceScope = computed(
386+
export const $selectedInstanceResourceScope = computed(
387387
[
388388
$selectedInstanceKeyWithRoot,
389389
$variableValuesByInstanceSelector,
@@ -398,8 +398,9 @@ const $selectedInstanceScope = computed(
398398
) => {
399399
const scope: Record<string, unknown> = {};
400400
const aliases = new Map<string, string>();
401+
const variableValues = new Map<DataSource["id"], unknown>();
401402
if (instanceKey === undefined) {
402-
return { scope, aliases };
403+
return { variableValues, scope, aliases };
403404
}
404405
const values = variableValuesByInstanceSelector.get(instanceKey);
405406
if (values) {
@@ -411,21 +412,21 @@ const $selectedInstanceScope = computed(
411412
if (dataSourceId === SYSTEM_VARIABLE_ID) {
412413
dataSource = systemParameter;
413414
}
414-
if (dataSource === undefined) {
415-
continue;
415+
if (dataSource) {
416+
const name = encodeDataVariableId(dataSourceId);
417+
variableValues.set(dataSourceId, value);
418+
scope[name] = value;
419+
aliases.set(name, dataSource.name);
416420
}
417-
const name = encodeDataSourceVariable(dataSourceId);
418-
scope[name] = value;
419-
aliases.set(name, dataSource.name);
420421
}
421422
}
422-
return { scope, aliases };
423+
return { variableValues, scope, aliases };
423424
}
424425
);
425426

426427
const useScope = ({ variable }: { variable?: DataSource }) => {
427428
const { scope: scopeWithCurrentVariable, aliases } = useStore(
428-
$selectedInstanceScope
429+
$selectedInstanceResourceScope
429430
);
430431
const currentVariableId = variable?.id;
431432
// prevent showing currently edited variable in suggestions
@@ -435,7 +436,7 @@ const useScope = ({ variable }: { variable?: DataSource }) => {
435436
return scopeWithCurrentVariable;
436437
}
437438
const newScope: Record<string, unknown> = { ...scopeWithCurrentVariable };
438-
delete newScope[encodeDataSourceVariable(currentVariableId)];
439+
delete newScope[encodeDataVariableId(currentVariableId)];
439440
return newScope;
440441
}, [scopeWithCurrentVariable, currentVariableId]);
441442
return { scope, aliases };

apps/builder/app/builder/features/settings-panel/shared.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export type ControlProps<Control> = {
7272
deletable: boolean;
7373
onChange: (value: PropValue) => void;
7474
onDelete: () => void;
75-
autoFocus?: boolean;
7675
};
7776

7877
export const RemovePropButton = (props: { onClick: () => void }) => (

apps/builder/app/shared/nano-states/props.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -570,11 +570,18 @@ const computeResource = (
570570
};
571571

572572
const $computedResources = computed(
573-
[$resources, $loaderVariableValues],
574-
(resources, values) => {
573+
[$dataSources, $resources, $loaderVariableValues],
574+
(dataSources, resources, values) => {
575575
const computedResources: ResourceRequest[] = [];
576-
for (const resource of resources.values()) {
577-
computedResources.push(computeResource(resource, values));
576+
// load only resources bound to variables
577+
// action resources should not be loaded automatically
578+
for (const dataSource of dataSources.values()) {
579+
if (dataSource.type === "resource") {
580+
const resource = resources.get(dataSource.resourceId);
581+
if (resource) {
582+
computedResources.push(computeResource(resource, values));
583+
}
584+
}
578585
}
579586
return computedResources;
580587
}

fixtures/react-router-docker/app/routes/[another-page]._index.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,6 @@ export const action = async ({
231231
formData.delete(formBotFieldName);
232232

233233
if (resource) {
234-
resource.headers.push({
235-
name: "Content-Type",
236-
value: "application/json",
237-
});
238234
resource.body = Object.fromEntries(formData);
239235
} else {
240236
if (contactEmail === undefined) {

fixtures/react-router-docker/app/routes/_index.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,6 @@ export const action = async ({
231231
formData.delete(formBotFieldName);
232232

233233
if (resource) {
234-
resource.headers.push({
235-
name: "Content-Type",
236-
value: "application/json",
237-
});
238234
resource.body = Object.fromEntries(formData);
239235
} else {
240236
if (contactEmail === undefined) {

fixtures/react-router-netlify/app/routes/[another-page]._index.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,6 @@ export const action = async ({
231231
formData.delete(formBotFieldName);
232232

233233
if (resource) {
234-
resource.headers.push({
235-
name: "Content-Type",
236-
value: "application/json",
237-
});
238234
resource.body = Object.fromEntries(formData);
239235
} else {
240236
if (contactEmail === undefined) {

0 commit comments

Comments
 (0)