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:
James Gatz 2022-11-24 14:44:31 +01:00 committed by GitHub
parent 2a8da1cf40
commit 32e788c49b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 322 additions and 205 deletions

View File

@ -1,12 +1,10 @@
import fs from 'fs'; import fs from 'fs';
import React, { FC, useCallback } from 'react'; import React, { FC, useCallback } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { documentationLinks } from '../../common/documentation'; import { documentationLinks } from '../../common/documentation';
import { selectFileOrFolder } from '../../common/select-file-or-folder'; import { selectFileOrFolder } from '../../common/select-file-or-folder';
import { faint } from '../css/css-in-js'; import { faint } from '../css/css-in-js';
import { selectActiveApiSpec } from '../redux/selectors';
import { Dropdown } from './base/dropdown/dropdown'; import { Dropdown } from './base/dropdown/dropdown';
import { DropdownButton } from './base/dropdown/dropdown-button'; import { DropdownButton } from './base/dropdown/dropdown-button';
import { DropdownItem } from './base/dropdown/dropdown-item'; import { DropdownItem } from './base/dropdown/dropdown-item';
@ -123,12 +121,6 @@ const SecondaryAction: FC<Props> = ({ onImport }) => {
}; };
export const DesignEmptyState: FC<Props> = ({ onImport }) => { export const DesignEmptyState: FC<Props> = ({ onImport }) => {
const activeApiSpec = useSelector(selectActiveApiSpec);
if (!activeApiSpec || activeApiSpec.contents) {
return null;
}
return ( return (
<Wrapper> <Wrapper>
<EmptyStatePane <EmptyStatePane

View File

@ -34,6 +34,10 @@ export const SwaggerUI: FC<{
supportedSubmitMethods, supportedSubmitMethods,
}); });
} }
return () => {
SwaggerUIInstance = null;
};
}, [supportedSubmitMethods, spec]); }, [supportedSubmitMethods, spec]);
return <div ref={domNodeRef} />; return <div ref={domNodeRef} />;

View File

