console: Import from OpenAPI beta

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7768
GitOrigin-RevId: 49eeaa8feba212dce7f505ebe567672616e1383c
This commit is contained in:
Daniele Cammareri 2023-02-20 11:40:35 +01:00 committed by hasura-bot
parent 8640346b4f
commit 47fc7f846f
32 changed files with 846 additions and 939 deletions

View File

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router'; import { browserHistory, Link } from 'react-router';
import { FaSearch } from 'react-icons/fa'; import { FaSearch } from 'react-icons/fa';
import { Button } from '@/new-components/Button'; import { Button } from '@/new-components/Button';
import { Analytics } from '@/features/Analytics'; import { Analytics } from '@/features/Analytics';
import styles from './LeftSubSidebar.module.scss'; import styles from './LeftSubSidebar.module.scss';
import { DropdownButton } from '@/new-components/DropdownButton';
interface Props extends React.ComponentProps<'div'> { interface Props extends React.ComponentProps<'div'> {
showAddBtn: boolean; showAddBtn: boolean;
@ -15,6 +16,11 @@ interface Props extends React.ComponentProps<'div'> {
addTrackId: string; addTrackId: string;
addTestString: string; addTestString: string;
childListTestString: string; childListTestString: string;
/* padding addBtn override the default "create" button
e.g. for action creation in pro console we pass the dropdown button to choose between
action form and import from OpenAPI
*/
addBtn?: React.ReactNode;
} }
const LeftSubSidebar: React.FC<Props> = props => { const LeftSubSidebar: React.FC<Props> = props => {
@ -28,6 +34,7 @@ const LeftSubSidebar: React.FC<Props> = props => {
addTestString, addTestString,
children, children,
childListTestString, childListTestString,
addBtn,
} = props; } = props;
const getAddButton = () => { const getAddButton = () => {
@ -71,7 +78,7 @@ const LeftSubSidebar: React.FC<Props> = props => {
> >
{heading} {heading}
</div> </div>
{getAddButton()} {addBtn ?? getAddButton()}
</div> </div>
<ul className={styles.subSidebarListUL} data-test={childListTestString}> <ul className={styles.subSidebarListUL} data-test={childListTestString}>
{children} {children}

View File

@ -45,7 +45,6 @@ import {
ResponseTransformStateBody, ResponseTransformStateBody,
} from '@/components/Common/ConfigureTransformation/stateDefaults'; } from '@/components/Common/ConfigureTransformation/stateDefaults';
import ConfigureTransformation from '@/components/Common/ConfigureTransformation/ConfigureTransformation'; import ConfigureTransformation from '@/components/Common/ConfigureTransformation/ConfigureTransformation';
import { GeneratedAction, OasGeneratorModal } from '@/features/Actions';
import ActionEditor from '../Common/components/ActionEditor'; import ActionEditor from '../Common/components/ActionEditor';
import { createAction } from '../ServerIO'; import { createAction } from '../ServerIO';
import { getActionDefinitionFromSdl } from '../../../../shared/utils/sdlUtils'; import { getActionDefinitionFromSdl } from '../../../../shared/utils/sdlUtils';
@ -243,8 +242,6 @@ const AddAction: React.FC<AddActionProps> = ({
const responseBodyOnChange = (responseBody: ResponseTransformStateBody) => { const responseBodyOnChange = (responseBody: ResponseTransformStateBody) => {
responseTransformDispatch(setResponseBody(responseBody)); responseTransformDispatch(setResponseBody(responseBody));
}; };
const [isActionGeneratorOpen, setIsActionGeneratorOpen] =
React.useState(false);
// we send separate requests for the `url` preview and `body` preview, as in case of error, // we send separate requests for the `url` preview and `body` preview, as in case of error,
// we will not be able to resolve if the error is with url or body transform, with the current state of `test_webhook_transform` api // we will not be able to resolve if the error is with url or body transform, with the current state of `test_webhook_transform` api
@ -351,66 +348,6 @@ const AddAction: React.FC<AddActionProps> = ({
} }
} }
const onImportGeneratedAction = (generatedAction: GeneratedAction): void => {
const {
types,
action,
description,
method,
baseUrl,
path,
requestTransforms,
responseTransforms,
headers: actionHeaders,
sampleInput,
queryParams,
} = generatedAction;
typeDefinitionOnChange(types, null, null, null);
actionDefinitionOnChange(action, null, null, null);
commentOnChange({
target: { value: description },
} as React.ChangeEvent<HTMLInputElement>);
handlerOnChange(baseUrl);
if (requestTransforms) {
requestPayloadTransformOnChange(true);
requestBodyOnChange({
action: 'transform',
template: requestTransforms,
});
} else {
requestPayloadTransformOnChange(false);
}
toggleForwardClientHeaders();
if (responseTransforms) {
responsePayloadTransformOnChange(true);
responseBodyOnChange({
action: 'transform',
template: responseTransforms,
});
} else {
responsePayloadTransformOnChange(false);
}
transformDispatch(setRequestSampleInput(sampleInput));
requestMethodOnChange(method);
requestUrlTransformOnChange(true);
requestUrlOnChange(path.replace(/\{([^}]+)\}/g, '{{$body.input.$1}}'));
requestQueryParamsOnChange(queryParams);
setHeaders(
actionHeaders.map(name => ({
name,
value: `{{$body.input.${name}}}`,
type: 'static',
}))
);
setTimeout(() => {
if (createActionRef.current) {
createActionRef.current.scrollIntoView();
}
}, 0);
};
return ( return (
<Analytics name="AddAction" {...REDACT_EVERYTHING}> <Analytics name="AddAction" {...REDACT_EVERYTHING}>
<div className="w-full overflow-y-auto bg-gray-50"> <div className="w-full overflow-y-auto bg-gray-50">
@ -418,13 +355,6 @@ const AddAction: React.FC<AddActionProps> = ({
<Helmet title="Add Action - Actions | Hasura" /> <Helmet title="Add Action - Actions | Hasura" />
<h2 className="font-bold text-xl mb-5">Add a new action</h2> <h2 className="font-bold text-xl mb-5">Add a new action</h2>
{isActionGeneratorOpen && (
<OasGeneratorModal
onImport={onImportGeneratedAction}
onClose={() => setIsActionGeneratorOpen(false)}
/>
)}
<ActionEditor <ActionEditor
handler={handler} handler={handler}
execution={kind} execution={kind}
@ -444,7 +374,6 @@ const AddAction: React.FC<AddActionProps> = ({
toggleForwardClientHeaders={toggleForwardClientHeaders} toggleForwardClientHeaders={toggleForwardClientHeaders}
actionDefinitionOnChange={actionDefinitionOnChange} actionDefinitionOnChange={actionDefinitionOnChange}
typeDefinitionOnChange={typeDefinitionOnChange} typeDefinitionOnChange={typeDefinitionOnChange}
onOpenActionGenerator={() => setIsActionGeneratorOpen(true)}
/> />
<ConfigureTransformation <ConfigureTransformation

View File

@ -2,14 +2,11 @@
import React from 'react'; import React from 'react';
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { IconTooltip } from '@/new-components/Tooltip'; import { IconTooltip } from '@/new-components/Tooltip';
import { Button } from '@/new-components/Button';
import { Analytics, REDACT_EVERYTHING } from '@/features/Analytics'; import { Analytics, REDACT_EVERYTHING } from '@/features/Analytics';
import { import {
availableFeatureFlagIds, availableFeatureFlagIds,
FeatureFlagToast,
useIsFeatureFlagEnabled, useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags'; } from '@/features/FeatureFlags';
import { isProConsole } from '@/utils';
import { FaFileCode, FaMagic, FaTable } from 'react-icons/fa'; import { FaFileCode, FaMagic, FaTable } from 'react-icons/fa';
import { DropdownButton } from '@/new-components/DropdownButton'; import { DropdownButton } from '@/new-components/DropdownButton';
import { Badge } from '@/new-components/Badge'; import { Badge } from '@/new-components/Badge';
@ -55,7 +52,6 @@ type ActionEditorProps = {
timer: Nullable<NodeJS.Timeout>, timer: Nullable<NodeJS.Timeout>,
ast: Nullable<Record<string, any>> ast: Nullable<Record<string, any>>
) => void; ) => void;
onOpenActionGenerator?: () => void;
}; };
const ActionEditor: React.FC<ActionEditorProps> = ({ const ActionEditor: React.FC<ActionEditorProps> = ({
@ -77,7 +73,6 @@ const ActionEditor: React.FC<ActionEditorProps> = ({
toggleForwardClientHeaders, toggleForwardClientHeaders,
actionDefinitionOnChange, actionDefinitionOnChange,
typeDefinitionOnChange, typeDefinitionOnChange,
onOpenActionGenerator,
}) => { }) => {
const { const {
sdl: typesDefinitionSdl, sdl: typesDefinitionSdl,
@ -142,24 +137,6 @@ const ActionEditor: React.FC<ActionEditorProps> = ({
You can use the custom types already defined by you or define new You can use the custom types already defined by you or define new
types in the new types definition editor below. types in the new types definition editor below.
</p> </p>
{onOpenActionGenerator &&
isProConsole(window.__env) &&
(isImportFromOASEnabled ? (
<div className="mb-xs">
<Analytics
name="action-tab-btn-import-action-from-openapi"
passHtmlAttributesToChildren
>
<Button icon={<FaMagic />} onClick={onOpenActionGenerator}>
Import from OpenAPI
</Button>
</Analytics>
</div>
) : (
<FeatureFlagToast
flagId={availableFeatureFlagIds.importActionFromOpenApiId}
/>
))}
<GraphQLEditor <GraphQLEditor
value={actionDefinitionSdl} value={actionDefinitionSdl}
error={actionDefinitionError} error={actionDefinitionError}

View File

@ -8,6 +8,9 @@ import { appPrefix, pageTitle } from '../constants';
import globals from '../../../../Globals'; import globals from '../../../../Globals';
import { Button } from '@/new-components/Button'; import { Button } from '@/new-components/Button';
import TopicDescription from '../../Common/Landing/TopicDescription'; import TopicDescription from '../../Common/Landing/TopicDescription';
import { isImportFromOpenAPIEnabled } from '@/utils';
import { FaEdit, FaFileImport } from 'react-icons/fa';
import { Badge } from '@/new-components/Badge';
// import TryItOut from '../../Common/Landing/TryItOut'; // import TryItOut from '../../Common/Landing/TryItOut';
@ -40,6 +43,7 @@ class Landing extends React.Component {
const addBtn = !readOnlyMode && ( const addBtn = !readOnlyMode && (
<div className="ml-md"> <div className="ml-md">
<Button <Button
icon={<FaEdit />}
data-test="data-create-actions" data-test="data-create-actions"
mode="primary" mode="primary"
onClick={handleClick} onClick={handleClick}
@ -61,6 +65,29 @@ class Landing extends React.Component {
<div className={'flex'}> <div className={'flex'}>
<h2 className="font-bold text-3xl pr-3">Actions</h2> <h2 className="font-bold text-3xl pr-3">Actions</h2>
{getAddBtn()} {getAddBtn()}
{isImportFromOpenAPIEnabled(window.__env) && (
<Analytics
name="action-tab-btn-import-action-from-openapi"
passHtmlAttributesToChildren
>
<Button
icon={<FaFileImport />}
className="ml-2"
onClick={() => {
dispatch(
push(
`${globals.urlPrefix}${appPrefix}/manage/add-oas`
)
);
}}
>
Import from OpenAPI
<Badge className="ml-2 font-xs" color="purple">
New
</Badge>
</Button>
</Analytics>
)}
</div> </div>
<hr className="mt-5 mb-5" /> <hr className="mt-5 mb-5" />
{getIntroSection()} {getIntroSection()}

View File

@ -12,6 +12,7 @@ import AddAction from './Add/Add';
import TypesManage from './Types/Manage'; import TypesManage from './Types/Manage';
import TypesRelationships from './Types/Relationships'; import TypesRelationships from './Types/Relationships';
import { exportMetadata } from '../../../metadata/actions'; import { exportMetadata } from '../../../metadata/actions';
import { OASGeneratorPage } from '@/features/Actions';
const actionsInit = ({ dispatch }) => { const actionsInit = ({ dispatch }) => {
return (nextState, replaceState, cb) => { return (nextState, replaceState, cb) => {
@ -40,6 +41,7 @@ const getActionsRouter = (connect, store, composeOnEnterHooks) => {
<IndexRedirect to="actions" /> <IndexRedirect to="actions" />
<Route path="actions" component={ActionsLandingPage(connect)} /> <Route path="actions" component={ActionsLandingPage(connect)} />
<Route path="add" component={AddAction} /> <Route path="add" component={AddAction} />
<Route path="add-oas" component={OASGeneratorPage} />
<Route path=":actionName/modify" component={ModifyAction} /> <Route path=":actionName/modify" component={ModifyAction} />
<Route <Route
path=":actionName/relationships" path=":actionName/relationships"

View File

@ -64,134 +64,178 @@ import {
getResponseTransformObject, getResponseTransformObject,
} from '../../Common/ConfigureTransformation/utils'; } from '../../Common/ConfigureTransformation/utils';
export const createActionMigration = (
rawState,
existingTypesList,
allActions,
requestTransform,
responseTransform
) => {
const actionComment = rawState.comment ? rawState.comment.trim() : null;
const {
name: actionName,
arguments: args,
outputType,
error: actionDefError,
type: actionType,
} = getActionDefinitionFromSdl(rawState.actionDefinition.sdl);
if (actionDefError) {
throw new Error('Invalid Action Definition', {
cause: actionDefError,
});
}
const { types, error: typeDefError } = getTypesFromSdl(
rawState.typeDefinition.sdl
);
if (typeDefError) {
throw new Error('Invalid Types Definition', { cause: typeDefError });
}
const state = {
handler: rawState.handler.trim(),
kind: rawState.kind,
types,
actionType,
name: actionName,
arguments: args,
outputType,
headers: rawState.headers,
comment: actionComment,
timeout: parseInt(rawState.timeout, 10),
forwardClientHeaders: rawState.forwardClientHeaders,
};
const validationError = getStateValidationError(state, existingTypesList);
if (validationError) {
throw new Error('Validation Error', { cause: validationError });
}
const typesWithRelationships = hydrateTypeRelationships(
state.types,
existingTypesList
);
const { types: mergedTypes, overlappingTypenames } = mergeCustomTypes(
typesWithRelationships,
existingTypesList
);
if (overlappingTypenames) {
const isOk = getOverlappingTypeConfirmation(
state.name,
allActions,
existingTypesList,
overlappingTypenames
);
if (!isOk) {
return;
}
}
// Migration queries start
const migration = new Migration();
const customFieldsQueryUp = generateSetCustomTypesQuery(
reformCustomTypes(mergedTypes)
);
const customFieldsQueryDown = generateSetCustomTypesQuery(
reformCustomTypes(existingTypesList)
);
migration.add(customFieldsQueryUp, customFieldsQueryDown);
const actionQueryUp = generateCreateActionQuery(
state.name,
generateActionDefinition(state, requestTransform, responseTransform),
actionComment
);
const actionQueryDown = generateDropActionQuery(state.name);
migration.add(actionQueryUp, actionQueryDown);
// Migration queries end
return { name: actionName, migration };
};
export const executeActionCreation = (
dispatch,
getState,
name,
migration,
rawState,
redirect = true,
onSuccess,
onFail
) => {
const migrationName = `create_action_${name}`;
const requestMsg = 'Creating action...';
const successMsg = 'Created action successfully';
const errorMsg = 'Creating action failed';
const customOnSuccess = () => {
if (rawState.derive?.operation) {
persistDerivedAction(name, rawState.derive.operation);
}
dispatch(exportMetadata())
.then(() => {
dispatch(createActionRequestComplete());
if (redirect) {
dispatch(
push(`${globals.urlPrefix}${appPrefix}/manage/${name}/modify`)
);
}
if (onSuccess) {
onSuccess();
}
})
.catch(e => {
dispatch(showErrorNotification('Action creation failed!', e?.message));
});
};
const customOnError = () => {
dispatch(createActionRequestComplete());
if (onFail) {
onFail();
}
};
dispatch(createActionRequestInProgress());
makeMigrationCall(
dispatch,
getState,
migration.upMigration,
migration.downMigration,
migrationName,
customOnSuccess,
customOnError,
requestMsg,
successMsg,
errorMsg
);
};
export const createAction = export const createAction =
(transformState, responseTransformState) => (dispatch, getState) => { (transformState, responseTransformState) => (dispatch, getState) => {
const { add: rawState } = getState().actions; const { add: rawState } = getState().actions;
const existingTypesList = customTypesSelector(getState()); const existingTypesList = customTypesSelector(getState());
const allActions = actionsSelector(getState()); const allActions = actionsSelector(getState());
const actionComment = rawState.comment ? rawState.comment.trim() : null;
const {
name: actionName,
arguments: args,
outputType,
error: actionDefError,
type: actionType,
} = getActionDefinitionFromSdl(rawState.actionDefinition.sdl);
if (actionDefError) {
return dispatch(
showErrorNotification('Invalid Action Definition', actionDefError)
);
}
const { types, error: typeDefError } = getTypesFromSdl(
rawState.typeDefinition.sdl
);
if (typeDefError) {
return dispatch(
showErrorNotification('Invalid Types Definition', typeDefError)
);
}
const state = {
handler: rawState.handler.trim(),
kind: rawState.kind,
types,
actionType,
name: actionName,
arguments: args,
outputType,
headers: rawState.headers,
comment: actionComment,
timeout: parseInt(rawState.timeout, 10),
forwardClientHeaders: rawState.forwardClientHeaders,
};
const requestTransform = getRequestTransformObject(transformState); const requestTransform = getRequestTransformObject(transformState);
const responseTransform = getResponseTransformObject( const responseTransform = getResponseTransformObject(
responseTransformState responseTransformState
); );
const validationError = getStateValidationError(state, existingTypesList);
if (validationError) {
return dispatch(showErrorNotification(validationError));
}
const typesWithRelationships = hydrateTypeRelationships( try {
state.types, const { name, migration } = createActionMigration(
existingTypesList rawState,
);
const { types: mergedTypes, overlappingTypenames } = mergeCustomTypes(
typesWithRelationships,
existingTypesList
);
if (overlappingTypenames) {
const isOk = getOverlappingTypeConfirmation(
state.name,
allActions,
existingTypesList, existingTypesList,
overlappingTypenames allActions,
requestTransform,
responseTransform
); );
if (!isOk) { executeActionCreation(dispatch, getState, name, migration, rawState);
return; } catch (e) {
} return dispatch(showErrorNotification(e.message, e.cause));
} }
// Migration queries start
const migration = new Migration();
const customFieldsQueryUp = generateSetCustomTypesQuery(
reformCustomTypes(mergedTypes)
);
const customFieldsQueryDown = generateSetCustomTypesQuery(
reformCustomTypes(existingTypesList)
);
migration.add(customFieldsQueryUp, customFieldsQueryDown);
const actionQueryUp = generateCreateActionQuery(
state.name,
generateActionDefinition(state, requestTransform, responseTransform),
actionComment
);
const actionQueryDown = generateDropActionQuery(state.name);
migration.add(actionQueryUp, actionQueryDown);
// Migration queries end
const migrationName = `create_action_${state.name}`;
const requestMsg = 'Creating action...';
const successMsg = 'Created action successfully';
const errorMsg = 'Creating action failed';
const customOnSuccess = () => {
if (rawState.derive.operation) {
persistDerivedAction(state.name, rawState.derive.operation);
}
dispatch(exportMetadata()).then(() => {
dispatch(createActionRequestComplete());
dispatch(
push(`${globals.urlPrefix}${appPrefix}/manage/${state.name}/modify`)
);
});
};
const customOnError = () => {
dispatch(createActionRequestComplete());
};
dispatch(createActionRequestInProgress());
makeMigrationCall(
dispatch,
getState,
migration.upMigration,
migration.downMigration,
migrationName,
customOnSuccess,
customOnError,
requestMsg,
successMsg,
errorMsg
);
}; };
export const saveAction = export const saveAction =
@ -344,53 +388,63 @@ export const saveAction =
); );
}; };
export const deleteAction = currentAction => (dispatch, getState) => { export const deleteAction =
const confirmMessage = `This will permanently delete the action "${currentAction.name}" from this table`; currentAction =>
const isOk = getConfirmation(confirmMessage, true, currentAction.name); (dispatch, getState, redirect = true, onSuccess, onFail) => {
if (!isOk) { const confirmMessage = `This will permanently delete the action "${currentAction.name}" from this table`;
return; const isOk = getConfirmation(confirmMessage, true, currentAction.name);
} if (!isOk) {
return;
}
// Migration queries start // Migration queries start
const migration = new Migration(); const migration = new Migration();
migration.add( migration.add(
generateDropActionQuery(currentAction.name), generateDropActionQuery(currentAction.name),
generateCreateActionQuery( generateCreateActionQuery(
currentAction.name, currentAction.name,
currentAction.definition, currentAction.definition,
currentAction.comment currentAction.comment
) )
); );
const migrationName = `delete_action_${currentAction.name}`; const migrationName = `delete_action_${currentAction.name}`;
const requestMsg = 'Deleting action...'; const requestMsg = 'Deleting action...';
const successMsg = 'Action deleted successfully'; const successMsg = 'Action deleted successfully';
const errorMsg = 'Deleting action failed'; const errorMsg = 'Deleting action failed';
const customOnSuccess = () => { const customOnSuccess = () => {
dispatch(modifyActionRequestComplete()); dispatch(modifyActionRequestComplete());
dispatch(push(`${globals.urlPrefix}${appPrefix}/manage`)); if (redirect) {
dispatch(exportMetadata()); dispatch(push(`${globals.urlPrefix}${appPrefix}/manage`));
removePersistedDerivedAction(currentAction.name); }
dispatch(exportMetadata());
removePersistedDerivedAction(currentAction.name);
if (onSuccess) {
onSuccess();
}
};
const customOnError = () => {
dispatch(modifyActionRequestComplete());
if (onFail) {
onFail();
}
};
dispatch(modifyActionRequestInProgress());
makeMigrationCall(
dispatch,
getState,
migration.upMigration,
migration.downMigration,
migrationName,
customOnSuccess,
customOnError,
requestMsg,
successMsg,
errorMsg
);
}; };
const customOnError = () => {
dispatch(modifyActionRequestComplete());
};
dispatch(modifyActionRequestInProgress());
makeMigrationCall(
dispatch,
getState,
migration.upMigration,
migration.downMigration,
migrationName,
customOnSuccess,
customOnError,
requestMsg,
successMsg,
errorMsg
);
};
export const addActionRel = export const addActionRel =
(relConfig, successCb, existingRelConfig) => (dispatch, getState) => { (relConfig, successCb, existingRelConfig) => (dispatch, getState) => {

View File

@ -1,9 +1,13 @@
import { DropdownButton } from '@/new-components/DropdownButton';
import { Analytics } from '@/features/Analytics';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { FaBook, FaEdit, FaWrench } from 'react-icons/fa'; import { FaBook, FaEdit, FaFileImport, FaWrench } from 'react-icons/fa';
import { Link } from 'react-router'; import { browserHistory, Link } from 'react-router';
import LeftSubSidebar from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar'; import LeftSubSidebar from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar';
import styles from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar.module.scss'; import styles from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar.module.scss';
import { isProConsole } from '@/utils';
import { Badge } from '@/new-components/Badge';
const LeftSidebar = ({ const LeftSidebar = ({
appPrefix, appPrefix,
@ -17,13 +21,15 @@ const LeftSidebar = ({
const getSearchInput = () => { const getSearchInput = () => {
return ( return (
<input <div className="mr-2">
type="text" <input
onChange={handleSearch} type="text"
className="form-control" onChange={handleSearch}
placeholder="search actions" className="form-control"
data-test="search-actions" placeholder="search actions"
/> data-test="search-actions"
/>
</div>
); );
}; };
@ -101,6 +107,48 @@ const LeftSidebar = ({
addTrackId="action-tab-button-add-actions-sidebar" addTrackId="action-tab-button-add-actions-sidebar"
addTestString={'actions-sidebar-add-table'} addTestString={'actions-sidebar-add-table'}
childListTestString={'actions-table-links'} childListTestString={'actions-table-links'}
addBtn={
isProConsole(window.__env) ? (
<div
className={`col-xs-4 text-center ${styles.padd_left_remove} ${styles.sidebarCreateTable}`}
>
<DropdownButton
className="relative -left-2"
data-testid="dropdown-button"
items={[
[
<Analytics name="action-tab-button-add-actions-sidebar-with-form">
<div
className="py-1 "
onClick={() => {
browserHistory.push(`${appPrefix}/manage/add`);
}}
>
<FaEdit className="relative -top-[1px]" /> New Action
</div>
</Analytics>,
<Analytics name="action-tab-button-add-actions-sidebar-openapi">
<div
className="py-1 "
onClick={() => {
browserHistory.push(`${appPrefix}/manage/add-oas`);
}}
>
<FaFileImport className="relative -left-[2px] -top-[1px]" />{' '}
Import OpenAPI{' '}
<Badge className="ml-1 font-xs" color="purple">
New
</Badge>
</div>
</Analytics>,
],
]}
>
Create
</DropdownButton>
</div>
) : undefined
}
> >
{getChildList()} {getChildList()}
</LeftSubSidebar> </LeftSubSidebar>

View File

@ -5,23 +5,25 @@ import { handlers } from '@/mocks/metadata.mock';
import { within, userEvent } from '@storybook/testing-library'; import { within, userEvent } from '@storybook/testing-library';
import { waitFor } from '@testing-library/react'; import { waitFor } from '@testing-library/react';
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import { OasGenerator, OasGeneratorProps } from './OASGenerator'; import { OASGenerator, OASGeneratorProps } from './OASGenerator';
import petstore from '../OASGeneratorModal/petstore.json'; import petstore from './petstore.json';
export default { export default {
title: 'Features/Actions/OASGenerator', title: 'Features/Actions/OASGenerator',
component: OasGenerator, component: OASGenerator,
decorators: [ReactQueryDecorator()], decorators: [ReactQueryDecorator()],
parameters: { parameters: {
msw: handlers({ delay: 500 }), msw: handlers({ delay: 500 }),
}, },
argTypes: { argTypes: {
onGenerate: { action: 'Generate Action' }, onGenerate: { action: 'Create Action' },
onDelete: { action: 'Create Action' },
disabled: false,
}, },
} as Meta; } as unknown as Meta;
export const Default: Story<OasGeneratorProps> = args => { export const Default: Story<OASGeneratorProps> = args => {
return <OasGenerator {...args} />; return <OASGenerator {...args} />;
}; };
Default.play = async ({ canvasElement }) => { Default.play = async ({ canvasElement }) => {
@ -56,14 +58,4 @@ Default.play = async ({ canvasElement }) => {
expect(canvas.queryAllByTestId(/^operation.*/)).toHaveLength(0); expect(canvas.queryAllByTestId(/^operation.*/)).toHaveLength(0);
// clear search // clear search
userEvent.clear(searchBox); userEvent.clear(searchBox);
// Generate action button should be disabled
expect(canvas.getByText('Generate Action').parentElement).toBeDisabled();
// click on the first operation
userEvent.click(canvas.getByTestId('operation-findPets'));
// wait for generate action button to be enabled
await waitFor(() => {
return expect(
canvas.getByText('Generate Action').parentElement
).toHaveAttribute('disabled', '');
});
}; };

View File

@ -1,33 +1,24 @@
import { SimpleForm } from '@/new-components/Form';
import React from 'react'; import React from 'react';
import { z } from 'zod'; import { GeneratedAction } from './types';
import { GeneratedAction } from '../OASGeneratorModal'; import { SimpleForm } from '@/new-components/Form';
import { OasGeneratorForm } from './OASGeneratorForm'; import { OasGeneratorForm } from './OASGeneratorForm';
import { formSchema } from './OASGeneratorPage';
export interface OasGeneratorProps { export interface OASGeneratorProps {
onGenerate: (values: GeneratedAction) => void; onGenerate: (action: GeneratedAction) => void;
onDelete: (actionName: string) => void;
disabled: boolean;
} }
export const formSchema = z.object({ export const OASGenerator = (props: OASGeneratorProps) => {
oas: z.string(), const { onGenerate, onDelete, disabled } = props;
operation: z.string(),
url: z.string().url({ message: 'Invalid URL' }),
search: z.string(),
});
export const OasGenerator = (props: OasGeneratorProps) => {
const [values, setValues] = React.useState<GeneratedAction>();
return ( return (
<SimpleForm <SimpleForm onSubmit={() => {}} schema={formSchema}>
schema={formSchema} <OasGeneratorForm
onSubmit={() => { onGenerate={onGenerate}
if (values) { onDelete={onDelete}
props.onGenerate(values); disabled={disabled}
} />
}}
>
<OasGeneratorForm setValues={setValues} />
</SimpleForm> </SimpleForm>
); );
}; };

View File

@ -0,0 +1,96 @@
import { Button } from '@/new-components/Button';
import { Analytics } from '@/features/Analytics';
import clsx from 'clsx';
import React from 'react';
import { FaChevronDown, FaExternalLinkAlt } from 'react-icons/fa';
import { Operation } from './types';
export interface OasGeneratorActionsProps {
operation: Operation;
existing?: boolean;
onCreate: () => void;
onDelete: () => void;
disabled?: boolean;
}
export const OasGeneratorActions: React.FC<
OasGeneratorActionsProps
> = props => {
const { operation, existing, onCreate, onDelete, disabled } = props;
const [isExpanded, setExpanded] = React.useState(false);
return (
<div data-testid={`operation-${operation.operationId}`}>
<div className="flex justify-between cursor-pointer">
<div className="max-w-[17vw] overflow-hidden truncate">
{operation.path}
</div>
{existing ? (
<div className="flex items-center space-x-xs -my-2">
<Analytics
name="action-tab-btn-import-openapi-delete-action"
passHtmlAttributesToChildren
>
<Button
disabled={disabled}
size="sm"
mode="destructive"
onClick={onDelete}
>
Delete
</Button>
</Analytics>
<Analytics
name="action-tab-btn-import-openapi-modify-action"
passHtmlAttributesToChildren
>
<Button
icon={<FaExternalLinkAlt />}
iconPosition="end"
disabled={disabled}
size="sm"
onClick={e => {
window.open(
`/actions/manage/${operation.operationId}/modify`,
'_blank'
);
}}
>
Modify
</Button>
</Analytics>
</div>
) : (
<div className="flex items-center space-x-xs -my-2">
<div onClick={() => setExpanded(!isExpanded)} className="mr-5">
<span className="text-sm text-gray-500">More info </span>
<FaChevronDown
className={clsx(
isExpanded ? 'rotate-180' : '',
'transition-all duration-300 ease-in-out w-3 h-3'
)}
/>
</div>
<Analytics
name="action-tab-btn-import-openapi-create-action"
passHtmlAttributesToChildren
>
<Button disabled={disabled} size="sm" onClick={onCreate}>
Create
</Button>
</Analytics>
</div>
)}
</div>
<div
className={clsx(
'max-w-[28vw] whitespace-normal break-all',
isExpanded ? 'h-auto pt-4' : 'h-0 pt-0',
'overflow-hidden transition-all duration-300 ease-in-out'
)}
>
{operation.description.trim() ??
'No description available for this endpoint'}
</div>
</div>
);
};

View File

@ -1,27 +1,24 @@
import React, { useEffect } from 'react'; import React, { ReactNode } from 'react';
import YAML from 'js-yaml'; import YAML from 'js-yaml';
import { Button } from '@/new-components/Button';
import { CardedTable } from '@/new-components/CardedTable'; import { CardedTable } from '@/new-components/CardedTable';
import { DropdownButton } from '@/new-components/DropdownButton'; import { DropdownButton } from '@/new-components/DropdownButton';
import { CodeEditorField, InputField } from '@/new-components/Form'; import { CodeEditorField, InputField } from '@/new-components/Form';
import { FaFilter, FaSearch } from 'react-icons/fa'; import { FaExclamationTriangle, FaFilter, FaSearch } from 'react-icons/fa';
import { trackCustomEvent } from '@/features/Analytics'; import { trackCustomEvent } from '@/features/Analytics';
import { useDebouncedEffect } from '@/hooks/useDebounceEffect'; import { useDebouncedEffect } from '@/hooks/useDebounceEffect';
import { Badge, BadgeColor } from '@/new-components/Badge'; import { Badge, BadgeColor } from '@/new-components/Badge';
import { Oas3 } from 'openapi-to-graphql'; import { Oas3 } from 'openapi-to-graphql';
import { useIsUnmounted } from '@/components/Services/Data/DataSources/CreateDataSource/Neon/useIsUnmounted'; import { useIsUnmounted } from '@/components/Services/Data/Common/tsUtils';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { OasGeneratorMoreInfo } from './OASGeneratorMoreInfo'; import { useMetadata } from '@/features/MetadataAPI';
import { GeneratedAction, Operation } from '../OASGeneratorModal/types'; import { hasuraToast } from '@/new-components/Toasts';
import { OasGeneratorActions } from './OASGeneratorActions';
import { GeneratedAction, Operation } from '../OASGenerator/types';
import { generateAction, getOperations } from '../OASGeneratorModal/utils'; import { generateAction, getOperations } from '../OASGenerator/utils';
import { UploadFile } from './UploadFile'; import { UploadFile } from './UploadFile';
export interface OasGeneratorFormProps { const fillToTenRows = (data: ReactNode[][]) => {
setValues: (values?: GeneratedAction) => void;
}
const fillToTenRows = (data: any[][]) => {
const rowsToFill = 10 - data.length; const rowsToFill = 10 - data.length;
for (let i = 0; i < rowsToFill; i++) { for (let i = 0; i < rowsToFill; i++) {
data.push([<div className="h-5" />, '', '']); data.push([<div className="h-5" />, '', '']);
@ -46,27 +43,29 @@ const editorOptions = {
wrap: true, wrap: true,
}; };
export const OasGeneratorForm = (props: OasGeneratorFormProps) => { interface OasGeneratorFormProps {
const { setValues } = props; onGenerate: (action: GeneratedAction) => void;
onDelete: (actionName: string) => void;
disabled?: boolean;
saveOas?: (oas: string) => void;
}
export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
const { onGenerate, onDelete, disabled } = props;
const [operations, setOperations] = React.useState<Operation[]>([]); const [operations, setOperations] = React.useState<Operation[]>([]);
const [parsedOas, setParsedOas] = React.useState<Oas3 | null>(null); const [parsedOas, setParsedOas] = React.useState<Oas3 | null>(null);
const [isOasTooBig, setIsOasTooBig] = React.useState(false);
const [selectedMethods, setSelectedMethods] = React.useState<string[]>([]); const [selectedMethods, setSelectedMethods] = React.useState<string[]>([]);
const { data: metadata } = useMetadata();
const isUnMounted = useIsUnmounted(); const isUnMounted = useIsUnmounted();
const { const { watch, setValue, setError, clearErrors, trigger, formState } =
watch, useFormContext();
setValue,
setError,
clearErrors,
register,
trigger,
formState,
} = useFormContext();
const oas = watch('oas'); const oas = watch('oas');
const operation = watch('operation');
const search = watch('search'); const search = watch('search');
const url = watch('url'); const url = watch('url');
@ -85,7 +84,7 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
}, [operations, search, selectedMethods]); }, [operations, search, selectedMethods]);
const columns = operations?.length const columns = operations?.length
? [null, 'Method', 'Endpoint'] ? ['Method', 'Endpoint']
: [ : [
<span className="normal-case font-normal tracking-normal"> <span className="normal-case font-normal tracking-normal">
2. All available endpoints will be listed here after the import 2. All available endpoints will be listed here after the import
@ -93,41 +92,6 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
</span>, </span>,
]; ];
useEffect(() => {
(async () => {
if (parsedOas && operation) {
try {
const generatedAction = await generateAction(parsedOas, operation);
if (!isUnMounted()) {
await trigger('url');
if (formState.isValid) {
setValues({ ...generatedAction, baseUrl: url });
} else {
setValues(undefined);
}
}
} catch (e) {
setError('operation', {
message: `Failed to generate action: ${(e as Error).message}`,
});
trackCustomEvent(
{
location: 'Import OAS Modal',
action: 'generate',
object: 'errors',
},
{
data: {
errors: (e as Error).message,
},
}
);
console.error(e);
}
}
})();
}, [setValues, operation, operations, url, parsedOas, setError, isUnMounted]);
useDebouncedEffect( useDebouncedEffect(
async () => { async () => {
let localParsedOas: Oas3 | undefined; let localParsedOas: Oas3 | undefined;
@ -136,6 +100,15 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
if (oas && oas?.trim() !== '') { if (oas && oas?.trim() !== '') {
try { try {
localParsedOas = JSON.parse(oas) as Oas3; localParsedOas = JSON.parse(oas) as Oas3;
// if oas is smaller that 3mb
if (oas.length < 1024 * 1024 * 1) {
setIsOasTooBig(false);
if (props.saveOas) {
props.saveOas(oas);
}
} else {
setIsOasTooBig(true);
}
} catch (e) { } catch (e) {
try { try {
localParsedOas = YAML.load(oas) as Oas3; localParsedOas = YAML.load(oas) as Oas3;
@ -188,6 +161,55 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
[oas, clearErrors, setError, setValue, url] [oas, clearErrors, setError, setValue, url]
); );
const createAction = async (operation: string) => {
if (parsedOas && operation) {
try {
const generatedAction = await generateAction(parsedOas, operation);
if (!isUnMounted()) {
await trigger('url');
if (formState.isValid) {
onGenerate({ ...generatedAction, baseUrl: url });
trackCustomEvent(
{
location: 'Import OAS Modal',
action: 'generate',
object: 'stats',
},
{
data: {
size: JSON.stringify(oas?.length || 0),
numberOfOperations: JSON.stringify(operations?.length || 0),
},
}
);
} else {
hasuraToast({
type: 'error',
title: 'Failed to generate action',
message: 'Please fill in all the required fields',
});
}
}
} catch (e) {
setError('operation', {
message: `Failed to generate action: ${(e as Error).message}`,
});
trackCustomEvent({
location: 'Import OAS Modal',
action: 'generate',
object: 'error',
});
// send notification
hasuraToast({
type: 'error',
title: 'Failed to generate action',
message: (e as Error).message,
});
console.error(e);
}
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (files) { if (files) {
@ -225,13 +247,20 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
noErrorPlaceholder noErrorPlaceholder
/> />
<DropdownButton <DropdownButton
className="rounded-l-none" options={{
item: {
onSelect(e) {
e.preventDefault();
},
},
}}
className="w-32 rounded-l-none"
size="md" size="md"
data-testid="dropdown-button" data-testid="dropdown-button"
items={[ items={[
Object.keys(badgeColors).map(method => ( Object.keys(badgeColors).map(method => (
<div <div
className="py-1" className="py-1 w-full"
onClick={() => { onClick={() => {
if (selectedMethods.includes(method)) { if (selectedMethods.includes(method)) {
setSelectedMethods( setSelectedMethods(
@ -243,11 +272,13 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
}} }}
> >
<div className="flex items-center"> <div className="flex items-center">
<input <div className="mr-2 relative -top-1">
type="checkbox" <input
className="mr-2 border border-gray-300 rounded" type="checkbox"
checked={selectedMethods.includes(method)} className="border border-gray-300 rounded "
/> checked={selectedMethods.includes(method)}
/>
</div>
<div>{method.toUpperCase()}</div> <div>{method.toUpperCase()}</div>
</div> </div>
</div> </div>
@ -275,38 +306,44 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
</div> </div>
<div> <div>
<CardedTable <CardedTable
rowClassNames={filteredOperations.map(op => {
if (op.operationId === operation) {
return 'bg-gray-100';
}
return '';
})}
className="h-[400px] relative" className="h-[400px] relative"
showActionCell={false}
columns={[...columns]} columns={[...columns]}
data={fillToTenRows( data={fillToTenRows(
filteredOperations.map(op => [ filteredOperations.map(op => {
<input const isActionAlreadyCreated =
className="pointer-events-auto" metadata?.metadata?.actions?.some(
{...register('operation')} action =>
type="radio" action.name.toLowerCase() === op.operationId.toLowerCase()
data-testid={`operation-${op.operationId}`} );
value={op.operationId} return [
id={op.operationId} <Badge
/>, color={badgeColors[op.method.toUpperCase()]}
<Badge className="text-xs inline-flex w-16 justify-center mr-2"
color={badgeColors[op.method.toUpperCase()]} >
className="text-xs inline-flex w-16 justify-center mr-2" {op.method.toUpperCase()}
> </Badge>,
{op.method.toUpperCase()} <div>
</Badge>, <OasGeneratorActions
<div> existing={isActionAlreadyCreated}
<OasGeneratorMoreInfo operation={op} /> operation={op}
</div>, onCreate={() => createAction(op.operationId)}
]) onDelete={() => onDelete(op.operationId)}
disabled={disabled}
/>
</div>,
];
})
)} )}
/> />
</div> </div>
</div> </div>
{isOasTooBig && (
<div>
<FaExclamationTriangle className="text-yellow-500" /> The spec is
larger than 3MB. It won't be saved for future use.
</div>
)}
<div> <div>
<div className="mt-xs"> <div className="mt-xs">
<h4 className="text-lg font-semibold mb-xs flex items-center mb-0"> <h4 className="text-lg font-semibold mb-xs flex items-center mb-0">
@ -322,19 +359,6 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
/> />
</div> </div>
</div> </div>
<div className="flex justify-end gap-3">
<Button size="md" mode="default">
Cancel
</Button>
<Button
type="submit"
size="md"
mode="primary"
disabled={!formState.isValid}
>
Generate Action
</Button>
</div>
</div> </div>
); );
}; };

View File

@ -1,44 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { FaChevronDown } from 'react-icons/fa';
import { Operation } from '../OASGeneratorModal/types';
export interface OasGeneratorMoreInfoProps {
operation: Operation;
}
export const OasGeneratorMoreInfo: React.FC<
OasGeneratorMoreInfoProps
> = props => {
const { operation } = props;
const [isExpanded, setExpanded] = React.useState(false);
return (
<div>
<div
className="flex justify-between cursor-pointer"
onClick={() => setExpanded(!isExpanded)}
>
<div>{operation.path}</div>
<div>
More info{' '}
<FaChevronDown
className={clsx(
isExpanded ? 'rotate-180' : '',
'transition-all duration-300 ease-in-out'
)}
/>
</div>
</div>
<div
className={clsx(
'whitespace-normal',
isExpanded ? 'h-auto pt-4' : 'h-0 pt-0',
'overflow-hidden transition-all duration-300 ease-in-out'
)}
>
{operation.description.trim() ??
'No description available for this endpoint'}
</div>
</div>
);
};

View File

@ -0,0 +1,148 @@
import {
createActionMigration,
deleteAction,
executeActionCreation,
} from '@/components/Services/Actions/ServerIO';
import { Link } from 'react-router';
import { useMetadata } from '@/features/MetadataAPI';
import { useDispatch, useStore } from 'react-redux';
import { GeneratedAction } from './types';
import { parseCustomTypes } from '../../../../shared/utils/hasuraCustomTypeUtils';
import { generatedActionToHasuraAction } from '../OASGenerator/utils';
import { FaAngleRight, FaFileImport, FaHome } from 'react-icons/fa';
import { z } from 'zod';
import { useQueryClient } from 'react-query';
import { isImportFromOpenAPIEnabled } from '@/utils';
import { browserHistory } from 'react-router';
import { SimpleForm } from '@/new-components/Form';
import { OasGeneratorForm } from './OASGeneratorForm';
import React from 'react';
import { useLocalStorage } from '@/hooks';
export const formSchema = z.object({
oas: z.string(),
url: z
.string()
.url({ message: 'Invalid URL' })
.refine(val => !val.endsWith('/'), {
message: "Base URL can't end with a slash",
}),
search: z.string(),
});
export const Breadcrumbs = () => (
<div className="flex items-center space-x-xs mb-4">
<Link
to="/actions"
className="cursor-pointer flex items-center text-muted hover:text-gray-900"
>
<FaHome className="mr-1.5" />
<span className="text-sm">Actions</span>
</Link>
<FaAngleRight className="text-muted" />
<div className="cursor-pointer flex items-center text-yellow-500">
<FaFileImport className="mr-1.5" />
<span className="text-sm">Import OpenAPI</span>
</div>
</div>
);
export const OASGeneratorPage = () => {
const dispatch = useDispatch();
const store = useStore();
const queryClient = useQueryClient();
const [savedOas, setSavedOas] = useLocalStorage<string>('oas', '');
const { data: metadata } = useMetadata();
const [busy, setBusy] = React.useState(false);
const onGenerate = (action: GeneratedAction) => {
if (metadata) {
const { state, requestTransform, responseTransform } =
generatedActionToHasuraAction(action);
const actionMigration = createActionMigration(
state,
parseCustomTypes(metadata.metadata.custom_types ?? {}),
metadata.metadata.actions ?? [],
requestTransform,
responseTransform
);
if (actionMigration) {
setBusy(true);
executeActionCreation(
dispatch,
store.getState,
actionMigration.name,
actionMigration.migration,
state,
false,
() => {
queryClient.invalidateQueries(['metadata']);
setBusy(false);
},
() => {
setBusy(false);
}
);
}
}
};
const onDelete = (actionName: string) => {
const action = metadata?.metadata?.actions?.find(
a => a.name === actionName
);
if (action) {
setBusy(true);
deleteAction(action)(
dispatch,
store.getState,
false,
() => {
queryClient.invalidateQueries(['metadata']);
setBusy(false);
},
() => {
setBusy(false);
}
);
}
};
if (!isImportFromOpenAPIEnabled(window.__env)) {
browserHistory.push('/actions');
return null;
}
return (
<div>
<div>
<div className="border-b mb-8 border-solid border-gray-200 -mx-4 px-4 pb-6">
<Breadcrumbs />
<h1 className="text-xl font-semibold">Import from OpenAPI spec</h1>
<p className="text-muted m-0">
Import a REST endpoint as an Action from an OpenAPI (OAS3) spec.
</p>
</div>
</div>
<SimpleForm
onSubmit={() => {}}
schema={formSchema}
options={{
defaultValues: {
oas: savedOas || '',
},
}}
>
<OasGeneratorForm
onGenerate={onGenerate}
onDelete={onDelete}
disabled={busy}
saveOas={setSavedOas}
/>
</SimpleForm>
</div>
);
};

View File

@ -1 +1,2 @@
export { OasGenerator } from './OASGenerator'; export { OASGeneratorPage } from './OASGeneratorPage';
export { GeneratedAction } from './types';

View File

@ -1,7 +1,7 @@
import { RequestTransformMethod } from '@/metadata/types'; import { RequestTransformMethod } from '@/metadata/types';
import { createGraphQLSchema } from 'openapi-to-graphql'; import { createGraphQLSchema } from 'openapi-to-graphql';
import z from 'zod'; import z from 'zod';
import { formSchema } from './OASGeneratorForm'; import { formSchema } from './OASGeneratorPage';
export type SchemaType = z.infer<typeof formSchema>; export type SchemaType = z.infer<typeof formSchema>;
export type GeneratedAction = { export type GeneratedAction = {

View File

@ -1,4 +1,8 @@
import { RequestTransformMethod } from '@/metadata/types'; import {
RequestTransform,
RequestTransformMethod,
ResponseTranform,
} from '@/metadata/types';
import { import {
buildClientSchema, buildClientSchema,
getIntrospectionQuery, getIntrospectionQuery,
@ -485,3 +489,94 @@ export const getOperations = async (oas: Oas2 | Oas3): Promise<Operation[]> => {
const graphqlSchema = await parseOas(oas); const graphqlSchema = await parseOas(oas);
return Object.values(graphqlSchema.data.operations); return Object.values(graphqlSchema.data.operations);
}; };
type ActionState = {
handler: string;
actionDefinition: {
sdl: string;
};
typeDefinition: {
sdl: string;
};
headers: {
name: string;
value: string;
type: 'static';
}[];
forwardClientHeaders: boolean;
kind: 'synchronous';
timeout: string;
comment: string;
};
export const generatedActionToHasuraAction = (
generatedAction: GeneratedAction
): {
state: ActionState;
requestTransform: RequestTransform;
responseTransform: ResponseTranform | null;
} => {
const state: ActionState = {
handler: generatedAction.baseUrl,
actionDefinition: {
sdl: generatedAction.action,
},
typeDefinition: {
sdl: generatedAction.types,
},
headers: generatedAction.headers.map(name => ({
name,
value: `{{$body.input.${name}}}`,
type: 'static',
})),
forwardClientHeaders: true,
kind: 'synchronous',
timeout: '',
comment: generatedAction.description,
};
const requestTransform: RequestTransform = {
version: 2,
template_engine: 'Kriti',
method: generatedAction.method,
url: `{{$base_url}}${generatedAction.path}`,
query_params:
typeof generatedAction.queryParams === 'string'
? generatedAction.queryParams
: generatedAction.queryParams.reduce(
(acc, curr) => ({
...acc,
[curr.name]: curr.value,
}),
{} as Record<string, string>
),
...(generatedAction.requestTransforms
? {
body: {
action: 'transform',
template: generatedAction.requestTransforms,
},
}
: {}),
};
const responseTransform: ResponseTranform | null =
generatedAction.responseTransforms
? {
version: 2,
body: {
action: 'transform',
template: generatedAction.responseTransforms,
},
template_engine: 'Kriti',
}
: null;
return {
state,
requestTransform,
responseTransform,
};
};

View File

@ -1,331 +0,0 @@
import React, { useEffect } from 'react';
import z from 'zod';
import {
CodeEditorField,
FieldWrapper,
InputField,
Radio,
} from '@/new-components/Form';
import { FaSearch } from 'react-icons/fa';
import { useFormContext } from 'react-hook-form';
import { useDebouncedEffect } from '@/hooks/useDebounceEffect';
import { Oas3 } from 'openapi-to-graphql';
import YAML from 'js-yaml';
import { Badge, BadgeColor } from '@/new-components/Badge';
import { trackCustomEvent } from '@/features/Analytics';
import { useIsUnmounted } from '@/components/Services/Data/Common/tsUtils';
import { generateAction, getOperations } from './utils';
import { GeneratedAction, Operation } from './types';
export const formSchema = z.object({
oas: z.string(),
operation: z.string(),
url: z.string().url({ message: 'Invalid URL' }),
search: z.string(),
});
const editorOptions = {
minLines: 16,
maxLines: 16,
showLineNumbers: true,
useSoftTabs: true,
showPrintMargin: false,
};
const badgeColors: Record<string, BadgeColor> = {
GET: 'green',
POST: 'blue',
PUT: 'yellow',
DELETE: 'red',
PATCH: 'purple',
};
export const OasGeneratorForm = (props: {
setValues: (values?: GeneratedAction) => void;
}) => {
const { setValues } = props;
const [operations, setOperations] = React.useState<Operation[]>([]);
const [parsedOas, setParsedOas] = React.useState<Oas3 | null>(null);
const [isOasTooBig, setIsOasTooBig] = React.useState(false);
const [selectedMethods, setSelectedMethods] = React.useState<string[]>([]);
const isUnMounted = useIsUnmounted();
const { watch, setValue, setError, clearErrors, trigger, formState } =
useFormContext();
const oas = watch('oas');
const operation = watch('operation');
const search = watch('search');
const url = watch('url');
const filteredOperations = React.useMemo(() => {
return operations.filter(op => {
const searchMatch =
!search ||
op.operationId.toLowerCase().includes(search.toLowerCase()) ||
op.path.toLowerCase().includes(search.toLowerCase()) ||
op.method.toLowerCase().includes(search.toLowerCase());
const methodMatch =
selectedMethods.length === 0 ||
selectedMethods.includes(op.method.toUpperCase());
return searchMatch && methodMatch;
});
}, [operations, search, selectedMethods]);
useEffect(() => {
(async () => {
if (parsedOas && operation) {
try {
const generatedAction = await generateAction(parsedOas, operation);
await trigger('url');
if (isUnMounted()) {
return;
}
if (formState.isValid) {
setValues({ ...generatedAction, baseUrl: url });
} else {
setValues(undefined);
}
} catch (e) {
setError('operation', {
message: `Failed to generate action: ${(e as Error).message}`,
});
trackCustomEvent(
{
location: 'Import OAS Modal',
action: 'generate',
object: 'errors',
},
{
data: {
errors: (e as Error).message,
},
}
);
console.error(e);
}
}
})();
}, [setValues, operation, operations, url, parsedOas, setError, isUnMounted]);
useDebouncedEffect(
async () => {
let localParsedOas: Oas3 | undefined;
clearErrors();
if (oas && oas?.trim() !== '') {
try {
localParsedOas = JSON.parse(oas) as Oas3;
} catch (e) {
try {
localParsedOas = YAML.load(oas) as Oas3;
} catch (e2) {
setError('oas', {
message: 'Invalid JSON or YAML format',
});
setOperations([]);
}
}
}
try {
if (localParsedOas) {
if (!url && localParsedOas.servers?.[0]?.url) {
setValue('url', localParsedOas.servers?.[0]?.url);
} else {
await trigger('url');
if (isUnMounted()) {
return;
}
}
const ops = await getOperations(localParsedOas);
setOperations(ops);
// send number of operations and file length
const size = oas?.length || 0;
const numberOfOperations = ops?.length || 0;
trackCustomEvent(
{
location: 'Import OAS Modal',
action: 'change',
object: 'specification',
},
{
data: {
size,
numberOfOperations: numberOfOperations.toString(),
},
}
);
}
} catch (e) {
console.error(e);
setError('oas', {
message: `Invalid spec: ${(e as Error).message}`,
});
}
setParsedOas(localParsedOas ?? null);
},
400,
[oas, clearErrors, setError, setValue, url]
);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
const reader = new FileReader();
reader.onload = loadEvent => {
if (loadEvent.target) {
// set isOasTooBig to true if the oas is larger than 512kb
setIsOasTooBig(
(loadEvent?.target?.result as string)?.length > 512 * 1024
);
setValue('oas', loadEvent.target.result);
}
};
// set error
reader.onerror = () => {
setError('oas', {
message: 'Invalid spec',
});
};
reader.readAsText(files[0]);
}
};
return (
<div>
<div>
<div>
<FieldWrapper
label="Upload a YAML or JSON file"
id="file"
tooltip="Upload a YAML or JSON file containing your OpenAPI specification to fill up the form below."
>
<div className="mb-4">
<input
data-testid="file"
type="file"
id="file"
aria-invalid="false"
aria-label="upload open api specification"
onChange={handleFileUpload}
accept=".json,.yaml,.yml"
/>
</div>
</FieldWrapper>
{isOasTooBig ? (
<div className="mb-8">
<Badge color="yellow">File is too big to show</Badge>
</div>
) : (
<div className="h-96 mb-4" data-testid="oas-editor">
<CodeEditorField
noErrorPlaceholder
name="oas"
label="Or paste an Open API Specification"
tooltip="Enter a sample request in JSON or YAML format to generate the input type"
editorOptions={editorOptions}
editorProps={{
className: 'rounded`-r-none',
}}
/>
</div>
)}
</div>
{operations?.length > 0 ? (
<div className="flex flex-col">
<FieldWrapper
label="Configure server URL"
id="url"
className="-mt-4 -mb-6"
>
<InputField
size="medium"
name="url"
type="text"
placeholder="http://example.com"
/>
</FieldWrapper>
<FieldWrapper label="Search" id="search" className="-mb-6">
<InputField
size="medium"
name="search"
type="text"
placeholder="Search endpoints..."
icon={<FaSearch />}
/>
{/* add badges to filter based on http method */}
<div className="flex flex-row space-x-2 -mt-2 mb-4">
{Object.keys(badgeColors).map(method => (
<Badge
onClick={() => {
if (selectedMethods.includes(method)) {
setSelectedMethods(
selectedMethods.filter(m => m !== method)
);
} else {
setSelectedMethods([...selectedMethods, method]);
}
}}
data-testid={`badge-${method}`}
key={method}
color={
selectedMethods.includes(method)
? badgeColors[method]
: 'gray'
}
className="cursor-pointer"
>
{method}
</Badge>
))}
</div>
</FieldWrapper>
<Radio
name="operation"
label="Choose an endpoint:"
orientation="vertical"
tooltip="Choose an endpoint to generate the input type"
options={[
...filteredOperations.slice(0, 50).map(op => ({
label: (
<div className="text-bold my-1">
<Badge
color={badgeColors[op.method.toUpperCase()]}
className="text-xs inline-flex w-16 justify-center mr-2"
>
{op.method.toUpperCase()}
</Badge>
{op.path}
</div>
),
value: op.operationId,
})),
]}
/>
{filteredOperations.length > 50 && (
<div className="text-sm text-gray-500 mb-2">
{filteredOperations.length - 50} more endpoints... Use search to
filter them
</div>
)}
{filteredOperations.length === 0 && (
<div className="text-sm text-gray-500 mb-2">
No endpoints found. Try changing the search or the selected
method
</div>
)}
</div>
) : null}
</div>
</div>
);
};

View File

@ -1,68 +0,0 @@
import React from 'react';
import { Story, Meta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { handlers } from '@/mocks/metadata.mock';
import { within, userEvent } from '@storybook/testing-library';
import { waitFor } from '@testing-library/react';
import { expect } from '@storybook/jest';
import { OasGeneratorModal, OasGeneratorModalProps } from './OASGeneratorModal';
import petstore from './petstore.json';
export default {
title: 'Features/Actions/OASGeneratorModal',
component: OasGeneratorModal,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers({ delay: 500 }),
},
} as Meta;
export const Default: Story<OasGeneratorModalProps> = args => {
return <OasGeneratorModal {...args} />;
};
Default.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByTestId('file');
userEvent.upload(
input,
new File([JSON.stringify(petstore)], 'test.json', {
type: 'application/json',
})
);
await waitFor(() => {
return canvas.queryByTestId('search');
});
// wait for searchbox to appear
const searchBox = await canvas.findByTestId('search');
// count number of operations
expect(canvas.getAllByTestId(/^operation.*/)).toHaveLength(4);
// search operations with 'get'
userEvent.type(searchBox, 'GET');
// count filtered number of operations
expect(canvas.getAllByTestId(/^operation.*/)).toHaveLength(2);
// clear search
userEvent.clear(searchBox);
// search not existing operation
userEvent.type(searchBox, 'not-existing');
// look for 'No endpoints found' message
expect(canvas.getByText(/No endpoints found/)).toBeInTheDocument();
// clear search
userEvent.clear(searchBox);
// click on 'POST' badge
userEvent.click(canvas.getByTestId('badge-POST'));
// count filtered number of operations
expect(canvas.getAllByTestId(/^operation.*/)).toHaveLength(1);
// Generate action button should be disabled
expect(canvas.getByText('Generate Action').parentElement).toBeDisabled();
// click on the first operation
userEvent.click(canvas.getByTestId(/^operation.*/));
// wait for generate action button to be enabled
await waitFor(() => {
return expect(
canvas.getByText('Generate Action').parentElement
).toHaveAttribute('disabled', '');
});
};

View File

@ -1,56 +0,0 @@
import React from 'react';
import { Dialog } from '@/new-components/Dialog';
import { SimpleForm } from '@/new-components/Form';
import { formSchema, OasGeneratorForm } from './OASGeneratorForm';
import { GeneratedAction } from './types';
export interface OasGeneratorModalProps {
onImport: (output: GeneratedAction) => void;
onClose: () => void;
}
export const OasGeneratorModal = (props: OasGeneratorModalProps) => {
const { onClose, onImport } = props;
const [values, setValues] = React.useState<GeneratedAction>();
return (
<Dialog
size="xl"
footer={
<Dialog.Footer
onSubmit={
values
? () => {
onImport(values);
onClose();
}
: undefined
}
onClose={onClose}
callToDeny="Cancel"
callToAction="Generate Action"
onSubmitAnalyticsName="action-tab-btn-generate-import-action-from-openapi"
onCancelAnalyticsName="action-tab-btn-cancel-import-action-from-openapi"
disabled={!values}
/>
}
title="Import OpenAPI endpoint"
hasBackdrop
onClose={onClose}
>
<div className="px-sm">
<p className="text-muted mb-6">
Generate your action from a Open API spec.
</p>
<SimpleForm
className="pl-0 pr-0"
schema={formSchema}
onSubmit={() => {}}
>
<OasGeneratorForm setValues={setValues} />
</SimpleForm>
</div>
</Dialog>
);
};

View File

@ -1,2 +0,0 @@
export { OasGeneratorModal } from './OASGeneratorModal';
export { GeneratedAction } from './types';

View File

@ -1,2 +1 @@
export * from './components/OASGenerator'; export * from './components/OASGenerator';
export * from './components/OASGeneratorModal';

View File

@ -11,17 +11,6 @@ export const availableFeatureFlagIds = {
enabledNewUIForBigQuery, enabledNewUIForBigQuery,
}; };
const importActionFromOpenApi: FeatureFlagDefinition = {
id: importActionFromOpenApiId,
title: 'Import Action from OpenAPI',
description:
'Try out the very experimental feature to generate one action from an OpenAPI endpoint',
section: 'data',
status: 'experimental',
defaultValue: false,
discussionUrl: '',
};
export const availableFeatureFlags: FeatureFlagDefinition[] = [ export const availableFeatureFlags: FeatureFlagDefinition[] = [
{ {
id: relationshipTabTablesId, id: relationshipTabTablesId,
@ -42,6 +31,4 @@ export const availableFeatureFlags: FeatureFlagDefinition[] = [
defaultValue: false, defaultValue: false,
discussionUrl: '', discussionUrl: '',
}, },
// eslint-disable-next-line no-underscore-dangle
...(isProConsole(window.__env) ? [importActionFromOpenApi] : []),
]; ];

View File

@ -7,3 +7,4 @@ export * from './useFKRelationships';
export * from './usePrimaryKeys'; export * from './usePrimaryKeys';
export * from './useCheckConstraints'; export * from './useCheckConstraints';
export * from './useUniqueKeys'; export * from './useUniqueKeys';
export * from './useLocalStorage';

View File

@ -0,0 +1,31 @@
import { useState } from 'react';
// Hook
export const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
const setValue = (value: T) => {
try {
setStoredValue(value);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(value));
}
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
};

View File

@ -100,15 +100,14 @@ const TableBodyActionCell = ({ children }: ChildrenProps) => {
interface BodyProps { interface BodyProps {
data: ReactNode[][]; data: ReactNode[][];
showActionCell?: boolean; showActionCell?: boolean;
rowClassNames?: (string | undefined)[];
} }
const Body = ({ data, showActionCell = false, rowClassNames }: BodyProps) => { const Body = ({ data, showActionCell = false }: BodyProps) => {
return ( return (
<TableBody> <TableBody>
{data.map((row, rowIndex) => { {data.map((row, rowIndex) => {
return ( return (
<TableBodyRow className={rowClassNames?.[rowIndex]}> <TableBodyRow>
{row.map((cell, index) => { {row.map((cell, index) => {
if (showActionCell && index + 1 === row.length) { if (showActionCell && index + 1 === row.length) {
return <TableBodyActionCell>{cell}</TableBodyActionCell>; return <TableBodyActionCell>{cell}</TableBodyActionCell>;
@ -126,7 +125,6 @@ type CardedTableProps = HeaderProps & BodyProps & React.ComponentProps<'table'>;
export const CardedTable = ({ export const CardedTable = ({
columns, columns,
rowClassNames,
data, data,
showActionCell, showActionCell,
...rest ...rest
@ -134,11 +132,7 @@ export const CardedTable = ({
return ( return (
<Table {...rest}> <Table {...rest}>
<Header columns={columns} /> <Header columns={columns} />
<Body <Body data={data} showActionCell={showActionCell} />
data={data}
showActionCell={showActionCell}
rowClassNames={rowClassNames}
/>
</Table> </Table>
); );
}; };

View File

@ -1,17 +1,19 @@
import React from 'react'; import React from 'react';
import { FaChevronDown } from 'react-icons/fa'; import { FaChevronDown } from 'react-icons/fa';
import { Button } from '../Button'; import { Button } from '../Button';
import { DropdownMenu } from '../DropdownMenu'; import { DropdownMenu, DropdownMenuProps } from '../DropdownMenu';
interface DropdownButtonProps extends React.ComponentProps<typeof Button> { interface DropdownButtonProps extends React.ComponentProps<typeof Button> {
items: React.ReactNode[][]; items: React.ReactNode[][];
options?: DropdownMenuProps['options'];
} }
export const DropdownButton: React.FC<DropdownButtonProps> = ({ export const DropdownButton: React.FC<DropdownButtonProps> = ({
items, items,
options,
...rest ...rest
}) => ( }) => (
<DropdownMenu items={items}> <DropdownMenu options={options} items={items}>
<Button <Button
iconPosition="end" iconPosition="end"
icon={ icon={

View File

@ -44,7 +44,7 @@ export const DropdownMenuItem: React.FC<
// an implementation of a dropdownmenu. // an implementation of a dropdownmenu.
// for more flexibility, such as being able to use labels, combine the styled components with other primatives from radix. // for more flexibility, such as being able to use labels, combine the styled components with other primatives from radix.
interface DropdownMenuProps { export interface DropdownMenuProps {
options?: { options?: {
root?: React.ComponentProps<typeof DropdownMenuPrimitive.Root>; root?: React.ComponentProps<typeof DropdownMenuPrimitive.Root>;
trigger?: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>; trigger?: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>;

View File

@ -24,6 +24,7 @@ export {
isProLiteConsole, isProLiteConsole,
isMonitoringTabSupportedEnvironment, isMonitoringTabSupportedEnvironment,
isEnvironmentSupportMultiTenantConnectionPooling, isEnvironmentSupportMultiTenantConnectionPooling,
isImportFromOpenAPIEnabled,
} from './proConsole'; } from './proConsole';
export { default as requestAction } from './requestAction'; export { default as requestAction } from './requestAction';
export { default as requestActionPlain } from './requestActionPlain'; export { default as requestActionPlain } from './requestActionPlain';

View File

@ -58,3 +58,5 @@ export const isEnvironmentSupportMultiTenantConnectionPooling = (
// there should not be any other console modes // there should not be any other console modes
throw new Error(`Invalid consoleMode: ${env.consoleMode}`); throw new Error(`Invalid consoleMode: ${env.consoleMode}`);
}; };
export const isImportFromOpenAPIEnabled = isProConsole;