mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
console: Import from OpenAPI beta
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7768 GitOrigin-RevId: 49eeaa8feba212dce7f505ebe567672616e1383c
This commit is contained in:
parent
8640346b4f
commit
47fc7f846f
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
@ -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()}
|
||||||
|
@ -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"
|
||||||
|
@ -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) => {
|
||||||
|
@ -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>
|
||||||
|
@ -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', '');
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -1 +1,2 @@
|
|||||||
export { OasGenerator } from './OASGenerator';
|
export { OASGeneratorPage } from './OASGeneratorPage';
|
||||||
|
export { GeneratedAction } from './types';
|
||||||
|
@ -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 = {
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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', '');
|
|
||||||
});
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,2 +0,0 @@
|
|||||||
export { OasGeneratorModal } from './OASGeneratorModal';
|
|
||||||
export { GeneratedAction } from './types';
|
|
@ -1,2 +1 @@
|
|||||||
export * from './components/OASGenerator';
|
export * from './components/OASGenerator';
|
||||||
export * from './components/OASGeneratorModal';
|
|
||||||
|
@ -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] : []),
|
|
||||||
];
|
];
|
||||||
|
@ -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';
|
||||||
|
@ -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];
|
||||||
|
};
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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={
|
||||||
|
@ -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>;
|
||||||
|
@ -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';
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user