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 { Link } from 'react-router';
import { browserHistory, Link } from 'react-router';
import { FaSearch } from 'react-icons/fa';
import { Button } from '@/new-components/Button';
import { Analytics } from '@/features/Analytics';
import styles from './LeftSubSidebar.module.scss';
import { DropdownButton } from '@/new-components/DropdownButton';
interface Props extends React.ComponentProps<'div'> {
showAddBtn: boolean;
@ -15,6 +16,11 @@ interface Props extends React.ComponentProps<'div'> {
addTrackId: string;
addTestString: 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 => {
@ -28,6 +34,7 @@ const LeftSubSidebar: React.FC<Props> = props => {
addTestString,
children,
childListTestString,
addBtn,
} = props;
const getAddButton = () => {
@ -71,7 +78,7 @@ const LeftSubSidebar: React.FC<Props> = props => {
>
{heading}
</div>
{getAddButton()}
{addBtn ?? getAddButton()}
</div>
<ul className={styles.subSidebarListUL} data-test={childListTestString}>
{children}

View File

@ -45,7 +45,6 @@ import {
ResponseTransformStateBody,
} from '@/components/Common/ConfigureTransformation/stateDefaults';
import ConfigureTransformation from '@/components/Common/ConfigureTransformation/ConfigureTransformation';
import { GeneratedAction, OasGeneratorModal } from '@/features/Actions';
import ActionEditor from '../Common/components/ActionEditor';
import { createAction } from '../ServerIO';
import { getActionDefinitionFromSdl } from '../../../../shared/utils/sdlUtils';
@ -243,8 +242,6 @@ const AddAction: React.FC<AddActionProps> = ({
const responseBodyOnChange = (responseBody: ResponseTransformStateBody) => {
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 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 (
<Analytics name="AddAction" {...REDACT_EVERYTHING}>
<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" />
<h2 className="font-bold text-xl mb-5">Add a new action</h2>
{isActionGeneratorOpen && (
<OasGeneratorModal
onImport={onImportGeneratedAction}
onClose={() => setIsActionGeneratorOpen(false)}
/>
)}
<ActionEditor
handler={handler}
execution={kind}
@ -444,7 +374,6 @@ const AddAction: React.FC<AddActionProps> = ({
toggleForwardClientHeaders={toggleForwardClientHeaders}
actionDefinitionOnChange={actionDefinitionOnChange}
typeDefinitionOnChange={typeDefinitionOnChange}
onOpenActionGenerator={() => setIsActionGeneratorOpen(true)}
/>
<ConfigureTransformation

View File

@ -2,14 +2,11 @@
import React from 'react';
import { GraphQLError } from 'graphql';
import { IconTooltip } from '@/new-components/Tooltip';
import { Button } from '@/new-components/Button';
import { Analytics, REDACT_EVERYTHING } from '@/features/Analytics';
import {
availableFeatureFlagIds,
FeatureFlagToast,
useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags';
import { isProConsole } from '@/utils';
import { FaFileCode, FaMagic, FaTable } from 'react-icons/fa';
import { DropdownButton } from '@/new-components/DropdownButton';
import { Badge } from '@/new-components/Badge';
@ -55,7 +52,6 @@ type ActionEditorProps = {
timer: Nullable<NodeJS.Timeout>,
ast: Nullable<Record<string, any>>
) => void;
onOpenActionGenerator?: () => void;
};
const ActionEditor: React.FC<ActionEditorProps> = ({
@ -77,7 +73,6 @@ const ActionEditor: React.FC<ActionEditorProps> = ({
toggleForwardClientHeaders,
actionDefinitionOnChange,
typeDefinitionOnChange,
onOpenActionGenerator,
}) => {
const {
sdl: typesDefinitionSdl,
@ -142,24 +137,6 @@ const ActionEditor: React.FC<ActionEditorProps> = ({
You can use the custom types already defined by you or define new
types in the new types definition editor below.
</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
value={actionDefinitionSdl}
error={actionDefinitionError}

View File

@ -8,6 +8,9 @@ import { appPrefix, pageTitle } from '../constants';
import globals from '../../../../Globals';
import { Button } from '@/new-components/Button';
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';
@ -40,6 +43,7 @@ class Landing extends React.Component {
const addBtn = !readOnlyMode && (
<div className="ml-md">
<Button
icon={<FaEdit />}
data-test="data-create-actions"
mode="primary"
onClick={handleClick}
@ -61,6 +65,29 @@ class Landing extends React.Component {
<div className={'flex'}>
<h2 className="font-bold text-3xl pr-3">Actions</h2>
{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>
<hr className="mt-5 mb-5" />
{getIntroSection()}

View File

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

View File

@ -64,134 +64,178 @@ import {
getResponseTransformObject,
} 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 =
(transformState, responseTransformState) => (dispatch, getState) => {
const { add: rawState } = getState().actions;
const existingTypesList = customTypesSelector(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 responseTransform = getResponseTransformObject(
responseTransformState
);
const validationError = getStateValidationError(state, existingTypesList);
if (validationError) {
return dispatch(showErrorNotification(validationError));
}
const typesWithRelationships = hydrateTypeRelationships(
state.types,
existingTypesList
);
const { types: mergedTypes, overlappingTypenames } = mergeCustomTypes(
typesWithRelationships,
existingTypesList
);
if (overlappingTypenames) {
const isOk = getOverlappingTypeConfirmation(
state.name,
allActions,
try {
const { name, migration } = createActionMigration(
rawState,
existingTypesList,
overlappingTypenames
allActions,
requestTransform,
responseTransform
);
if (!isOk) {
return;
}
executeActionCreation(dispatch, getState, name, migration, rawState);
} 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 =
@ -344,53 +388,63 @@ export const saveAction =
);
};
export const deleteAction = currentAction => (dispatch, getState) => {
const confirmMessage = `This will permanently delete the action "${currentAction.name}" from this table`;
const isOk = getConfirmation(confirmMessage, true, currentAction.name);
if (!isOk) {
return;
}
export const deleteAction =
currentAction =>
(dispatch, getState, redirect = true, onSuccess, onFail) => {
const confirmMessage = `This will permanently delete the action "${currentAction.name}" from this table`;
const isOk = getConfirmation(confirmMessage, true, currentAction.name);
if (!isOk) {
return;
}
// Migration queries start
const migration = new Migration();
// Migration queries start
const migration = new Migration();
migration.add(
generateDropActionQuery(currentAction.name),
generateCreateActionQuery(
currentAction.name,
currentAction.definition,
currentAction.comment
)
);
migration.add(
generateDropActionQuery(currentAction.name),
generateCreateActionQuery(
currentAction.name,
currentAction.definition,
currentAction.comment
)
);
const migrationName = `delete_action_${currentAction.name}`;
const requestMsg = 'Deleting action...';
const successMsg = 'Action deleted successfully';
const errorMsg = 'Deleting action failed';
const customOnSuccess = () => {
dispatch(modifyActionRequestComplete());
dispatch(push(`${globals.urlPrefix}${appPrefix}/manage`));
dispatch(exportMetadata());
removePersistedDerivedAction(currentAction.name);
const migrationName = `delete_action_${currentAction.name}`;
const requestMsg = 'Deleting action...';
const successMsg = 'Action deleted successfully';
const errorMsg = 'Deleting action failed';
const customOnSuccess = () => {
dispatch(modifyActionRequestComplete());
if (redirect) {
dispatch(push(`${globals.urlPrefix}${appPrefix}/manage`));
}
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 =
(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 { FaBook, FaEdit, FaWrench } from 'react-icons/fa';
import { Link } from 'react-router';
import { FaBook, FaEdit, FaFileImport, FaWrench } from 'react-icons/fa';
import { browserHistory, Link } from 'react-router';
import LeftSubSidebar from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar';
import styles from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar.module.scss';
import { isProConsole } from '@/utils';
import { Badge } from '@/new-components/Badge';
const LeftSidebar = ({
appPrefix,
@ -17,13 +21,15 @@ const LeftSidebar = ({
const getSearchInput = () => {
return (
<input
type="text"
onChange={handleSearch}
className="form-control"
placeholder="search actions"
data-test="search-actions"
/>
<div className="mr-2">
<input
type="text"
onChange={handleSearch}
className="form-control"
placeholder="search actions"
data-test="search-actions"
/>
</div>
);
};
@ -101,6 +107,48 @@ const LeftSidebar = ({
addTrackId="action-tab-button-add-actions-sidebar"
addTestString={'actions-sidebar-add-table'}
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()}
</LeftSubSidebar>

View File

@ -5,23 +5,25 @@ 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 { OasGenerator, OasGeneratorProps } from './OASGenerator';
import petstore from '../OASGeneratorModal/petstore.json';
import { OASGenerator, OASGeneratorProps } from './OASGenerator';
import petstore from './petstore.json';
export default {
title: 'Features/Actions/OASGenerator',
component: OasGenerator,
component: OASGenerator,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers({ delay: 500 }),
},
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 => {
return <OasGenerator {...args} />;
export const Default: Story<OASGeneratorProps> = args => {
return <OASGenerator {...args} />;
};
Default.play = async ({ canvasElement }) => {
@ -56,14 +58,4 @@ Default.play = async ({ canvasElement }) => {
expect(canvas.queryAllByTestId(/^operation.*/)).toHaveLength(0);
// clear search
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 { z } from 'zod';
import { GeneratedAction } from '../OASGeneratorModal';
import { GeneratedAction } from './types';
import { SimpleForm } from '@/new-components/Form';
import { OasGeneratorForm } from './OASGeneratorForm';
import { formSchema } from './OASGeneratorPage';
export interface OasGeneratorProps {
onGenerate: (values: GeneratedAction) => void;
export interface OASGeneratorProps {
onGenerate: (action: GeneratedAction) => void;
onDelete: (actionName: string) => void;
disabled: boolean;
}
export const formSchema = z.object({
oas: z.string(),
operation: z.string(),
url: z.string().url({ message: 'Invalid URL' }),
search: z.string(),
});
export const OasGenerator = (props: OasGeneratorProps) => {
const [values, setValues] = React.useState<GeneratedAction>();
export const OASGenerator = (props: OASGeneratorProps) => {
const { onGenerate, onDelete, disabled } = props;
return (
<SimpleForm
schema={formSchema}
onSubmit={() => {
if (values) {
props.onGenerate(values);
}
}}
>
<OasGeneratorForm setValues={setValues} />
<SimpleForm onSubmit={() => {}} schema={formSchema}>
<OasGeneratorForm
onGenerate={onGenerate}
onDelete={onDelete}
disabled={disabled}
/>
</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 { Button } from '@/new-components/Button';
import { CardedTable } from '@/new-components/CardedTable';
import { DropdownButton } from '@/new-components/DropdownButton';
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 { useDebouncedEffect } from '@/hooks/useDebounceEffect';
import { Badge, BadgeColor } from '@/new-components/Badge';
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 { OasGeneratorMoreInfo } from './OASGeneratorMoreInfo';
import { GeneratedAction, Operation } from '../OASGeneratorModal/types';
import { useMetadata } from '@/features/MetadataAPI';
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';
export interface OasGeneratorFormProps {
setValues: (values?: GeneratedAction) => void;
}
const fillToTenRows = (data: any[][]) => {
const fillToTenRows = (data: ReactNode[][]) => {
const rowsToFill = 10 - data.length;
for (let i = 0; i < rowsToFill; i++) {
data.push([<div className="h-5" />, '', '']);
@ -46,27 +43,29 @@ const editorOptions = {
wrap: true,
};
export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
const { setValues } = props;
interface OasGeneratorFormProps {
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 [parsedOas, setParsedOas] = React.useState<Oas3 | null>(null);
const [isOasTooBig, setIsOasTooBig] = React.useState(false);
const [selectedMethods, setSelectedMethods] = React.useState<string[]>([]);
const { data: metadata } = useMetadata();
const isUnMounted = useIsUnmounted();
const {
watch,
setValue,
setError,
clearErrors,
register,
trigger,
formState,
} = useFormContext();
const { watch, setValue, setError, clearErrors, trigger, formState } =
useFormContext();
const oas = watch('oas');
const operation = watch('operation');
const search = watch('search');
const url = watch('url');
@ -85,7 +84,7 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
}, [operations, search, selectedMethods]);
const columns = operations?.length
? [null, 'Method', 'Endpoint']
? ['Method', 'Endpoint']
: [
<span className="normal-case font-normal tracking-normal">
2. All available endpoints will be listed here after the import
@ -93,41 +92,6 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
</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(
async () => {
let localParsedOas: Oas3 | undefined;
@ -136,6 +100,15 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
if (oas && oas?.trim() !== '') {
try {
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) {
try {
localParsedOas = YAML.load(oas) as Oas3;
@ -188,6 +161,55 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
[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 files = e.target.files;
if (files) {
@ -225,13 +247,20 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
noErrorPlaceholder
/>
<DropdownButton
className="rounded-l-none"
options={{
item: {
onSelect(e) {
e.preventDefault();
},
},
}}
className="w-32 rounded-l-none"
size="md"
data-testid="dropdown-button"
items={[
Object.keys(badgeColors).map(method => (
<div
className="py-1"
className="py-1 w-full"
onClick={() => {
if (selectedMethods.includes(method)) {
setSelectedMethods(
@ -243,11 +272,13 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
}}
>
<div className="flex items-center">
<input
type="checkbox"
className="mr-2 border border-gray-300 rounded"
checked={selectedMethods.includes(method)}
/>
<div className="mr-2 relative -top-1">
<input
type="checkbox"
className="border border-gray-300 rounded "
checked={selectedMethods.includes(method)}
/>
</div>
<div>{method.toUpperCase()}</div>
</div>
</div>
@ -275,38 +306,44 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
</div>
<div>
<CardedTable
rowClassNames={filteredOperations.map(op => {
if (op.operationId === operation) {
return 'bg-gray-100';
}
return '';
})}
className="h-[400px] relative"
showActionCell={false}
columns={[...columns]}
data={fillToTenRows(
filteredOperations.map(op => [
<input
className="pointer-events-auto"
{...register('operation')}
type="radio"
data-testid={`operation-${op.operationId}`}
value={op.operationId}
id={op.operationId}
/>,
<Badge
color={badgeColors[op.method.toUpperCase()]}
className="text-xs inline-flex w-16 justify-center mr-2"
>
{op.method.toUpperCase()}
</Badge>,
<div>
<OasGeneratorMoreInfo operation={op} />
</div>,
])
filteredOperations.map(op => {
const isActionAlreadyCreated =
metadata?.metadata?.actions?.some(
action =>
action.name.toLowerCase() === op.operationId.toLowerCase()
);
return [
<Badge
color={badgeColors[op.method.toUpperCase()]}
className="text-xs inline-flex w-16 justify-center mr-2"
>
{op.method.toUpperCase()}
</Badge>,
<div>
<OasGeneratorActions
existing={isActionAlreadyCreated}
operation={op}
onCreate={() => createAction(op.operationId)}
onDelete={() => onDelete(op.operationId)}
disabled={disabled}
/>
</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 className="mt-xs">
<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 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>
);
};

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 { createGraphQLSchema } from 'openapi-to-graphql';
import z from 'zod';
import { formSchema } from './OASGeneratorForm';
import { formSchema } from './OASGeneratorPage';
export type SchemaType = z.infer<typeof formSchema>;
export type GeneratedAction = {

View File

@ -1,4 +1,8 @@
import { RequestTransformMethod } from '@/metadata/types';
import {
RequestTransform,
RequestTransformMethod,
ResponseTranform,
} from '@/metadata/types';
import {
buildClientSchema,
getIntrospectionQuery,
@ -485,3 +489,94 @@ export const getOperations = async (oas: Oas2 | Oas3): Promise<Operation[]> => {
const graphqlSchema = await parseOas(oas);
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/OASGeneratorModal';

View File

@ -11,17 +11,6 @@ export const availableFeatureFlagIds = {
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[] = [
{
id: relationshipTabTablesId,
@ -42,6 +31,4 @@ export const availableFeatureFlags: FeatureFlagDefinition[] = [
defaultValue: false,
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 './useCheckConstraints';
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 {
data: ReactNode[][];
showActionCell?: boolean;
rowClassNames?: (string | undefined)[];
}
const Body = ({ data, showActionCell = false, rowClassNames }: BodyProps) => {
const Body = ({ data, showActionCell = false }: BodyProps) => {
return (
<TableBody>
{data.map((row, rowIndex) => {
return (
<TableBodyRow className={rowClassNames?.[rowIndex]}>
<TableBodyRow>
{row.map((cell, index) => {
if (showActionCell && index + 1 === row.length) {
return <TableBodyActionCell>{cell}</TableBodyActionCell>;
@ -126,7 +125,6 @@ type CardedTableProps = HeaderProps & BodyProps & React.ComponentProps<'table'>;
export const CardedTable = ({
columns,
rowClassNames,
data,
showActionCell,
...rest
@ -134,11 +132,7 @@ export const CardedTable = ({
return (
<Table {...rest}>
<Header columns={columns} />
<Body
data={data}
showActionCell={showActionCell}
rowClassNames={rowClassNames}
/>
<Body data={data} showActionCell={showActionCell} />
</Table>
);
};

View File

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

View File

@ -44,7 +44,7 @@ export const DropdownMenuItem: React.FC<
// an implementation of a dropdownmenu.
// 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?: {
root?: React.ComponentProps<typeof DropdownMenuPrimitive.Root>;
trigger?: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>;

View File

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

View File

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