@ -124,11 +124,22 @@ const router = createMemoryRouter(
}, },
{ {
path: `${ACTIVITY_SPEC}`, path: `${ACTIVITY_SPEC}`,
loader: async (...args) => (await import('./routes/design')).loader(...args),
element: ( element: (
<Suspense fallback={<AppLoadingIndicator />}> <Suspense fallback={<AppLoadingIndicator />}>
<Design /> <Design />
</Suspense> </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/*', path: 'test/*',

View File

@ -1,9 +1,11 @@
import type { IRuleResult } from '@stoplight/spectral-core';
import { generate, runTests, Test } from 'insomnia-testing'; import { generate, runTests, Test } from 'insomnia-testing';
import { ActionFunction, redirect } from 'react-router-dom'; import { ActionFunction, redirect } from 'react-router-dom';
import * as session from '../../account/session'; import * as session from '../../account/session';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants'; import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
import { database } from '../../common/database'; import { database } from '../../common/database';
import { importRaw } from '../../common/import';
import * as models from '../../models'; import * as models from '../../models';
import * as workspaceOperations from '../../models/helpers/workspace-operations'; import * as workspaceOperations from '../../models/helpers/workspace-operations';
import { DEFAULT_ORGANIZATION_ID } from '../../models/organization'; 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}`); 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}`);
};

View File

@ -1,13 +1,23 @@
import type { IRuleResult } from '@stoplight/spectral-core'; import type { IRuleResult } from '@stoplight/spectral-core';
import React, { createRef, FC, RefObject, useCallback, useEffect, useMemo, useState } from 'react'; import React, { createRef, FC, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux'; import {
LoaderFunction,
useFetcher,
useLoaderData,
useParams,
} from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs'; import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs';
import { database } from '../../common/database'; import { ACTIVITY_SPEC } from '../../common/constants';
import { debounce } from '../../common/misc'; import { debounce } from '../../common/misc';
import { ApiSpec } from '../../models/api-spec';
import * as models from '../../models/index'; 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 { DesignEmptyState } from '../components/design-empty-state';
import { ErrorBoundary } from '../components/error-boundary'; import { ErrorBoundary } from '../components/error-boundary';
import { Notice, NoticeTable } from '../components/notice-table'; 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 { SpecEditorSidebar } from '../components/spec-editor/spec-editor-sidebar';
import { SwaggerUI } from '../components/swagger-ui'; import { SwaggerUI } from '../components/swagger-ui';
import { superFaint } from '../css/css-in-js'; import { superFaint } from '../css/css-in-js';
import { useActiveApiSpecSyncVCSVersion, useGitVCSVersion } from '../hooks/use-vcs-version'; import {
import { selectActiveApiSpec } from '../redux/selectors'; useActiveApiSpecSyncVCSVersion,
useGitVCSVersion,
} from '../hooks/use-vcs-version';
const isLintError = (result: IRuleResult) => result.severity === 0; const isLintError = (result: IRuleResult) => result.severity === 0;
@ -29,218 +41,260 @@ const EmptySpaceHelper = styled.div({
textAlign: 'center', textAlign: 'center',
}); });
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'];
}
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');
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}`,
line: range.start.line,
// Attach range that will be returned to our click handler
range,
}));
}
let swaggerSpec: ParsedApiSpec['contents'] = {};
try {
swaggerSpec = parseApiSpec(apiSpec.contents).contents;
} catch (err) {}
return {
lintMessages,
apiSpec,
swaggerSpec,
};
};
interface LintMessage extends Notice { interface LintMessage extends Notice {
range: IRuleResult['range']; range: IRuleResult['range'];
} }
const RenderEditor: FC<{ editor: RefObject<CodeEditorHandle> }> = ({ editor }) => { const Design: FC = () => {
const activeApiSpec = useSelector(selectActiveApiSpec); const { organizationId, projectId, workspaceId } = useParams() as {
const [lintMessages, setLintMessages] = useState<LintMessage[]>([]); organizationId: string;
const contents = activeApiSpec?.contents ?? ''; projectId: string;
const gitVersion = useGitVCSVersion(); workspaceId: string;
const syncVersion = useActiveApiSpecSyncVCSVersion(); };
const { apiSpec, lintMessages, swaggerSpec } = useLoaderData() as LoaderData;
const editor = createRef<CodeEditorHandle>();
const onImport = useCallback(async (value: string) => { const updateApiSpecFetcher = useFetcher();
if (!activeApiSpec) { const generateRequestCollectionFetcher = useFetcher();
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 onCodeEditorChange = useMemo(() => {
const handler = async (contents: string) => { const handler = async (contents: string) => {
if (!activeApiSpec) { updateApiSpecFetcher.submit(
return; {
} contents: contents,
},
await models.apiSpec.update({ ...activeApiSpec, contents }); {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/update`,
method: 'post',
}
);
}; };
return debounce(handler, 500); return debounce(handler, 500);
}, [activeApiSpec]); }, [organizationId, projectId, updateApiSpecFetcher, workspaceId]);
useEffect(() => { const handleScrollToSelection = useCallback(
let isMounted = true; (chStart: number, chEnd: number, lineStart: number, lineEnd: number) => {
const update = async () => { if (!editor.current) {
// Lint only if spec has content return;
if (contents && contents.length !== 0) {
const run = await window.main.spectralRun(contents);
const results: LintMessage[] = run.filter(isLintError)
.map(({ severity, code, message, range }) => ({
type: severity === 0 ? 'error' : 'warning',
message: `${code} ${message}`,
line: range.start.line,
// Attach range that will be returned to our click handler
range,
}));
isMounted && setLintMessages(results);
} else {
isMounted && setLintMessages([]);
} }
}; editor.current.scrollToSelection(chStart, chEnd, lineStart, lineEnd);
update(); },
[editor]
return () => {
isMounted = false;
};
}, [contents]);
const handleScrollToSelection = useCallback((notice: LintMessage) => {
if (!editor.current) {
return;
}
if (!notice.range) {
return;
}
const { start, end } = notice.range;
editor.current.scrollToSelection(start.character, end.character, start.line, end.line);
}, [editor]);
if (!activeApiSpec) {
return null;
}
return (
<div className="column tall theme--pane__body">
<div className="tall relative overflow-hidden">
<CodeEditor
key={uniquenessKey}
showPrettifyButton
ref={editor}
lintOptions={{ delay: 1000 }}
mode="openapi"
defaultValue={contents}
onChange={onCodeEditorChange}
uniquenessKey={uniquenessKey}
/>
{contents ? null : (
<DesignEmptyState
onImport={onImport}
/>
)}
</div>
{lintMessages.length > 0 && (
<NoticeTable
notices={lintMessages}
onClick={handleScrollToSelection}
/>
)}
</div>
); );
};
const RenderPreview: FC = () => { const handleScrollToLintMessage = useCallback(
const activeApiSpec = useSelector(selectActiveApiSpec); (notice: LintMessage) => {
if (!editor.current) {
if (!activeApiSpec) { return;
return null; }
} if (!notice.range) {
return;
if (!activeApiSpec.contents) { }
return ( const { start, end } = notice.range;
<EmptySpaceHelper> editor.current.scrollToSelection(
Documentation for your OpenAPI spec will render here start.character,
</EmptySpaceHelper> end.character,
); start.line,
} end.line
);
let swaggerUiSpec: ParsedApiSpec['contents'] | null = null; },
[editor]
try {
swaggerUiSpec = parseApiSpec(activeApiSpec.contents).contents;
} catch (err) { }
if (!swaggerUiSpec) {
swaggerUiSpec = {};
}
return (
<div id="swagger-ui-wrapper">
<ErrorBoundary
invalidationKey={activeApiSpec.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.
</p>
</div>
)}
>
<SwaggerUI
spec={swaggerUiSpec}
supportedSubmitMethods={[
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace',
]}
/>
</ErrorBoundary>
</div>
); );
};
const RenderPageSidebar: FC<{ editor: RefObject<CodeEditorHandle> }> = ({ editor }) => { const gitVersion = useGitVCSVersion();
const activeApiSpec = useSelector(selectActiveApiSpec); const syncVersion = useActiveApiSpecSyncVCSVersion();
const handleScrollToSelection = useCallback((chStart: number, chEnd: number, lineStart: number, lineEnd: number) => { const uniquenessKey = `${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${syncVersion}`;
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
</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 ( return (
<SidebarLayout <SidebarLayout
renderPaneOne={<RenderEditor editor={editor} />} renderPageSidebar={
renderPaneTwo={<RenderPreview />} apiSpec.contents ? (
renderPageSidebar={<RenderPageSidebar editor={editor} />} <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
key={uniquenessKey}
showPrettifyButton
ref={editor}
lintOptions={{ delay: 1000 }}
mode="openapi"
defaultValue={apiSpec.contents || ''}
onChange={onCodeEditorChange}
uniquenessKey={uniquenessKey}
/>
{apiSpec.contents ? null : (
<DesignEmptyState
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={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>
) : null
}
renderPaneTwo={
apiSpec.contents && swaggerSpec ? (
<div id="swagger-ui-wrapper">
<ErrorBoundary
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.
</p>
</div>
)}
>
<SwaggerUI
spec={swaggerSpec}
supportedSubmitMethods={[
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace',
]}
/>
</ErrorBoundary>
</div>
) : (
<EmptySpaceHelper>
Documentation for your OpenAPI spec will render here
</EmptySpaceHelper>
)
}
/> />
); );
}; };
export default WrapperDesign; export default Design;