feat: add a one-click feature to create CRUD rest endpoints from a table

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9585
GitOrigin-RevId: 54757d51010a44211029d6f63714bfc08e152224
This commit is contained in:
Daniele Cammareri 2023-07-06 10:58:17 +02:00 committed by hasura-bot
parent 1a1e046b47
commit fa398581f5
16 changed files with 409 additions and 104 deletions

View File

@ -0,0 +1,35 @@
import React from 'react';
import { RestEndpointModal } from '../../Services/ApiExplorer/Rest/RestEndpointModal/RestEndpointModal';
import { Button } from '../../../new-components/Button';
import { FaLink } from 'react-icons/fa';
import { Badge } from '../../../new-components/Badge';
import { Analytics } from '../../../features/Analytics';
interface CreateRestEndpointProps {
tableName: string;
}
export const CreateRestEndpoint = (props: CreateRestEndpointProps) => {
const { tableName } = props;
const [isModalOpen, setIsModalOpen] = React.useState(false);
const toggleModal = () => {
setIsModalOpen(!isModalOpen);
};
return (
<>
<Analytics name="data-tab-btn-create-rest-endpoints">
<Button size="sm" onClick={toggleModal} icon={<FaLink />}>
Create REST Endpoints{' '}
<Badge className="pt-0 pb-0 pl-2 pr-2 ml-1" color="indigo">
New
</Badge>
</Button>
</Analytics>
{isModalOpen && (
<RestEndpointModal onClose={toggleModal} tableName={tableName} />
)}
</>
);
};

View File

