Improve UI for design view (#6476)

* design ui

* preview toggle

* handle spec errors and fix test

* aria labels

* disabled items

* fix lint test
This commit is contained in:
James Gatz 2023-09-11 16:56:17 +02:00 committed by GitHub
parent 7867080f26
commit ea77b6d663
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 804 additions and 1920 deletions

View File

@ -8,7 +8,7 @@ test('Clone from github', async ({ page }) => {
await page.getByPlaceholder('MyUser').fill('J');
await page.getByPlaceholder('88e7ee63b254e4b0bf047559eafe86ba9dd49507').fill('J');
await page.getByTestId('git-repository-settings-modal__sync-btn').click();
await page.getByRole('button', { name: 'Toggle Preview' }).click();
await page.getByLabel('Toggle preview').click();
});
test('Sign in with GitHub', async ({ app, page }) => {
await page.getByRole('button', { name: 'New Document' }).click();

View File

@ -16,5 +16,6 @@ test('can render Spectral OpenAPI lint errors', async ({ page }) => {
await page.locator('textarea').nth(1).press('Tab');
// TODO - fix the locator so we don't rely on `.nth(1)` https://linear.app/insomnia/issue/INS-2255/revisit-codemirror-playwright-selectorfill
await page.getByLabel('Toggle lint panel').click();
await expect(codeEditor).toContainText('oas3-schema Object must have required property "info"');
});

View File

@ -1,94 +0,0 @@
import classNames from 'classnames';
import React, { FC, Fragment, ReactNode } from 'react';
import styled from 'styled-components';
import * as session from '../../account/session';
import { GitHubStarsButton } from './github-stars-button';
import { InsomniaAILogo } from './insomnia-icon';
const LogoWrapper = styled.div({
display: 'flex',
justifyContent: 'center',
});
export interface AppHeaderProps {
gridCenter?: ReactNode;
gridRight?: ReactNode;
}
export interface HeaderProps {
className?: string;
gridLeft?: ReactNode;
gridCenter?: ReactNode;
gridRight?: ReactNode;
}
const StyledHeader = styled.div({
gridArea: 'Header',
borderBottom: '1px solid var(--hl-md)',
display: 'grid',
padding: 'var(--padding-xs) 0',
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateRows: '1fr',
gridTemplateAreas: "'header_left header_center header_right'",
'.header_left': {
gridArea: 'header_left',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
},
'.header_center': {
gridArea: 'header_center',
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
'.header_right': {
gridArea: 'header_right',
textAlign: 'right',
display: 'flex',
gap: 'var(--padding-xs)',
padding: 'var(--padding-xs)',
alignItems: 'center',
justifyContent: 'flex-end',
},
'&&': {
whiteSpace: 'nowrap',
},
});
const Header: FC<HeaderProps> = ({ className, gridLeft, gridCenter, gridRight }) => (
<StyledHeader className={classNames('app-header theme--app-header', className)}>
<div className="header_left">{gridLeft}</div>
<div className="header_center">{gridCenter}</div>
<div className="header_right">{gridRight}</div>
</StyledHeader>
);
Header.displayName = 'Header';
export const AppHeader: FC<AppHeaderProps> = ({
gridCenter,
gridRight,
}) => {
return (
<Header
gridLeft={(
<Fragment>
<LogoWrapper>
<InsomniaAILogo />
</LogoWrapper>
{!session.isLoggedIn() ? <GitHubStarsButton /> : null}
</Fragment>
)}
gridCenter={gridCenter}
gridRight={
<Fragment>
{gridRight}
</Fragment>
}
/>
);
};

View File

@ -1,297 +0,0 @@
import React, { PropsWithChildren, ReactNode, useCallback, useState } from 'react';
import styled from 'styled-components';
import { IconEnum, SvgIcon } from './svg-icon';
import { Button } from './themed-button';
export interface Notice {
type: 'warning' | 'error' | 'info';
line: number;
message: string;
}
export interface TableProps {
children: ReactNode;
striped?: boolean;
outlined?: boolean;
compact?: boolean;
headings?: ReactNode[];
}
export const Table = styled.table<TableProps>`
width: 100%;
border-spacing: 0;
border-collapse: collapse;
td,
th {
padding: ${({ compact }) => (compact ? 'var(--padding-xs)' : 'var(--padding-sm)')}
${({ compact }) => (compact ? 'var(--padding-sm)' : 'var(--padding-md)')};
}
${({ striped }) =>
striped &&
`
tbody tr:nth-child(odd) {
background: var(--hl-xs);
}`}
${({ outlined }) =>
outlined &&
`
& {
th {
background: var(--hl-xxs);
}
&,
td {
border: 1px solid var(--hl-sm);
}
tr.table--no-outline-row td {
border-left: 0;
border-right: 0;
}
& {
border-radius: 3px;
border-collapse: unset;
}
td {
border-left: 0;
border-bottom: 0;
&:last-child {
border-right: 0;
}
}
}`}
`;
/********/
/* <tr> */
/********/
export const TableRow = styled.tr``;
/********/
/* <td> */
/********/
export interface TableDataProps {
compact?: boolean;
align?: 'center' | 'left';
}
export const TableData = styled.td<TableDataProps>`
vertical-align: top;
padding: 0 var(--padding-md);
text-align: ${({ align }) => align || 'left'};
`;
export interface TableHeaderProps {
compact?: boolean;
align?: 'center' | 'left';
}
export const TableHeader = styled.th<TableHeaderProps>`
vertical-align: top;
padding: 0 var(--padding-md);
text-align: ${({ align }) => align || 'left'};
`;
export const TableHead = styled.thead``;
export const TableBody = styled.tbody``;
export interface NoticeTableProps<T extends Notice> {
notices: T[];
onClick: (notice: T) => void;
onVisibilityToggle?: (expanded: boolean) => any;
toggleRightPane: () => void;
compact?: boolean;
className?: string;
}
const Wrapper = styled.div`
width: 100%;
td {
position: relative;
}
tr:hover {
background-color: var(--hl-sm) !important;
}
`;
const ScrollWrapperStyled = styled.div`
height: 100%;
width: 100%;
max-height: 13rem;
overflow-y: auto;
`;
const ErrorCount = styled.div`
margin-right: var(--padding-md);
display: inline-flex;
align-items: center;
`;
const JumpButton = styled.button`
outline: 0;
border: 0;
ebackground: transparent;
font-size: var(--font-size-md);
margin: 0;
display: none;
position: absolute;
top: 0;
bottom: 0;
right: -0.5em;
padding-left: 0.5em;
padding-right: 0.5em;
z-index: 1;
& svg {
opacity: 0.7;
}
&:hover svg {
opacity: 1;
}
&:active svg {
opacity: 0.85;
}
tr:hover & {
// To keep icon centered vertically
display: flex;
align-items: center;
}
`;
const Header = styled.header`
display: flex;
align-items: center;
flex-grow: 0;
justify-content: space-between;
border: 1px solid var(--hl-sm);
border-left: 0;
border-right: 0;
padding-left: var(--padding-md);
`;
const NoticeRow = <T extends Notice>({
notice,
onClick: propsOnClick,
}: PropsWithChildren<{
notice: T;
onClick: (notice: T) => void;
}>) => {
const onClick = useCallback(() => {
propsOnClick?.(notice);
}, [notice, propsOnClick]);
return (
<TableRow key={`${notice.line}:${notice.type}:${notice.message}`}>
<TableData align="center">
<SvgIcon icon={notice.type} />
</TableData>
<TableData align="center">
{notice.line}
<JumpButton onClick={onClick}>
<SvgIcon icon={IconEnum.arrowRight} />
</JumpButton>
</TableData>
<TableData align="left">{notice.message}</TableData>
</TableRow>
);
};
export const NoticeTable = <T extends Notice>({
notices,
compact,
onClick,
onVisibilityToggle,
toggleRightPane,
}: PropsWithChildren<NoticeTableProps<T>>) => {
const [collapsed, setCollapsed] = useState(false);
const onCollapse = useCallback(() => {
setCollapsed(!collapsed);
if (onVisibilityToggle) {
onVisibilityToggle(!collapsed);
}
}, [onVisibilityToggle, collapsed]);
const errors = notices.filter(notice => notice.type === 'error');
const warnings = notices.filter(notice => notice.type === 'warning');
return (
<Wrapper>
<Header>
<div>
{errors.length > 0 && (
<ErrorCount>
<SvgIcon icon={IconEnum.error} label={errors.length} />
</ErrorCount>
)}
{warnings.length > 0 && (
<ErrorCount>
<SvgIcon icon={IconEnum.warning} label={warnings.length} />
</ErrorCount>
)}
</div>
<Button className='btn btn--compact' onClick={onCollapse}>
Toggle Warnings
<SvgIcon
style={{ marginLeft: 'var(--padding-xs)' }}
icon={collapsed ? IconEnum.chevronUp : IconEnum.chevronDown}
/>
</Button>
<Button className='btn btn--compact' onClick={toggleRightPane}>
Toggle Preview
</Button>
</Header>
{!collapsed && (
<ScrollWrapperStyled>
<Table striped compact={compact}>
<TableHead>
<TableRow>
<TableHeader align="center">Type</TableHeader>
<TableHeader
style={{
minWidth: '3em',
}}
align="center"
>
Line
</TableHeader>
<TableHeader
style={{
width: '100%',
}}
align="left"
>
Message
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{notices.map(notice => (
<NoticeRow
key={`${notice.line}${notice.message}`}
notice={notice}
onClick={onClick}
/>
))}
</TableBody>
</Table>
</ScrollWrapperStyled>
)}
</Wrapper>
);
};

View File

@ -1,17 +0,0 @@
export { Sidebar, type SidebarProps } from './sidebar';
export { SidebarBadge, type SidebarBadgeProps } from './sidebar-badge';
export { SidebarFilter, type SidebarFilterProps } from './sidebar-filter';
export { SidebarHeader, type SidebarHeaderProps } from './sidebar-header';
export { SidebarHeaders, type SidebarHeadersProps } from './sidebar-headers';
export { SidebarInfo, type SidebarInfoProps, type SidebarInfoType } from './sidebar-info';
export { SidebarInvalidSection, type SidebarInvalidSectionProps } from './sidebar-invalid-section';
export { SidebarItem, type SidebarItemProps } from './sidebar-item';
export { SidebarParameters, type SidebarParametersProps } from './sidebar-parameters';
export { SidebarPaths, type SidebarPathsProps, type SidebarPathsType } from './sidebar-paths';
export { SidebarRequests, type SidebarRequestsProps } from './sidebar-requests';
export { SidebarResponses, type SidebarResponsesProps } from './sidebar-responses';
export { SidebarSchemas, type SidebarSchemasProps } from './sidebar-schemas';
export { SidebarSection, type SidebarSectionProps } from './sidebar-section';
export { SidebarSecurity, type SidebarSecurityProps } from './sidebar-security';
export { SidebarServers, type SidebarServersProps, type SidebarServer } from './sidebar-servers';
export { SidebarTextItem, type SidebarTextItemProps } from './sidebar-text-item';

View File

@ -1,86 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
export interface SidebarBadgeProps {
label?: string;
onClick?: () => void;
method?: 'get' | 'post' | 'delete' | 'parameters' | 'patch' | 'put' | 'options-head' | string;
}
const StyledBadge = styled.span`
display: table;
border-spacing: var(--padding-xxs) 0;
&:first-of-type {
padding-left: var(--padding-lg);
}
margin: 0px !important;
span {
display: table-cell;
vertical-align: middle;
padding: var(--padding-xxs) var(--padding-xs);
text-transform: uppercase;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: #fff;
border-radius: var(--radius-sm);
text-shadow: 1px 1px 0px var(--hl-sm);
transition: background-color 0.2s ease;
&.post {
background-color: rgba(var(--color-success-rgb), 0.8);
&:hover {
background-color: var(--color-success);
}
}
&.get {
background-color: rgba(var(--color-surprise-rgb), 0.8);
&:hover {
background-color: var(--color-surprise);
}
}
&.delete {
background-color: rgba(var(--color-danger-rgb), 0.8);
&:hover {
background-color: var(--color-danger);
}
}
&.parameters {
display: none;
margin-right: 0px !important;
}
&.options-head,
&.custom {
background-color: rgba(var(--color-info-rgb), 0.8);
&:hover {
background-color: var(--color-info);
}
}
&.patch {
background-color: rgba(var(--color-notice-rgb), 0.8);
&:hover {
background-color: var(--color-notice);
}
}
&.put {
background-color: rgba(var(--color-warning-rgb), 0.8);
&:hover {
background-color: var(--color-warning);
}
}
&:hover {
cursor: default;
}
}
`;
export const SidebarBadge: FunctionComponent<SidebarBadgeProps> = ({
onClick = () => {},
method = 'post',
label = method,
}) => {
return (
<StyledBadge onClick={onClick}>
<span className={method}>{label}</span>
</StyledBadge>
);
};

View File

@ -1,56 +0,0 @@
import React, { createRef, FunctionComponent, useLayoutEffect } from 'react';
import styled from 'styled-components';
export interface SidebarFilterProps {
filter: boolean;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const StyledFilter = styled.div`
padding-left: var(--padding-md);
padding-right: var(--padding-md);
overflow: hidden;
input {
box-sizing: border-box;
width: 100%;
font-size: var(--font-size-sm);
padding: var(--padding-xs);
margin-top: 0;
margin-bottom: var(--padding-sm);
outline-style: none;
box-shadow: none;
border: 1px solid var(--hl-md);
color: var(--color-font);
background: transparent;
:focus::placeholder {
color: transparent;
}
::placeholder {
color: var(--color-font);
}
}
`;
export const SidebarFilter: FunctionComponent<SidebarFilterProps> = ({ filter, onChange }) => {
const filterField = createRef<HTMLInputElement>();
useLayoutEffect(() => {
if (filterField.current && !filter) {
filterField.current.value = '';
} else if (filterField.current) {
filterField.current.focus();
}
}, [filter, filterField]);
return (
<StyledFilter
style={{
height: filter ? '100%' : '0px',
}}
>
<input type="text" placeholder="Filter..." onChange={onChange} ref={filterField} />
</StyledFilter>
);
};

View File

@ -1,88 +0,0 @@
import React, { FunctionComponent, ReactNode } from 'react';
import styled from 'styled-components';
import { IconEnum, SvgIcon } from '../../svg-icon';
export interface SidebarHeaderProps {
headerTitle: string;
toggleSection: React.MouseEventHandler<HTMLLIElement>;
toggleFilter?: () => void;
sectionVisible: boolean;
children?: ReactNode;
}
const StyledHeader = styled.li`
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
background-color: var(--hl-xs);
}
h6 {
font-size: var(--font-size-sm);
display: flex;
flex-grow: 1;
&:hover {
cursor: default;
}
}
h6:hover {
text-decoration: underline;
}
label {
color: red !important;
position: absolute;
padding-top: var(--padding-xs);
}
& > * {
padding: var(--padding-md) var(--padding-md) var(--padding-md) var(--padding-md);
font-size: var(--font-size-md);
svg {
margin-left: var(--padding-sm);
font-size: var(--font-size-xl);
&:hover {
fill: var(--color-font);
opacity: 1;
}
}
}
`;
export const SidebarHeader: FunctionComponent<SidebarHeaderProps> = ({
headerTitle,
toggleSection,
toggleFilter,
sectionVisible,
children,
}) => {
const handleFilterClick: React.MouseEventHandler<HTMLSpanElement> | undefined =
sectionVisible && toggleFilter // only handle a click if the section is open
? event => {
event.stopPropagation(); // Prevent a parent from also handling the click
toggleFilter();
}
: undefined;
return (
<StyledHeader onClick={toggleSection}>
<h6>{headerTitle}</h6>
<div>
{children || (
<span
onClick={handleFilterClick}
style={{ opacity: sectionVisible ? 0.6 : 0 }}
>
<SvgIcon icon={IconEnum.search} />
</span>
)}
</div>
</StyledHeader>
);
};

View File

@ -1,55 +0,0 @@
import React, { Component, Fragment, ReactNode } from 'react';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { Tooltip } from '../../tooltip';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export interface SidebarHeadersProps {
headers: Record<string, any>;
onClick: (section: string, ...args: any) => void;
}
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarHeaders extends Component<SidebarHeadersProps> {
renderBody = (filter: string): null | ReactNode => {
const { headers, onClick } = this.props;
if (Object.prototype.toString.call(headers) !== '[object Object]') {
return <SidebarInvalidSection name={'header'} />;
}
const filteredValues = Object.keys(headers).filter(header =>
header.toLowerCase().includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{filteredValues.map(header => (
<Fragment key={header}>
<SidebarItem onClick={() => onClick('components', 'headers', header)}>
<div>
<SvgIcon icon={IconEnum.indentation} />
</div>
<span>
<Tooltip message={headers[header].description} position="right">
{header}
</Tooltip>
</span>
</SidebarItem>
</Fragment>
))}
</div>
);
};
render() {
return <SidebarSection title="HEADERS" renderBody={this.renderBody} />;
}
}

View File

@ -1,55 +0,0 @@
import React, { FunctionComponent } from 'react';
import { SidebarItem } from './sidebar-item';
import { SidebarTextItem } from './sidebar-text-item';
export interface SidebarInfoType {
title: string;
description: string;
version: string;
license: {
name: string;
};
}
export interface SidebarInfoProps {
info: SidebarInfoType;
childrenVisible: boolean;
onClick: (section: string, ...args: string[]) => void;
}
export const SidebarInfo: FunctionComponent<SidebarInfoProps> = ({
info: {
title,
description,
version,
license,
},
childrenVisible,
onClick,
}) => {
return (
<div style={{ height: childrenVisible ? '100%' : 0 }}>
{title && (
<SidebarItem onClick={() => onClick('info', 'title')}>
<SidebarTextItem label={'Title:'} headline={title} />
</SidebarItem>
)}
{description && (
<SidebarItem onClick={() => onClick('info', 'description')}>
<SidebarTextItem label={'Description:'} headline={description} />
</SidebarItem>
)}
{version && (
<SidebarItem onClick={() => onClick('info', 'version')}>
<SidebarTextItem label={'Version:'} headline={version} />
</SidebarItem>
)}
{license && license.name && (
<SidebarItem onClick={() => onClick('info', 'license')}>
<SidebarTextItem label={'License:'} headline={license.name} />
</SidebarItem>
)}
</div>
);
};

View File

@ -1,15 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
export interface SidebarInvalidSectionProps {
name: string;
}
const StyledInvalidSection = styled.div`
padding: var(--padding-xs) var(--padding-xs) var(--padding-md) var(--padding-md);
color: var(--color-warning);
`;
export const SidebarInvalidSection: FunctionComponent<SidebarInvalidSectionProps> = ({ name }) => (
<StyledInvalidSection>Error: Invalid {name} specification.</StyledInvalidSection>
);

View File

@ -1,84 +0,0 @@
import React, { FunctionComponent, ReactNode } from 'react';
import styled from 'styled-components';
export interface SidebarItemProps {
children: ReactNode;
gridLayout?: boolean;
onClick?: () => void;
}
const StyledBlockItem = styled.div`
padding: var(--padding-xs) var(--padding-md) var(--padding-xs) 0;
margin: 0;
display: flex;
align-items: center;
font-size: var(--font-size-md);
line-height: var(--font-size-md);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
&:hover {
background-color: var(--hl-xxs);
cursor: default;
}
&:last-child {
margin-bottom: var(--padding-md);
}
span {
margin: 0 0 0 var(--padding-xs);
}
div {
display: inline;
margin: 0;
}
div:nth-child(1) {
padding-left: var(--padding-xs);
}
div.tooltip {
padding: 0;
}
`;
const StyledGridItem = styled.li`
padding: 0 0 0 var(--padding-sm);
margin: 0;
display: flex;
align-items: center;
white-space: nowrap;
font-size: var(--font-size-md);
line-height: var(--font-size-sm);
&:first-child {
margin-top: var(--padding-xxs);
}
span {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding: 4px var(--padding-sm) var(--padding-xs) 0px;
margin-left: var(--padding-xs);
}
a {
color: var(--hl-xl);
}
div:nth-child(1) {
text-align: right;
svg {
padding-left: var(--padding-sm);
}
}
&:hover {
background-color: var(--hl-xxs);
cursor: default;
}
&:last-child {
margin-bottom: var(--padding-md);
}
`;
export const SidebarItem: FunctionComponent<SidebarItemProps> = ({ children, gridLayout, onClick }) => {
if (gridLayout) {
return <StyledGridItem onClick={onClick}>{children}</StyledGridItem>;
} else {
return <StyledBlockItem onClick={onClick}>{children}</StyledBlockItem>;
}
};

View File

@ -1,55 +0,0 @@
import React, { Component, Fragment, ReactNode } from 'react';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { Tooltip } from '../../tooltip';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export interface SidebarParametersProps {
parameters: Record<string, any>;
onClick: (section: string, ...args: any) => void;
}
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarParameters extends Component<SidebarParametersProps> {
renderBody = (filter: string): null | ReactNode => {
const { parameters, onClick } = this.props;
if (Object.prototype.toString.call(parameters) !== '[object Object]') {
return <SidebarInvalidSection name={'parameter'} />;
}
const filteredValues = Object.keys(parameters).filter(parameter =>
parameter.toLowerCase().includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{filteredValues.map(parameter => (
<Fragment key={parameter}>
<SidebarItem onClick={() => onClick('components', 'parameters', parameter)}>
<div>
<SvgIcon icon={IconEnum.indentation} />
</div>
<span>
<Tooltip message={parameters[parameter].description} position="right">
{parameter}
</Tooltip>
</span>
</SidebarItem>
</Fragment>
))}
</div>
);
};
render() {
return <SidebarSection title="PARAMETERS" renderBody={this.renderBody} />;
}
}

View File

@ -1,73 +0,0 @@
import React, { Component, Fragment, ReactNode } from 'react';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { SidebarBadge } from './sidebar-badge';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export type SidebarPathsType = Record<string, any> | string;
export interface SidebarPathsProps {
paths: SidebarPathsType;
onClick: (section: string, ...args: any) => void;
}
const isNotXDashKey = (key: string) => key.indexOf('x-') !== 0;
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarPaths extends Component<SidebarPathsProps> {
renderBody = (filter: string): null | ReactNode => {
const { paths, onClick } = this.props;
let pathItems = {};
if (typeof paths !== 'string') {
pathItems = Object.entries(paths || {});
}
if (Object.prototype.toString.call(pathItems) !== '[object Array]') {
return <SidebarInvalidSection name={'path'} />;
}
// @ts-expect-error TSCONVERSION
const filteredValues = pathItems.filter(pathDetail =>
pathDetail[0].toLowerCase().includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{// @ts-expect-error TSCONVERSION
filteredValues.map(([route, routeBody]) => (
<Fragment key={route}>
<SidebarItem gridLayout onClick={() => onClick('paths', route)}>
<div>
<SvgIcon icon={IconEnum.indentation} />
</div>
<span>{route}</span>
</SidebarItem>
<SidebarItem>
{Object.keys(routeBody)
.filter(isNotXDashKey)
.map(method => (
<SidebarBadge
key={method}
method={method}
onClick={() => onClick('paths', route, method)}
/>
))}
</SidebarItem>
</Fragment>
))}
</div>
);
};
render() {
return <SidebarSection title="PATHS" renderBody={this.renderBody} />;
}
}

View File

@ -1,110 +0,0 @@
import React, { Component, Fragment, ReactNode } from 'react';
import styled from 'styled-components';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { Tooltip } from '../../tooltip';
import { SidebarBadge } from './sidebar-badge';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export interface SidebarRequestsProps {
requests: Record<string, any>;
onClick: (section: string, ...args: any) => void;
}
const StyledRequestFormat = styled.span`
padding-left: var(--padding-sm);
`;
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarRequests extends Component<SidebarRequestsProps> {
renderBody = (filter: string): null | ReactNode => {
const { requests, onClick } = this.props;
if (Object.prototype.toString.call(requests) !== '[object Object]') {
return <SidebarInvalidSection name={'request'} />;
}
const filteredValues = Object.keys(requests).filter(requestName =>
requestName.toLowerCase().includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{filteredValues.map(requestName => {
const { description, content } = requests[requestName];
return (
<Fragment key={requestName}>
<SidebarItem
gridLayout
onClick={() => onClick('components', 'requestBodies', requestName)}
>
<div>
<SvgIcon icon={IconEnum.folderOpen} />
</div>
<span>
<Tooltip message={description} position="right">
{requestName}
</Tooltip>
</span>
</SidebarItem>
{Object.keys(content).map(requestFormat => (
<Fragment key={requestFormat}>
<SidebarItem>
<StyledRequestFormat>
<SvgIcon icon={IconEnum.indentation} />
<span
onClick={() =>
onClick(
'components',
'requestBodies',
requestName,
'content',
requestFormat,
)
}
>
{requestFormat}
</span>
</StyledRequestFormat>
</SidebarItem>
{content[requestFormat].examples && (
<SidebarItem>
{Object.keys(content[requestFormat].examples).map(requestExample => (
<SidebarBadge
key={requestExample}
label={requestExample}
onClick={() =>
onClick(
'components',
'requestBodies',
requestName,
'content',
requestFormat,
'examples',
requestExample,
)
}
/>
))}
</SidebarItem>
)}
</Fragment>
))}
</Fragment>
);
})}
</div>
);
};
render() {
return <SidebarSection title="REQUESTS" renderBody={this.renderBody} />;
}
}

View File

@ -1,53 +0,0 @@
import React, { Component, ReactNode } from 'react';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { Tooltip } from '../../tooltip';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export interface SidebarResponsesProps {
responses: Record<string, any>;
onClick: (section: string, ...args: any) => void;
}
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarResponses extends Component<SidebarResponsesProps> {
renderBody = (filter: string): null | ReactNode => {
const { responses, onClick } = this.props;
if (Object.prototype.toString.call(responses) !== '[object Object]') {
return <SidebarInvalidSection name={'response'} />;
}
const filteredValues = Object.keys(responses).filter(response =>
response.toLowerCase().includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{filteredValues.map(response => (
<SidebarItem key={response} onClick={() => onClick('components', 'responses', response)}>
<div>
<SvgIcon icon={IconEnum.indentation} />
</div>
<span>
<Tooltip message={responses[response].description} position="right">
{response}
</Tooltip>
</span>
</SidebarItem>
))}
</div>
);
};
render() {
return <SidebarSection title="RESPONSES" renderBody={this.renderBody} />;
}
}

View File

@ -1,48 +0,0 @@
import React, { Component, ReactNode } from 'react';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export interface SidebarSchemasProps {
schemas: Record<string, any>;
onClick: (section: string, ...args: any) => void;
}
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarSchemas extends Component<SidebarSchemasProps> {
renderBody = (filter: string): null | ReactNode => {
const { schemas, onClick } = this.props;
if (Object.prototype.toString.call(schemas) !== '[object Object]') {
return <SidebarInvalidSection name={'schema'} />;
}
const filteredValues = Object.keys(schemas).filter(schema =>
schema.toLowerCase().includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{filteredValues.map(schema => (
<SidebarItem key={schema} onClick={() => onClick('components', 'schemas', schema)}>
<div>
<SvgIcon icon={IconEnum.brackets} />
</div>
<span>{schema}</span>
</SidebarItem>
))}
</div>
);
};
render() {
return <SidebarSection title="SCHEMAS" renderBody={this.renderBody} />;
}
}

View File

@ -1,54 +0,0 @@
import React, { ChangeEvent, FunctionComponent, ReactNode, useCallback, useLayoutEffect, useState } from 'react';
import useToggle from 'react-use/lib/useToggle';
import styled from 'styled-components';
import { SidebarFilter } from './sidebar-filter';
import { SidebarHeader } from './sidebar-header';
export interface SidebarSectionProps {
title: string;
renderBody: (filterValue: string) => ReactNode;
}
const StyledSection = styled.ul`
overflow: hidden;
box-sizing: border-box;
border-bottom: 1px solid var(--hl-md);
`;
const StyledNoResults = styled.div`
padding: var(--padding-xs) var(--padding-xs) var(--padding-md) var(--padding-md);
color: var(--color-warning);
`;
export const SidebarSection: FunctionComponent<SidebarSectionProps> = ({ title, renderBody }) => {
const [bodyVisible, toggleBodyVisible] = useToggle(false);
const [filterVisible, toggleFilterVisible] = useToggle(false);
const [filterValue, setFilterValue] = useState('');
const handleFilterChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setFilterValue(event.target.value);
}, []);
useLayoutEffect(() => {
toggleFilterVisible(false);
setFilterValue('');
}, [bodyVisible, toggleFilterVisible]);
return (
<StyledSection>
<SidebarHeader
headerTitle={title}
sectionVisible={bodyVisible}
toggleSection={toggleBodyVisible}
toggleFilter={toggleFilterVisible}
/>
<div style={{ height: bodyVisible ? '100%' : 0 }}>
<SidebarFilter filter={filterVisible} onChange={handleFilterChange} />
{renderBody(filterValue) || (
<StyledNoResults>No results found for "{filterValue}"...</StyledNoResults>
)}
</div>
</StyledSection>
);
};

View File

@ -1,50 +0,0 @@
import React, { Component, Fragment, ReactNode } from 'react';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export interface SidebarSecurityProps {
security: Record<string, any>;
onClick: (section: string, ...args: any) => void;
}
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarSecurity extends Component<SidebarSecurityProps> {
renderBody = (filter: string): null | ReactNode => {
const { security, onClick } = this.props;
if (Object.prototype.toString.call(security) !== '[object Object]') {
return <SidebarInvalidSection name={'security'} />;
}
const filteredValues = Object.keys(security).filter(scheme =>
scheme.toLowerCase().includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{filteredValues.map(scheme => (
<Fragment key={scheme}>
<SidebarItem onClick={() => onClick('components', 'securitySchemes', scheme)}>
<div>
<SvgIcon icon={IconEnum.key} />
</div>
<span>{scheme}</span>
</SidebarItem>
</Fragment>
))}
</div>
);
};
render() {
return <SidebarSection title="SECURITY" renderBody={this.renderBody} />;
}
}

View File

@ -1,54 +0,0 @@
import React, { Component, Fragment, ReactNode } from 'react';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { SidebarInvalidSection } from './sidebar-invalid-section';
import { SidebarItem } from './sidebar-item';
import { SidebarSection } from './sidebar-section';
export interface SidebarServer {
url: string;
}
export interface SidebarServersProps {
servers: SidebarServer[];
onClick: (section: string, path: string | number) => void;
}
// Implemented as a class component because of a caveat with render props
// https://reactjs.org/docs/render-props.html#be-careful-when-using-render-props-with-reactpurecomponent
export class SidebarServers extends Component<SidebarServersProps> {
renderBody = (filter: string): null | ReactNode => {
const { servers, onClick } = this.props;
if (!Array.isArray(servers)) {
return <SidebarInvalidSection name={'server'} />;
}
const filteredValues = servers.filter(server =>
server.url.includes(filter.toLocaleLowerCase()),
);
if (!filteredValues.length) {
return null;
}
return (
<div>
{filteredValues.map((server, index) => (
<Fragment key={server.url}>
<SidebarItem onClick={() => onClick('servers', index)}>
<div>
<SvgIcon icon={IconEnum.indentation} />
</div>
<span>{server.url}</span>
</SidebarItem>
</Fragment>
))}
</div>
);
};
render() {
return <SidebarSection title="SERVERS" renderBody={this.renderBody} />;
}
}

View File

@ -1,22 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
export interface SidebarTextItemProps {
label: string;
headline: string;
}
const StyledTextItem = styled.span`
display: block;
padding-left: var(--padding-sm);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`;
export const SidebarTextItem: FunctionComponent<SidebarTextItemProps> = ({ label, headline }) => (
<StyledTextItem>
<strong>{label}</strong>
<span>{headline}</span>
</StyledTextItem>
);

View File

@ -1,258 +0,0 @@
import React, { FunctionComponent } from 'react';
import useToggle from 'react-use/lib/useToggle';
import styled from 'styled-components';
import { Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../../base/dropdown';
import { IconEnum, SvgIcon } from '../../svg-icon';
import { SidebarHeader } from './sidebar-header';
import { SidebarHeaders } from './sidebar-headers';
import { SidebarInfo, SidebarInfoType } from './sidebar-info';
import { SidebarParameters } from './sidebar-parameters';
import { SidebarPaths, SidebarPathsType } from './sidebar-paths';
import { SidebarRequests } from './sidebar-requests';
import { SidebarResponses } from './sidebar-responses';
import { SidebarSchemas } from './sidebar-schemas';
import { SidebarSecurity } from './sidebar-security';
import { SidebarServer, SidebarServers } from './sidebar-servers';
export interface SidebarProps {
className?: string;
onClick: (section: string, path: any) => void;
jsonData?: {
servers?: SidebarServer[];
info?: SidebarInfoType;
paths?: SidebarPathsType;
components?: {
requestBodies?: Record<string, any>;
responses?: Record<string, any>;
parameters?: Record<string, any>;
headers?: Record<string, any>;
schemas?: Record<string, any>;
securitySchemes?: Record<string, any>;
};
};
pathItems?: Record<string, any>[];
}
const StyledSidebar = styled.div`
width: 100%;
height: 100%;
background-color: var(--color-bg);
border: none;
color: var(--color-font);
position: relative;
svg {
fill: var(--hl-lg);
}
ul:first-child {
border-top: none;
}
ul:last-child {
border-bottom: none;
}
`;
const StyledSection = styled.ul`
overflow: hidden;
box-sizing: border-box;
border-bottom: 1px solid var(--hl-md);
`;
const StyledItem = styled.div({
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-xs)',
label: {
paddingTop: 0,
},
});
interface ItemWrapperProps {
checked: boolean;
htmlFor: string;
label: string;
}
const ItemWrapper = ({ checked, htmlFor, label }: ItemWrapperProps) => {
return (
<StyledItem>
<input type="checkbox" checked={checked} readOnly />
<label htmlFor={htmlFor}>{label}</label>
</StyledItem>
);
};
const DropdownEllipsis = () => <SvgIcon icon={IconEnum.ellipsesCircle} />;
// Section Expansion & Filtering
export const Sidebar: FunctionComponent<SidebarProps> = ({ jsonData, onClick }) => {
// Section Visibility
const [infoSec, setInfoSec] = useToggle(false);
const [pathsVisible, setPathsVisible] = useToggle(true);
const [serversVisible, setServersVisible] = useToggle(true);
const [requestsVisible, setRequestsVisible] = useToggle(true);
const [responsesVisible, setResponsesVisible] = useToggle(true);
const [parametersVisible, setParametersVisible] = useToggle(true);
const [headersVisible, setHeadersVisible] = useToggle(true);
const [schemasVisible, setSchemasVisible] = useToggle(true);
const [securityVisible, setSecurityVisible] = useToggle(true);
// Sections
if (jsonData === null) {
return null;
}
const {
servers,
info,
paths,
} = jsonData || {};
const {
requestBodies,
responses,
parameters,
headers,
schemas,
securitySchemes,
} = jsonData?.components || {};
return (
<StyledSidebar className="theme--sidebar">
{info && (
<StyledSection>
<SidebarHeader headerTitle="INFO" sectionVisible={infoSec} toggleSection={setInfoSec}>
<Dropdown
aria-label='Info Dropdown'
closeOnSelect={false}
triggerButton={
<DropdownButton>
<DropdownEllipsis />
</DropdownButton>
}
>
<DropdownSection
aria-label='Visibility section'
title="VISIBILITY"
>
<DropdownItem aria-label='Servers'>
<ItemContent
stayOpenAfterClick
onClick={setServersVisible}
>
<ItemWrapper
checked={serversVisible}
htmlFor="servers"
label="Servers"
/>
</ItemContent>
</DropdownItem>
<DropdownItem aria-label='Paths'>
<ItemContent
stayOpenAfterClick
onClick={setPathsVisible}
>
<ItemWrapper
checked={pathsVisible}
htmlFor="paths"
label="Paths"
/>
</ItemContent>
</DropdownItem>
<DropdownItem aria-label='Requests'>
<ItemContent
stayOpenAfterClick
onClick={setRequestsVisible}
>
<ItemWrapper
checked={requestsVisible}
htmlFor="requests"
label="Requests"
/>
</ItemContent>
</DropdownItem>
<DropdownItem aria-label='Responses'>
<ItemContent
stayOpenAfterClick
onClick={setResponsesVisible}
>
<ItemWrapper
checked={responsesVisible}
htmlFor="responses"
label="Responses"
/>
</ItemContent>
</DropdownItem>
<DropdownItem aria-label='Parameters'>
<ItemContent
stayOpenAfterClick
onClick={setParametersVisible}
>
<StyledItem>
<input type="checkbox" checked={parametersVisible} readOnly />
<label htmlFor="parameters">Parameters</label>
</StyledItem>
</ItemContent>
</DropdownItem>
<DropdownItem aria-label='Headers'>
<ItemContent
stayOpenAfterClick
onClick={setHeadersVisible}
>
<ItemWrapper
checked={headersVisible}
htmlFor="headers"
label="Headers"
/>
</ItemContent>
</DropdownItem>
<DropdownItem aria-label='Schemas'>
<ItemContent
stayOpenAfterClick
onClick={setSchemasVisible}
>
<ItemWrapper
checked={schemasVisible}
htmlFor="schemas"
label="Schemas"
/>
</ItemContent>
</DropdownItem>
<DropdownItem aria-label='Security'>
<ItemContent
stayOpenAfterClick
onClick={setSecurityVisible}
>
<ItemWrapper
checked={securityVisible}
htmlFor="security"
label="Security"
/>
</ItemContent>
</DropdownItem>
</DropdownSection>
</Dropdown>
</SidebarHeader>
<SidebarInfo childrenVisible={infoSec} info={info} onClick={onClick} />
</StyledSection>
)}
{serversVisible && servers && <SidebarServers servers={servers} onClick={onClick} />}
{pathsVisible && paths && <SidebarPaths paths={paths} onClick={onClick} />}
{requestsVisible && requestBodies && (
<SidebarRequests requests={requestBodies} onClick={onClick} />
)}
{responsesVisible && responses && (
<SidebarResponses responses={responses} onClick={onClick} />
)}
{parametersVisible && parameters && (
<SidebarParameters parameters={parameters} onClick={onClick} />
)}
{headersVisible && headers && <SidebarHeaders headers={headers} onClick={onClick} />}
{schemasVisible && schemas && <SidebarSchemas schemas={schemas} onClick={onClick} />}
{securityVisible && securitySchemes && (
<SidebarSecurity security={securitySchemes} onClick={onClick} />
)}
</StyledSidebar>
);
};

View File

@ -1,88 +0,0 @@
import React, { FC } from 'react';
import styled from 'styled-components';
import YAML from 'yaml';
import YAMLSourceMap from 'yaml-source-map';
import type { ApiSpec } from '../../../models/api-spec';
import { useAIContext } from '../../context/app/ai-context';
import { InsomniaAI } from '../insomnia-ai-icon';
import { Button } from '../themed-button';
import { Sidebar } from './sidebar';
interface Props {
apiSpec: ApiSpec;
handleSetSelection: (chStart: number, chEnd: number, lineStart: number, lineEnd: number) => void;
}
const StyledSpecEditorSidebar = styled.div`
overflow: hidden;
overflow-y: auto;
`;
export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) => {
const {
generating: loading,
generateTestsFromSpec,
access,
} = useAIContext();
const onClick = (...itemPath: any[]): void => {
const scrollPosition = { start: { line: 0, col: 0 }, end: { line: 0, col: 200 } };
try {
JSON.parse(apiSpec.contents);
// Account for JSON (as string) line number shift
scrollPosition.start.line = 1;
} catch { }
const sourceMap = new YAMLSourceMap();
const specMap = sourceMap.index(
YAML.parseDocument(apiSpec.contents, {
keepCstNodes: true,
}),
);
const itemMappedPosition = sourceMap.lookup(itemPath, specMap);
if (itemMappedPosition) {
scrollPosition.start.line += itemMappedPosition.start.line;
}
const isServersSection = itemPath[0] === 'servers';
if (!isServersSection) {
scrollPosition.start.line -= 1;
}
scrollPosition.end.line = scrollPosition.start.line;
// NOTE: We're subtracting 1 from everything because YAML CST uses
// 1-based indexing and we use 0-based.
handleSetSelection(scrollPosition.start.col - 1, scrollPosition.end.col - 1, scrollPosition.start.line - 1, scrollPosition.end.line - 1);
};
const specJSON = YAML.parse(apiSpec.contents);
return (
<StyledSpecEditorSidebar>
<div>
{access.enabled && (
<Button
variant="text"
disabled={loading}
style={{
width: '100%',
justifyContent: 'flex-start!important',
gap: 'var(--padding-xs)',
}}
onClick={generateTestsFromSpec}
>
<InsomniaAI
style={{
flex: '0 0 20px',
}}
/>
<span>
Auto-generate Tests For Collection
</span>
</Button>
)}
</div>
<Sidebar jsonData={specJSON} onClick={onClick} />
</StyledSpecEditorSidebar>
);
};

File diff suppressed because it is too large Load Diff