diff --git a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx
index d9814fa1ef3b..32a0c3641c1b 100644
--- a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx
+++ b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx
@@ -10,6 +10,7 @@ import { UrlControl } from "./url";
import type { ControlProps } from "../shared";
import { JsonControl } from "./json";
import { TextContent } from "./text-content";
+import { ResourceControl } from "./resource-control";
export const renderControl = ({
meta,
@@ -96,6 +97,10 @@ export const renderControl = ({
return ;
}
+ if (meta.control === "resource") {
+ return ;
+ }
+
// Type in meta can be changed at some point without updating props in DB that are still using the old type
// In this case meta and prop will mismatch, but we try to guess a matching control based just on the prop type
if (prop) {
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
new file mode 100644
index 000000000000..96232cefbc6c
--- /dev/null
+++ b/apps/builder/app/builder/features/settings-panel/controls/resource-control.tsx
@@ -0,0 +1,267 @@
+import { nanoid } from "nanoid";
+import { computed } from "nanostores";
+import {
+ forwardRef,
+ useId,
+ useRef,
+ useState,
+ type ComponentProps,
+} from "react";
+import {
+ EnhancedTooltip,
+ Flex,
+ InputField,
+ NestedInputButton,
+ theme,
+} from "@webstudio-is/design-system";
+import { useStore } from "@nanostores/react";
+import { encodeDataSourceVariable, Prop, Resource } from "@webstudio-is/sdk";
+import { GearIcon } from "@webstudio-is/icons";
+import { $resources, $selectedPage } from "~/shared/nano-states";
+import { updateWebstudioData } from "~/shared/instance-utils";
+import { FloatingPanel } from "~/builder/shared/floating-panel";
+import {
+ BindingPopoverProvider,
+ evaluateExpressionWithinScope,
+} from "~/builder/shared/binding-popover";
+import {
+ humanizeAttribute,
+ Label,
+ ResponsiveLayout,
+ setPropMutable,
+ type ControlProps,
+} from "../shared";
+import {
+ $selectedInstanceResourceScope,
+ Headers,
+ MethodField,
+ parseResource,
+ UrlField,
+} from "../resource-panel";
+
+const ResourceButton = forwardRef<
+ HTMLButtonElement,
+ ComponentProps
+>((props, ref) => {
+ return (
+
+
+
+
+
+ );
+});
+ResourceButton.displayName = "ResourceButton";
+
+// resource scope has access to system parameter
+// which cannot be used in action resource
+const $scope = computed(
+ [$selectedInstanceResourceScope, $selectedPage],
+ ({ scope, aliases }, page) => {
+ if (page === undefined) {
+ return { scope, aliases };
+ }
+ const newScope: Record = { ...scope };
+ const newAliases = new Map(aliases);
+ const systemIdentifier = encodeDataSourceVariable(page.systemDataSourceId);
+ delete newScope[systemIdentifier];
+ newAliases.delete(systemIdentifier);
+ return { scope: newScope, aliases: newAliases };
+ }
+);
+
+const ResourceForm = ({ resource }: { resource: undefined | Resource }) => {
+ const bindingPopoverContainerRef = useRef(null);
+ // @todo exclude collection item and system
+ // basically all parameter variables
+ const { scope, aliases } = useStore($scope);
+ const [url, setUrl] = useState(resource?.url ?? `""`);
+ const [method, setMethod] = useState(
+ resource?.method ?? "post"
+ );
+ 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 setResource = ({
+ instanceId,
+ propId,
+ propName,
+ resource,
+}: {
+ instanceId: Prop["instanceId"];
+ propId?: Prop["id"];
+ propName: Prop["name"];
+ resource: Resource;
+}) => {
+ updateWebstudioData((data) => {
+ setPropMutable({
+ data,
+ update: {
+ id: propId ?? nanoid(),
+ instanceId,
+ name: propName,
+ type: "resource",
+ value: resource.id,
+ },
+ });
+ data.resources.set(resource.id, resource);
+ });
+};
+
+const areAllFormErrorsVisible = (form: null | HTMLFormElement) => {
+ if (form === null) {
+ return true;
+ }
+ // 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;
+};
+
+export const ResourceControl = ({
+ meta,
+ prop,
+ instanceId,
+ propName,
+ deletable,
+ onDelete,
+}: ControlProps<"resource">) => {
+ const resources = useStore($resources);
+ const { scope } = useStore($scope);
+ const resourceId = prop?.type === "resource" ? prop.value : undefined;
+ const resource = resources.get(resourceId ?? "");
+ const urlExpression = resource?.url ?? `""`;
+ const url = String(evaluateExpressionWithinScope(urlExpression, scope));
+ const id = useId();
+ const label = humanizeAttribute(meta.label ?? propName);
+ const [isResourceOpen, setIsResourceOpen] = useState(false);
+ const form = useRef(null);
+
+ return (
+
+ {label}
+
+ }
+ deletable={deletable}
+ onDelete={onDelete}
+ >
+ {
+ if (isOpen) {
+ setIsResourceOpen(true);
+ return;
+ }
+ // attempt to save form on close
+ if (areAllFormErrorsVisible(form.current)) {
+ console.log("submit");
+ form.current?.requestSubmit();
+ setIsResourceOpen(false);
+ } else {
+ console.log("validate");
+ form.current?.checkValidity();
+ // prevent closing when not all errors are shown to user
+ }
+ }}
+ content={
+
+ }
+ >
+
+
+ }
+ value={url}
+ />
+
+ );
+};
diff --git a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx
index e682a46fb382..484e0b78fab0 100644
--- a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx
+++ b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx
@@ -22,8 +22,9 @@ import {
import { CollapsibleSectionWithAddButton } from "~/builder/shared/collapsible-section";
import { renderControl } from "../controls/combined";
import { usePropsLogic, type PropAndMeta } from "./use-props-logic";
-import { Row } from "../shared";
+import { Row, setPropMutable } from "../shared";
import { serverSyncStore } from "~/shared/sync";
+import { updateWebstudioData } from "~/shared/instance-utils";
type Item = {
name: string;
@@ -208,23 +209,11 @@ export const PropsSectionContainer = ({
const logic = usePropsLogic({
instance,
props: propsByInstanceId.get(instance.id) ?? [],
-
updateProp: (update) => {
- const { propsByInstanceId } = $propsIndex.get();
- const instanceProps = propsByInstanceId.get(instance.id) ?? [];
- // Fixing a bug that caused some props to be duplicated on unmount by removing duplicates.
- // see for details https://github.com/webstudio-is/webstudio/pull/2170
- const duplicateProps = instanceProps
- .filter((prop) => prop.id !== update.id)
- .filter((prop) => prop.name === update.name);
- serverSyncStore.createTransaction([$props], (props) => {
- for (const prop of duplicateProps) {
- props.delete(prop.id);
- }
- props.set(update.id, update);
+ updateWebstudioData((data) => {
+ setPropMutable({ data, update });
});
},
-
deleteProp: (propId) => {
serverSyncStore.createTransaction([$props], (props) => {
props.delete(propId);
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 45057e9c44e9..810dcdeb3dba 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 {
encodeDataSourceVariable,
generateObjectExpression,
@@ -22,6 +22,7 @@ import {
import {
Box,
Button,
+ EnhancedTooltip,
Flex,
Grid,
InputErrorsTooltip,
@@ -30,7 +31,6 @@ import {
Select,
SmallIconButton,
TextArea,
- Tooltip,
theme,
} from "@webstudio-is/design-system";
import { DeleteIcon, InfoCircleIcon, PlusIcon } from "@webstudio-is/icons";
@@ -57,6 +57,30 @@ import {
} from "~/builder/shared/code-editor-base";
import { parseCurl, type CurlRequest } from "./curl";
+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") {
@@ -73,7 +97,7 @@ const validateUrl = (value: string, scope: Record) => {
return "";
};
-const UrlField = ({
+export const UrlField = ({
scope,
aliases,
value,
@@ -102,19 +126,19 @@ const UrlField = ({
css={{ display: "flex", alignItems: "center", gap: theme.spacing[3] }}
>
URL
-
-
+
+
void;
+}) => {
+ return (
+
+
+
+ );
+};
+
const validateHeaderName = (value: string) =>
value.trim().length === 0 ? "Header name is required" : "";
@@ -239,7 +285,7 @@ const HeaderPair = ({
+
{
const { scope: scopeWithCurrentVariable, aliases } = useStore(
- $selectedInstanceScope
+ $selectedInstanceResourceScope
);
const currentVariableId = variable?.id;
// prevent showing currently edited variable in suggestions
@@ -581,20 +633,18 @@ export const ResourceForm = forwardRef<
useImperativeHandle(ref, () => ({
save: (formData) => {
+ console.log(Object.fromEntries(formData));
const instanceSelector = $selectedInstanceSelector.get();
if (instanceSelector === undefined) {
return;
}
const name = z.string().parse(formData.get("name"));
const [instanceId] = instanceSelector;
- const newResource: Resource = {
+ const newResource = parseResource({
id: resource?.id ?? nanoid(),
name,
- url,
- method,
- headers,
- body,
- };
+ formData,
+ });
const newVariable: DataSource = {
id: variable?.id ?? nanoid(),
// preserve existing instance scope when edit
@@ -633,15 +683,7 @@ export const ResourceForm = forwardRef<
setBody(JSON.stringify(curl.body));
}}
/>
-
-
-
+
["value"] }
| { type: "action"; value: Extract["value"] };
+export const setPropMutable = ({
+ data,
+ update,
+}: {
+ data: WebstudioData;
+ update: Prop;
+}) => {
+ // fixing a bug that caused some props to be duplicated on unmount by removing duplicates.
+ // see for details https://github.com/webstudio-is/webstudio/pull/2170
+ for (const prop of data.props.values()) {
+ if (
+ prop.instanceId === update.instanceId &&
+ prop.name === update.name &&
+ prop.id !== update.id
+ ) {
+ data.props.delete(prop.id);
+ }
+ }
+ data.props.set(update.id, update);
+};
+
// Weird code is to make type distributive
// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
type PropMetaByControl = Control extends string
diff --git a/apps/builder/app/shared/webstudio-data-migrator.ts b/apps/builder/app/shared/webstudio-data-migrator.ts
index 239d6d78cf09..35cac1670d61 100644
--- a/apps/builder/app/shared/webstudio-data-migrator.ts
+++ b/apps/builder/app/shared/webstudio-data-migrator.ts
@@ -1,6 +1,7 @@
import { camelCase } from "change-case";
import {
getStyleDeclKey,
+ replaceFormActionsWithResources,
type StyleDecl,
type WebstudioData,
} from "@webstudio-is/sdk";
@@ -51,4 +52,5 @@ export const migrateWebstudioDataMutable = (data: WebstudioData) => {
}
}
}
+ replaceFormActionsWithResources(data);
};
diff --git a/packages/react-sdk/src/prop-meta.ts b/packages/react-sdk/src/prop-meta.ts
index e10577d3c34a..b6608346dce0 100644
--- a/packages/react-sdk/src/prop-meta.ts
+++ b/packages/react-sdk/src/prop-meta.ts
@@ -167,6 +167,13 @@ const TextContent = z.object({
defaultValue: z.string().optional(),
});
+const Resource = z.object({
+ ...common,
+ control: z.literal("resource"),
+ type: z.literal("resource"),
+ defaultValue: z.undefined().optional(),
+});
+
export const PropMeta = z.union([
Number,
Range,
@@ -187,6 +194,7 @@ export const PropMeta = z.union([
Date,
Action,
TextContent,
+ Resource,
]);
export type PropMeta = z.infer;
diff --git a/packages/sdk-components-react-remix/src/webhook-form.ws.ts b/packages/sdk-components-react-remix/src/webhook-form.ws.ts
index 844b3382f7ae..24bbd92fb9f0 100644
--- a/packages/sdk-components-react-remix/src/webhook-form.ws.ts
+++ b/packages/sdk-components-react-remix/src/webhook-form.ws.ts
@@ -127,6 +127,13 @@ export const meta: WsComponentMeta = {
};
export const propsMeta: WsComponentPropsMeta = {
- props,
+ props: {
+ ...props,
+ action: {
+ control: "resource",
+ type: "resource",
+ required: true,
+ },
+ },
initialProps: ["id", "className", "state", "action"],
};