@ -3,6 +3,7 @@ import { FaEdit } from 'react-icons/fa';
import styles from '../Common.module.scss';
import { PrimaryDBInfo } from './PrimaryDBInfo';
import { TryOperation } from './TryOperation';
import { CreateRestEndpoint } from './CreateRestEndpoints';
class Heading extends React.Component {
state = {
@ -51,7 +52,10 @@ class Heading extends React.Component {
<>
<div className={`${styles.editable_heading_text} pb-2`}>
<h2>{currentValue}</h2>
<TryOperation table={table} dispatch={dispatch} source={source} />
<div className="text-base font-normal flex gap-2">
<TryOperation table={table} dispatch={dispatch} source={source} />
<CreateRestEndpoint tableName={table.table_name} />
</div>
</div>
<div className="pb-5">
<PrimaryDBInfo source={source} />
@ -69,7 +73,10 @@ class Heading extends React.Component {
<>
<div className={styles.editable_heading_text}>
<h2>{currentValue}</h2>
<TryOperation table={table} dispatch={dispatch} source={source} />
<div className="text-base font-normal flex gap-2">
<TryOperation table={table} dispatch={dispatch} source={source} />
<CreateRestEndpoint tableName={table.table_name} />
</div>
<div
onClick={this.toggleEditting}
className={styles.editable_heading_action}

View File

@ -1,6 +1,6 @@
import React from 'react';
import React, { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Link } from 'react-router';
import { Link, RouteComponentProps } from 'react-router';
import { FaEdit, FaTimes } from 'react-icons/fa';
import { Analytics, REDACT_EVERYTHING } from '../../../../features/Analytics';
import { LearnMoreLink } from '../../../../new-components/LearnMoreLink';
@ -11,18 +11,28 @@ import { mapDispatchToPropsEmpty } from '../../../Common/utils/reactUtils';
import AceEditor from '../../../Common/AceEditor/BaseEditor';
import URLPreview from './URLPreview';
import { allowedQueriesCollection } from '../../../../metadata/utils';
import { dropRESTEndpoint } from '../../../../metadata/actions';
import { dropRESTEndpoint, exportMetadata } from '../../../../metadata/actions';
import _push from '../../Data/push';
import Landing from './Landing';
import { badgeSort } from './utils';
import CollapsibleToggle from '../../../Common/CollapsibleToggle/CollapsibleToggle';
import { ExportOpenApiButton } from './Form/ExportOpenAPI';
const ListComponent: React.FC<Props> = ({
interface ListComponentProps extends Props {
location: RouteComponentProps<unknown, unknown>['location'];
}
const ListComponent: React.FC<ListComponentProps> = ({
location,
restEndpoints,
queryCollections,
dispatch,
}) => {
// refetch metadata on mount
useEffect(() => {
dispatch(exportMetadata());
}, []);
const highlighted = (location.query?.highlight as string)?.split(',') || [];
const allowedQueries = queryCollections?.find(
collection => collection.name === allowedQueriesCollection
);
@ -43,6 +53,10 @@ const ListComponent: React.FC<Props> = ({
dispatch(_push(`/api/rest/edit/${link}`));
};
const sortedEndpoints = [...restEndpoints].sort(endpoint =>
highlighted.includes(endpoint.name) ? -1 : 1
);
return (
<Analytics name="RestList" {...REDACT_EVERYTHING}>
<div className="pl-md pt-md pr-md">
@ -80,7 +94,7 @@ const ListComponent: React.FC<Props> = ({
</th>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{restEndpoints.map(endpoint => (
{sortedEndpoints.map(endpoint => (
<tr key={`rest_list_endpoint_${endpoint.name}`}>
{/* Details */}
<td className="px-sm py-xs max-w-xs align-top">
@ -94,7 +108,14 @@ const ListComponent: React.FC<Props> = ({
},
}}
>
<h4>{endpoint.name}</h4>
<h4>
{endpoint.name}{' '}
{highlighted.includes(endpoint.name) && (
<span className="relative bottom-2 text-green-700">
</span>
)}
</h4>
</Link>
</div>
{endpoint.comment && <p>{endpoint.comment}</p>}

View File

@ -55,6 +55,7 @@ const Variables: React.FC<VariableComponentProps> = ({
placeholder="Value..."
onChange={updateVariableValue(v.name)}
type="text"
className="my-1 w-full"
/>
</td>
</tr>

View File

@ -1,25 +1,25 @@
import React from 'react';
import z from 'zod';
import { ComponentMeta } from '@storybook/react';
import { RestEndpointModal, modalSchema } from './RestEndpointModal';
import { SimpleForm } from '../../../../../new-components/Form';
import { RestEndpointModal } from './RestEndpointModal';
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
import { handlers } from '../../../../../mocks/metadata.mock';
import { Meta } from '@storybook/react';
import { rest } from 'msw';
import introspectionSchema from '../../../../../features/RestEndpoints/hooks/mocks/introspectionWithoutCustomizations.json';
export default {
title: 'Features/REST endpoints/Modal',
component: RestEndpointModal,
} as ComponentMeta<typeof RestEndpointModal>;
const modalInitialValues: z.infer<typeof modalSchema> = {
tableName: 'Table',
methods: ['CREATE', 'DELETE'],
};
decorators: [ReactQueryDecorator()],
parameters: {
msw: [
...handlers({ delay: 500 }),
rest.post(`http://localhost:8080/v1/graphql`, async (req, res, ctx) => {
return res(ctx.json(introspectionSchema));
}),
],
},
} as Meta<typeof RestEndpointModal>;
export const Base = () => (
<SimpleForm
schema={modalSchema}
options={{ defaultValues: modalInitialValues }}
onSubmit={() => {}}
>
<RestEndpointModal />
</SimpleForm>
<RestEndpointModal tableName="user" onClose={() => {}} />
);

View File

@ -1,48 +1,166 @@
import React from 'react';
import z from 'zod';
import { Dialog } from '../../../../../new-components/Dialog';
import {
CheckboxesField,
InputField,
} from '../../../../../new-components/Form';
import { Checkbox } from '../../../../../new-components/Form';
import { useCreateRestEndpoints } from '../../../../../features/RestEndpoints/hooks/useCreateRestEndpoints';
import { hasuraToast } from '../../../../../new-components/Toasts';
import { EndpointType } from '../../../../../features/RestEndpoints/hooks/useRestEndpointDefinitions';
import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
import { LearnMoreLink } from '../../../../../new-components/LearnMoreLink';
import { CardedTable } from '../../../../../new-components/CardedTable';
import { Badge, BadgeColor } from '../../../../../new-components/Badge';
import { Link, browserHistory } from 'react-router';
import { FaExclamation, FaExternalLinkAlt } from 'react-icons/fa';
export const modalSchema = z.object({
tableName: z.string(),
methods: z
.enum(['READ', 'READ ALL', 'CREATE', 'UPDATE', 'DELETE'])
.array()
.nonempty({ message: 'Choose at least one method' }),
});
const ENDPOINTS: {
value: EndpointType;
label: string;
color: BadgeColor;
}[] = [
{ value: 'READ', label: 'READ', color: 'indigo' },
{ value: 'READ_ALL', label: 'READ ALL', color: 'indigo' },
{ value: 'CREATE', label: 'CREATE', color: 'yellow' },
{ value: 'UPDATE', label: 'UPDATE', color: 'yellow' },
{ value: 'DELETE', label: 'DELETE', color: 'red' },
];
export interface RestEndpointModalProps {
onClose: () => void;
tableName: string;
}
export const RestEndpointModal = (props: RestEndpointModalProps) => {
const { onClose, tableName } = props;
const { createRestEndpoints, endpointDefinitions, isLoading } =
useCreateRestEndpoints();
const tableEndpointDefinitions = endpointDefinitions?.[tableName] ?? {};
const [selectedMethods, setSelectedMethods] = React.useState<EndpointType[]>(
[]
);
export const RestEndpointModal = () => {
return (
<Dialog
hasBackdrop
title="Auto-Create REST Endpoints"
onClose={() => {}}
onClose={onClose}
description="One-click to create REST endpoint from selected table"
footer={<Dialog.Footer callToAction="Create" onSubmit={() => {}} />}
>
<div className="p-md">
<InputField
name="tableName"
label="Table Name"
disabled
className="w-2/3"
/>
footer={
<Dialog.Footer
onSubmitAnalyticsName="data-tab-rest-endpoints-modal-create"
onCancelAnalyticsName="data-tab-rest-endpoints-modal-cancel"
callToAction="Create"
isLoading={isLoading}
callToDeny="Cancel"
onClose={onClose}
disabled={selectedMethods.length === 0}
onSubmit={() => {
createRestEndpoints(tableName, selectedMethods, {
onSuccess: () => {
hasuraToast({
type: 'success',
title: 'Successfully generated rest endpoints',
message: `Successfully generated rest endpoints for ${tableName}: ${selectedMethods.join(
', '
)}`,
});
onClose();
const createdEndpoints = selectedMethods.map(
method =>
endpointDefinitions?.[tableName]?.[method]?.query?.name
);
<CheckboxesField
description="Each selected method will generate one endpoint"
name="methods"
label="Methods *"
options={[
{ value: 'READ', label: 'READ' },
{ value: 'READ ALL', label: 'READ ALL' },
{ value: 'CREATE', label: 'CREATE' },
{ value: 'UPDATE', label: 'UPDATE' },
{ value: 'DELETE', label: 'DELETE' },
browserHistory.push(
`/api/rest/list?highlight=${createdEndpoints.join(',')}`
);
},
onError: error => {
hasuraToast({
type: 'error',
title: 'Failed to generate endpoints',
message: error.message,
});
},
});
}}
/>
}
>
<div className="p-4 flex flex-col gap-4">
<CardedTable
columns={[
<Checkbox
checked={selectedMethods.length === ENDPOINTS.length}
onCheckedChange={checked => {
if (checked) {
setSelectedMethods(ENDPOINTS.map(endpoint => endpoint.value));
} else {
setSelectedMethods([]);
}
}}
/>,
'OPERATION',
'METHOD',
'PATH',
]}
orientation="horizontal"
data={ENDPOINTS.map(method => {
const endpointDefinition =
tableEndpointDefinitions[method.value as EndpointType];
return [
<Checkbox
checked={selectedMethods.includes(method.value as EndpointType)}
disabled={endpointDefinition?.exists}
onCheckedChange={checked => {
if (checked) {
setSelectedMethods([
...selectedMethods,
method.value as EndpointType,
]);
} else {
setSelectedMethods(
selectedMethods.filter(
selectedMethod => selectedMethod !== method.value
)
);
}
}}
/>,
<div>
{endpointDefinition?.exists ? (
<Link
to={{
pathname: `/api/rest/details/${endpointDefinition.restEndpoint?.name}`,
state: {
...endpointDefinition.restEndpoint,
currentQuery: endpointDefinition.query.query,
},
}}
>
{method.label}{' '}
<FaExternalLinkAlt className="relative ml-1 -top-0.5" />
</Link>
) : (
method.label
)}
</div>,
<Badge color={method.color}>
{endpointDefinition?.restEndpoint?.methods?.join(', ')}
</Badge>,
<div>/{endpointDefinition?.restEndpoint?.url ?? 'N/A'}</div>,
];
})}
/>
<IndicatorCard
showIcon
children={
<div>
Creating REST Endpoints will add metadata entries to your Hasura
project
<LearnMoreLink href="https://hasura.io/docs/latest/restified/overview" />
</div>
}
status="info"
customIcon={FaExclamation}
/>
</div>
</Dialog>

View File

@ -294,7 +294,11 @@ const getValueWithType = (variableData: VariableState) => {
}
// NOTE: bool_exp are of JSON type, so pass it as JSON object (issue: https://github.com/hasura/graphql-engine/issues/9671)
if (variableData.type.endsWith('_exp') && isJsonString(variableData.value)) {
if (
(variableData.type.endsWith('_exp') ||
variableData.type.endsWith('_input')) &&
isJsonString(variableData.value)
) {
return JSON.parse(variableData.value);
}

View File

@ -5,4 +5,7 @@ export {
export { useEditOperationInQueryCollection } from './useEditOperationInQueryCollection';
export { useMoveOperationsToQueryCollection } from './useMoveOperationsToQueryCollection';
export { useOperationsFromQueryCollection } from './useOperationsFromQueryCollection';
export { useRemoveOperationsFromQueryCollection } from './useRemoveOperationsFromQueryCollection';
export {
useRemoveOperationsFromQueryCollection,
removeOperationsFromQueryCollectionMetadataArgs,
} from './useRemoveOperationsFromQueryCollection';

View File

@ -23,6 +23,7 @@ export const createAddOperationToQueryCollectionMetadataArgs = (
},
})),
];
export const useAddOperationsToQueryCollection = () => {
const { mutate, ...rest } = useMetadataMigration();

View File

@ -3,6 +3,19 @@ import { useCallback } from 'react';
import { useMetadata, useMetadataMigration } from '../../../MetadataAPI';
import { QueryCollection } from '../../../../metadata/types';
export const removeOperationsFromQueryCollectionMetadataArgs = (
queryCollection: string,
queries: string[]
) => [
...queries.map(query => ({
type: 'drop_query_from_collection',
args: {
collection_name: queryCollection,
query_name: query,
},
})),
];
export const useRemoveOperationsFromQueryCollection = () => {
const { mutate, ...rest } = useMetadataMigration();
@ -27,13 +40,10 @@ export const useRemoveOperationsFromQueryCollection = () => {
...(metadata?.resource_version && {
resource_version: metadata.resource_version,
}),
args: queries.map(query => ({
type: 'drop_query_from_collection',
args: {
collection_name: queryCollection,
query_name: query.name,
},
})),
args: removeOperationsFromQueryCollectionMetadataArgs(
queryCollection,
queries.map(query => query.name)
),
},
},
{

View File

@ -52,5 +52,5 @@ export const useCreateRestEndpoints = () => {
[endpointDefinitions, mutate, metadata]
);
return { createRestEndpoints, isReady, ...rest };
return { createRestEndpoints, endpointDefinitions, isReady, ...rest };
};

View File

@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { useMetadata, useMetadataMigration } from '../../MetadataAPI';
import { removeOperationsFromQueryCollectionMetadataArgs } from '../../QueryCollections/hooks';
export const useDeleteRestEndpoints = () => {
const { mutate, ...rest } = useMetadataMigration();
const { data: metadata } = useMetadata();
const deleteRestEndpoints = useCallback(
(endpoints: string[], options?: Parameters<typeof mutate>[1]) => {
return mutate(
{
query: {
type: 'bulk',
...(metadata?.resource_version && {
resource_version: metadata.resource_version,
}),
args: [
...endpoints.map(endpoint => ({
type: 'drop_rest_endpoint',
args: {
name: endpoint,
},
})),
...removeOperationsFromQueryCollectionMetadataArgs(
'allowed-queries',
endpoints
),
],
},
},
{
...options,
}
);
},
[mutate, metadata]
);
return { deleteRestEndpoints, ...rest };
};

View File

@ -3,6 +3,7 @@ import { useIntrospectionSchema } from '../../../components/Services/Actions/Com
import { useEffect, useState } from 'react';
import { Query, RestEndpoint } from '../../hasura-metadata-types';
import {
Operation,
generateDeleteEndpoint,
generateInsertEndpoint,
generateUpdateEndpoint,
@ -10,8 +11,9 @@ import {
generateViewEndpoint,
} from './utils';
import { formatSdl } from 'format-graphql';
import { useMetadata } from '../../MetadataAPI';
export type EndpointType = 'VIEW' | 'VIEW_ALL' | 'CREATE' | 'UPDATE' | 'DELETE';
export type EndpointType = 'READ' | 'READ_ALL' | 'CREATE' | 'UPDATE' | 'DELETE';
export type EndpointDefinition = {
restEndpoint: RestEndpoint;
@ -19,7 +21,9 @@ export type EndpointDefinition = {
};
type EndpointDefinitions = {
[key: string]: Partial<Record<EndpointType, EndpointDefinition>>;
[key: string]: Partial<
Record<EndpointType, EndpointDefinition & { exists: boolean }>
>;
};
export type Generator = {
@ -27,7 +31,7 @@ export type Generator = {
generator: (
root: string,
table: string,
operation: any,
operation: Operation,
microfiber: any
) => EndpointDefinition;
};
@ -85,11 +89,11 @@ export const getOperations = (microfiber: any) => {
};
const generators: Record<EndpointType, Generator> = {
VIEW: {
READ: {
regExp: /fetch data from the table: "(.+)" using primary key columns$/,
generator: generateViewEndpoint,
},
VIEW_ALL: {
READ_ALL: {
regExp: /fetch data from the table: "(.+)"$/,
generator: generateViewAllEndpoint,
},
@ -115,9 +119,13 @@ export const useRestEndpointDefinitions = () => {
error,
} = useIntrospectionSchema();
const { data: metadata } = useMetadata();
const [data, setData] = useState<EndpointDefinitions>();
useEffect(() => {
const existingRestEndpoints = metadata?.metadata?.rest_endpoints || [];
if (introspectionSchema) {
const response: EndpointDefinitions = {};
const microfiber = new Microfiber(introspectionSchema);
@ -131,7 +139,7 @@ export const useRestEndpointDefinitions = () => {
for (const operation of operations.operations) {
for (const endpointType in generators) {
const match = operation.description.match(
const match = operation.description?.match(
generators[endpointType as EndpointType].regExp
);
const table = match?.[1];
@ -147,14 +155,21 @@ export const useRestEndpointDefinitions = () => {
response[table] = {
...(response[table] || {}),
[endpointType]: definition,
[endpointType]: {
...definition,
exists: existingRestEndpoints.some(
endpoint =>
endpoint.definition.query.query_name ===
definition.query.name
),
},
};
}
}
}
setData(response);
}
}, [introspectionSchema]);
}, [introspectionSchema, metadata]);
return {
data,

View File

@ -281,8 +281,8 @@ describe('generateUpdateEndpoint', () => {
expect(query).toEqual({
name: 'update_user_by_pk',
query:
formatSdl(`mutation update_user_by_pk($id: user_pk_columns_input!, $object: user_set_input!) {
update_user_by_pk(pk_columns: $id, _set: $object) {
formatSdl(`mutation update_user_by_pk($id: Int!, $object: user_set_input!) {
update_user_by_pk(pk_columns: {id: $id}, _set: $object) {
address
bool
count
@ -322,9 +322,9 @@ describe('generateUpdateEndpoint', () => {
expect(query).toEqual({
name: 'a_update_user_by_pk_b',
query:
formatSdl(`mutation a_update_user_by_pk_b($id: c_user_pk_columns_input_d!, $object: c_user_set_input_d!) {
formatSdl(`mutation a_update_user_by_pk_b($id: Int!, $object: c_user_set_input_d!) {
root_ {
a_update_user_by_pk_b(pk_columns: $id, _set: $object) {
a_update_user_by_pk_b(pk_columns: {id: $id}, _set: $object) {
address
bool
count

View File

@ -12,14 +12,55 @@ const wrapRoot = (root: string, operation: string) => {
return root ? `${root} { ${operation} }` : operation;
};
const extractFields = (operation: any, microfiber: any) => {
const type = microfiber.getType(recursiveType(operation.type));
export type Operation = {
name: string;
type: GraphQLType;
args: Field[];
};
type Field = {
type: GraphQLType;
name: string;
};
type MicrofiberType = {
fields: Field[];
};
/**
* Given an operation , it extract all the scalar fields of the type of the operation.
* This exlcudes, for example, all the relationship fields
*/
const extractFields = (operation: Operation, microfiber: any) => {
const type: MicrofiberType = microfiber.getType(
recursiveType(operation.type)
);
const fields = type?.fields
?.filter((field: any) => recursiveType(field.type)?.kind === 'SCALAR')
?.filter(field => recursiveType(field.type)?.kind === 'SCALAR')
?.map((f: { name: string }) => f.name);
return { fields };
};
/**
* This function builds parts of the query for a given operation.
* It process the args and produces as output the fields of the operation,
* the fields of the query and the URL path
*/
const buildArgs = (fields: Field[]) => {
const args = fields?.map(arg => ({
name: arg.name,
type: recursiveType(arg.type)?.name,
}));
const operationArgs = args?.map(arg => `${arg.name}: $${arg.name}`);
const queryArgs = args?.map(arg => `$${arg.name}: ${arg.type}!`);
const path = fields?.map(arg => `:${arg.name}`).join('/');
return { queryArgs, operationArgs, path };
};
export const recursiveType = (type?: GraphQLType): GraphQLType | undefined => {
if (!type) {
return undefined;
@ -38,12 +79,10 @@ export const generateViewEndpoint: Generator['generator'] = (
) => {
const { fields } = extractFields(operation, microfiber);
const idType = recursiveType(
operation.args?.find((arg: any) => arg.name === 'id')?.type
)?.name;
const { queryArgs, operationArgs, path } = buildArgs(operation.args);
const grapqhlOperation = `
${operation.name}(id: $id) {
${operation.name}(${operationArgs}) {
${fields?.join('\n')}
}
`;
@ -51,14 +90,14 @@ export const generateViewEndpoint: Generator['generator'] = (
const query: Query = {
name: operation.name,
query: formatSdl(`
query ${operation.name}($id: ${idType}!) {
query ${operation.name}(${queryArgs}) {
${wrapRoot(root, grapqhlOperation)}
}`),
};
const restEndpoint: RestEndpoint = {
name: operation.name,
url: `${table}/:id`,
url: `${table}/${path}`,
methods: ['GET'],
definition: {
query: {
@ -117,12 +156,11 @@ export const generateDeleteEndpoint: Generator['generator'] = (
microfiber
) => {
const { fields } = extractFields(operation, microfiber);
const idType = recursiveType(
operation.args?.find((arg: any) => arg.name === 'id')?.type
)?.name;
const { queryArgs, operationArgs, path } = buildArgs(operation.args);
const grapqhlOperation = `
${operation.name}(id: $id) {
${operation.name}(${operationArgs}) {
${fields?.join('\n')}
}
`;
@ -130,14 +168,14 @@ export const generateDeleteEndpoint: Generator['generator'] = (
const query: Query = {
name: operation.name,
query: formatSdl(`
mutation ${operation.name}($id: ${idType}!) {
mutation ${operation.name}(${queryArgs}) {
${wrapRoot(root, grapqhlOperation)}
}`),
};
const restEndpoint: RestEndpoint = {
name: operation.name,
url: `${table}/:id`,
url: `${table}/${path}`,
methods: ['DELETE'],
definition: {
query: {
@ -159,7 +197,7 @@ export const generateInsertEndpoint: Generator['generator'] = (
) => {
const { fields } = extractFields(operation, microfiber);
const inputType = recursiveType(
operation.args?.find((arg: any) => arg.name === 'object')?.type
operation.args?.find(arg => arg.name === 'object')?.type
)?.name;
const grapqhlOperation = `
@ -199,16 +237,26 @@ export const generateUpdateEndpoint: Generator['generator'] = (
microfiber
) => {
const { fields } = extractFields(operation, microfiber);
const idType = recursiveType(
operation.args?.find((arg: any) => arg.name === 'pk_columns')?.type
const pkTypeName = recursiveType(
operation.args?.find(arg => arg.name === 'pk_columns')?.type
)?.name;
const pkType = microfiber.getType({
kind: 'INPUT_OBJECT',
name: pkTypeName,
});
const { queryArgs, operationArgs, path } = buildArgs(pkType?.inputFields);
const inputType = recursiveType(
operation.args?.find((arg: any) => arg.name === '_set')?.type
operation.args?.find(arg => arg.name === '_set')?.type
)?.name;
const grapqhlOperation = `
${operation.name}(pk_columns: $id, _set: $object) {
${operation.name}(pk_columns: {
${operationArgs}
}, _set: $object) {
${fields?.join('\n')}
}
`;
@ -216,14 +264,14 @@ export const generateUpdateEndpoint: Generator['generator'] = (
const query: Query = {
name: operation.name,
query: formatSdl(`
mutation ${operation.name}($id: ${idType}!, $object: ${inputType}!) {
mutation ${operation.name}(${queryArgs}, $object: ${inputType}!) {
${wrapRoot(root, grapqhlOperation)}
}`),
};
const restEndpoint: RestEndpoint = {
name: operation.name,
url: `${table}/:id`,
url: `${table}/${path}`,
methods: ['POST'],
definition: {
query: {

View File

@ -62,7 +62,7 @@ export const IndicatorCard = ({
{showIcon ? (
<div
className={clsx(
'flex items-center justify-center h-xl w-xl rounded-full mr-md',
'flex items-center justify-center h-xl w-xl rounded-full mr-md flex-shrink-0',
iconColorsPerStatus[status]
)}
>