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 { 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}
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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()}
|
||||
|
@ -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"
|
||||
|
@ -64,12 +64,13 @@ import {
|
||||
getResponseTransformObject,
|
||||
} from '../../Common/ConfigureTransformation/utils';
|
||||
|
||||
export const createAction =
|
||||
(transformState, responseTransformState) => (dispatch, getState) => {
|
||||
const { add: rawState } = getState().actions;
|
||||
const existingTypesList = customTypesSelector(getState());
|
||||
const allActions = actionsSelector(getState());
|
||||
|
||||
export const createActionMigration = (
|
||||
rawState,
|
||||
existingTypesList,
|
||||
allActions,
|
||||
requestTransform,
|
||||
responseTransform
|
||||
) => {
|
||||
const actionComment = rawState.comment ? rawState.comment.trim() : null;
|
||||
|
||||
const {
|
||||
@ -80,9 +81,9 @@ export const createAction =
|
||||
type: actionType,
|
||||
} = getActionDefinitionFromSdl(rawState.actionDefinition.sdl);
|
||||
if (actionDefError) {
|
||||
return dispatch(
|
||||
showErrorNotification('Invalid Action Definition', actionDefError)
|
||||
);
|
||||
throw new Error('Invalid Action Definition', {
|
||||
cause: actionDefError,
|
||||
});
|
||||
}
|
||||
|
||||
const { types, error: typeDefError } = getTypesFromSdl(
|
||||
@ -90,9 +91,7 @@ export const createAction =
|
||||
);
|
||||
|
||||
if (typeDefError) {
|
||||
return dispatch(
|
||||
showErrorNotification('Invalid Types Definition', typeDefError)
|
||||
);
|
||||
throw new Error('Invalid Types Definition', { cause: typeDefError });
|
||||
}
|
||||
|
||||
const state = {
|
||||
@ -109,13 +108,9 @@ export const createAction =
|
||||
forwardClientHeaders: rawState.forwardClientHeaders,
|
||||
};
|
||||
|
||||
const requestTransform = getRequestTransformObject(transformState);
|
||||
const responseTransform = getResponseTransformObject(
|
||||
responseTransformState
|
||||
);
|
||||
const validationError = getStateValidationError(state, existingTypesList);
|
||||
if (validationError) {
|
||||
return dispatch(showErrorNotification(validationError));
|
||||
throw new Error('Validation Error', { cause: validationError });
|
||||
}
|
||||
|
||||
const typesWithRelationships = hydrateTypeRelationships(
|
||||
@ -160,24 +155,48 @@ export const createAction =
|
||||
|
||||
migration.add(actionQueryUp, actionQueryDown);
|
||||
// Migration queries end
|
||||
return { name: actionName, migration };
|
||||
};
|
||||
|
||||
const migrationName = `create_action_${state.name}`;
|
||||
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(state.name, rawState.derive.operation);
|
||||
if (rawState.derive?.operation) {
|
||||
persistDerivedAction(name, rawState.derive.operation);
|
||||
}
|
||||
dispatch(exportMetadata()).then(() => {
|
||||
dispatch(exportMetadata())
|
||||
.then(() => {
|
||||
dispatch(createActionRequestComplete());
|
||||
if (redirect) {
|
||||
dispatch(
|
||||
push(`${globals.urlPrefix}${appPrefix}/manage/${state.name}/modify`)
|
||||
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(
|
||||
@ -194,6 +213,31 @@ export const createAction =
|
||||
);
|
||||
};
|
||||
|
||||
export const createAction =
|
||||
(transformState, responseTransformState) => (dispatch, getState) => {
|
||||
const { add: rawState } = getState().actions;
|
||||
|
||||
const existingTypesList = customTypesSelector(getState());
|
||||
const allActions = actionsSelector(getState());
|
||||
const requestTransform = getRequestTransformObject(transformState);
|
||||
const responseTransform = getResponseTransformObject(
|
||||
responseTransformState
|
||||
);
|
||||
|
||||
try {
|
||||
const { name, migration } = createActionMigration(
|
||||
rawState,
|
||||
existingTypesList,
|
||||
allActions,
|
||||
requestTransform,
|
||||
responseTransform
|
||||
);
|
||||
executeActionCreation(dispatch, getState, name, migration, rawState);
|
||||
} catch (e) {
|
||||
return dispatch(showErrorNotification(e.message, e.cause));
|
||||
}
|
||||
};
|
||||
|
||||
export const saveAction =
|
||||
(currentAction, transformState, responseTransformState) =>
|
||||
(dispatch, getState) => {
|
||||
@ -344,7 +388,9 @@ export const saveAction =
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteAction = currentAction => (dispatch, getState) => {
|
||||
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) {
|
||||
@ -369,12 +415,20 @@ export const deleteAction = currentAction => (dispatch, getState) => {
|
||||
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());
|
||||
|
@ -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,6 +21,7 @@ const LeftSidebar = ({
|
||||
|
||||
const getSearchInput = () => {
|
||||
return (
|
||||
<div className="mr-2">
|
||||
<input
|
||||
type="text"
|
||||
onChange={handleSearch}
|
||||
@ -24,6 +29,7 @@ const LeftSidebar = ({
|
||||
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>
|
||||
|
@ -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', '');
|
||||
});
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 { 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">
|
||||
<div className="mr-2 relative -top-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2 border border-gray-300 rounded"
|
||||
className="border border-gray-300 rounded "
|
||||
checked={selectedMethods.includes(method)}
|
||||
/>
|
||||
</div>
|
||||
<div>{method.toUpperCase()}</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -275,24 +306,17 @@ 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}
|
||||
/>,
|
||||
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"
|
||||
@ -300,13 +324,26 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
|
||||
{op.method.toUpperCase()}
|
||||
</Badge>,
|
||||
<div>
|
||||
<OasGeneratorMoreInfo operation={op} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
@ -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 { 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 = {
|
@ -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,
|
||||
};
|
||||
};
|
@ -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/OASGeneratorModal';
|
||||
|
@ -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] : []),
|
||||
];
|
||||
|
@ -7,3 +7,4 @@ export * from './useFKRelationships';
|
||||
export * from './usePrimaryKeys';
|
||||
export * from './useCheckConstraints';
|
||||
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 {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -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={
|
||||
|
@ -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>;
|
||||
|
@ -24,6 +24,7 @@ export {
|
||||
isProLiteConsole,
|
||||
isMonitoringTabSupportedEnvironment,
|
||||
isEnvironmentSupportMultiTenantConnectionPooling,
|
||||
isImportFromOpenAPIEnabled,
|
||||
} from './proConsole';
|
||||
export { default as requestAction } from './requestAction';
|
||||
export { default as requestActionPlain } from './requestActionPlain';
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user