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:
Daniele Cammareri 2023-07-06 18:42:52 +02:00 committed by hasura-bot
parent fdddac8057
commit 9eb1097957
13 changed files with 581 additions and 8 deletions

View File

@ -280,7 +280,7 @@ export const supportedNumericTypes = [
'decimal', 'decimal',
]; ];
const getValueWithType = (variableData: VariableState) => { export const getValueWithType = (variableData: VariableState) => {
if (variableData.type === 'Boolean') { if (variableData.type === 'Boolean') {
if (variableData.value.trim().toLowerCase() === 'false') { if (variableData.value.trim().toLowerCase() === 'false') {
return false; return false;

View File

@ -41,7 +41,20 @@ describe('useOperationsFromQueryCollection with no query collections', () => {
const operations = result.current.data!; 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); expect(operations).toHaveLength(3);
}); });

View File

@ -12,7 +12,22 @@ export const queryCollectionInitialData: Partial<Metadata['metadata']> = {
name: 'allowed-queries', name: 'allowed-queries',
definition: { definition: {
queries: [ 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: 'MyQuery2', query: 'query MyQuery2 { user { email name}}' },
{ name: 'MyQuery3', query: 'query MyQuery3 { user { email name}}' }, { name: 'MyQuery3', query: 'query MyQuery3 { user { email name}}' },
], ],

View File

@ -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>
);
};

View File

@ -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'));
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './RestEndpointDetails';

View File

@ -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,
};
};

View File

@ -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,
});
};

View File

@ -7,16 +7,16 @@ import { MetadataReducer } from '../../../mocks/actions';
export const restEndpointsInitialData: Partial<Metadata['metadata']> = { export const restEndpointsInitialData: Partial<Metadata['metadata']> = {
rest_endpoints: [ rest_endpoints: [
{ {
comment: '', comment: 'Description of my rest endpoint',
definition: { definition: {
query: { query: {
collection_name: 'allowed-queries', collection_name: 'allowed-queries',
query_name: 'test', query_name: 'MyQuery',
}, },
}, },
methods: ['GET'], methods: ['POST'],
name: 'test', name: 'MyQuery',
url: 'test', url: 'test/:id',
}, },
], ],
}; };