From 32e788c49b87ae86da2fffb6730cbdef0539f3bd Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 24 Nov 2022 14:44:31 +0100 Subject: [PATCH] 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 --- .../src/ui/components/design-empty-state.tsx | 8 - .../insomnia/src/ui/components/swagger-ui.tsx | 4 + packages/insomnia/src/ui/index.tsx | 11 + packages/insomnia/src/ui/routes/actions.tsx | 56 +++ packages/insomnia/src/ui/routes/design.tsx | 448 ++++++++++-------- 5 files changed, 322 insertions(+), 205 deletions(-) diff --git a/packages/insomnia/src/ui/components/design-empty-state.tsx b/packages/insomnia/src/ui/components/design-empty-state.tsx index 67dcf1439..863f7ebca 100644 --- a/packages/insomnia/src/ui/components/design-empty-state.tsx +++ b/packages/insomnia/src/ui/components/design-empty-state.tsx @@ -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 = ({ onImport }) => { }; export const DesignEmptyState: FC = ({ onImport }) => { - const activeApiSpec = useSelector(selectActiveApiSpec); - - if (!activeApiSpec || activeApiSpec.contents) { - return null; - } - return ( { + SwaggerUIInstance = null; + }; }, [supportedSubmitMethods, spec]); return
; diff --git a/packages/insomnia/src/ui/index.tsx b/packages/insomnia/src/ui/index.tsx index 23e6e187e..718af192b 100644 --- a/packages/insomnia/src/ui/index.tsx +++ b/packages/insomnia/src/ui/index.tsx @@ -124,11 +124,22 @@ const router = createMemoryRouter( }, { path: `${ACTIVITY_SPEC}`, + loader: async (...args) => (await import('./routes/design')).loader(...args), element: ( }> ), + 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/*', diff --git a/packages/insomnia/src/ui/routes/actions.tsx b/packages/insomnia/src/ui/routes/actions.tsx index 44dd6d417..1bf510e38 100644 --- a/packages/insomnia/src/ui/routes/actions.tsx +++ b/packages/insomnia/src/ui/routes/actions.tsx @@ -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}`); +}; diff --git a/packages/insomnia/src/ui/routes/design.tsx b/packages/insomnia/src/ui/routes/design.tsx index f3da2f43f..43fcdbfec 100644 --- a/packages/insomnia/src/ui/routes/design.tsx +++ b/packages/insomnia/src/ui/routes/design.tsx @@ -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,218 +41,260 @@ const EmptySpaceHelper = styled.div({ 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 => { + 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 { range: IRuleResult['range']; } -const RenderEditor: FC<{ editor: RefObject }> = ({ editor }) => { - const activeApiSpec = useSelector(selectActiveApiSpec); - const [lintMessages, setLintMessages] = useState([]); - const contents = activeApiSpec?.contents ?? ''; - const gitVersion = useGitVCSVersion(); - const syncVersion = useActiveApiSpecSyncVCSVersion(); +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(); - const onImport = useCallback(async (value: string) => { - if (!activeApiSpec) { - return; - } + const updateApiSpecFetcher = useFetcher(); + const generateRequestCollectionFetcher = useFetcher(); - 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 }); + updateApiSpecFetcher.submit( + { + contents: contents, + }, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/update`, + method: 'post', + } + ); }; return debounce(handler, 500); - }, [activeApiSpec]); + }, [organizationId, projectId, updateApiSpecFetcher, workspaceId]); - 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) - .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([]); + const handleScrollToSelection = useCallback( + (chStart: number, chEnd: number, lineStart: number, lineEnd: number) => { + if (!editor.current) { + return; } - }; - update(); - - 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 ( -
-
- - {contents ? null : ( - - )} -
- {lintMessages.length > 0 && ( - - )} -
+ editor.current.scrollToSelection(chStart, chEnd, lineStart, lineEnd); + }, + [editor] ); -}; -const RenderPreview: FC = () => { - const activeApiSpec = useSelector(selectActiveApiSpec); - - if (!activeApiSpec) { - return null; - } - - if (!activeApiSpec.contents) { - return ( - - Documentation for your OpenAPI spec will render here - - ); - } - - let swaggerUiSpec: ParsedApiSpec['contents'] | null = null; - - try { - swaggerUiSpec = parseApiSpec(activeApiSpec.contents).contents; - } catch (err) { } - - if (!swaggerUiSpec) { - swaggerUiSpec = {}; - } - - return ( -
- ( -
-

An error occurred while trying to render Swagger UI

-

- This preview will automatically refresh, once you have a valid specification that - can be previewed. -

-
- )} - > - -
-
+ const handleScrollToLintMessage = 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] ); -}; -const RenderPageSidebar: FC<{ editor: RefObject }> = ({ 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 ( - - A spec navigator will render here - - ); - } - - return ( - ( -
-

An error occurred while trying to render your spec's navigation.

-

- This navigation will automatically refresh, once you have a valid specification that - can be rendered. -

-
- )} - > - -
- ); -}; - -export const WrapperDesign: FC = () => { - const editor = createRef(); + const gitVersion = useGitVCSVersion(); + const syncVersion = useActiveApiSpecSyncVCSVersion(); + const uniquenessKey = `${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${syncVersion}`; return ( } - renderPaneTwo={} - renderPageSidebar={} + renderPageSidebar={ + apiSpec.contents ? ( + ( +
+

+ An error occurred while trying to render your spec's + navigation. +

+

+ This navigation will automatically refresh, once you have a + valid specification that can be rendered. +

+
+ )} + > + +
+ ) : ( + A spec navigator will render here + ) + } + renderPaneOne={ + apiSpec ? ( +
+
+ + {apiSpec.contents ? null : ( + { + updateApiSpecFetcher.submit( + { + contents: value, + fromSync: 'true', + }, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/update`, + method: 'post', + } + ); + }} + /> + )} +
+ {lintMessages.length > 0 && ( + + )} + {apiSpec.contents ? ( + + + + ) : null} +
+ ) : null + } + renderPaneTwo={ + apiSpec.contents && swaggerSpec ? ( +
+ ( +
+

An error occurred while trying to render Swagger UI

+

+ This preview will automatically refresh, once you have a + valid specification that can be previewed. +

+
+ )} + > + +
+
+ ) : ( + + Documentation for your OpenAPI spec will render here + + ) + } /> ); }; -export default WrapperDesign; +export default Design;