From 0c09313d42c177a461bbe9f2d33c7cbb41fbff88 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Thu, 13 Jan 2022 09:03:18 -0500 Subject: [PATCH] Empty State for Design Tab (#4345) --- .../insomnia-app/app/common/documentation.ts | 16 +- .../app/ui/components/design-empty-state.tsx | 161 ++++++++++++++++++ .../components/editors/body/body-editor.tsx | 3 +- .../ui/components/panes/empty-state-pane.tsx | 23 ++- .../panes/grpc-request-pane/index.tsx | 3 +- .../app/ui/components/wrapper-design.tsx | 58 ++++++- .../app/ui/redux/modules/import.ts | 16 +- .../src/assets/icn-drafting-compass.svg | 3 + packages/insomnia-components/src/svg-icon.tsx | 3 + 9 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 packages/insomnia-app/app/ui/components/design-empty-state.tsx create mode 100644 packages/insomnia-components/src/assets/icn-drafting-compass.svg diff --git a/packages/insomnia-app/app/common/documentation.ts b/packages/insomnia-app/app/common/documentation.ts index 52894e56d..883bfd2d5 100644 --- a/packages/insomnia-app/app/common/documentation.ts +++ b/packages/insomnia-app/app/common/documentation.ts @@ -6,10 +6,24 @@ export const docsTemplateTags = insomniaDocs('/article/171-template-tags'); export const docsVersionControl = insomniaDocs('/article/165-version-control-sync'); export const docsPlugins = insomniaDocs('/article/173-plugins'); export const docsImportExport = insomniaDocs('/article/172-importing-and-exporting-data'); +export const docsKeyMaps = insomniaDocs('/article/203-key-maps'); +export const docsIntroductionInsomnia = insomniaDocs('/insomnia/get-started'); +export const docsWorkingWithDesignDocs = insomniaDocs('/insomnia/design-documents'); + export const docsGitAccessToken = { github: 'https://docs.github.com/github/authenticating-to-github/creating-a-personal-access-token', gitlab: 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html', bitbucket: 'https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/', bitbucketServer: 'https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html', }; -export const docsKeyMaps = insomniaDocs('/article/203-key-maps'); + +export const documentationLinks = { + introductionToInsomnia: { + title: 'Introduction to Insomnia', + url: docsIntroductionInsomnia, + }, + workingWithDesignDocs: { + title: 'Working with Design Documents', + url: docsWorkingWithDesignDocs, + }, +} as const; diff --git a/packages/insomnia-app/app/ui/components/design-empty-state.tsx b/packages/insomnia-app/app/ui/components/design-empty-state.tsx new file mode 100644 index 000000000..0b833f9be --- /dev/null +++ b/packages/insomnia-app/app/ui/components/design-empty-state.tsx @@ -0,0 +1,161 @@ +import fs from 'fs'; +import { Button, Dropdown, DropdownItem, SvgIcon } from 'insomnia-components'; +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 * as models from '../../models'; +import { faint } from '../css/css-in-js'; +import { selectActiveApiSpec } from '../redux/selectors'; +import { showPrompt } from './modals'; +import { EmptyStatePane } from './panes/empty-state-pane'; + +const Wrapper = styled.div({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + pointerEvents: 'none', + width: '100%', +}); + +const StyledButton = styled(Button)({ + pointerEvents: 'all', + color: 'var(--color-font)', + marginTop: 'var(--padding-md)', + marginLeft: '0 !important', // unfortunately, we're in specificty battle with a default marginLeft +}); + +const ExampleButton = styled.div({ + cursor: 'pointer', + display: 'inline', + textDecoration: 'underline', + pointerEvents: 'all', + '&:hover': { + ...faint, + }, +}); + +interface Props { + onUpdateContents: () => void; +} + +const useUpdateApiSpecContents = () => { + const activeApiSpec = useSelector(selectActiveApiSpec); + return useCallback(async (contents: string) => { + if (!contents) { + return; + } + if (!activeApiSpec) { + return; + } + await models.apiSpec.update(activeApiSpec, { contents }); + }, [activeApiSpec]); +}; + +const ImportSpecButton: FC = ({ onUpdateContents }) => { + const updateApiSpecContents = useUpdateApiSpecContents(); + + const handleImportFile = useCallback(async () => { + const { canceled, filePath } = await selectFileOrFolder({ + extensions: ['yml', 'yaml'], + itemTypes: ['file'], + }); + // Exit if no file selected + if (canceled || !filePath) { + return; + } + + const contents = String(await fs.promises.readFile(filePath)); + await updateApiSpecContents(contents); + }, [updateApiSpecContents]); + + const handleImportUri = useCallback(async () => { + showPrompt({ + title: 'Import document from URL', + submitName: 'Fetch and Import', + label: 'URL', + placeholder: 'e.g. https://petstore.swagger.io/v2/swagger.json', + onComplete: async (uri: string) => { + const response = await window.fetch(uri); + if (!response) { + return; + } + const contents = await response.text(); + await updateApiSpecContents(contents); + onUpdateContents(); + }, + }); + }, [updateApiSpecContents, onUpdateContents]); + + const button = ( + + Import + + + ); + + return ( + + } + onClick={handleImportFile} + > + File + + } + onClick={handleImportUri} + > + URL + + + ); +}; + +const SecondaryAction: FC = ({ onUpdateContents }) => { + const PETSTORE_EXAMPLE_URI = 'https://gist.githubusercontent.com/gschier/4e2278d5a50b4bbf1110755d9b48a9f9/raw/801c05266ae102bcb9288ab92c60f52d45557425/petstore-spec.yaml'; + + const updateApiSpecContents = useUpdateApiSpecContents(); + const onClick = useCallback(async () => { + const response = await window.fetch(PETSTORE_EXAMPLE_URI); + if (!response) { + return; + } + const contents = await response.text(); + await updateApiSpecContents(contents); + onUpdateContents(); + }, [updateApiSpecContents, onUpdateContents]); + + return ( +
+
+ Or import existing an OpenAPI spec or start from an example +
+ +
+ ); +}; + +export const DesignEmptyState: FC = ({ onUpdateContents }) => { + const activeApiSpec = useSelector(selectActiveApiSpec); + + if (!activeApiSpec || activeApiSpec.contents) { + return null; + } + + return ( + + } + documentationLinks={[ + documentationLinks.workingWithDesignDocs, + documentationLinks.introductionToInsomnia, + ]} + secondaryAction={} + title="Enter an OpenAPI specification here" + /> + + ); +}; diff --git a/packages/insomnia-app/app/ui/components/editors/body/body-editor.tsx b/packages/insomnia-app/app/ui/components/editors/body/body-editor.tsx index a42b7c075..2d3c9b979 100644 --- a/packages/insomnia-app/app/ui/components/editors/body/body-editor.tsx +++ b/packages/insomnia-app/app/ui/components/editors/body/body-editor.tsx @@ -12,6 +12,7 @@ import { CONTENT_TYPE_GRAPHQL, getContentTypeFromHeaders, } from '../../../../common/constants'; +import { documentationLinks } from '../../../../common/documentation'; import { getContentTypeHeader } from '../../../../common/misc'; import type { Request, @@ -171,7 +172,7 @@ export class BodyEditor extends PureComponent { return ( } - documentationLinks={[{ title: 'Introduction to Insomnia', url: 'https://docs.insomnia.rest/insomnia/get-started' }]} + documentationLinks={[documentationLinks.introductionToInsomnia]} secondaryAction="Select a body type from above to send data in the body of a request" title="Enter a URL and send to get a response" /> diff --git a/packages/insomnia-app/app/ui/components/panes/empty-state-pane.tsx b/packages/insomnia-app/app/ui/components/panes/empty-state-pane.tsx index cb42edd61..92b3f6a92 100644 --- a/packages/insomnia-app/app/ui/components/panes/empty-state-pane.tsx +++ b/packages/insomnia-app/app/ui/components/panes/empty-state-pane.tsx @@ -1,8 +1,9 @@ import { SvgIcon } from 'insomnia-components'; -import React, { FC, Fragment, ReactNode } from 'react'; +import React, { FC, ReactNode } from 'react'; import styled from 'styled-components'; import { superDuperFaint, superFaint, ultraFaint } from '../../css/css-in-js'; +import { Link } from '../base/link'; const Panel = styled.div({ height: '100%', @@ -10,6 +11,7 @@ const Panel = styled.div({ display: 'flex', justifyContent: 'center', alignItems: 'center', + pointerEvents: 'none', }); const Wrapper = styled.div({ @@ -38,6 +40,7 @@ const Divider = styled.div({ margin: 'var(--padding-md) var(--padding-xl)', maxWidth: 500, width: '100%', + minWidth: 300, ...ultraFaint, }); @@ -48,13 +51,19 @@ const DocumentationLinks = styled.div({ marginTop: 'calc(var(--padding-lg))', margiBottom: 'var(--padding-md)', display: 'flex', - alignItems: 'end', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-end', + flexWrap: 'wrap', }); // @ts-expect-error the types don't like the addition of !important. can't say I blame them. -const Link = styled.a({ +const StyledLink = styled(Link)({ + display: 'flex', + marginTop: 'var(--padding-md)', color: 'var(--color-font) !important', // unfortunately, we've set at the root with !important fontWeight: 'normal !important', // unfortunately, we've set at the root with !important + pointerEvents: 'all', '& hover': { textDecoration: 'none', }, @@ -67,7 +76,7 @@ const LinkIcon = styled(SvgIcon)({ export const EmptyStatePane: FC<{ icon: ReactNode; title: string; - secondaryAction: string; + secondaryAction: ReactNode; documentationLinks: { title: string; url: string; @@ -87,10 +96,10 @@ export const EmptyStatePane: FC<{ {secondaryAction} {documentationLinks.map(({ title, url }) => ( - - {title} + + {title} - + ))} diff --git a/packages/insomnia-app/app/ui/components/panes/grpc-request-pane/index.tsx b/packages/insomnia-app/app/ui/components/panes/grpc-request-pane/index.tsx index 52337e13e..8faf4bfc3 100644 --- a/packages/insomnia-app/app/ui/components/panes/grpc-request-pane/index.tsx +++ b/packages/insomnia-app/app/ui/components/panes/grpc-request-pane/index.tsx @@ -4,6 +4,7 @@ import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; import styled from 'styled-components'; import { getCommonHeaderNames, getCommonHeaderValues } from '../../../../common/common-headers'; +import { documentationLinks } from '../../../../common/documentation'; import { hotKeyRefs } from '../../../../common/hotkeys'; import { executeHotKey } from '../../../../common/hotkeys-listener'; import type { GrpcRequest } from '../../../../models/grpc-request'; @@ -157,7 +158,7 @@ export const GrpcRequestPane: FunctionComponent = ({ {!methodType && ( } - documentationLinks={[{ title: 'Introduction to Insomnia', url: 'https://docs.insomnia.rest/insomnia/get-started' }]} + documentationLinks={[documentationLinks.introductionToInsomnia]} secondaryAction="Select a body type from above to send data in the body of a request" title="Enter a URL and send to get a response" /> diff --git a/packages/insomnia-app/app/ui/components/wrapper-design.tsx b/packages/insomnia-app/app/ui/components/wrapper-design.tsx index 8dfb2a69c..a4313f904 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-design.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper-design.tsx @@ -1,6 +1,7 @@ import { autoBindMethodsForReact } from 'class-autobind-decorator'; import { Button, NoticeTable } from 'insomnia-components'; import React, { Fragment, PureComponent, ReactNode } from 'react'; +import styled from 'styled-components'; import SwaggerUI from 'swagger-ui-react'; import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs'; @@ -9,20 +10,31 @@ import { ACTIVITY_HOME, AUTOBIND_CFG } from '../../common/constants'; import { initializeSpectral, isLintError } from '../../common/spectral'; import type { ApiSpec } from '../../models/api-spec'; import * as models from '../../models/index'; +import { superFaint } from '../css/css-in-js'; import previewIcon from '../images/icn-eye.svg'; import { CodeEditor, UnconnectedCodeEditor } from './codemirror/code-editor'; +import { DesignEmptyState } from './design-empty-state'; import { ErrorBoundary } from './error-boundary'; import { PageLayout } from './page-layout'; import { SpecEditorSidebar } from './spec-editor/spec-editor-sidebar'; import { WorkspacePageHeader } from './workspace-page-header'; import type { WrapperProps } from './wrapper'; +const EmptySpaceHelper = styled.div({ + ...superFaint, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + padding: '2em', + textAlign: 'center', +}); + const spectral = initializeSpectral(); interface Props { gitSyncDropdown: ReactNode; handleActivityChange: (options: {workspaceId?: string; nextActivity: GlobalActivity}) => Promise; - handleUpdateApiSpec: (s: ApiSpec) => Promise; + handleUpdateApiSpec: (apiSpec: ApiSpec) => Promise; wrapperProps: WrapperProps; } @@ -32,6 +44,7 @@ interface State { line: number; type: 'error' | 'warning'; }[]; + forceRefreshCounter: number; } @autoBindMethodsForReact(AUTOBIND_CFG) @@ -43,6 +56,7 @@ export class WrapperDesign extends PureComponent { super(props); this.state = { lintMessages: [], + forceRefreshCounter: 0, }; } @@ -68,7 +82,7 @@ export class WrapperDesign extends PureComponent { await models.workspaceMeta.updateByParentId(workspaceId, { previewHidden: !previewHidden }); } - _handleOnChange(v: string) { + onChangeSpecContents(contents: string) { const { wrapperProps: { activeApiSpec }, handleUpdateApiSpec, @@ -85,7 +99,7 @@ export class WrapperDesign extends PureComponent { } this.debounceTimeout = setTimeout(async () => { - await handleUpdateApiSpec({ ...activeApiSpec, contents: v }); + await handleUpdateApiSpec({ ...activeApiSpec, contents }); }, 500); } @@ -145,25 +159,35 @@ export class WrapperDesign extends PureComponent { } } + _onUpdateContents() { + const { forceRefreshCounter } = this.state; + this.setState({ forceRefreshCounter: forceRefreshCounter + 1 }); + } + _renderEditor() { const { activeApiSpec } = this.props.wrapperProps; - const { lintMessages } = this.state; + const { lintMessages, forceRefreshCounter } = this.state; if (!activeApiSpec) { return null; } + const uniquenessKey = `${forceRefreshCounter}::${activeApiSpec._id}`; + return (
-
+
+
{lintMessages.length > 0 && ( @@ -180,6 +204,14 @@ export class WrapperDesign extends PureComponent { return null; } + if (!activeApiSpec.contents) { + return ( + + Documentation for your OpenAPI spec will render here + + ); + } + let swaggerUiSpec: ParsedApiSpec['contents'] | null = null; try { @@ -196,7 +228,7 @@ export class WrapperDesign extends PureComponent { invalidationKey={activeApiSpec.contents} renderError={() => (
-

An error occurred while trying to render Swagger UI 😢

+

An error occurred while trying to render Swagger UI

This preview will automatically refresh, once you have a valid specification that can be previewed. @@ -249,12 +281,20 @@ export class WrapperDesign extends PureComponent { return null; } + if (!activeApiSpec.contents) { + return ( + + A spec navigator will render here + + ); + } + return ( (

-

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

+

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. diff --git a/packages/insomnia-app/app/ui/redux/modules/import.ts b/packages/insomnia-app/app/ui/redux/modules/import.ts index 4e34094a6..3ad5a0b82 100644 --- a/packages/insomnia-app/app/ui/redux/modules/import.ts +++ b/packages/insomnia-app/app/ui/redux/modules/import.ts @@ -115,10 +115,7 @@ export const importFile = ( } }; -export const importClipBoard = ( - options: ImportOptions = {}, -): ThunkAction => async (dispatch, getState) => { - dispatch(loadStart()); +export const readFromClipBoard = () => { const schema = electron.clipboard.readText(); if (!schema) { @@ -126,6 +123,17 @@ export const importClipBoard = ( title: 'Import Failed', message: 'Your clipboard appears to be empty.', }); + } + + return schema; +}; + +export const importClipBoard = ( + options: ImportOptions = {}, +): ThunkAction => async (dispatch, getState) => { + dispatch(loadStart()); + const schema = readFromClipBoard(); + if (!schema) { return; } diff --git a/packages/insomnia-components/src/assets/icn-drafting-compass.svg b/packages/insomnia-components/src/assets/icn-drafting-compass.svg new file mode 100644 index 000000000..be9af4370 --- /dev/null +++ b/packages/insomnia-components/src/assets/icn-drafting-compass.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/insomnia-components/src/svg-icon.tsx b/packages/insomnia-components/src/svg-icon.tsx index e5d6700f5..9e5742a10 100644 --- a/packages/insomnia-components/src/svg-icon.tsx +++ b/packages/insomnia-components/src/svg-icon.tsx @@ -12,6 +12,7 @@ import { SvgIcnChevronDown } from './assets/svgr/IcnChevronDown'; import { SvgIcnChevronUp } from './assets/svgr/IcnChevronUp'; import { SvgIcnClock } from './assets/svgr/IcnClock'; import { SvgIcnCookie } from './assets/svgr/IcnCookie'; +import { SvgIcnDraftingCompass } from './assets/svgr/IcnDraftingCompass'; import { SvgIcnDragGrip } from './assets/svgr/IcnDragGrip'; import { SvgIcnElevator } from './assets/svgr/IcnElevator'; import { SvgIcnEllipsis } from './assets/svgr/IcnEllipsis'; @@ -78,6 +79,7 @@ export const IconEnum = { chevronUp: 'chevron-up', clock: 'clock', cookie: 'cookie', + draftingCompass: 'drafting-compass', dragGrip: 'drag-grip', elevator: 'elevator', ellipsesCircle: 'ellipses-circle', @@ -175,6 +177,7 @@ export class SvgIcon extends Component { [IconEnum.chevronUp]: [ThemeEnum.default, SvgIcnChevronUp], [IconEnum.clock]: [ThemeEnum.default, SvgIcnClock], [IconEnum.cookie]: [ThemeEnum.default, SvgIcnCookie], + [IconEnum.draftingCompass]: [ThemeEnum.default, SvgIcnDraftingCompass], [IconEnum.dragGrip]: [ThemeEnum.default, SvgIcnDragGrip], [IconEnum.elevator]: [ThemeEnum.default, SvgIcnElevator], [IconEnum.elevator]: [ThemeEnum.default, SvgIcnElevator],