experimental: add graphql resource control (#3368)

Here added new "GraphQL" option into variable "type" select. It allows
to switch to special resource subset with "query" and "variables" fields
instead of single json body.

<img width="571" alt="Screenshot 2024-05-16 at 17 54 27"
src="https://github.com/webstudio-is/webstudio/assets/5635476/db9ab321-18e9-4208-a46e-13bd7a88b6c7">
<img width="324" alt="Screenshot 2024-05-16 at 17 54 06"
src="https://github.com/webstudio-is/webstudio/assets/5635476/07b9f6d4-117c-4974-b31c-a581bfdf4668">
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2024-05-16 20:57:03 +03:00
1 parent 0b48e9d314
commit e87cc005bb
2 files changed
+335 -92

No files matched your search

@@ -8,12 +8,15 @@ import {
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import { useStore } from "@nanostores/react";
import type { DataSource, Resource } from "@webstudio-is/sdk";
import {
encodeDataSourceVariable,
generateObjectExpression,
isLiteralExpression,
parseObjectExpression,
sitemapResourceUrl,
} from "@webstudio-is/sdk";
import {
@@ -53,8 +56,52 @@ import {
composeFields,
} from "~/shared/form-utils";
import { ExpressionEditor } from "~/builder/shared/expression-editor";
import {
EditorDialog,
EditorDialogButton,
EditorDialogControl,
} from "~/builder/shared/code-editor-base";
import { parseCurl, type CurlRequest } from "./curl";
export const composeWithNativeForm = (
formAccessorRef: RefObject<HTMLInputElement>,
form: ComposedFields
): ComposedFields => {
return {
isValid() {
const formElement = formAccessorRef.current?.form;
return form.isValid() && formElement?.checkValidity() === true;
},
areAllErrorsVisible() {
const formElement = formAccessorRef.current?.form;
// check all errors in form fields are visible
if (formElement) {
for (const element of formElement.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 form.areAllErrorsVisible();
},
showAllErrors() {
const formElement = formAccessorRef.current?.form;
formElement?.checkValidity();
form.showAllErrors();
},
};
};
const validateUrl = (value: string, scope: Record<string, unknown>) => {
const evaluatedValue = evaluateExpressionWithinScope(value, scope);
if (typeof evaluatedValue !== "string") {
@@ -82,7 +129,7 @@ const UrlField = ({
scope: Record<string, unknown>;
value: string;
onChange: (value: string) => void;
onCurlPaste: (curl: CurlRequest) => void;
onCurlPaste?: (curl: CurlRequest) => void;
}) => {
const urlId = useId();
const ref = useRef<HTMLTextAreaElement>(null);
@@ -122,7 +169,7 @@ const UrlField = ({
value={String(evaluateExpressionWithinScope(value, scope))}
onChange={(value) => {
const curl = parseCurl(value);
if (curl) {
if (curl && onCurlPaste) {
onCurlPaste(curl);
} else {
// update text value as string literal
@@ -314,39 +361,42 @@ const Headers = ({
onChange: (headers: Resource["headers"]) => void;
}) => {
return (
<Grid gap={3}>
{headers.map((header, index) => (
<HeaderPair
key={index}
scope={scope}
aliases={aliases}
name={header.name}
value={header.value}
onChange={(name, value) => {
const newHeaders = [...headers];
newHeaders[index] = { name, value };
<Grid gap={1}>
<Label>Headers</Label>
<Grid gap={3}>
{headers.map((header, index) => (
<HeaderPair
key={index}
scope={scope}
aliases={aliases}
name={header.name}
value={header.value}
onChange={(name, value) => {
const newHeaders = [...headers];
newHeaders[index] = { name, value };
onChange(newHeaders);
}}
onDelete={() => {
const newHeaders = [...headers];
newHeaders.splice(index, 1);
onChange(newHeaders);
}}
/>
))}
<Button
type="button"
color="neutral"
css={{ justifySelf: "center" }}
prefix={<PlusIcon />}
onClick={() => {
// use empty string expression as default
const newHeaders = [...headers, { name: "", value: `""` }];
onChange(newHeaders);
}}
onDelete={() => {
const newHeaders = [...headers];
newHeaders.splice(index, 1);
onChange(newHeaders);
}}
/>
))}
<Button
type="button"
color="neutral"
css={{ justifySelf: "center" }}
prefix={<PlusIcon />}
onClick={() => {
// use empty string expression as default
const newHeaders = [...headers, { name: "", value: `""` }];
onChange(newHeaders);
}}
>
Add another header pair
</Button>
>
Add another header pair
</Button>
</Grid>
</Grid>
);
};
@@ -412,6 +462,24 @@ const $selectedInstanceScope = computed(
}
);
const useScope = ({ variable }: { variable?: DataSource }) => {
const { scope: scopeWithCurrentVariable, aliases } = useStore(
$selectedInstanceScope
);
const currentVariableId = variable?.id;
// prevent showing currently edited variable in suggestions
// to avoid cirular dependeny
const scope = useMemo(() => {
if (currentVariableId === undefined) {
return scopeWithCurrentVariable;
}
const newScope: Record<string, unknown> = { ...scopeWithCurrentVariable };
delete newScope[encodeDataSourceVariable(currentVariableId)];
return newScope;
}, [scopeWithCurrentVariable, currentVariableId]);
return { scope, aliases };
};
const BodyField = ({
editorAliases,
editorScope,
@@ -512,20 +580,7 @@ export const ResourceForm = forwardRef<
undefined | PanelApi,
{ variable?: DataSource; nameField: Field<string> }
>(({ variable, nameField }, ref) => {
const { scope: scopeWithCurrentVariable, aliases } = useStore(
$selectedInstanceScope
);
const currentVariableId = variable?.id;
// prevent showing currently edited variable in suggestions
// to avoid cirular dependeny
const scope = useMemo(() => {
if (currentVariableId === undefined) {
return scopeWithCurrentVariable;
}
const newScope: Record<string, unknown> = { ...scopeWithCurrentVariable };
delete newScope[encodeDataSourceVariable(currentVariableId)];
return newScope;
}, [scopeWithCurrentVariable, currentVariableId]);
const { scope, aliases } = useScope({ variable });
const resources = useStore($resources);
const resource =
@@ -557,40 +612,13 @@ export const ResourceForm = forwardRef<
},
});
const form = composeFields(nameField, bodyField);
const formAccessorRef = useRef<HTMLInputElement>(null);
const form = composeWithNativeForm(
formAccessorRef,
composeFields(nameField, bodyField)
);
useImperativeHandle(ref, () => ({
isValid() {
const formElement = formAccessorRef.current?.form;
return form.isValid() && formElement?.checkValidity() === true;
},
areAllErrorsVisible() {
const formElement = formAccessorRef.current?.form;
// check all errors in form fields are visible
if (formElement) {
for (const element of formElement.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 form.areAllErrorsVisible();
},
showAllErrors() {
const formElement = formAccessorRef.current?.form;
formElement?.checkValidity();
form.showAllErrors();
},
...form,
save: () => {
const instanceSelector = $selectedInstanceSelector.get();
if (instanceSelector === undefined) {
@@ -653,15 +681,12 @@ export const ResourceForm = forwardRef<
onChange={(newValue) => setMethod(newValue)}
/>
</Grid>
<Grid gap={1}>
<Label>Headers</Label>
<Headers
scope={scope}
aliases={aliases}
headers={headers}
onChange={setHeaders}
/>
</Grid>
<Headers
scope={scope}
aliases={aliases}
headers={headers}
onChange={setHeaders}
/>
{method !== "get" && (
<BodyField
editorScope={scope}
@@ -771,4 +796,188 @@ export const SystemResourceForm = forwardRef<
</>
);
});
SystemResourceForm.displayName = "ResourceForm";
SystemResourceForm.displayName = "SystemResourceForm";
export const GraphqlResourceForm = forwardRef<
undefined | PanelApi,
{ variable?: DataSource; nameField: Field<string> }
>(({ variable, nameField }, ref) => {
const { scope, aliases } = useScope({ variable });
const resources = useStore($resources);
const resource =
variable?.type === "resource"
? resources.get(variable.resourceId)
: undefined;
const [url, setUrl] = useState(resource?.url ?? `""`);
const [headers, setHeaders] = useState(
resource?.headers ?? [
{ name: "Content-Type", value: JSON.stringify("application/json") },
]
);
const [bodyExpressions] = useState(() =>
parseObjectExpression(resource?.body ?? "")
);
const queryId = useId();
const [query, setQuery] = useState(
() =>
evaluateExpressionWithinScope(bodyExpressions.get("query") ?? "", {}) ??
""
);
const [variables, setVariables] = useState(
() => bodyExpressions.get("variables") ?? "{}"
);
const [isVariablesLiteral, setIsVariablesLiteral] = useState(() =>
isLiteralExpression(variables)
);
const [variablesError, setVariablesError] = useState("");
const variablesRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const evaluatedValue = evaluateExpressionWithinScope(variables, scope);
variablesRef.current?.setCustomValidity(
typeof evaluatedValue === "object" && evaluatedValue !== null
? ""
: "Expected valid JSON object in GraphQL variables"
);
setVariablesError("");
}, [variables, scope]);
const formAccessorRef = useRef<HTMLInputElement>(null);
const form = composeWithNativeForm(formAccessorRef, composeFields(nameField));
useImperativeHandle(ref, () => ({
...form,
save: () => {
const instanceSelector = $selectedInstanceSelector.get();
if (instanceSelector === undefined) {
return;
}
const body = generateObjectExpression(
new Map([
["query", JSON.stringify(query)],
["variables", variables],
])
);
const [instanceId] = instanceSelector;
const newResource: Resource = {
id: resource?.id ?? nanoid(),
name: nameField.value,
control: "graphql",
url,
method: "post",
headers,
body,
};
const newVariable: DataSource = {
id: variable?.id ?? nanoid(),
// preserve existing instance scope when edit
scopeInstanceId: variable?.scopeInstanceId ?? instanceId,
name: nameField.value,
type: "resource",
resourceId: newResource.id,
};
serverSyncStore.createTransaction(
[$dataSources, $resources],
(dataSources, resources) => {
dataSources.set(newVariable.id, newVariable);
resources.set(newResource.id, newResource);
}
);
},
}));
return (
<>
<input ref={formAccessorRef} type="hidden" name="form-accessor" />
<UrlField scope={scope} aliases={aliases} value={url} onChange={setUrl} />
<Grid gap={1}>
<Label htmlFor={queryId}>Query</Label>
<EditorDialogControl>
<TextArea
name="query"
id={queryId}
rows={3}
maxRows={10}
autoGrow={true}
value={query}
onChange={setQuery}
/>
<EditorDialog
title="GraphQL Query"
content={<TextArea grow={true} value={query} onChange={setQuery} />}
>
<EditorDialogButton />
</EditorDialog>
</EditorDialogControl>
</Grid>
<Grid gap={1}>
<Label>GraphQL Variables</Label>
{/* use invisible text input to reflect expression editor in form
type=hidden does not emit invalid event */}
<input
ref={variablesRef}
style={{ display: "none" }}
type="text"
name="variables"
data-color={variablesError ? "error" : undefined}
value={variables}
onChange={() => {}}
onInvalid={(event) =>
setVariablesError(event.currentTarget.validationMessage)
}
/>
<BindingControl>
<InputErrorsTooltip
errors={variablesError ? [variablesError] : undefined}
>
{/* wrap with div to position error tooltip */}
<div>
<ExpressionEditor
color={variablesError ? "error" : undefined}
readOnly={isVariablesLiteral === false}
value={
isVariablesLiteral
? variables
: JSON.stringify(
evaluateExpressionWithinScope(variables, scope),
null,
2
) ?? ""
}
onChange={(value) => {
setVariables(value);
}}
onBlur={() => variablesRef.current?.checkValidity()}
/>
</div>
</InputErrorsTooltip>
<BindingPopover
scope={scope}
aliases={aliases}
variant={isVariablesLiteral ? "default" : "bound"}
value={variables}
onChange={(value) => {
setVariables(value);
setIsVariablesLiteral(isLiteralExpression(value));
}}
onRemove={(evaluatedValue) => {
setVariables(JSON.stringify(evaluatedValue));
setIsVariablesLiteral(true);
}}
/>
</BindingControl>
</Grid>
<Headers
scope={scope}
aliases={aliases}
headers={headers}
onChange={setHeaders}
/>
</>
);
});
GraphqlResourceForm.displayName = "GraphqlResourceForm";
@@ -62,7 +62,11 @@ import {
EditorDialogButton,
EditorDialogControl,
} from "~/builder/shared/code-editor-base";
import { ResourceForm, SystemResourceForm } from "./resource-panel";
import {
GraphqlResourceForm,
ResourceForm,
SystemResourceForm,
} from "./resource-panel";
import { generateCurl } from "./curl";
/**
@@ -103,13 +107,14 @@ const parseJsonValue = (code: string) => {
};
type VariableType =
| "parameter"
| "string"
| "number"
| "boolean"
| "json"
| "resource"
| "system-resource"
| "parameter";
| "graphql-resource"
| "system-resource";
const TypeField = ({
value,
@@ -157,6 +162,18 @@ const TypeField = ({
description:
"A Resource is a configuration for secure data fetching. You can safely use secrets in any field.",
},
{
value: "graphql-resource",
disabled: allowDynamicData === false,
label: (
<Flex direction="row" gap="2" align="center">
GraphQL
{allowDynamicData === false && <ProBadge>Pro</ProBadge>}
</Flex>
),
description:
"A Resource is a configuration for secure data fetching. You can safely use secrets in any field.",
},
{
value: "system-resource",
disabled: allowDynamicData === false,
@@ -471,6 +488,9 @@ const VariablePanel = forwardRef<
if (resource?.control === "system") {
return "system-resource";
}
if (resource?.control === "graphql") {
return "graphql-resource";
}
return "resource";
}
if (variable?.type === "parameter") {
@@ -541,6 +561,20 @@ const VariablePanel = forwardRef<
);
}
if (variableType === "graphql-resource") {
return (
<>
{nameFieldElement}
<TypeField value={variableType} onChange={setVariableType} />
<GraphqlResourceForm
ref={ref}
variable={variable}
nameField={nameField}
/>
</>
);
}
if (variableType === "system-resource") {
return (
<>