Skip to content

Commit 0d971a3

Browse files
committed
feat: support resource in webhook form
Ref #4093 Succeeds #4333 Added resource button in webhook form action prop
1 parent b4cc9ff commit 0d971a3

File tree

6 files changed

+301
-92
lines changed

6 files changed

+301
-92
lines changed

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

+237-63
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,249 @@
11
import { nanoid } from "nanoid";
2-
import { useId } from "react";
2+
import { computed } from "nanostores";
3+
import {
4+
forwardRef,
5+
useId,
6+
useMemo,
7+
useRef,
8+
useState,
9+
type ComponentProps,
10+
} from "react";
311
import { useStore } from "@nanostores/react";
4-
import { InputField } from "@webstudio-is/design-system";
12+
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
13+
import { GearIcon } from "@webstudio-is/icons";
14+
import {
15+
EnhancedTooltip,
16+
Flex,
17+
FloatingPanel,
18+
InputField,
19+
NestedInputButton,
20+
theme,
21+
} from "@webstudio-is/design-system";
522
import { isLiteralExpression, Resource, type Prop } from "@webstudio-is/sdk";
623
import {
724
BindingControl,
825
BindingPopover,
926
type BindingVariant,
1027
} from "~/builder/shared/binding-popover";
11-
import {
12-
type ControlProps,
13-
useLocalValue,
14-
humanizeAttribute,
15-
VerticalLayout,
16-
} from "../shared";
17-
import { $resources } from "~/shared/nano-states";
18-
import { $selectedInstanceResourceScope } from "../resource-panel";
28+
import { $props, $resources } from "~/shared/nano-states";
1929
import { computeExpression } from "~/shared/data-variables";
2030
import { updateWebstudioData } from "~/shared/instance-utils";
31+
import { $selectedInstance } from "~/shared/awareness";
32+
import {
33+
$selectedInstanceResourceScope,
34+
UrlField,
35+
MethodField,
36+
Headers,
37+
parseResource,
38+
} from "../resource-panel";
39+
import { type ControlProps, useLocalValue, VerticalLayout } from "../shared";
2140
import { PropertyLabel } from "../property-label";
2241

