Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support resource in webhook form #5006

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof NestedInputButton>
>((props, ref) => {
return (
<EnhancedTooltip content="Edit Resource">
<NestedInputButton {...props} ref={ref} aria-label="Edit Resource">
<GearIcon />
</NestedInputButton>
</EnhancedTooltip>
);
});
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"]>(resource.method);
const [headers, setHeaders] = useState<Resource["headers"]>(resource.headers);
return (
<Flex
direction="column"
css={{
width: theme.spacing[30],
overflow: "hidden",
gap: theme.spacing[9],
p: theme.spacing[9],
}}
>
<UrlField
scope={scope}
aliases={aliases}
value={url}
onChange={setUrl}
onCurlPaste={(curl) => {
// 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),
}))
);
}}
/>
<MethodField value={method} onChange={setMethod} />
<Headers
scope={scope}
aliases={aliases}
headers={headers}
onChange={setHeaders}
/>
</Flex>
);
};

const ResourceControlPanel = ({
resource,
propName,
onChange,
}: {
resource: Resource;
propName: string;
onChange: (resource: Resource) => void;
}) => {
const [isResourceOpen, setIsResourceOpen] = useState(false);
const form = useRef<HTMLFormElement>(null);
return (
<FloatingPanel
title="Edit Resource"
open={isResourceOpen}
onOpenChange={(isOpen) => {
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={
<form
ref={form}
// ref={formRef}
noValidate={true}
// exclude from the flow
style={{ display: "contents" }}
onSubmit={(event) => {
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 */}
<button hidden></button>
<ResourceForm resource={resource} />
</form>
}
>
<ResourceButton />
</FloatingPanel>
);
};

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]);
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,
Expand All @@ -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 (
Expand All @@ -121,20 +281,34 @@ export const ResourceControl = ({
onChange={(event) => localValue.set(event.target.value)}
onBlur={localValue.save}
onSubmit={localValue.save}
suffix={
isFeatureEnabled("resourceProp") && (
<ResourceControlPanel
resource={resource}
propName={propName}
onChange={updateResource}
/>
)
}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => {
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)),
})
}
/>
</BindingControl>
Expand Down
Loading