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={ +
{ + event.preventDefault(); + if (event.currentTarget.checkValidity()) { + const formData = new FormData(event.currentTarget); + const resource = parseResource({ + id: resourceId ?? nanoid(), + name: propName, + formData, + }); + setResource({ + instanceId, + propId: prop?.id, + propName: propName, + resource, + }); + } + }} + > + {/* submit is not triggered when press enter on input without submit button */} + + + + } + > + + + } + 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 - - +