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;
}
dispatch(_push('/api/rest/create'));
dispatch(_push('/api/rest/create?from=graphiql'));
};
const _toggleCacheDirective = () => {

View File

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

View File

@ -1,10 +1,12 @@
import React from 'react';
import { RouteComponentProps } from 'react-router';
import { connect, ConnectedProps } from 'react-redux';
import queryString from 'query-string';
import {
Analytics,
REDACT_EVERYTHING,
} from '../../../../../features/Analytics';
import { parse, print } from 'graphql';
import {
AllowedRESTMethods,
RestEndpointEntry,
@ -84,8 +86,14 @@ const useRestEndpointFormStateForCreation: RestEndpointFormStateHook = (
formSubmitHandler: RestEndpointFormSubmitHandler;
} => {
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 };
};

View File

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

View File

@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { z } from 'zod';
import AceEditor from 'react-ace';
import {
CheckboxesField,
CodeEditorField,
@ -10,13 +11,15 @@ import {
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';
import { openInGraphiQL } from './utils';
import { LS_KEYS, getLSItem } from '../../../../utils';
import { hasuraToast } from '../../../../new-components/Toasts';
import { Analytics } from '../../../Analytics';
export type Variable = Exclude<
ReturnType<typeof parseQueryVariables>,
@ -66,10 +69,37 @@ const validationSchema = z.object({
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) => {
const endpoint = useRestEndpoint(props.name);
const initialHeaders = getSessionVarsFromLS();
const initialHeaders = getInitialHeaders();
const [headers, setHeaders] = React.useState(
initialHeaders.map(header => ({
@ -80,11 +110,7 @@ export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
const [variables, setVariables] = React.useState<Variable[]>([]);
const { data, refetch, isFetching, error } = useRestEndpointRequest(
endpoint?.endpoint,
headers,
variables
);
const { data, mutate, isLoading } = useRestEndpointRequest();
const {
Form,
@ -129,10 +155,17 @@ export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
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 className="text-sm absolute top-3 right-0 mt-2">
<Button
icon={<FaArrowRight />}
iconPosition="end"
size="sm"
onClick={e => {
openInGraphiQL(endpoint.query.query);
}}
>
Test it in GraphiQL
</Button>
</div>
</div>
<Textarea
@ -163,26 +196,64 @@ export const RestEndpointDetails = (props: RestEndpointDetailsProps) => {
)}
orientation="horizontal"
/>
<RequestHeaders headers={headers} setHeaders={setHeaders} />
<Variables variables={variables} setVariables={setVariables} />
<RequestHeaders headers={headers} setHeaders={setHeaders} />
<div className="mt-2">
{error && (
<IndicatorCard status="negative" headline="An error has occured">
{JSON.stringify(error, null, 2)}
</IndicatorCard>
)}
<Analytics name="api-tab-rest-endpoint-details-run-request">
<Button
disabled={!endpoint?.endpoint}
isLoading={isFetching}
isLoading={isLoading}
icon={<FaPlay />}
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"
>
Run Request
</Button>
</Analytics>
</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 { Collapsible } from '../../../../new-components/Collapsible';
import { Variable } from './RestEndpointDetails';
@ -9,6 +10,11 @@ type VariablesProps = {
export const Variables = (props: VariablesProps) => {
const { variables, setVariables } = props;
if (variables.length === 0) {
return null;
}
return (
<Collapsible
defaultOpen
@ -26,9 +32,14 @@ export const Variables = (props: VariablesProps) => {
<span className="font-semibold text-muted">{variable.name}</span>,
variable.type,
<input
type={
supportedNumericTypes.includes(variable.type)
? 'number'
: 'text'
}
data-testid={`variable-${variable.name}`}
placeholder="Enter value..."
className="w-full font-normal text-muted"
className="w-full font-normal text-muted border-0"
value={variable.value}
onChange={e =>
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 { Header, Variable } from '../components/RestEndpointDetails';
import { Api } from '../../../hooks/apiUtils';
import {
getCurrentPageHost,
getValueWithType,
} from '../../../components/Services/ApiExplorer/Rest/utils';
import { useQuery } from 'react-query';
import { useMutation } from 'react-query';
type QueryKey = [
string,
{
interface IApiArgs {
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;
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 { endpoint, headers, variables } = options;
const selectedHeaders = headers
.filter(h => !!h.name && h.selected)
@ -49,13 +64,16 @@ export const useRestEndpointRequest = (
}
}
return Api.base({
return fetchApi({
method: endpoint.methods?.[0],
url: `${getCurrentPageHost()}/api/rest/${url}`,
body: bodyVariables.reduce((acc, curr) => {
body:
bodyVariables.length > 0
? bodyVariables.reduce((acc, curr) => {
acc[curr.name] = curr.value;
return acc;
}, {} as Record<string, unknown>),
}, {} as Record<string, unknown>)
: undefined,
headers: selectedHeaders.reduce((acc, curr) => {
acc[curr.name] = curr.value;
return acc;
@ -63,13 +81,5 @@ export const useRestEndpointRequest = (
});
};
return useQuery({
queryKey: [
'rest-endpoint-request ',
{ endpoint, headers, variables },
] as QueryKey,
queryFn: makeRequest,
enabled: false,
retry: 1,
});
return useMutation(makeRequest);
};

View File

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

View File

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