Skip to content

Commit 28c7e09

Browse files
committed
feat: add resource control for webhook form action
Ref #4093 #3871 Here added new resource control for out properties. It is replaced action and method properties webhook form. Now users will be able to additionally provide headers. In another PR headers will let user to customize body format. Also migrated "action" in existing webhook forms to resource prop type.
1 parent 73dd80a commit 28c7e09

File tree

8 files changed

+383
-42
lines changed

8 files changed

+383
-42
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,
@@ -96,6 +97,10 @@ export const renderControl = ({
9697
return <UrlControl key={key} meta={meta} prop={prop} {...rest} />;
9798
}
9899

100+
if (meta.control === "resource") {
101+
return <ResourceControl key={key} meta={meta} prop={prop} {...rest} />;
102+
}
103+
99104
// Type in meta can be changed at some point without updating props in DB that are still using the old type
100105
// In this case meta and prop will mismatch, but we try to guess a matching control based just on the prop type
101106
if (prop) {
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { nanoid } from "nanoid";
2+
import { computed } from "nanostores";
3+
import {
4+
forwardRef,
5+
useId,
6+
useRef,
7+
useState,
8+
type ComponentProps,
9+
} from "react";
10+
import {
11+
EnhancedTooltip,
12+
Flex,
13+
InputField,
14+
NestedInputButton,
15+
theme,
16+
} from "@webstudio-is/design-system";
17+
import { useStore } from "@nanostores/react";
18+
import { encodeDataSourceVariable, Prop, Resource } from "@webstudio-is/sdk";
19+
import { GearIcon } from "@webstudio-is/icons";
20+
import { $resources, $selectedPage } from "~/shared/nano-states";
21+
import { updateWebstudioData } from "~/shared/instance-utils";
22+
import { FloatingPanel } from "~/builder/shared/floating-panel";
23+
import {
24+
BindingPopoverProvider,
25+
evaluateExpressionWithinScope,
26+
} from "~/builder/shared/binding-popover";
27+
import {
28+
humanizeAttribute,
29+
Label,
30+
ResponsiveLayout,
31+
setPropMutable,
32+
type ControlProps,
33+
} from "../shared";
34+
import {
35+
$selectedInstanceResourceScope,
36+
Headers,
37+
MethodField,
38+
parseResource,
39+
UrlField,
40+
} from "../resource-panel";
41+
42+
const ResourceButton = forwardRef<
43+
HTMLButtonElement,
44+
ComponentProps<typeof NestedInputButton>
45+
>((props, ref) => {
46+
return (
47+
<EnhancedTooltip content="Edit Resource">
48+
<NestedInputButton {...props} ref={ref} aria-label="Edit Resource">
49+
<GearIcon />
50+
</NestedInputButton>
51+
</EnhancedTooltip>
52+
);
53+
});
54+
ResourceButton.displayName = "ResourceButton";
55+
56+
// resource scope has access to system parameter
57+
// which cannot be used in action resource
58+
const $scope = computed(
59+
[$selectedInstanceResourceScope, $selectedPage],
60+
({ scope, aliases }, page) => {
61+
if (page === undefined) {
62+
return { scope, aliases };
63+
}
64+
const newScope: Record<string, unknown> = { ...scope };
65+
const newAliases = new Map(aliases);
66+
const systemIdentifier = encodeDataSourceVariable(page.systemDataSourceId);
67+
delete newScope[systemIdentifier];
68+
newAliases.delete(systemIdentifier);
69+
return { scope: newScope, aliases: newAliases };
70+
}
71+
);
72+
73+
const ResourceForm = ({ resource }: { resource: undefined | Resource }) => {
74+
const bindingPopoverContainerRef = useRef<HTMLDivElement>(null);
75+
// @todo exclude collection item and system
76+
// basically all parameter variables
77+
const { scope, aliases } = useStore($scope);
78+
const [url, setUrl] = useState(resource?.url ?? `""`);
79+
const [method, setMethod] = useState<Resource["method"]>(
80+
resource?.method ?? "post"
81+
);
82+
const [headers, setHeaders] = useState<Resource["headers"]>(
83+
resource?.headers ?? []
84+
);
85+
return (
86+
<Flex
87+
ref={bindingPopoverContainerRef}
88+
direction="column"
89+
css={{
90+
width: theme.spacing[30],
91+
overflow: "hidden",
92+
gap: theme.spacing[9],
93+
p: theme.spacing[9],
94+
}}
95+
>
96+
<BindingPopoverProvider
97+
value={{ containerRef: bindingPopoverContainerRef }}
98+
>
99+
<UrlField
100+
scope={scope}
101+
aliases={aliases}
102+
value={url}
103+
onChange={setUrl}
104+
onCurlPaste={(curl) => {
105+
// update all feilds when curl is paste into url field
106+
setUrl(JSON.stringify(curl.url));
107+
setMethod(curl.method);
108+
setHeaders(
109+
curl.headers.map((header) => ({
110+
name: header.name,
111+
value: JSON.stringify(header.value),
112+
}))
113+
);
114+
}}
115+
/>
116+
<MethodField value={method} onChange={setMethod} />
117+
<Headers
118+
scope={scope}
119+
aliases={aliases}
120+
headers={headers}
121+
onChange={setHeaders}
122+
/>
123+
</BindingPopoverProvider>
124+
</Flex>
125+
);
126+
};
127+
128+
const setResource = ({
129+
instanceId,
130+
propId,
131+
propName,
132+
resource,
133+
}: {
134+
instanceId: Prop["instanceId"];
135+
propId?: Prop["id"];
136+
propName: Prop["name"];
137+
resource: Resource;
138+
}) => {
139+
updateWebstudioData((data) => {
140+
setPropMutable({
141+
data,
142+
update: {
143+
id: propId ?? nanoid(),
144+
instanceId,
145+
name: propName,
146+
type: "resource",
147+
value: resource.id,
148+
},
149+
});
150+
data.resources.set(resource.id, resource);
151+
});
152+
};
153+
154+
const areAllFormErrorsVisible = (form: null | HTMLFormElement) => {
155+
if (form === null) {
156+
return true;
157+
}
158+
// check all errors in form fields are visible
159+
for (const element of form.elements) {
160+
if (
161+
element instanceof HTMLInputElement ||
162+
element instanceof HTMLTextAreaElement
163+
) {
164+
// field is invalid and the error is not visible
165+
if (
166+
element.validity.valid === false &&
167+
// rely on data-color=error convention in webstudio design system
168+
element.getAttribute("data-color") !== "error"
169+
) {
170+
return false;
171+
}
172+
}
173+
}
174+
return true;
175+
};
176+
177+
export const ResourceControl = ({
178+
meta,
179+
prop,
180+
instanceId,
181+
propName,
182+
deletable,
183+
onDelete,
184+
}: ControlProps<"resource">) => {
185+
const resources = useStore($resources);
186+
const { scope } = useStore($scope);
187+
const resourceId = prop?.type === "resource" ? prop.value : undefined;
188+
const resource = resources.get(resourceId ?? "");
189+
const urlExpression = resource?.url ?? `""`;
190+
const url = String(evaluateExpressionWithinScope(urlExpression, scope));
191+
const id = useId();
192+
const label = humanizeAttribute(meta.label ?? propName);
193+
const [isResourceOpen, setIsResourceOpen] = useState(false);
194+
const form = useRef<HTMLFormElement>(null);
195+
196+
return (
197+
<ResponsiveLayout
198+
label={
199+
<Label htmlFor={id} description={meta.description}>
200+
{label}
201+
</Label>
202+
}
203+
deletable={deletable}
204+
onDelete={onDelete}
205+
>
206+
<InputField
207+
id={id}
208+
disabled={true}
209+
suffix={
210+
<FloatingPanel
211+
title="Edit Resource"
212+
isOpen={isResourceOpen}
213+
onIsOpenChange={(isOpen) => {
214+
if (isOpen) {
215+
setIsResourceOpen(true);
216+
return;
217+
}
218+
// attempt to save form on close
219+
if (areAllFormErrorsVisible(form.current)) {
220+
console.log("submit");
221+
form.current?.requestSubmit();
222+
setIsResourceOpen(false);
223+
} else {
224+
console.log("validate");
225+
form.current?.checkValidity();
226+
// prevent closing when not all errors are shown to user
227+
}
228+
}}
229+
content={
230+
<form
231+
ref={form}
232+
// ref={formRef}
233+
noValidate={true}
234+
// exclude from the flow
235+
style={{ display: "contents" }}
236+
onSubmit={(event) => {
237+
event.preventDefault();
238+
if (event.currentTarget.checkValidity()) {
239+
const formData = new FormData(event.currentTarget);
240+
const resource = parseResource({
241+
id: resourceId ?? nanoid(),
242+
name: propName,
243+
formData,
244+
});
245+
setResource({
246+
instanceId,
247+
propId: prop?.id,
248+
propName: propName,
249+
resource,
250+
});
251+
}
252+
}}
253+
>
254+
{/* submit is not triggered when press enter on input without submit button */}
255+
<button hidden></button>
256+
<ResourceForm resource={resource} />
257+
</form>
258+
}
259+
>
260+
<ResourceButton />
261+
</FloatingPanel>
262+
}
263+
value={url}
264+
/>
265+
</ResponsiveLayout>
266+
);
267+
};

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

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ import {
2222
import { CollapsibleSectionWithAddButton } from "~/builder/shared/collapsible-section";
2323
import { renderControl } from "../controls/combined";
2424
import { usePropsLogic, type PropAndMeta } from "./use-props-logic";
25-
import { Row } from "../shared";
25+
import { Row, setPropMutable } from "../shared";
2626
import { serverSyncStore } from "~/shared/sync";
27+
import { updateWebstudioData } from "~/shared/instance-utils";
2728

2829
type Item = {
2930
name: string;
@@ -208,23 +209,11 @@ export const PropsSectionContainer = ({
208209
const logic = usePropsLogic({
209210
instance,
210211
props: propsByInstanceId.get(instance.id) ?? [],
211-
212212
updateProp: (update) => {
213-
const { propsByInstanceId } = $propsIndex.get();
214-
const instanceProps = propsByInstanceId.get(instance.id) ?? [];
215-
// Fixing a bug that caused some props to be duplicated on unmount by removing duplicates.
216-
// see for details https://github.com/webstudio-is/webstudio/pull/2170
217-
const duplicateProps = instanceProps
218-
.filter((prop) => prop.id !== update.id)
219-
.filter((prop) => prop.name === update.name);
220-
serverSyncStore.createTransaction([$props], (props) => {
221-
for (const prop of duplicateProps) {
222-
props.delete(prop.id);
223-
}
224-
props.set(update.id, update);
213+
updateWebstudioData((data) => {
214+
setPropMutable({ data, update });
225215
});
226216
},
227-
228217
deleteProp: (propId) => {
229218
serverSyncStore.createTransaction([$props], (props) => {
230219
props.delete(propId);

0 commit comments

Comments
 (0)