mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-06 12:04:00 +09:00
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:
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 (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user