mirror of
https://github.com/Kong/insomnia
synced 2024-11-08 06:39:48 +00:00
Update the design view to use data loading/routing (#5458)
* update design route * Add button to generate a request collection from an api spec * move the generate requests button to the bottom
This commit is contained in:
parent
2a8da1cf40
commit
32e788c49b
@ -1,12 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { documentationLinks } from '../../common/documentation';
|
||||
import { selectFileOrFolder } from '../../common/select-file-or-folder';
|
||||
import { faint } from '../css/css-in-js';
|
||||
import { selectActiveApiSpec } from '../redux/selectors';
|
||||
import { Dropdown } from './base/dropdown/dropdown';
|
||||
import { DropdownButton } from './base/dropdown/dropdown-button';
|
||||
import { DropdownItem } from './base/dropdown/dropdown-item';
|
||||
@ -123,12 +121,6 @@ const SecondaryAction: FC<Props> = ({ onImport }) => {
|
||||
};
|
||||
|
||||
export const DesignEmptyState: FC<Props> = ({ onImport }) => {
|
||||
const activeApiSpec = useSelector(selectActiveApiSpec);
|
||||
|
||||
if (!activeApiSpec || activeApiSpec.contents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<EmptyStatePane
|
||||
|
@ -34,6 +34,10 @@ export const SwaggerUI: FC<{
|
||||
supportedSubmitMethods,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
SwaggerUIInstance = null;
|
||||
};
|
||||
}, [supportedSubmitMethods, spec]);
|
||||
|
||||
return <div ref={domNodeRef} />;
|
||||
|
@ -124,11 +124,22 @@ const router = createMemoryRouter(
|
||||
},
|
||||
{
|
||||
path: `${ACTIVITY_SPEC}`,
|
||||
loader: async (...args) => (await import('./routes/design')).loader(...args),
|
||||
element: (
|
||||
<Suspense fallback={<AppLoadingIndicator />}>
|
||||
<Design />
|
||||
</Suspense>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: 'update',
|
||||
action: async (...args) => (await import('./routes/actions')).updateApiSpecAction(...args),
|
||||
},
|
||||
{
|
||||
path: 'generate-request-collection',
|
||||
action: async (...args) => (await import('./routes/actions')).generateCollectionFromApiSpecAction(...args),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'test/*',
|
||||
|
@ -1,9 +1,11 @@
|
||||
import type { IRuleResult } from '@stoplight/spectral-core';
|
||||
import { generate, runTests, Test } from 'insomnia-testing';
|
||||
import { ActionFunction, redirect } from 'react-router-dom';
|
||||
|
||||
import * as session from '../../account/session';
|
||||
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
|
||||
import { database } from '../../common/database';
|
||||
import { importRaw } from '../../common/import';
|
||||
import * as models from '../../models';
|
||||
import * as workspaceOperations from '../../models/helpers/workspace-operations';
|
||||
import { DEFAULT_ORGANIZATION_ID } from '../../models/organization';
|
||||
@ -427,3 +429,57 @@ export const runTestAction: ActionFunction = async ({ params }) => {
|
||||
|
||||
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${testSuiteId}/test-result/${testResult._id}`);
|
||||
};
|
||||
|
||||
// Api Spec
|
||||
export const updateApiSpecAction: ActionFunction = async ({
|
||||
request,
|
||||
params,
|
||||
}) => {
|
||||
const { workspaceId } = params;
|
||||
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
|
||||
const formData = await request.formData();
|
||||
const contents = formData.get('contents');
|
||||
const fromSync = Boolean(formData.get('fromSync'));
|
||||
|
||||
invariant(typeof contents === 'string', 'Contents is required');
|
||||
|
||||
const apiSpec = await models.apiSpec.getByParentId(workspaceId);
|
||||
|
||||
invariant(apiSpec, 'API Spec not found');
|
||||
await database.update({
|
||||
...apiSpec,
|
||||
modified: Date.now(),
|
||||
created: fromSync ? Date.now() : apiSpec.created,
|
||||
contents,
|
||||
}, fromSync);
|
||||
};
|
||||
|
||||
export const generateCollectionFromApiSpecAction: ActionFunction = async ({
|
||||
params,
|
||||
}) => {
|
||||
const { organizationId, projectId, workspaceId } = params;
|
||||
|
||||
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
|
||||
|
||||
const apiSpec = await models.apiSpec.getByParentId(workspaceId);
|
||||
|
||||
if (!apiSpec) {
|
||||
throw new Error('No API Specification was found');
|
||||
}
|
||||
const isLintError = (result: IRuleResult) => result.severity === 0;
|
||||
const results = (await window.main.spectralRun(apiSpec.contents)).filter(isLintError);
|
||||
if (apiSpec.contents && results && results.length) {
|
||||
throw new Error('Error Generating Configuration');
|
||||
}
|
||||
|
||||
await importRaw(apiSpec.contents, {
|
||||
getWorkspaceId: () => Promise.resolve(workspaceId),
|
||||
enableDiffBasedPatching: true,
|
||||
enableDiffDeep: true,
|
||||
bypassDiffProps: {
|
||||
url: true,
|
||||
},
|
||||
});
|
||||
|
||||
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_DEBUG}`);
|
||||
};
|
||||
|
@ -1,13 +1,23 @@
|
||||
import type { IRuleResult } from '@stoplight/spectral-core';
|
||||
import React, { createRef, FC, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import React, { createRef, FC, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
LoaderFunction,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs';
|
||||
import { database } from '../../common/database';
|
||||
import { ACTIVITY_SPEC } from '../../common/constants';
|
||||
import { debounce } from '../../common/misc';
|
||||
import { ApiSpec } from '../../models/api-spec';
|
||||
import * as models from '../../models/index';
|
||||
import { CodeEditor, CodeEditorHandle } from '../components/codemirror/code-editor';
|
||||
import { invariant } from '../../utils/invariant';
|
||||
import {
|
||||
CodeEditor,
|
||||
CodeEditorHandle,
|
||||
} from '../components/codemirror/code-editor';
|
||||
import { DesignEmptyState } from '../components/design-empty-state';
|
||||
import { ErrorBoundary } from '../components/error-boundary';
|
||||
import { Notice, NoticeTable } from '../components/notice-table';
|
||||
@ -15,8 +25,10 @@ import { SidebarLayout } from '../components/sidebar-layout';
|
||||
import { SpecEditorSidebar } from '../components/spec-editor/spec-editor-sidebar';
|
||||
import { SwaggerUI } from '../components/swagger-ui';
|
||||
import { superFaint } from '../css/css-in-js';
|
||||
import { useActiveApiSpecSyncVCSVersion, useGitVCSVersion } from '../hooks/use-vcs-version';
|
||||
import { selectActiveApiSpec } from '../redux/selectors';
|
||||
import {
|
||||
useActiveApiSpecSyncVCSVersion,
|
||||
useGitVCSVersion,
|
||||
} from '../hooks/use-vcs-version';
|
||||
|
||||
const isLintError = (result: IRuleResult) => result.severity === 0;
|
||||
|
||||
@ -29,46 +41,43 @@ const EmptySpaceHelper = styled.div({
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
interface LintMessage extends Notice {
|
||||
range: IRuleResult['range'];
|
||||
export const Toolbar = styled.div({
|
||||
boxSizing: 'content-box',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: 'var(--color-bg)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
borderTop: '1px solid var(--hl-md)',
|
||||
height: 'var(--line-height-sm)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
'& > button': {
|
||||
color: 'var(--hl)',
|
||||
padding: 'var(--padding-xs) var(--padding-xs)',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
interface LoaderData {
|
||||
lintMessages: LintMessage[];
|
||||
apiSpec: ApiSpec;
|
||||
swaggerSpec: ParsedApiSpec['contents'];
|
||||
}
|
||||
|
||||
const RenderEditor: FC<{ editor: RefObject<CodeEditorHandle> }> = ({ editor }) => {
|
||||
const activeApiSpec = useSelector(selectActiveApiSpec);
|
||||
const [lintMessages, setLintMessages] = useState<LintMessage[]>([]);
|
||||
const contents = activeApiSpec?.contents ?? '';
|
||||
const gitVersion = useGitVCSVersion();
|
||||
const syncVersion = useActiveApiSpecSyncVCSVersion();
|
||||
export const loader: LoaderFunction = async ({
|
||||
params,
|
||||
}): Promise<LoaderData> => {
|
||||
const { workspaceId } = params;
|
||||
invariant(workspaceId, 'Workspace ID is required');
|
||||
const apiSpec = await models.apiSpec.getByParentId(workspaceId);
|
||||
invariant(apiSpec, 'API spec not found');
|
||||
|
||||
const onImport = useCallback(async (value: string) => {
|
||||
if (!activeApiSpec) {
|
||||
return;
|
||||
}
|
||||
|
||||
await database.update({ ...activeApiSpec, modified: Date.now(), created: Date.now(), contents: value }, true);
|
||||
}, [activeApiSpec]);
|
||||
|
||||
const uniquenessKey = `${activeApiSpec?._id}::${activeApiSpec?.created}::${gitVersion}::${syncVersion}`;
|
||||
const onCodeEditorChange = useMemo(() => {
|
||||
const handler = async (contents: string) => {
|
||||
if (!activeApiSpec) {
|
||||
return;
|
||||
}
|
||||
|
||||
await models.apiSpec.update({ ...activeApiSpec, contents });
|
||||
};
|
||||
|
||||
return debounce(handler, 500);
|
||||
}, [activeApiSpec]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const update = async () => {
|
||||
// Lint only if spec has content
|
||||
if (contents && contents.length !== 0) {
|
||||
const run = await window.main.spectralRun(contents);
|
||||
|
||||
const results: LintMessage[] = run.filter(isLintError)
|
||||
let lintMessages: LintMessage[] = [];
|
||||
if (apiSpec.contents && apiSpec.contents.length !== 0) {
|
||||
lintMessages = (await window.main.spectralRun(apiSpec.contents))
|
||||
.filter(isLintError)
|
||||
.map(({ severity, code, message, range }) => ({
|
||||
type: severity === 0 ? 'error' : 'warning',
|
||||
message: `${code} ${message}`,
|
||||
@ -76,19 +85,64 @@ const RenderEditor: FC<{ editor: RefObject<CodeEditorHandle> }> = ({ editor }) =
|
||||
// Attach range that will be returned to our click handler
|
||||
range,
|
||||
}));
|
||||
isMounted && setLintMessages(results);
|
||||
} else {
|
||||
isMounted && setLintMessages([]);
|
||||
}
|
||||
};
|
||||
update();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [contents]);
|
||||
let swaggerSpec: ParsedApiSpec['contents'] = {};
|
||||
try {
|
||||
swaggerSpec = parseApiSpec(apiSpec.contents).contents;
|
||||
} catch (err) {}
|
||||
|
||||
const handleScrollToSelection = useCallback((notice: LintMessage) => {
|
||||
return {
|
||||
lintMessages,
|
||||
apiSpec,
|
||||
swaggerSpec,
|
||||
};
|
||||
};
|
||||
|
||||
interface LintMessage extends Notice {
|
||||
range: IRuleResult['range'];
|
||||
}
|
||||
|
||||
const Design: FC = () => {
|
||||
const { organizationId, projectId, workspaceId } = useParams() as {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
const { apiSpec, lintMessages, swaggerSpec } = useLoaderData() as LoaderData;
|
||||
const editor = createRef<CodeEditorHandle>();
|
||||
|
||||
const updateApiSpecFetcher = useFetcher();
|
||||
const generateRequestCollectionFetcher = useFetcher();
|
||||
|
||||
const onCodeEditorChange = useMemo(() => {
|
||||
const handler = async (contents: string) => {
|
||||
updateApiSpecFetcher.submit(
|
||||
{
|
||||
contents: contents,
|
||||
},
|
||||
{
|
||||
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/update`,
|
||||
method: 'post',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return debounce(handler, 500);
|
||||
}, [organizationId, projectId, updateApiSpecFetcher, workspaceId]);
|
||||
|
||||
const handleScrollToSelection = useCallback(
|
||||
(chStart: number, chEnd: number, lineStart: number, lineEnd: number) => {
|
||||
if (!editor.current) {
|
||||
return;
|
||||
}
|
||||
editor.current.scrollToSelection(chStart, chEnd, lineStart, lineEnd);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const handleScrollToLintMessage = useCallback(
|
||||
(notice: LintMessage) => {
|
||||
if (!editor.current) {
|
||||
return;
|
||||
}
|
||||
@ -96,14 +150,50 @@ const RenderEditor: FC<{ editor: RefObject<CodeEditorHandle> }> = ({ editor }) =
|
||||
return;
|
||||
}
|
||||
const { start, end } = notice.range;
|
||||
editor.current.scrollToSelection(start.character, end.character, start.line, end.line);
|
||||
}, [editor]);
|
||||
editor.current.scrollToSelection(
|
||||
start.character,
|
||||
end.character,
|
||||
start.line,
|
||||
end.line
|
||||
);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
if (!activeApiSpec) {
|
||||
return null;
|
||||
}
|
||||
const gitVersion = useGitVCSVersion();
|
||||
const syncVersion = useActiveApiSpecSyncVCSVersion();
|
||||
const uniquenessKey = `${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${syncVersion}`;
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
renderPageSidebar={
|
||||
apiSpec.contents ? (
|
||||
<ErrorBoundary
|
||||
invalidationKey={apiSpec.contents}
|
||||
renderError={() => (
|
||||
<div className="text-left margin pad">
|
||||
<h4>
|
||||
An error occurred while trying to render your spec's
|
||||
navigation.
|
||||
</h4>
|
||||
<p>
|
||||
This navigation will automatically refresh, once you have a
|
||||
valid specification that can be rendered.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<SpecEditorSidebar
|
||||
apiSpec={apiSpec}
|
||||
handleSetSelection={handleScrollToSelection}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<EmptySpaceHelper>A spec navigator will render here</EmptySpaceHelper>
|
||||
)
|
||||
}
|
||||
renderPaneOne={
|
||||
apiSpec ? (
|
||||
<div className="column tall theme--pane__body">
|
||||
<div className="tall relative overflow-hidden">
|
||||
<CodeEditor
|
||||
@ -112,67 +202,78 @@ const RenderEditor: FC<{ editor: RefObject<CodeEditorHandle> }> = ({ editor }) =
|
||||
ref={editor}
|
||||
lintOptions={{ delay: 1000 }}
|
||||
mode="openapi"
|
||||
defaultValue={contents}
|
||||
defaultValue={apiSpec.contents || ''}
|
||||
onChange={onCodeEditorChange}
|
||||
uniquenessKey={uniquenessKey}
|
||||
/>
|
||||
{contents ? null : (
|
||||
{apiSpec.contents ? null : (
|
||||
<DesignEmptyState
|
||||
onImport={onImport}
|
||||
onImport={value => {
|
||||
updateApiSpecFetcher.submit(
|
||||
{
|
||||
contents: value,
|
||||
fromSync: 'true',
|
||||
},
|
||||
{
|
||||
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/update`,
|
||||
method: 'post',
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{lintMessages.length > 0 && (
|
||||
<NoticeTable
|
||||
notices={lintMessages}
|
||||
onClick={handleScrollToSelection}
|
||||
onClick={handleScrollToLintMessage}
|
||||
/>
|
||||
)}
|
||||
{apiSpec.contents ? (
|
||||
<Toolbar>
|
||||
<button
|
||||
disabled={lintMessages.length > 0 || generateRequestCollectionFetcher.state !== 'idle'}
|
||||
className="btn btn--compact"
|
||||
onClick={() => {
|
||||
generateRequestCollectionFetcher.submit(
|
||||
{},
|
||||
{
|
||||
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/generate-request-collection`,
|
||||
method: 'post',
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{generateRequestCollectionFetcher.state === 'loading' ? (
|
||||
<i className="fa fa-spin fa-spinner" />
|
||||
) : (
|
||||
<i className="fa fa-file-import" />
|
||||
)} Generate Request
|
||||
Collection
|
||||
</button>
|
||||
</Toolbar>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderPreview: FC = () => {
|
||||
const activeApiSpec = useSelector(selectActiveApiSpec);
|
||||
|
||||
if (!activeApiSpec) {
|
||||
return null;
|
||||
) : null
|
||||
}
|
||||
|
||||
if (!activeApiSpec.contents) {
|
||||
return (
|
||||
<EmptySpaceHelper>
|
||||
Documentation for your OpenAPI spec will render here
|
||||
</EmptySpaceHelper>
|
||||
);
|
||||
}
|
||||
|
||||
let swaggerUiSpec: ParsedApiSpec['contents'] | null = null;
|
||||
|
||||
try {
|
||||
swaggerUiSpec = parseApiSpec(activeApiSpec.contents).contents;
|
||||
} catch (err) { }
|
||||
|
||||
if (!swaggerUiSpec) {
|
||||
swaggerUiSpec = {};
|
||||
}
|
||||
|
||||
return (
|
||||
renderPaneTwo={
|
||||
apiSpec.contents && swaggerSpec ? (
|
||||
<div id="swagger-ui-wrapper">
|
||||
<ErrorBoundary
|
||||
invalidationKey={activeApiSpec.contents}
|
||||
key={uniquenessKey}
|
||||
invalidationKey={apiSpec.contents}
|
||||
renderError={() => (
|
||||
<div className="text-left margin pad">
|
||||
<h3>An error occurred while trying to render Swagger UI</h3>
|
||||
<p>
|
||||
This preview will automatically refresh, once you have a valid specification that
|
||||
can be previewed.
|
||||
This preview will automatically refresh, once you have a
|
||||
valid specification that can be previewed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<SwaggerUI
|
||||
spec={swaggerUiSpec}
|
||||
spec={swaggerSpec}
|
||||
supportedSubmitMethods={[
|
||||
'get',
|
||||
'put',
|
||||
@ -186,61 +287,14 @@ const RenderPreview: FC = () => {
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderPageSidebar: FC<{ editor: RefObject<CodeEditorHandle> }> = ({ editor }) => {
|
||||
const activeApiSpec = useSelector(selectActiveApiSpec);
|
||||
const handleScrollToSelection = useCallback((chStart: number, chEnd: number, lineStart: number, lineEnd: number) => {
|
||||
if (!editor.current) {
|
||||
return;
|
||||
}
|
||||
editor.current.scrollToSelection(chStart, chEnd, lineStart, lineEnd);
|
||||
}, [editor]);
|
||||
|
||||
if (!activeApiSpec) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!activeApiSpec.contents) {
|
||||
return (
|
||||
) : (
|
||||
<EmptySpaceHelper>
|
||||
A spec navigator will render here
|
||||
Documentation for your OpenAPI spec will render here
|
||||
</EmptySpaceHelper>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
invalidationKey={activeApiSpec.contents}
|
||||
renderError={() => (
|
||||
<div className="text-left margin pad">
|
||||
<h4>An error occurred while trying to render your spec's navigation.</h4>
|
||||
<p>
|
||||
This navigation will automatically refresh, once you have a valid specification that
|
||||
can be rendered.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<SpecEditorSidebar
|
||||
apiSpec={activeApiSpec}
|
||||
handleSetSelection={handleScrollToSelection}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export const WrapperDesign: FC = () => {
|
||||
const editor = createRef<CodeEditorHandle>();
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
renderPaneOne={<RenderEditor editor={editor} />}
|
||||
renderPaneTwo={<RenderPreview />}
|
||||
renderPageSidebar={<RenderPageSidebar editor={editor} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WrapperDesign;
|
||||
export default Design;
|
||||
|
Loading…
Reference in New Issue
Block a user