feat: replacement of the legacy rest endpoint details page with the new one

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9821
Co-authored-by: Varun Choudhary <68095256+Varun-Choudhary@users.noreply.github.com>
GitOrigin-RevId: 7b50fc7d429c865678837e2ac9391c7b4973fa56
This commit is contained in:
Daniele Cammareri 2023-07-13 09:56:58 +02:00 committed by hasura-bot
parent 6853688e20
commit 5897262d75
11 changed files with 332 additions and 148 deletions

View File

@ -198,7 +198,7 @@ class GraphiQLWrapper extends Component {
); );
return; return;
} }
dispatch(_push('/api/rest/create')); dispatch(_push('/api/rest/create?from=graphiql'));
}; };
const _toggleCacheDirective = () => { const _toggleCacheDirective = () => {

View File

@ -15,7 +15,8 @@ import globals from '../../../../../Globals';
import { FaArrowRight, FaMagic } from 'react-icons/fa'; import { FaArrowRight, FaMagic } from 'react-icons/fa';
import { IndicatorCard } from '../../../../../new-components/IndicatorCard'; import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
import clsx from 'clsx'; import clsx from 'clsx';
import { Link } from 'react-router'; import { openInGraphiQL } from '../../../../../features/RestEndpoints/components/RestEndpointDetails/utils';
import { Analytics } from '../../../../../features/Analytics';
const editorOptions = { const editorOptions = {
minLines: 10, minLines: 10,
@ -131,11 +132,19 @@ export const RestEndpointForm: React.FC<RestEndpointFormProps> = ({
description="Support GraphQL queries and mutations." description="Support GraphQL queries and mutations."
editorOptions={editorOptions} editorOptions={editorOptions}
/> />
<div className="text-sm absolute top-6 right-0 mt-2 mr-2"> <div className="text-sm absolute top-3 right-0 mt-2">
<Link to="/api/api-explorer?mode=rest"> <Analytics name="api-tab-rest-endpoint-form-graphiql-link">
<Button
icon={<FaArrowRight />}
iconPosition="end"
size="sm"
onClick={e => {
openInGraphiQL(request);
}}
>
{request ? 'Test it in ' : 'Import from '} GraphiQL{' '} {request ? 'Test it in ' : 'Import from '} GraphiQL{' '}
<FaArrowRight /> </Button>
</Link> </Analytics>
</div> </div>
</div> </div>
<InputField name="name" label="Name" placeholder="Name" /> <InputField name="name" label="Name" placeholder="Name" />
@ -159,8 +168,8 @@ export const RestEndpointForm: React.FC<RestEndpointFormProps> = ({
customIcon={FaMagic} customIcon={FaMagic}
headline="No Parameterized variable specification needed" headline="No Parameterized variable specification needed"
> >
All parameterized variables in your GraphQL query will be All parameterized variables (e.g. {prependLabel}example/:id) in your
auto-specifed in the URL GraphQL query will be auto-specifed in the URL
</IndicatorCard> </IndicatorCard>
</div> </div>
<CheckboxesField <CheckboxesField
@ -179,14 +188,18 @@ export const RestEndpointForm: React.FC<RestEndpointFormProps> = ({
<Button type="button" onClick={onCancel} disabled={loading}> <Button type="button" onClick={onCancel} disabled={loading}>
Cancel Cancel
</Button> </Button>
<Analytics name={`api-tab-rest-endpoint-form-${mode}-button`}>
<Button <Button
type="submit" type="submit"
mode="primary" mode="primary"
isLoading={loading} isLoading={loading}
loadingText={{ create: 'Creating...', edit: 'Modifying ..' }[mode]} loadingText={
{ create: 'Creating...', edit: 'Modifying ..' }[mode]
}
> >
{{ create: 'Create', edit: 'Modify' }[mode]} {{ create: 'Create', edit: 'Modify' }[mode]}
</Button> </Button>
</Analytics>
</div> </div>
</div> </div>
</Form> </Form>

View File

@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { RouteComponentProps } from 'react-router'; import { RouteComponentProps } from 'react-router';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import queryString from 'query-string';
import { import {
Analytics, Analytics,
REDACT_EVERYTHING, REDACT_EVERYTHING,
} from '../../../../../features/Analytics'; } from '../../../../../features/Analytics';
import { parse, print } from 'graphql';
import { import {
AllowedRESTMethods, AllowedRESTMethods,
RestEndpointEntry, RestEndpointEntry,
@ -84,8 +86,14 @@ const useRestEndpointFormStateForCreation: RestEndpointFormStateHook = (
formSubmitHandler: RestEndpointFormSubmitHandler; formSubmitHandler: RestEndpointFormSubmitHandler;
} => { } => {
const formState: RestEndpointFormState = {}; const formState: RestEndpointFormState = {};
formState.request = getLSItem(LS_KEYS.graphiqlQuery) ?? undefined; try {
const parsedQuery = queryString.parseUrl(window.location.href);
if (parsedQuery.query?.from === 'graphiql') {
formState.request = print(parse(getLSItem(LS_KEYS.graphiqlQuery) ?? ''));
}
} catch (e) {
// ignore
}
return { formState, formSubmitHandler: createEndpoint }; return { formState, formSubmitHandler: createEndpoint };
}; };

View File

@ -12,6 +12,12 @@ type RequestHeadersProps = {
export const RequestHeaders = (props: RequestHeadersProps) => { export const RequestHeaders = (props: RequestHeadersProps) => {
const { headers, setHeaders } = props; const { headers, setHeaders } = props;
const showRemove = !!(
headers.length > 1 ||
headers?.[0]?.name ||
headers?.[0]?.value
);
return ( return (
<Collapsible <Collapsible
defaultOpen defaultOpen
@ -33,7 +39,7 @@ export const RequestHeaders = (props: RequestHeadersProps) => {
</div> </div>
<div className="font-semibold text-muted mb-4">Headers List</div> <div className="font-semibold text-muted mb-4">Headers List</div>
<CardedTable <CardedTable
showActionCell showActionCell={showRemove}
columns={[ columns={[
<Checkbox <Checkbox
checked={headers.every(header => header.selected)} checked={headers.every(header => header.selected)}
@ -49,7 +55,8 @@ export const RequestHeaders = (props: RequestHeadersProps) => {
'Name', 'Name',
'Value', 'Value',
]} ]}
data={headers.map((header, i) => [ data={headers.map((header, i) =>
[
<Checkbox <Checkbox
checked={header.selected} checked={header.selected}
onCheckedChange={checked => onCheckedChange={checked =>
@ -78,7 +85,10 @@ export const RequestHeaders = (props: RequestHeadersProps) => {
<input <input
data-testid={`header-value-${i}`} data-testid={`header-value-${i}`}
placeholder="Enter value..." placeholder="Enter value..."
className="w-full" className="w-full border-0"
type={
header.name === 'x-hasura-admin-secret' ? 'password' : 'text'
}
value={header.value} value={header.value}
onChange={e => onChange={e =>
setHeaders( setHeaders(
@ -89,10 +99,7 @@ export const RequestHeaders = (props: RequestHeadersProps) => {
) )
} }
/>, />,
showRemove && (
(headers.length > 1 ||
headers?.[0]?.name ||
headers?.[0]?.value) && (
<Button <Button
mode="destructive" mode="destructive"
size="sm" size="sm"
@ -114,7 +121,8 @@ export const RequestHeaders = (props: RequestHeadersProps) => {
Remove Remove
</Button> </Button>
), ),
])} ].filter(Boolean)
)}
/> />
</div> </div>
</Collapsible> </Collapsible>

View File

@ -1,5 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import AceEditor from 'react-ace';
import { import {
CheckboxesField, CheckboxesField,
CodeEditorField, CodeEditorField,
@ -10,13 +11,15 @@ import {
import { Button } from '../../../../new-components/Button'; import { Button } from '../../../../new-components/Button';
import { FaArrowRight, FaPlay } from 'react-icons/fa'; import { FaArrowRight, FaPlay } from 'react-icons/fa';
import { useRestEndpoint } from '../../hooks/useRestEndpoint'; import { useRestEndpoint } from '../../hooks/useRestEndpoint';
import { getSessionVarsFromLS } from '../../../../components/Common/ConfigureTransformation/utils';
import { parseQueryVariables } from '../../../../components/Services/ApiExplorer/Rest/utils'; import { parseQueryVariables } from '../../../../components/Services/ApiExplorer/Rest/utils';
import { useRestEndpointRequest } from '../../hooks/useRestEndpointRequest'; import { useRestEndpointRequest } from '../../hooks/useRestEndpointRequest';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { RequestHeaders } from './RequestHeaders'; import { RequestHeaders } from './RequestHeaders';
import { Variables } from './Variables'; import { Variables } from './Variables';
import { AllowedRESTMethods } from '../../../../metadata/types'; import { AllowedRESTMethods } from '../../../../metadata/types';
import { openInGraphiQL } from './utils';
import { LS_KEYS, getLSItem } from '../../../../utils';
import { hasuraToast } from '../../../../new-components/Toasts';
import { Analytics } from '../../../Analytics';
export type Variable = Exclude< export type Variable = Exclude<
ReturnType<typeof parseQueryVariables>, ReturnType<typeof parseQueryVariables>,
@ -66,10 +69,37 @@ const validationSchema = z.object({
request: z.string().min(1, { message: 'Please add a GraphQL query' }), request: z.string().min(1, { message: 'Please add a GraphQL query' }),
}); });
export const getInitialHeaders = (): Header[] => {
const headers = getLSItem(LS_KEYS.apiExplorerConsoleGraphQLHeaders);
if (headers) {
return JSON.parse(headers).map(
(header: { key: string; value: string; isDisabled: boolean }) => {
const value =
header.key === 'x-hasura-admin-secret'
? window.__env.adminSecret || getLSItem(LS_KEYS.consoleAdminSecret)
: header.value;
return {
name: header.key,
value,
selected: !header.isDisabled,
};
}
);
}
return [
{
name: '',
value: '',
selected: true,
},
];
};
export const RestEndpointDetails = (props: RestEndpointDetailsProps) => { export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
const endpoint = useRestEndpoint(props.name); const endpoint = useRestEndpoint(props.name);
const initialHeaders = getSessionVarsFromLS(); const initialHeaders = getInitialHeaders();
const [headers, setHeaders] = React.useState( const [headers, setHeaders] = React.useState(
initialHeaders.map(header => ({ initialHeaders.map(header => ({
@ -80,11 +110,7 @@ export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
const [variables, setVariables] = React.useState<Variable[]>([]); const [variables, setVariables] = React.useState<Variable[]>([]);
const { data, refetch, isFetching, error } = useRestEndpointRequest( const { data, mutate, isLoading } = useRestEndpointRequest();
endpoint?.endpoint,
headers,
variables
);
const { const {
Form, Form,
@ -129,10 +155,17 @@ export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
label="GraphQL Request" label="GraphQL Request"
/> />
<div className="text-sm absolute top-6 right-0 mt-2 mr-2"> <div className="text-sm absolute top-3 right-0 mt-2">
<a href="/api/api-explorer"> <Button
Test it in GraphiQL <FaArrowRight /> icon={<FaArrowRight />}
</a> iconPosition="end"
size="sm"
onClick={e => {
openInGraphiQL(endpoint.query.query);
}}
>
Test it in GraphiQL
</Button>
</div> </div>
</div> </div>
<Textarea <Textarea
@ -163,26 +196,64 @@ export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
)} )}
orientation="horizontal" orientation="horizontal"
/> />
<RequestHeaders headers={headers} setHeaders={setHeaders} />
<Variables variables={variables} setVariables={setVariables} /> <Variables variables={variables} setVariables={setVariables} />
<RequestHeaders headers={headers} setHeaders={setHeaders} />
<div className="mt-2"> <div className="mt-2">
{error && ( <Analytics name="api-tab-rest-endpoint-details-run-request">
<IndicatorCard status="negative" headline="An error has occured">
{JSON.stringify(error, null, 2)}
</IndicatorCard>
)}
<Button <Button
disabled={!endpoint?.endpoint} disabled={!endpoint?.endpoint}
isLoading={isFetching} isLoading={isLoading}
icon={<FaPlay />} icon={<FaPlay />}
onClick={() => { onClick={() => {
refetch(); mutate(
{
endpoint: endpoint?.endpoint,
headers,
variables,
},
{
onSuccess: data => {
hasuraToast({
title: 'Success',
message: 'Request successful',
type: 'success',
});
window.scrollTo({
top: 0,
behavior: 'smooth',
});
},
onError: error => {
hasuraToast({
title: 'Error',
message: 'Request failed',
type: 'error',
children: (
<div className="overflow-hidden">
<AceEditor
theme="github"
setOptions={{
minLines: 1,
maxLines: Infinity,
showGutter: false,
useWorker: false,
}}
value={JSON.stringify(error, null, 2)}
/>
</div>
),
});
},
}
);
}} }}
mode="primary" mode="primary"
> >
Run Request Run Request
</Button> </Button>
</Analytics>
</div> </div>
</div> </div>
<div> <div>

View File

@ -0,0 +1,54 @@
import React from 'react';
import { RestEndpointDetails } from './RestEndpointDetails';
import { Button } from '../../../../new-components/Button';
import {
BreadcrumbItem,
Breadcrumbs,
} from '../../../../new-components/Breadcrumbs/Breadcrumbs';
import { RouteComponentProps, browserHistory } from 'react-router';
interface RestEndpointDetailsPageProps {
params: RouteComponentProps<{ name: string }, unknown>['params'];
}
export const RestEndpointDetailsPage = (
props: RestEndpointDetailsPageProps
) => {
const name = props.params.name;
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Rest Endpoints',
onClick: () => browserHistory.push('/api/rest'),
},
{
title: name,
},
];
if (!name) {
return null;
}
return (
<div className="p-9">
<div className="-ml-2 mb-2">
<Breadcrumbs items={breadcrumbs} />
</div>
<div className="flex items-center">
<div className="pb-2">
<h1 className="text-xl font-semibold mb-0">{name}</h1>
<p className="text-gray-500 mt-0">Test your REST endpoint</p>
</div>
<Button
className="ml-auto"
onClick={() => browserHistory.push(`/api/rest/edit/${name}`)}
>
Edit Endpoint
</Button>
</div>
<hr className="mb-4 mt-2 -mx-9" />
<RestEndpointDetails name={name} />
</div>
);
};

View File

@ -1,3 +1,4 @@
import { supportedNumericTypes } from '../../../../components/Services/ApiExplorer/Rest/utils';
import { CardedTable } from '../../../../new-components/CardedTable'; import { CardedTable } from '../../../../new-components/CardedTable';
import { Collapsible } from '../../../../new-components/Collapsible'; import { Collapsible } from '../../../../new-components/Collapsible';
import { Variable } from './RestEndpointDetails'; import { Variable } from './RestEndpointDetails';
@ -9,6 +10,11 @@ type VariablesProps = {
export const Variables = (props: VariablesProps) => { export const Variables = (props: VariablesProps) => {
const { variables, setVariables } = props; const { variables, setVariables } = props;
if (variables.length === 0) {
return null;
}
return ( return (
<Collapsible <Collapsible
defaultOpen defaultOpen
@ -26,9 +32,14 @@ export const Variables = (props: VariablesProps) => {
<span className="font-semibold text-muted">{variable.name}</span>, <span className="font-semibold text-muted">{variable.name}</span>,
variable.type, variable.type,
<input <input
type={
supportedNumericTypes.includes(variable.type)
? 'number'
: 'text'
}
data-testid={`variable-${variable.name}`} data-testid={`variable-${variable.name}`}
placeholder="Enter value..." placeholder="Enter value..."
className="w-full font-normal text-muted" className="w-full font-normal text-muted border-0"
value={variable.value} value={variable.value}
onChange={e => onChange={e =>
setVariables( setVariables(

View File

@ -0,0 +1,9 @@
import { browserHistory } from 'react-router';
import { LS_KEYS, setLSItem } from '../../../../utils';
export const openInGraphiQL = (query: string) => {
if (query) {
setLSItem(LS_KEYS.graphiqlQuery, query);
}
browserHistory.push('/api/api-explorer?mode=rest');
};

View File

@ -1,28 +1,43 @@
import { RestEndpointEntry } from '../../../metadata/types'; import { RestEndpointEntry } from '../../../metadata/types';
import { Header, Variable } from '../components/RestEndpointDetails'; import { Header, Variable } from '../components/RestEndpointDetails';
import { Api } from '../../../hooks/apiUtils';
import { import {
getCurrentPageHost, getCurrentPageHost,
getValueWithType, getValueWithType,
} from '../../../components/Services/ApiExplorer/Rest/utils'; } from '../../../components/Services/ApiExplorer/Rest/utils';
import { useQuery } from 'react-query'; import { useMutation } from 'react-query';
type QueryKey = [ interface IApiArgs {
string, headers: Record<string, string>;
{ url: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: Record<any, any> | string;
}
async function fetchApi<T = unknown, V = T>(args: IApiArgs): Promise<V> {
const { headers, url, method, body } = args;
const response = await fetch(url, {
headers,
method,
body: typeof body !== 'string' ? JSON.stringify(body) : body,
});
const contentType = response.headers.get('Content-Type');
const isResponseJson = `${contentType}`.includes('application/json');
if (response.ok) {
if (!isResponseJson) {
return (await response.text()) as unknown as V;
}
return response.json();
}
throw response.text();
}
export const useRestEndpointRequest = () => {
const makeRequest = async (options: {
endpoint: RestEndpointEntry | undefined; endpoint: RestEndpointEntry | undefined;
headers: Header[]; headers: Header[];
variables: Variable[]; variables: Variable[];
} }) => {
]; const { endpoint, headers, variables } = options;
export const useRestEndpointRequest = (
endpoint: RestEndpointEntry | undefined,
headers: Header[],
variables: Variable[]
) => {
const makeRequest = ({ queryKey }: { queryKey: QueryKey }) => {
const [, { endpoint, headers, variables }] = queryKey;
const selectedHeaders = headers const selectedHeaders = headers
.filter(h => !!h.name && h.selected) .filter(h => !!h.name && h.selected)
@ -49,13 +64,16 @@ export const useRestEndpointRequest = (
} }
} }
return Api.base({ return fetchApi({
method: endpoint.methods?.[0], method: endpoint.methods?.[0],
url: `${getCurrentPageHost()}/api/rest/${url}`, url: `${getCurrentPageHost()}/api/rest/${url}`,
body: bodyVariables.reduce((acc, curr) => { body:
bodyVariables.length > 0
? bodyVariables.reduce((acc, curr) => {
acc[curr.name] = curr.value; acc[curr.name] = curr.value;
return acc; return acc;
}, {} as Record<string, unknown>), }, {} as Record<string, unknown>)
: undefined,
headers: selectedHeaders.reduce((acc, curr) => { headers: selectedHeaders.reduce((acc, curr) => {
acc[curr.name] = curr.value; acc[curr.name] = curr.value;
return acc; return acc;
@ -63,13 +81,5 @@ export const useRestEndpointRequest = (
}); });
}; };
return useQuery({ return useMutation(makeRequest);
queryKey: [
'rest-endpoint-request ',
{ endpoint, headers, variables },
] as QueryKey,
queryFn: makeRequest,
enabled: false,
retry: 1,
});
}; };

View File

@ -28,13 +28,13 @@ import { SupportContainer } from './components/Services/Support/SupportContainer
import HelpPage from './components/Services/Support/HelpPage'; import HelpPage from './components/Services/Support/HelpPage';
import FormRestView from './components/Services/ApiExplorer/Rest/Form'; import FormRestView from './components/Services/ApiExplorer/Rest/Form';
import RestListView from './components/Services/ApiExplorer/Rest/List'; import RestListView from './components/Services/ApiExplorer/Rest/List';
import DetailsView from './components/Services/ApiExplorer/Rest/Details';
import { HerokuCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Heroku/TempCallback'; import { HerokuCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Heroku/TempCallback';
import { NeonCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Neon/TempCallback'; import { NeonCallbackHandler } from './components/Services/Data/DataSources/CreateDataSource/Neon/TempCallback';
import InsecureDomains from './components/Services/Settings/InsercureDomains/AllowInsecureDomains'; import InsecureDomains from './components/Services/Settings/InsercureDomains/AllowInsecureDomains';
import AuthContainer from './components/Services/Auth/AuthContainer'; import AuthContainer from './components/Services/Auth/AuthContainer';
import { FeatureFlags } from './features/FeatureFlags'; import { FeatureFlags } from './features/FeatureFlags';
import { AllowListDetail } from './components/Services/AllowList'; import { AllowListDetail } from './components/Services/AllowList';
import { RestEndpointDetailsPage } from './features/RestEndpoints/components/RestEndpointDetails/RestEndpointDetailsPage';
const routes = store => { const routes = store => {
// load hasuraCliServer migration status // load hasuraCliServer migration status
@ -121,7 +121,7 @@ const routes = store => {
<IndexRedirect to="list" /> <IndexRedirect to="list" />
<Route path="create" component={FormRestView} /> <Route path="create" component={FormRestView} />
<Route path="list" component={RestListView} /> <Route path="list" component={RestListView} />
<Route path="details/:name" component={DetailsView} /> <Route path="details/:name" component={RestEndpointDetailsPage} />
<Route path="edit/:name" component={FormRestView} /> <Route path="edit/:name" component={FormRestView} />
</Route> </Route>
<Route path="allow-list"> <Route path="allow-list">

View File

@ -38,7 +38,6 @@ import {
ApiContainer, ApiContainer,
CreateRestView, CreateRestView,
RestListView, RestListView,
DetailsView,
InheritedRolesContainer, InheritedRolesContainer,
ApiLimits, ApiLimits,
IntrospectionOptions, IntrospectionOptions,
@ -54,6 +53,7 @@ import {
SingleSignOnPage, SingleSignOnPage,
SchemaRegistryContainer, SchemaRegistryContainer,
SchemaDetailsView, SchemaDetailsView,
RestEndpointDetailsPage,
} from '@hasura/console-legacy-ce'; } from '@hasura/console-legacy-ce';
import AccessDeniedComponent from './components/AccessDenied/AccessDenied'; import AccessDeniedComponent from './components/AccessDenied/AccessDenied';
@ -344,7 +344,7 @@ const routes = store => {
<IndexRedirect to="list" /> <IndexRedirect to="list" />
<Route path="create" component={CreateRestView} /> <Route path="create" component={CreateRestView} />
<Route path="list" component={RestListView} /> <Route path="list" component={RestListView} />
<Route path="details/:name" component={DetailsView} /> <Route path="details/:name" component={RestEndpointDetailsPage} />
<Route path="edit/:name" component={CreateRestView} /> <Route path="edit/:name" component={CreateRestView} />
</Route> </Route>
<Route path="allow-list"> <Route path="allow-list">