mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
feat: refactor of rest endpoint details component
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9783 GitOrigin-RevId: b40ef6dafd699c51da1089d0b6687c9cf934e275
This commit is contained in:
parent
fdddac8057
commit
9eb1097957
@ -280,7 +280,7 @@ export const supportedNumericTypes = [
|
||||
'decimal',
|
||||
];
|
||||
|
||||
const getValueWithType = (variableData: VariableState) => {
|
||||
export const getValueWithType = (variableData: VariableState) => {
|
||||
if (variableData.type === 'Boolean') {
|
||||
if (variableData.value.trim().toLowerCase() === 'false') {
|
||||
return false;
|
||||
|
@ -41,7 +41,20 @@ describe('useOperationsFromQueryCollection with no query collections', () => {
|
||||
|
||||
const operations = result.current.data!;
|
||||
|
||||
expect(operations[0].query).toEqual('query MyQuery { user { email name}}');
|
||||
expect(operations[0].query)
|
||||
.toEqual(`mutation update_user_by_pk($id: Int!, $object: user_set_input!) {
|
||||
update_user_by_pk(pk_columns: {id: $id}, _set: $object) {
|
||||
address
|
||||
bool
|
||||
count
|
||||
date
|
||||
email
|
||||
id
|
||||
name
|
||||
uuid
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(operations).toHaveLength(3);
|
||||
});
|
||||
|
@ -12,7 +12,22 @@ export const queryCollectionInitialData: Partial<Metadata['metadata']> = {
|
||||
name: 'allowed-queries',
|
||||
definition: {
|
||||
queries: [
|
||||
{ name: 'MyQuery', query: 'query MyQuery { user { email name}}' },
|
||||
{
|
||||
name: 'MyQuery',
|
||||
query: `mutation update_user_by_pk($id: Int!, $object: user_set_input!) {
|
||||
update_user_by_pk(pk_columns: {id: $id}, _set: $object) {
|
||||
address
|
||||
bool
|
||||
count
|
||||
date
|
||||
email
|
||||
id
|
||||
name
|
||||
uuid
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{ name: 'MyQuery2', query: 'query MyQuery2 { user { email name}}' },
|
||||
{ name: 'MyQuery3', query: 'query MyQuery3 { user { email name}}' },
|
||||
],
|
||||
|
@ -0,0 +1,122 @@
|
||||
import { FaPlus } from 'react-icons/fa';
|
||||
import { Button } from '../../../../new-components/Button';
|
||||
import { Collapsible } from '../../../../new-components/Collapsible';
|
||||
import { Header } from './RestEndpointDetails';
|
||||
import { CardedTable } from '../../../../new-components/CardedTable';
|
||||
import { Checkbox } from '../../../../new-components/Form';
|
||||
|
||||
type RequestHeadersProps = {
|
||||
headers: Header[];
|
||||
setHeaders: (headers: Header[]) => void;
|
||||
};
|
||||
|
||||
export const RequestHeaders = (props: RequestHeadersProps) => {
|
||||
const { headers, setHeaders } = props;
|
||||
return (
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
triggerChildren={
|
||||
<div className="font-semibold text-muted">Request Headers</div>
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
<Button
|
||||
icon={<FaPlus />}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setHeaders([...headers, { name: '', value: '', selected: true }]);
|
||||
}}
|
||||
>
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
<div className="font-semibold text-muted mb-4">Headers List</div>
|
||||
<CardedTable
|
||||
showActionCell
|
||||
columns={[
|
||||
<Checkbox
|
||||
checked={headers.every(header => header.selected)}
|
||||
onCheckedChange={checked =>
|
||||
setHeaders(
|
||||
headers.map(header => ({
|
||||
...header,
|
||||
selected: !!checked,
|
||||
}))
|
||||
)
|
||||
}
|
||||
/>,
|
||||
'Name',
|
||||
'Value',
|
||||
]}
|
||||
data={headers.map((header, i) => [
|
||||
<Checkbox
|
||||
checked={header.selected}
|
||||
onCheckedChange={checked =>
|
||||
setHeaders(
|
||||
headers.map(h => ({
|
||||
...h,
|
||||
selected: h.name === header.name ? !!checked : h.selected,
|
||||
}))
|
||||
)
|
||||
}
|
||||
/>,
|
||||
<input
|
||||
data-testid={`header-name-${i}`}
|
||||
placeholder="Enter name..."
|
||||
className="w-full"
|
||||
value={header.name}
|
||||
onChange={e =>
|
||||
setHeaders(
|
||||
headers.map(h => ({
|
||||
...h,
|
||||
name: h.name === header.name ? e.target.value : h.name,
|
||||
}))
|
||||
)
|
||||
}
|
||||
/>,
|
||||
<input
|
||||
data-testid={`header-value-${i}`}
|
||||
placeholder="Enter value..."
|
||||
className="w-full"
|
||||
value={header.value}
|
||||
onChange={e =>
|
||||
setHeaders(
|
||||
headers.map(h => ({
|
||||
...h,
|
||||
value: h.name === header.name ? e.target.value : h.value,
|
||||
}))
|
||||
)
|
||||
}
|
||||
/>,
|
||||
|
||||
(headers.length > 1 ||
|
||||
headers?.[0]?.name ||
|
||||
headers?.[0]?.value) && (
|
||||
<Button
|
||||
mode="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newHeaders = headers
|
||||
.slice(0, i)
|
||||
.concat(headers.slice(i + 1));
|
||||
|
||||
if (newHeaders.length === 0) {
|
||||
newHeaders.push({
|
||||
name: '',
|
||||
value: '',
|
||||
selected: true,
|
||||
});
|
||||
}
|
||||
setHeaders(newHeaders);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
),
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { StoryObj, Meta } from '@storybook/react';
|
||||
import { within, userEvent, waitFor } from '@storybook/testing-library';
|
||||
import { expect } from '@storybook/jest';
|
||||
import {
|
||||
RestEndpointDetails,
|
||||
RestEndpointDetailsProps,
|
||||
} from './RestEndpointDetails';
|
||||
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
|
||||
import { handlers } from '../../../../mocks/metadata.mock';
|
||||
import { rest } from 'msw';
|
||||
|
||||
const meta = {
|
||||
title: 'Features/REST endpoints/Rest Endpoint Details',
|
||||
component: RestEndpointDetails,
|
||||
decorators: [ReactQueryDecorator()],
|
||||
parameters: {
|
||||
msw: [
|
||||
...handlers({ delay: 0 }),
|
||||
rest.post('**/api/rest/test/1', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(0),
|
||||
ctx.json({
|
||||
data: {
|
||||
update_user_by_pk: {
|
||||
id: 1,
|
||||
name: 'Hasura',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
argTypes: {},
|
||||
} satisfies Meta<typeof RestEndpointDetails>;
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<RestEndpointDetailsProps> = {
|
||||
render: args => {
|
||||
return <RestEndpointDetails {...args} name="MyQuery" />;
|
||||
},
|
||||
};
|
||||
|
||||
Default.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.queryByText('Run Request')).toBeEnabled();
|
||||
});
|
||||
|
||||
// await expect(screen.queryByText('user')).toBeInTheDocument();
|
||||
|
||||
// // toggle the user permission and check the success notification
|
||||
// await userEvent.click(canvas.getByTestId('user'));
|
||||
// await expect(
|
||||
// await canvas.findByText(`Allow list permissions updated`)
|
||||
// ).toBeInTheDocument();
|
||||
|
||||
// Add new role
|
||||
await userEvent.type(canvas.getByTestId('header-name-0'), 'Authorization');
|
||||
|
||||
await userEvent.type(canvas.getByTestId('header-value-0'), 'Bearer 123');
|
||||
await userEvent.click(canvas.getByText('Add Header'));
|
||||
|
||||
await userEvent.type(canvas.getByTestId('variable-id'), '1');
|
||||
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('variable-object'),
|
||||
'{"object": {"name": "Hasura"}}'
|
||||
);
|
||||
|
||||
await userEvent.click(canvas.getByText('Run Request'));
|
||||
};
|
@ -0,0 +1,198 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CheckboxesField,
|
||||
CodeEditorField,
|
||||
InputField,
|
||||
Textarea,
|
||||
useConsoleForm,
|
||||
} from '../../../../new-components/Form';
|
||||
import { Button } from '../../../../new-components/Button';
|
||||
import { FaArrowRight, FaPlay } from 'react-icons/fa';
|
||||
import { useRestEndpoint } from '../../hooks/useRestEndpoint';
|
||||
import { getSessionVarsFromLS } from '../../../../components/Common/ConfigureTransformation/utils';
|
||||
import { parseQueryVariables } from '../../../../components/Services/ApiExplorer/Rest/utils';
|
||||
import { useRestEndpointRequest } from '../../hooks/useRestEndpointRequest';
|
||||
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
|
||||
import { RequestHeaders } from './RequestHeaders';
|
||||
import { Variables } from './Variables';
|
||||
import { AllowedRESTMethods } from '../../../../metadata/types';
|
||||
|
||||
export type Variable = Exclude<
|
||||
ReturnType<typeof parseQueryVariables>,
|
||||
undefined
|
||||
>[0] & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Header = {
|
||||
name: string;
|
||||
value: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type RestEndpointDetailsProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const commonEditorOptions = {
|
||||
showLineNumbers: true,
|
||||
useSoftTabs: true,
|
||||
showPrintMargin: false,
|
||||
showGutter: true,
|
||||
wrap: true,
|
||||
};
|
||||
|
||||
const requestEditorOptions = {
|
||||
...commonEditorOptions,
|
||||
minLines: 10,
|
||||
maxLines: 10,
|
||||
};
|
||||
|
||||
const responseEditorOptions = {
|
||||
...commonEditorOptions,
|
||||
minLines: 50,
|
||||
maxLines: 50,
|
||||
};
|
||||
|
||||
const validationSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Please add a name' }),
|
||||
comment: z.union([z.string(), z.null()]),
|
||||
url: z.string().min(1, { message: 'Please add a location' }),
|
||||
methods: z
|
||||
.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
.array()
|
||||
.nonempty({ message: 'Choose at least one method' }),
|
||||
request: z.string().min(1, { message: 'Please add a GraphQL query' }),
|
||||
});
|
||||
|
||||
export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
|
||||
const endpoint = useRestEndpoint(props.name);
|
||||
|
||||
const initialHeaders = getSessionVarsFromLS();
|
||||
|
||||
const [headers, setHeaders] = React.useState(
|
||||
initialHeaders.map(header => ({
|
||||
...header,
|
||||
selected: true,
|
||||
}))
|
||||
);
|
||||
|
||||
const [variables, setVariables] = React.useState<Variable[]>([]);
|
||||
|
||||
const { data, refetch, isFetching, error } = useRestEndpointRequest(
|
||||
endpoint?.endpoint,
|
||||
headers,
|
||||
variables
|
||||
);
|
||||
|
||||
const {
|
||||
Form,
|
||||
methods: { setValue },
|
||||
} = useConsoleForm({
|
||||
schema: validationSchema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (endpoint?.query?.query) {
|
||||
const parsedVariables = parseQueryVariables(endpoint.query.query);
|
||||
setVariables(parsedVariables?.map(v => ({ ...v, value: '' })) ?? []);
|
||||
}
|
||||
|
||||
if (endpoint) {
|
||||
setValue('name', endpoint.endpoint?.name);
|
||||
setValue('comment', endpoint?.endpoint?.comment);
|
||||
setValue('url', endpoint?.endpoint?.url);
|
||||
setValue('methods', endpoint?.endpoint?.methods);
|
||||
setValue('request', endpoint?.query?.query);
|
||||
}
|
||||
}, [endpoint?.query?.query, endpoint?.endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue('response', JSON.stringify(data, null, 2));
|
||||
}, [data]);
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={() => {}}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<CodeEditorField
|
||||
disabled
|
||||
editorOptions={requestEditorOptions}
|
||||
description="Support GraphQL queries and mutations."
|
||||
name="request"
|
||||
label="GraphQL Request"
|
||||
/>
|
||||
|
||||
<div className="text-sm absolute top-6 right-0 mt-2 mr-2">
|
||||
<a href="/api/api-explorer">
|
||||
Test it in GraphiQL <FaArrowRight />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
disabled
|
||||
name="comment"
|
||||
label="Description"
|
||||
placeholder="Description"
|
||||
/>
|
||||
<InputField
|
||||
disabled
|
||||
name="url"
|
||||
label="Location"
|
||||
placeholder="Location"
|
||||
description={`This is the location of your endpoint (must be unique). Any parameterized variables`}
|
||||
/>
|
||||
<CheckboxesField
|
||||
disabled
|
||||
name="methods"
|
||||
label="Methods"
|
||||
options={[
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'PATCH', label: 'PATCH' },
|
||||
{ value: 'DELETE', label: 'DELETE' },
|
||||
].filter(({ value }) =>
|
||||
endpoint.endpoint?.methods.includes(value as AllowedRESTMethods)
|
||||
)}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<RequestHeaders headers={headers} setHeaders={setHeaders} />
|
||||
<Variables variables={variables} setVariables={setVariables} />
|
||||
|
||||
<div className="mt-2">
|
||||
{error && (
|
||||
<IndicatorCard status="negative" headline="An error has occured">
|
||||
{JSON.stringify(error, null, 2)}
|
||||
</IndicatorCard>
|
||||
)}
|
||||
<Button
|
||||
disabled={!endpoint?.endpoint}
|
||||
isLoading={isFetching}
|
||||
icon={<FaPlay />}
|
||||
onClick={() => {
|
||||
refetch();
|
||||
}}
|
||||
mode="primary"
|
||||
>
|
||||
Run Request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CodeEditorField
|
||||
editorOptions={responseEditorOptions}
|
||||
name="response"
|
||||
label="GraphQL Response"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
@ -0,0 +1,47 @@
|
||||
import { CardedTable } from '../../../../new-components/CardedTable';
|
||||
import { Collapsible } from '../../../../new-components/Collapsible';
|
||||
import { Variable } from './RestEndpointDetails';
|
||||
|
||||
type VariablesProps = {
|
||||
variables: Variable[];
|
||||
setVariables: (variables: Variable[]) => void;
|
||||
};
|
||||
|
||||
export const Variables = (props: VariablesProps) => {
|
||||
const { variables, setVariables } = props;
|
||||
return (
|
||||
<Collapsible
|
||||
defaultOpen
|
||||
triggerChildren={
|
||||
<div className="font-semibold text-muted">Request Variables</div>
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0"></div>
|
||||
<div className="font-semibold text-muted mb-4">Variables List</div>
|
||||
<CardedTable
|
||||
showActionCell
|
||||
columns={['Name', 'Type', 'Value']}
|
||||
data={variables.map((variable, i) => [
|
||||
<span className="font-semibold text-muted">{variable.name}</span>,
|
||||
variable.type,
|
||||
<input
|
||||
data-testid={`variable-${variable.name}`}
|
||||
placeholder="Enter value..."
|
||||
className="w-full font-normal text-muted"
|
||||
value={variable.value}
|
||||
onChange={e =>
|
||||
setVariables(
|
||||
variables.map(v => ({
|
||||
...v,
|
||||
value: v.name === variable.name ? e.target.value : v.value,
|
||||
}))
|
||||
)
|
||||
}
|
||||
/>,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './RestEndpointDetails';
|
@ -0,0 +1,27 @@
|
||||
import { useMetadata } from '../../MetadataAPI';
|
||||
|
||||
export const useRestEndpoint = (name: string) => {
|
||||
const { data: metadata } = useMetadata();
|
||||
|
||||
const endpoint = metadata?.metadata?.rest_endpoints?.find(
|
||||
endpoint => endpoint.name === name
|
||||
);
|
||||
|
||||
const queryCollection = metadata?.metadata?.query_collections?.find(
|
||||
collection =>
|
||||
collection.name === endpoint?.definition?.query?.collection_name
|
||||
);
|
||||
|
||||
const query = queryCollection?.definition?.queries?.find(
|
||||
query => query.name === endpoint?.definition?.query?.query_name
|
||||
);
|
||||
|
||||
if (!endpoint || !query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
query,
|
||||
};
|
||||
};
|
@ -0,0 +1,75 @@
|
||||
import { RestEndpointEntry } from '../../../metadata/types';
|
||||
import { Header, Variable } from '../components/RestEndpointDetails';
|
||||
import { Api } from '../../../hooks/apiUtils';
|
||||
import {
|
||||
getCurrentPageHost,
|
||||
getValueWithType,
|
||||
} from '../../../components/Services/ApiExplorer/Rest/utils';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
type QueryKey = [
|
||||
string,
|
||||
{
|
||||
endpoint: RestEndpointEntry | undefined;
|
||||
headers: Header[];
|
||||
variables: Variable[];
|
||||
}
|
||||
];
|
||||
|
||||
export const useRestEndpointRequest = (
|
||||
endpoint: RestEndpointEntry | undefined,
|
||||
headers: Header[],
|
||||
variables: Variable[]
|
||||
) => {
|
||||
const makeRequest = ({ queryKey }: { queryKey: QueryKey }) => {
|
||||
const [, { endpoint, headers, variables }] = queryKey;
|
||||
|
||||
const selectedHeaders = headers
|
||||
.filter(h => !!h.name && h.selected)
|
||||
.map(h => ({ name: h.name, value: h.value }));
|
||||
|
||||
const processedVariable = variables.map(v => ({
|
||||
name: v.name,
|
||||
value: getValueWithType(v),
|
||||
}));
|
||||
|
||||
const bodyVariables = [];
|
||||
|
||||
if (!endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = endpoint.url;
|
||||
|
||||
for (const variable of processedVariable) {
|
||||
if (url.match(`/:${variable.name}`)) {
|
||||
url = url.replace(`/:${variable.name}`, `/${variable.value}`);
|
||||
} else {
|
||||
bodyVariables.push(variable);
|
||||
}
|
||||
}
|
||||
|
||||
return Api.base({
|
||||
method: endpoint.methods?.[0],
|
||||
url: `${getCurrentPageHost()}/api/rest/${url}`,
|
||||
body: bodyVariables.reduce((acc, curr) => {
|
||||
acc[curr.name] = curr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, unknown>),
|
||||
headers: selectedHeaders.reduce((acc, curr) => {
|
||||
acc[curr.name] = curr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
});
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'rest-endpoint-request ',
|
||||
{ endpoint, headers, variables },
|
||||
] as QueryKey,
|
||||
queryFn: makeRequest,
|
||||
enabled: false,
|
||||
retry: 1,
|
||||
});
|
||||
};
|
@ -7,16 +7,16 @@ import { MetadataReducer } from '../../../mocks/actions';
|
||||
export const restEndpointsInitialData: Partial<Metadata['metadata']> = {
|
||||
rest_endpoints: [
|
||||
{
|
||||
comment: '',
|
||||
comment: 'Description of my rest endpoint',
|
||||
definition: {
|
||||
query: {
|
||||
collection_name: 'allowed-queries',
|
||||
query_name: 'test',
|
||||
query_name: 'MyQuery',
|
||||
},
|
||||
},
|
||||
methods: ['GET'],
|
||||
name: 'test',
|
||||
url: 'test',
|
||||
methods: ['POST'],
|
||||
name: 'MyQuery',
|
||||
url: 'test/:id',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user