23-
export const ResourceControl = ({
24-
meta,
25-
prop,
42+
// dirty, dirty hack
43+
const areAllFormErrorsVisible = (form: null | HTMLFormElement) => {
44+
if (form === null) {
45+
return false;
46+
}
47+
// check all errors in form fields are visible
48+
for (const element of form.elements) {
49+
if (
50+
element instanceof HTMLInputElement ||
51+
element instanceof HTMLTextAreaElement
52+
) {
53+
// field is invalid and the error is not visible
54+
if (
55+
element.validity.valid === false &&
56+
// rely on data-color=error convention in webstudio design system
57+
element.getAttribute("data-color") !== "error"
58+
) {
59+
return false;
60+
}
61+
}
62+
}
63+
return true;
64+
};
65+
66+
const ResourceButton = forwardRef<
67+
HTMLButtonElement,
68+
ComponentProps<typeof NestedInputButton>
69+
>((props, ref) => {
70+
return (
71+
<EnhancedTooltip content="Edit Resource">
72+
<NestedInputButton {...props} ref={ref} aria-label="Edit Resource">
73+
<GearIcon />
74+
</NestedInputButton>
75+
</EnhancedTooltip>
76+
);
77+
});
78+
ResourceButton.displayName = "ResourceButton";
79+
80+
const ResourceForm = ({ resource }: { resource: Resource }) => {
81+
const { scope, aliases } = useStore($selectedInstanceResourceScope);
82+
const [url, setUrl] = useState(resource.url);
83+
const [method, setMethod] = useState<Resource["method"]>(resource.method);
84+
const [headers, setHeaders] = useState<Resource["headers"]>(resource.headers);
85+
return (
86+
<Flex
87+
direction="column"
88+
css={{
89+
width: theme.spacing[30],
90+
overflow: "hidden",
91+
gap: theme.spacing[9],
92+
p: theme.spacing[9],
93+
}}
94+
>
95+
<UrlField
96+
scope={scope}
97+
aliases={aliases}
98+
value={url}
99+
onChange={setUrl}
100+
onCurlPaste={(curl) => {
101+
// update all feilds when curl is paste into url field
102+
setUrl(JSON.stringify(curl.url));
103+
setMethod(curl.method);
104+
setHeaders(
105+
curl.headers.map((header) => ({
106+
name: header.name,
107+
value: JSON.stringify(header.value),
108+
}))
109+
);
110+
}}
111+
/>
112+
<MethodField value={method} onChange={setMethod} />
113+
<Headers
114+
scope={scope}
115+
aliases={aliases}
116+
headers={headers}
117+
onChange={setHeaders}
118+
/>
119+
</Flex>
120+
);
121+
};
122+
123+
const ResourceControlPanel = ({
124+
resource,
26125
propName,
126+
onChange,
127+
}: {
128+
resource: Resource;
129+
propName: string;
130+
onChange: (resource: Resource) => void;
131+
}) => {
132+
const [isResourceOpen, setIsResourceOpen] = useState(false);
133+
const form = useRef<HTMLFormElement>(null);
134+
return (
135+
<FloatingPanel
136+
title="Edit Resource"
137+
open={isResourceOpen}
138+
onOpenChange={(isOpen) => {
139+
if (isOpen) {
140+
setIsResourceOpen(true);
141+
return;
142+
}
143+
// attempt to save form on close
144+
if (areAllFormErrorsVisible(form.current)) {
145+
form.current?.requestSubmit();
146+
setIsResourceOpen(false);
147+
} else {
148+
form.current?.checkValidity();
149+
// prevent closing when not all errors are shown to user
150+
}
151+
}}
152+
content={
153+
<form
154+
ref={form}
155+
// ref={formRef}
156+
noValidate={true}
157+
// exclude from the flow
158+
style={{ display: "contents" }}
159+
onSubmit={(event) => {
160+
event.preventDefault();
161+
if (event.currentTarget.checkValidity()) {
162+
const formData = new FormData(event.currentTarget);
163+
const newResource = parseResource({
164+
id: resource?.id ?? nanoid(),
165+
name: resource?.name ?? propName,
166+
formData,
167+
});
168+
onChange(newResource);
169+
}
170+
}}
171+
>
172+
{/* submit is not triggered when press enter on input without submit button */}
173+
<button hidden></button>
174+
<ResourceForm resource={resource} />
175+
</form>
176+
}
177+
>
178+
<ResourceButton />
179+
</FloatingPanel>
180+
);
181+
};
182+
183+
const $methodPropValue = computed(
184+
[$selectedInstance, $props],
185+
(instance, props): Resource["method"] => {
186+
for (const prop of props.values()) {
187+
if (
188+
prop.instanceId === instance?.id &&
189+
prop.type === "string" &&
190+
prop.name === "method"
191+
) {
192+
const value = prop.value.toLowerCase();
193+
if (
194+
value === "get" ||
195+
value === "post" ||
196+
value === "put" ||
197+
value === "delete"
198+
) {
199+
return value;
200+
}
201+
break;
202+
}
203+
}
204+
return "post";
205+
}
206+
);
207+
208+
export const ResourceControl = ({
27209
instanceId,
210+
propName,
211+
prop,
28212
}: ControlProps<"resource">) => {
29213
const resources = useStore($resources);
30214
const { variableValues, scope, aliases } = useStore(
31215
$selectedInstanceResourceScope
32216
);
33-
34-
let computedValue: unknown;
35-
let expression: string = JSON.stringify("");
217+
const methodPropValue = useStore($methodPropValue);
218+
let resource: undefined | Resource;
219+
let urlExpression: string = JSON.stringify("");
36220
if (prop?.type === "string") {
37-
expression = JSON.stringify(prop.value);
38-
computedValue = prop.value;
221+
urlExpression = JSON.stringify(prop.value);
39222
}
40223
if (prop?.type === "expression") {
41-
expression = prop.value;
42-
computedValue = computeExpression(prop.value, variableValues);
224+
urlExpression = prop.value;
43225
}
44226
if (prop?.type === "resource") {
45-
const resource = resources.get(prop.value);
227+
resource = resources.get(prop.value);
46228
if (resource) {
47-
expression = resource.url;
48-
computedValue = computeExpression(resource.url, variableValues);
229+
urlExpression = resource.url;
49230
}
50231
}
232+
// create temporary resource
233+
const resourceId = useMemo(() => resource?.id ?? nanoid(), []);
234+
resource ??= {
235+
id: resourceId,
236+
name: propName,
237+
url: urlExpression,
238+
method: methodPropValue,
239+
headers: [{ name: "Content-Type", value: `"application/json"` }],
240+
};
51241

52-
const updateResourceUrl = (urlExpression: string) => {
242+
const updateResource = (newResource: Resource) => {
53243
updateWebstudioData((data) => {
54244
if (prop?.type === "resource") {
55-
const resource = data.resources.get(prop.value);
56-
if (resource) {
57-
resource.url = urlExpression;
58-
}
245+
data.resources.set(newResource.id, newResource);
59246
} else {
60-
let method: Resource["method"] = "post";
61-
for (const prop of data.props.values()) {
62-
if (
63-
prop.instanceId === instanceId &&
64-
prop.type === "string" &&
65-
prop.name === "method"
66-
) {
67-
const value = prop.value.toLowerCase();
68-
if (
69-
value === "get" ||
70-
value === "post" ||
71-
value === "put" ||
72-
value === "delete"
73-
) {
74-
method = value;
75-
}
76-
break;
77-
}
78-
}
79-
80-
const newResource: Resource = {
81-
id: nanoid(),
82-
name: propName,
83-
url: urlExpression,
84-
method,
85-
headers: [{ name: "Content-Type", value: `"application/json"` }],
86-
};
87247
const newProp: Prop = {
88248
id: prop?.id ?? nanoid(),
89249
instanceId,
@@ -98,15 +258,15 @@ export const ResourceControl = ({
98258
};
99259

100260
const id = useId();
101-
const label = humanizeAttribute(meta.label || propName);
102261
let variant: BindingVariant = "bound";
103262
let readOnly = true;
104-
if (isLiteralExpression(expression)) {
263+
if (isLiteralExpression(urlExpression)) {
105264
variant = "default";
106265
readOnly = false;
107266
}
108-
const localValue = useLocalValue(String(computedValue ?? ""), (value) =>
109-
updateResourceUrl(JSON.stringify(value))
267+
const localValue = useLocalValue(
268+
String(computeExpression(resource.url, variableValues) ?? ""),
269+
(value) => updateResource({ ...resource, url: JSON.stringify(value) })
110270
);
111271

112272
return (
@@ -121,20 +281,34 @@ export const ResourceControl = ({
121281
onChange={(event) => localValue.set(event.target.value)}
122282
onBlur={localValue.save}
123283
onSubmit={localValue.save}
284+
suffix={
285+
isFeatureEnabled("resourceProp") && (
286+
<ResourceControlPanel
287+
resource={resource}
288+
propName={propName}
289+
onChange={updateResource}
290+
/>
291+
)
292+
}
124293
/>
125294
<BindingPopover
126295
scope={scope}
127296
aliases={aliases}
128297
validate={(value) => {
129298
if (value !== undefined && typeof value !== "string") {
130-
return `${label} expects a string value`;
299+
return `Expected URL string value`;
131300
}
132301
}}
133302
variant={variant}
134-
value={expression}
135-
onChange={(newExpression) => updateResourceUrl(newExpression)}
303+
value={urlExpression}
304+
onChange={(newExpression) =>
305+
updateResource({ ...resource, url: newExpression })
306+
}
136307
onRemove={(evaluatedValue) =>
137-
updateResourceUrl(JSON.stringify(String(evaluatedValue)))
308+
updateResource({
309+
...resource,
310+
url: JSON.stringify(String(evaluatedValue)),
311+
})
138312
}
139313
/>
140314
</BindingControl>

0 commit comments

Comments
 (0)