Empty State for Design Tab (#4345)

This commit is contained in:
Dimitri Mitropoulos 2022-01-13 09:03:18 -05:00 committed by GitHub
parent 914bcf30ed
commit 0c09313d42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 263 additions and 23 deletions

View File

@ -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;

View File

@ -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<Props> = ({ 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 = (
<StyledButton variant="outlined" bg="surprise" className="margin-left">
Import
<i className="fa fa-caret-down pad-left-sm" />
</StyledButton>
);
return (
<Dropdown renderButton={button}>
<DropdownItem
icon={<i className="fa fa-plus" />}
onClick={handleImportFile}
>
File
</DropdownItem>
<DropdownItem
icon={<i className="fa fa-link" />}
onClick={handleImportUri}
>
URL
</DropdownItem>
</Dropdown>
);
};
const SecondaryAction: FC<Props> = ({ 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 (
<div>
<div>
Or import existing an OpenAPI spec or <ExampleButton onClick={onClick}>start from an example</ExampleButton>
</div>
<ImportSpecButton onUpdateContents={onUpdateContents} />
</div>
);
};
export const DesignEmptyState: FC<Props> = ({ onUpdateContents }) => {
const activeApiSpec = useSelector(selectActiveApiSpec);
if (!activeApiSpec || activeApiSpec.contents) {
return null;
}
return (
<Wrapper>
<EmptyStatePane
icon={<SvgIcon icon="drafting-compass" />}
documentationLinks={[
documentationLinks.workingWithDesignDocs,
documentationLinks.introductionToInsomnia,
]}
secondaryAction={<SecondaryAction onUpdateContents={onUpdateContents} />}
title="Enter an OpenAPI specification here"
/>
</Wrapper>
);
};

View File

@ -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<Props> {
return (
<EmptyStatePane
icon={<SvgIcon icon="bug" />}
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"
/>

View File

@ -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>{secondaryAction}</SecondaryAction>
<DocumentationLinks>
{documentationLinks.map(({ title, url }) => (
<Fragment key={title}>
<Link key={title} href={url}>{title}</Link>
<StyledLink key={title} href={url}>
{title}
<LinkIcon icon="jump" />
</Fragment>
</StyledLink>
))}
</DocumentationLinks>
</Wrapper>

View File

@ -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<Props> = ({
{!methodType && (
<EmptyStatePane
icon={<SvgIcon icon="bug" />}
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"
/>

View File

@ -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<void>;
handleUpdateApiSpec: (s: ApiSpec) => Promise<void>;
handleUpdateApiSpec: (apiSpec: ApiSpec) => Promise<void>;
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<Props, State> {
super(props);
this.state = {
lintMessages: [],
forceRefreshCounter: 0,
};
}
@ -68,7 +82,7 @@ export class WrapperDesign extends PureComponent<Props, State> {
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<Props, State> {
}
this.debounceTimeout = setTimeout(async () => {
await handleUpdateApiSpec({ ...activeApiSpec, contents: v });
await handleUpdateApiSpec({ ...activeApiSpec, contents });
}, 500);
}
@ -145,25 +159,35 @@ export class WrapperDesign extends PureComponent<Props, State> {
}
}
_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 (
<div className="column tall theme--pane__body">
<div className="tall">
<div className="tall relative overflow-hidden">
<CodeEditor
manualPrettify
ref={this._setEditorRef}
lintOptions={WrapperDesign.lintOptions}
mode="openapi"
defaultValue={activeApiSpec.contents}
onChange={this._handleOnChange}
uniquenessKey={activeApiSpec._id}
onChange={this.onChangeSpecContents}
uniquenessKey={uniquenessKey}
/>
<DesignEmptyState
onUpdateContents={this._onUpdateContents}
/>
</div>
{lintMessages.length > 0 && (
@ -180,6 +204,14 @@ export class WrapperDesign extends PureComponent<Props, State> {
return null;
}
if (!activeApiSpec.contents) {
return (
<EmptySpaceHelper>
Documentation for your OpenAPI spec will render here
</EmptySpaceHelper>
);
}
let swaggerUiSpec: ParsedApiSpec['contents'] | null = null;
try {
@ -196,7 +228,7 @@ export class WrapperDesign extends PureComponent<Props, State> {
invalidationKey={activeApiSpec.contents}
renderError={() => (
<div className="text-left margin pad">
<h3>An error occurred while trying to render Swagger UI 😢</h3>
<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.
@ -249,12 +281,20 @@ export class WrapperDesign extends PureComponent<Props, State> {
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>
<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.

View File

@ -115,10 +115,7 @@ export const importFile = (
}
};
export const importClipBoard = (
options: ImportOptions = {},
): ThunkAction<void, RootState, void, AnyAction> => 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<void, RootState, void, AnyAction> => async (dispatch, getState) => {
dispatch(loadStart());
const schema = readFromClipBoard();
if (!schema) {
return;
}

View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 111 111">
<path d="M98.3593 74.3515C93.0184 78.6861 87.1379 82.2788 80.7607 84.8096L92.3553 104.892L103.858 109.805C105.95 110.698 108.312 109.333 108.585 107.075L110.082 94.6558L98.3593 74.3515ZM107.419 54.1901C108.455 52.5548 107.822 50.3544 106.129 49.4163L100.144 46.0965C98.5491 45.2117 96.6025 45.7853 95.611 47.3161C86.8458 60.8615 71.7589 69.1449 55.5014 69.1449C50.3992 69.1449 45.4314 68.2387 40.7023 66.6865L55.0643 41.8088C55.2136 41.8131 55.35 41.8536 55.4993 41.8536C55.6485 41.8536 55.7871 41.8131 55.9342 41.8088L66.8273 60.6782C73.4859 58.7678 79.5263 55.1879 84.4408 50.2478L73.3985 31.1205C74.9869 28.2144 75.9699 24.9309 75.9699 21.3852C75.9699 10.0806 66.806 0.916748 55.5014 0.916748C44.1969 0.916748 35.033 10.0806 35.033 21.3852C35.033 24.9309 36.0159 28.2144 37.6022 31.1205L23.0398 56.3457C20.364 53.8703 17.9312 51.1028 15.8588 48.0304C14.8375 46.5166 12.8802 45.9793 11.3003 46.894L5.38362 50.3203C3.7099 51.2883 3.11717 53.4972 4.1811 55.1112C7.50082 60.1515 11.5775 64.5373 16.1168 68.3347L0.918945 94.6579L2.4157 107.078C2.68861 109.335 5.05101 110.7 7.14263 109.807L18.6455 104.894L33.7409 78.748C40.6042 81.3577 47.958 82.7905 55.5014 82.7905C76.6202 82.7905 96.1995 71.9145 107.419 54.1901ZM55.5014 14.5624C59.2689 14.5624 62.3242 17.6177 62.3242 21.3852C62.3242 25.1527 59.2689 28.208 55.5014 28.208C51.734 28.208 48.6786 25.1527 48.6786 21.3852C48.6786 17.6177 51.734 14.5624 55.5014 14.5624Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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<SvgIconProps> {
[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],