From 0d971a3c32e6b242a086d6087832ed3d3e9ea283 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sat, 15 Mar 2025 23:58:41 +0700 Subject: [PATCH 1/2] feat: support resource in webhook form Ref https://github.com/webstudio-is/webstudio/issues/4093 Succeeds https://github.com/webstudio-is/webstudio/pull/4333 Added resource button in webhook form action prop --- .../controls/resource-control.tsx | 300 ++++++++++++++---- .../settings-panel/resource-panel.tsx | 76 +++-- .../font-family/font-family-control.tsx | 12 +- .../src/components/nested-input-button.tsx | 2 +- packages/feature-flags/src/flags.ts | 1 + .../src/webhook-form.ws.ts | 2 +- 6 files changed, 301 insertions(+), 92 deletions(-) diff --git a/apps/builder/app/builder/features/settings-panel/controls/resource-control.tsx b/apps/builder/app/builder/features/settings-panel/controls/resource-control.tsx index 056b495246d6..0e4a80e0207b 100644 --- a/apps/builder/app/builder/features/settings-panel/controls/resource-control.tsx +++ b/apps/builder/app/builder/features/settings-panel/controls/resource-control.tsx @@ -1,89 +1,249 @@ import { nanoid } from "nanoid"; -import { useId } from "react"; +import { computed } from "nanostores"; +import { + forwardRef, + useId, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; import { useStore } from "@nanostores/react"; -import { InputField } from "@webstudio-is/design-system"; +import { isFeatureEnabled } from "@webstudio-is/feature-flags"; +import { GearIcon } from "@webstudio-is/icons"; +import { + EnhancedTooltip, + Flex, + FloatingPanel, + InputField, + NestedInputButton, + theme, +} from "@webstudio-is/design-system"; import { isLiteralExpression, Resource, type Prop } from "@webstudio-is/sdk"; import { BindingControl, BindingPopover, type BindingVariant, } from "~/builder/shared/binding-popover"; -import { - type ControlProps, - useLocalValue, - humanizeAttribute, - VerticalLayout, -} from "../shared"; -import { $resources } from "~/shared/nano-states"; -import { $selectedInstanceResourceScope } from "../resource-panel"; +import { $props, $resources } from "~/shared/nano-states"; import { computeExpression } from "~/shared/data-variables"; import { updateWebstudioData } from "~/shared/instance-utils"; +import { $selectedInstance } from "~/shared/awareness"; +import { + $selectedInstanceResourceScope, + UrlField, + MethodField, + Headers, + parseResource, +} from "../resource-panel"; +import { type ControlProps, useLocalValue, VerticalLayout } from "../shared"; import { PropertyLabel } from "../property-label"; -export const ResourceControl = ({ - meta, - prop, +// dirty, dirty hack +const areAllFormErrorsVisible = (form: null | HTMLFormElement) => { + if (form === null) { + return false; + } + // check all errors in form fields are visible + for (const element of form.elements) { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement + ) { + // field is invalid and the error is not visible + if ( + element.validity.valid === false && + // rely on data-color=error convention in webstudio design system + element.getAttribute("data-color") !== "error" + ) { + return false; + } + } + } + return true; +}; + +const ResourceButton = forwardRef< + HTMLButtonElement, + ComponentProps +>((props, ref) => { + return ( + + + + + + ); +}); +ResourceButton.displayName = "ResourceButton"; + +const ResourceForm = ({ resource }: { resource: Resource }) => { + const { scope, aliases } = useStore($selectedInstanceResourceScope); + const [url, setUrl] = useState(resource.url); + const [method, setMethod] = useState(resource.method); + const [headers, setHeaders] = useState(resource.headers); + return ( + + { + // update all feilds when curl is paste into url field + setUrl(JSON.stringify(curl.url)); + setMethod(curl.method); + setHeaders( + curl.headers.map((header) => ({ + name: header.name, + value: JSON.stringify(header.value), + })) + ); + }} + /> + + + + ); +}; + +const ResourceControlPanel = ({ + resource, propName, + onChange, +}: { + resource: Resource; + propName: string; + onChange: (resource: Resource) => void; +}) => { + const [isResourceOpen, setIsResourceOpen] = useState(false); + const form = useRef(null); + return ( + { + if (isOpen) { + setIsResourceOpen(true); + return; + } + // attempt to save form on close + if (areAllFormErrorsVisible(form.current)) { + form.current?.requestSubmit(); + setIsResourceOpen(false); + } else { + form.current?.checkValidity(); + // prevent closing when not all errors are shown to user + } + }} + content={ +
{ + event.preventDefault(); + if (event.currentTarget.checkValidity()) { + const formData = new FormData(event.currentTarget); + const newResource = parseResource({ + id: resource?.id ?? nanoid(), + name: resource?.name ?? propName, + formData, + }); + onChange(newResource); + } + }} + > + {/* submit is not triggered when press enter on input without submit button */} + + + + } + > + +
+ ); +}; + +const $methodPropValue = computed( + [$selectedInstance, $props], + (instance, props): Resource["method"] => { + for (const prop of props.values()) { + if ( + prop.instanceId === instance?.id && + prop.type === "string" && + prop.name === "method" + ) { + const value = prop.value.toLowerCase(); + if ( + value === "get" || + value === "post" || + value === "put" || + value === "delete" + ) { + return value; + } + break; + } + } + return "post"; + } +); + +export const ResourceControl = ({ instanceId, + propName, + prop, }: ControlProps<"resource">) => { const resources = useStore($resources); const { variableValues, scope, aliases } = useStore( $selectedInstanceResourceScope ); - - let computedValue: unknown; - let expression: string = JSON.stringify(""); + const methodPropValue = useStore($methodPropValue); + let resource: undefined | Resource; + let urlExpression: string = JSON.stringify(""); if (prop?.type === "string") { - expression = JSON.stringify(prop.value); - computedValue = prop.value; + urlExpression = JSON.stringify(prop.value); } if (prop?.type === "expression") { - expression = prop.value; - computedValue = computeExpression(prop.value, variableValues); + urlExpression = prop.value; } if (prop?.type === "resource") { - const resource = resources.get(prop.value); + resource = resources.get(prop.value); if (resource) { - expression = resource.url; - computedValue = computeExpression(resource.url, variableValues); + urlExpression = resource.url; } } + // create temporary resource + const resourceId = useMemo(() => resource?.id ?? nanoid(), []); + resource ??= { + id: resourceId, + name: propName, + url: urlExpression, + method: methodPropValue, + headers: [{ name: "Content-Type", value: `"application/json"` }], + }; - const updateResourceUrl = (urlExpression: string) => { + const updateResource = (newResource: Resource) => { updateWebstudioData((data) => { if (prop?.type === "resource") { - const resource = data.resources.get(prop.value); - if (resource) { - resource.url = urlExpression; - } + data.resources.set(newResource.id, newResource); } else { - let method: Resource["method"] = "post"; - for (const prop of data.props.values()) { - if ( - prop.instanceId === instanceId && - prop.type === "string" && - prop.name === "method" - ) { - const value = prop.value.toLowerCase(); - if ( - value === "get" || - value === "post" || - value === "put" || - value === "delete" - ) { - method = value; - } - break; - } - } - - const newResource: Resource = { - id: nanoid(), - name: propName, - url: urlExpression, - method, - headers: [{ name: "Content-Type", value: `"application/json"` }], - }; const newProp: Prop = { id: prop?.id ?? nanoid(), instanceId, @@ -98,15 +258,15 @@ export const ResourceControl = ({ }; const id = useId(); - const label = humanizeAttribute(meta.label || propName); let variant: BindingVariant = "bound"; let readOnly = true; - if (isLiteralExpression(expression)) { + if (isLiteralExpression(urlExpression)) { variant = "default"; readOnly = false; } - const localValue = useLocalValue(String(computedValue ?? ""), (value) => - updateResourceUrl(JSON.stringify(value)) + const localValue = useLocalValue( + String(computeExpression(resource.url, variableValues) ?? ""), + (value) => updateResource({ ...resource, url: JSON.stringify(value) }) ); return ( @@ -121,20 +281,34 @@ export const ResourceControl = ({ onChange={(event) => localValue.set(event.target.value)} onBlur={localValue.save} onSubmit={localValue.save} + suffix={ + isFeatureEnabled("resourceProp") && ( + + ) + } /> { if (value !== undefined && typeof value !== "string") { - return `${label} expects a string value`; + return `Expected URL string value`; } }} variant={variant} - value={expression} - onChange={(newExpression) => updateResourceUrl(newExpression)} + value={urlExpression} + onChange={(newExpression) => + updateResource({ ...resource, url: newExpression }) + } onRemove={(evaluatedValue) => - updateResourceUrl(JSON.stringify(String(evaluatedValue))) + updateResource({ + ...resource, + url: JSON.stringify(String(evaluatedValue)), + }) } /> diff --git a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx index e24506ee92ea..de254a6688b7 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -11,7 +11,7 @@ import { useState, } from "react"; import { useStore } from "@nanostores/react"; -import type { DataSource, Resource } from "@webstudio-is/sdk"; +import { Resource, type DataSource } from "@webstudio-is/sdk"; import { encodeDataVariableId, generateObjectExpression, @@ -62,6 +62,30 @@ import { import { updateWebstudioData } from "~/shared/instance-utils"; import { rebindTreeVariablesMutable } from "~/shared/data-variables"; +export const parseResource = ({ + id, + name, + formData, +}: { + id: string; + name: string; + formData: FormData; +}) => { + const headerNames = formData.getAll("header-name"); + const headerValues = formData.getAll("header-value"); + return Resource.parse({ + id, + name, + url: formData.get("url"), + method: formData.get("method"), + headers: headerNames.map((name, index) => { + const value = headerValues[index]; + return { name, value }; + }), + body: formData.get("body") ?? undefined, + }); +}; + const validateUrl = (value: string, scope: Record) => { const evaluatedValue = evaluateExpressionWithinScope(value, scope); if (typeof evaluatedValue !== "string") { @@ -78,7 +102,7 @@ const validateUrl = (value: string, scope: Record) => { return ""; }; -const UrlField = ({ +export const UrlField = ({ scope, aliases, value, @@ -115,11 +139,12 @@ const UrlField = ({ +