From ea77b6d6636c43fb6fe7b3015102834ac77c847e Mon Sep 17 00:00:00 2001 From: James Gatz Date: Mon, 11 Sep 2023 16:56:17 +0200 Subject: [PATCH] Improve UI for design view (#6476) * design ui * preview toggle * handle spec errors and fix test * aria labels * disabled items * fix lint test --- .../tests/smoke/git-sync.test.ts | 2 +- .../tests/smoke/openapi.test.ts | 1 + .../insomnia/src/ui/components/app-header.tsx | 94 -- .../src/ui/components/notice-table.tsx | 297 ----- .../components/spec-editor/sidebar/index.tsx | 17 - .../spec-editor/sidebar/sidebar-badge.tsx | 86 -- .../spec-editor/sidebar/sidebar-filter.tsx | 56 - .../spec-editor/sidebar/sidebar-header.tsx | 88 -- .../spec-editor/sidebar/sidebar-headers.tsx | 55 - .../spec-editor/sidebar/sidebar-info.tsx | 55 - .../sidebar/sidebar-invalid-section.tsx | 15 - .../spec-editor/sidebar/sidebar-item.tsx | 84 -- .../sidebar/sidebar-parameters.tsx | 55 - .../spec-editor/sidebar/sidebar-paths.tsx | 73 -- .../spec-editor/sidebar/sidebar-requests.tsx | 110 -- .../spec-editor/sidebar/sidebar-responses.tsx | 53 - .../spec-editor/sidebar/sidebar-schemas.tsx | 48 - .../spec-editor/sidebar/sidebar-section.tsx | 54 - .../spec-editor/sidebar/sidebar-security.tsx | 50 - .../spec-editor/sidebar/sidebar-servers.tsx | 54 - .../spec-editor/sidebar/sidebar-text-item.tsx | 22 - .../spec-editor/sidebar/sidebar.tsx | 258 ----- .../spec-editor/spec-editor-sidebar.tsx | 88 -- packages/insomnia/src/ui/routes/design.tsx | 1009 +++++++++++++---- 24 files changed, 804 insertions(+), 1920 deletions(-) delete mode 100644 packages/insomnia/src/ui/components/app-header.tsx delete mode 100644 packages/insomnia/src/ui/components/notice-table.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/index.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-badge.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-filter.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-header.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-headers.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-info.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-invalid-section.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-item.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-parameters.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-paths.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-requests.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-responses.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-schemas.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-section.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-security.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-servers.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-text-item.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar.tsx delete mode 100644 packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx diff --git a/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts index d7e55889d..582ce29a7 100644 --- a/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts @@ -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(); diff --git a/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts b/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts index 5b099773e..117feb355 100644 --- a/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/openapi.test.ts @@ -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"'); }); diff --git a/packages/insomnia/src/ui/components/app-header.tsx b/packages/insomnia/src/ui/components/app-header.tsx deleted file mode 100644 index c407a5948..000000000 --- a/packages/insomnia/src/ui/components/app-header.tsx +++ /dev/null @@ -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 = ({ className, gridLeft, gridCenter, gridRight }) => ( - -
{gridLeft}
-
{gridCenter}
-
{gridRight}
-
-); - -Header.displayName = 'Header'; - -export const AppHeader: FC = ({ - gridCenter, - gridRight, -}) => { - return ( -
- - - - {!session.isLoggedIn() ? : null} - - )} - gridCenter={gridCenter} - gridRight={ - - {gridRight} - - } - /> - ); -}; diff --git a/packages/insomnia/src/ui/components/notice-table.tsx b/packages/insomnia/src/ui/components/notice-table.tsx deleted file mode 100644 index 6c2c00108..000000000 --- a/packages/insomnia/src/ui/components/notice-table.tsx +++ /dev/null @@ -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` - 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; - } - } - }`} -`; - -/********/ -/* */ -/********/ -export const TableRow = styled.tr``; - -/********/ -/* */ -/********/ -export interface TableDataProps { - compact?: boolean; - align?: 'center' | 'left'; -} - -export const TableData = styled.td` - 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` - 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 { - 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 = ({ - notice, - onClick: propsOnClick, -}: PropsWithChildren<{ - notice: T; - onClick: (notice: T) => void; -}>) => { - const onClick = useCallback(() => { - propsOnClick?.(notice); - }, [notice, propsOnClick]); - - return ( - - - - - - {notice.line} - - - - - {notice.message} - - ); -}; - -export const NoticeTable = ({ - notices, - compact, - onClick, - onVisibilityToggle, - toggleRightPane, -}: PropsWithChildren>) => { - 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 ( - -
-
- {errors.length > 0 && ( - - - - )} - {warnings.length > 0 && ( - - - - )} -
- - -
- {!collapsed && ( - - - - - Type - - Line - - - Message - - - - - {notices.map(notice => ( - - ))} - -
-
- )} -
- ); -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/index.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/index.tsx deleted file mode 100644 index e5c68589c..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/index.tsx +++ /dev/null @@ -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'; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-badge.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-badge.tsx deleted file mode 100644 index b574a7bc2..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-badge.tsx +++ /dev/null @@ -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 = ({ - onClick = () => {}, - method = 'post', - label = method, -}) => { - return ( - - {label} - - ); -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-filter.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-filter.tsx deleted file mode 100644 index 23b448da3..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-filter.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { createRef, FunctionComponent, useLayoutEffect } from 'react'; -import styled from 'styled-components'; - -export interface SidebarFilterProps { - filter: boolean; - onChange?: React.ChangeEventHandler; -} - -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 = ({ filter, onChange }) => { - const filterField = createRef(); - - useLayoutEffect(() => { - if (filterField.current && !filter) { - filterField.current.value = ''; - } else if (filterField.current) { - filterField.current.focus(); - } - }, [filter, filterField]); - - return ( - - - - ); -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-header.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-header.tsx deleted file mode 100644 index b4ad19d1b..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-header.tsx +++ /dev/null @@ -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; - 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 = ({ - headerTitle, - toggleSection, - toggleFilter, - sectionVisible, - children, -}) => { - const handleFilterClick: React.MouseEventHandler | 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 ( - -
{headerTitle}
-
- {children || ( - - - - )} -
-
- ); -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-headers.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-headers.tsx deleted file mode 100644 index f75012f78..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-headers.tsx +++ /dev/null @@ -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; - 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 { - renderBody = (filter: string): null | ReactNode => { - const { headers, onClick } = this.props; - - if (Object.prototype.toString.call(headers) !== '[object Object]') { - return ; - } - - const filteredValues = Object.keys(headers).filter(header => - header.toLowerCase().includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {filteredValues.map(header => ( - - onClick('components', 'headers', header)}> -
- -
- - - {header} - - -
-
- ))} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-info.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-info.tsx deleted file mode 100644 index 75f6635da..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-info.tsx +++ /dev/null @@ -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 = ({ - info: { - title, - description, - version, - license, - }, - childrenVisible, - onClick, -}) => { - return ( -
- {title && ( - onClick('info', 'title')}> - - - )} - {description && ( - onClick('info', 'description')}> - - - )} - {version && ( - onClick('info', 'version')}> - - - )} - {license && license.name && ( - onClick('info', 'license')}> - - - )} -
- ); -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-invalid-section.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-invalid-section.tsx deleted file mode 100644 index d0ed7343e..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-invalid-section.tsx +++ /dev/null @@ -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 = ({ name }) => ( - Error: Invalid {name} specification. -); diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-item.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-item.tsx deleted file mode 100644 index 6ee8a7e68..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-item.tsx +++ /dev/null @@ -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 = ({ children, gridLayout, onClick }) => { - if (gridLayout) { - return {children}; - } else { - return {children}; - } -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-parameters.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-parameters.tsx deleted file mode 100644 index 9f449b2e4..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-parameters.tsx +++ /dev/null @@ -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; - 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 { - renderBody = (filter: string): null | ReactNode => { - const { parameters, onClick } = this.props; - - if (Object.prototype.toString.call(parameters) !== '[object Object]') { - return ; - } - - const filteredValues = Object.keys(parameters).filter(parameter => - parameter.toLowerCase().includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {filteredValues.map(parameter => ( - - onClick('components', 'parameters', parameter)}> -
- -
- - - {parameter} - - -
-
- ))} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-paths.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-paths.tsx deleted file mode 100644 index 7edca053a..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-paths.tsx +++ /dev/null @@ -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; - -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 { - 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 ; - } - - // @ts-expect-error TSCONVERSION - const filteredValues = pathItems.filter(pathDetail => - pathDetail[0].toLowerCase().includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {// @ts-expect-error TSCONVERSION - filteredValues.map(([route, routeBody]) => ( - - onClick('paths', route)}> -
- -
- {route} -
- - {Object.keys(routeBody) - .filter(isNotXDashKey) - .map(method => ( - onClick('paths', route, method)} - /> - ))} - -
- ))} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-requests.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-requests.tsx deleted file mode 100644 index 961c6411c..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-requests.tsx +++ /dev/null @@ -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; - 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 { - renderBody = (filter: string): null | ReactNode => { - const { requests, onClick } = this.props; - - if (Object.prototype.toString.call(requests) !== '[object Object]') { - return ; - } - - const filteredValues = Object.keys(requests).filter(requestName => - requestName.toLowerCase().includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {filteredValues.map(requestName => { - const { description, content } = requests[requestName]; - return ( - - onClick('components', 'requestBodies', requestName)} - > -
- -
- - - {requestName} - - -
- {Object.keys(content).map(requestFormat => ( - - - - - - onClick( - 'components', - 'requestBodies', - requestName, - 'content', - requestFormat, - ) - } - > - {requestFormat} - - - - {content[requestFormat].examples && ( - - {Object.keys(content[requestFormat].examples).map(requestExample => ( - - onClick( - 'components', - 'requestBodies', - requestName, - 'content', - requestFormat, - 'examples', - requestExample, - ) - } - /> - ))} - - )} - - ))} -
- ); - })} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-responses.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-responses.tsx deleted file mode 100644 index 890200808..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-responses.tsx +++ /dev/null @@ -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; - 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 { - renderBody = (filter: string): null | ReactNode => { - const { responses, onClick } = this.props; - - if (Object.prototype.toString.call(responses) !== '[object Object]') { - return ; - } - - const filteredValues = Object.keys(responses).filter(response => - response.toLowerCase().includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {filteredValues.map(response => ( - onClick('components', 'responses', response)}> -
- -
- - - {response} - - -
- ))} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-schemas.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-schemas.tsx deleted file mode 100644 index db18dabbf..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-schemas.tsx +++ /dev/null @@ -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; - 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 { - renderBody = (filter: string): null | ReactNode => { - const { schemas, onClick } = this.props; - - if (Object.prototype.toString.call(schemas) !== '[object Object]') { - return ; - } - - const filteredValues = Object.keys(schemas).filter(schema => - schema.toLowerCase().includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {filteredValues.map(schema => ( - onClick('components', 'schemas', schema)}> -
- -
- {schema} -
- ))} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-section.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-section.tsx deleted file mode 100644 index ba72feedc..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-section.tsx +++ /dev/null @@ -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 = ({ title, renderBody }) => { - const [bodyVisible, toggleBodyVisible] = useToggle(false); - const [filterVisible, toggleFilterVisible] = useToggle(false); - const [filterValue, setFilterValue] = useState(''); - - const handleFilterChange = useCallback((event: ChangeEvent) => { - setFilterValue(event.target.value); - }, []); - - useLayoutEffect(() => { - toggleFilterVisible(false); - setFilterValue(''); - }, [bodyVisible, toggleFilterVisible]); - - return ( - - -
- - {renderBody(filterValue) || ( - No results found for "{filterValue}"... - )} -
-
- ); -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-security.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-security.tsx deleted file mode 100644 index c6420680e..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-security.tsx +++ /dev/null @@ -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; - 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 { - renderBody = (filter: string): null | ReactNode => { - const { security, onClick } = this.props; - - if (Object.prototype.toString.call(security) !== '[object Object]') { - return ; - } - - const filteredValues = Object.keys(security).filter(scheme => - scheme.toLowerCase().includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {filteredValues.map(scheme => ( - - onClick('components', 'securitySchemes', scheme)}> -
- -
- {scheme} -
-
- ))} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-servers.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-servers.tsx deleted file mode 100644 index 3d4094bcb..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-servers.tsx +++ /dev/null @@ -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 { - renderBody = (filter: string): null | ReactNode => { - const { servers, onClick } = this.props; - - if (!Array.isArray(servers)) { - return ; - } - - const filteredValues = servers.filter(server => - server.url.includes(filter.toLocaleLowerCase()), - ); - - if (!filteredValues.length) { - return null; - } - - return ( -
- {filteredValues.map((server, index) => ( - - onClick('servers', index)}> -
- -
- {server.url} -
-
- ))} -
- ); - }; - - render() { - return ; - } -} diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-text-item.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-text-item.tsx deleted file mode 100644 index 7ffa7a95f..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar-text-item.tsx +++ /dev/null @@ -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 = ({ label, headline }) => ( - - {label} - {headline} - -); diff --git a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar.tsx b/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar.tsx deleted file mode 100644 index 9189654bb..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/sidebar/sidebar.tsx +++ /dev/null @@ -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; - responses?: Record; - parameters?: Record; - headers?: Record; - schemas?: Record; - securitySchemes?: Record; - }; - }; - pathItems?: Record[]; -} - -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 ( - - - - - ); -}; - -const DropdownEllipsis = () => ; - -// Section Expansion & Filtering -export const Sidebar: FunctionComponent = ({ 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 ( - - {info && ( - - - - - - } - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - )} - {serversVisible && servers && } - {pathsVisible && paths && } - {requestsVisible && requestBodies && ( - - )} - {responsesVisible && responses && ( - - )} - {parametersVisible && parameters && ( - - )} - {headersVisible && headers && } - {schemasVisible && schemas && } - {securityVisible && securitySchemes && ( - - )} - - ); -}; diff --git a/packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx b/packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx deleted file mode 100644 index a8184aad2..000000000 --- a/packages/insomnia/src/ui/components/spec-editor/spec-editor-sidebar.tsx +++ /dev/null @@ -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 = ({ 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 ( - -
- {access.enabled && ( - - )} -
- -
- ); -}; diff --git a/packages/insomnia/src/ui/routes/design.tsx b/packages/insomnia/src/ui/routes/design.tsx index 9cb3dcf3b..d7ff1f88f 100644 --- a/packages/insomnia/src/ui/routes/design.tsx +++ b/packages/insomnia/src/ui/routes/design.tsx @@ -1,17 +1,40 @@ import type { IRuleResult } from '@stoplight/spectral-core'; import CodeMirror from 'codemirror'; import { stat } from 'fs/promises'; +import { OpenAPIV3 } from 'openapi-types'; import path from 'path'; -import React, { createRef, FC, Fragment, useCallback, useEffect, useMemo } from 'react'; +import React, { + createRef, + FC, + Fragment, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { + Button, + GridList, + Heading, + Item, + ListBox, + Menu, + MenuTrigger, + Popover, + ToggleButton, + Tooltip, + TooltipTrigger, +} from 'react-aria-components'; import { LoaderFunction, useFetcher, useLoaderData, useParams, } from 'react-router-dom'; -import { useToggle } from 'react-use'; -import styled from 'styled-components'; import { SwaggerUIBundle } from 'swagger-ui-dist'; +import YAML from 'yaml'; +import YAMLSourceMap from 'yaml-source-map'; import { parseApiSpec } from '../../common/api-specs'; import { ACTIVITY_SPEC } from '../../common/constants'; @@ -25,57 +48,21 @@ import { } from '../components/codemirror/code-editor'; import { DesignEmptyState } from '../components/design-empty-state'; import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dropdown'; -import { ErrorBoundary } from '../components/error-boundary'; -import { Notice, NoticeTable } from '../components/notice-table'; +import { Icon } from '../components/icon'; +import { InsomniaAI } from '../components/insomnia-ai-icon'; import { SidebarLayout } from '../components/sidebar-layout'; -import { SpecEditorSidebar } from '../components/spec-editor/spec-editor-sidebar'; -import { Tooltip } from '../components/tooltip'; +import { formatMethodName } from '../components/tags/method-tag'; +import { useAIContext } from '../context/app/ai-context'; import { useActiveApiSpecSyncVCSVersion, useGitVCSVersion, } from '../hooks/use-vcs-version'; -const EmptySpaceHelper = styled.div({ - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'center', - padding: '2em', - textAlign: 'center', - opacity: 'calc(var(--opacity-subtle) * 0.8)', -}); - -export const Toolbar = styled.div({ - boxSizing: 'content-box', - position: 'sticky', - top: 0, - zIndex: 1, - backgroundColor: 'var(--color-bg)', - display: 'flex', - justifyContent: 'space-between', - 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%', - }, -}); - -const RulesetLabel = styled.div({ - display: 'flex', - alignItems: 'center', - padding: 'var(--padding-md)', - height: '100%', - boxSizing: 'border-box', - gap: 'var(--padding-sm)', - color: 'var(--hl)', -}); interface LoaderData { lintMessages: LintMessage[]; apiSpec: ApiSpec; rulesetPath: string; + parsedSpec?: OpenAPIV3.Document; } export const loader: LoaderFunction = async ({ @@ -97,7 +84,7 @@ export const loader: LoaderFunction = async ({ try { const spectralRulesetPath = path.join( process.env['INSOMNIA_DATA_PATH'] || window.app.getPath('userData'), - `version-control/git/${workspaceMeta?.gitRepositoryId}/other/.spectral.yaml`, + `version-control/git/${workspaceMeta?.gitRepositoryId}/other/.spectral.yaml` ); if ((await stat(spectralRulesetPath)).isFile()) { @@ -109,26 +96,35 @@ export const loader: LoaderFunction = async ({ if (apiSpec.contents && apiSpec.contents.length !== 0) { try { - lintMessages = (await window.main.spectralRun({ - contents: apiSpec.contents, - rulesetPath, - })) - .map(({ severity, code, message, range }) => ({ - type: (['error', 'warning'][severity] ?? 'info') as Notice['type'], - message: `${code} ${message}`, - line: range.start.line, - // Attach range that will be returned to our click handler - range, - })); + lintMessages = ( + await window.main.spectralRun({ + contents: apiSpec.contents, + rulesetPath, + }) + ).map(({ severity, code, message, range }) => ({ + type: (['error', 'warning'][severity] ?? 'info') as LintMessage['type'], + message: `${code} ${message}`, + line: range.start.line, + range, + })); } catch (e) { console.log('Error linting spec', e); } } + let parsedSpec: OpenAPIV3.Document | undefined; + + try { + parsedSpec = YAML.parse(apiSpec.contents) as OpenAPIV3.Document; + } catch (error) { + console.log('Error parsing spec', error); + } + return { lintMessages, apiSpec, rulesetPath, + parsedSpec, }; }; @@ -137,35 +133,83 @@ const SwaggerUIDiv = ({ text }: { text: string }) => { let spec = {}; try { spec = parseApiSpec(text).contents || {}; - } catch (err) { } + } catch (err) {} SwaggerUIBundle({ spec, dom_id: '#swagger-ui' }); }, [text]); - return
; + return ( +
+ ); }; -interface LintMessage extends Notice { +interface LintMessage { + type: 'error' | 'warning' | 'info'; + message: string; + line: number; range: IRuleResult['range']; } +interface SpecActionItem { + id: string; + name: string; + icon: ReactNode; + isDisabled?: boolean; + action: () => void; +} + +const getMethodsFromOpenApiPathItem = ( + pathItem: OpenAPIV3.PathItemObject +): string[] => { + const OpenApiV3Methods = [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', + ] satisfies (keyof OpenAPIV3.PathItemObject)[]; + + const methods = OpenApiV3Methods.filter(method => pathItem[method]); + + return methods; +}; + const Design: FC = () => { const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; workspaceId: string; }; - const { apiSpec, lintMessages, rulesetPath } = useLoaderData() as LoaderData; + const { apiSpec, lintMessages, rulesetPath, parsedSpec } = useLoaderData() as LoaderData; const editor = createRef(); - + const { generating, generateTestsFromSpec, access } = useAIContext(); const updateApiSpecFetcher = useFetcher(); const generateRequestCollectionFetcher = useFetcher(); - const [showRightPane, toggleRightPane] = useToggle(true); + const [isLintPaneOpen, setIsLintPaneOpen] = useState(false); + const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(true); + + const { components, info, servers, paths } = parsedSpec || {}; + const { + requestBodies, + responses, + parameters, + headers, + schemas, + securitySchemes, + } = components || {}; + + const lintErrors = lintMessages.filter(message => message.type === 'error'); + const lintWarnings = lintMessages.filter( + message => message.type === 'warning' + ); useEffect(() => { CodeMirror.registerHelper('lint', 'openapi', async (contents: string) => { @@ -175,7 +219,10 @@ const Design: FC = () => { }); return diagnostics.map(result => ({ - from: CodeMirror.Pos(result.range.start.line, result.range.start.character), + from: CodeMirror.Pos( + result.range.start.line, + result.range.start.character + ), to: CodeMirror.Pos(result.range.end.line, result.range.end.character), message: result.message, severity: ['error', 'warning'][result.severity] ?? 'info', @@ -228,157 +275,705 @@ const Design: FC = () => { [editor] ); + const [expandedKeys, setExpandedKeys] = useState([]); + + const navigateToPath = (path: string): void => { + const pathSegments = path.split('.'); + 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(pathSegments, specMap); + if (itemMappedPosition) { + scrollPosition.start.line += itemMappedPosition.start.line; + } + const isServersSection = pathSegments[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. + handleScrollToSelection( + scrollPosition.start.col - 1, + scrollPosition.end.col - 1, + scrollPosition.start.line - 1, + scrollPosition.end.line - 1 + ); + }; + + const specActionList: SpecActionItem[] = [ + { + id: 'ai-generate-tests-in-collection', + name: 'Generate tests', + action: generateTestsFromSpec, + isDisabled: !access.enabled || generating, + icon: , + }, + { + id: 'generate-request-collection', + name: 'Generate requests from spec', + icon: , + isDisabled: + !apiSpec.contents || + lintErrors.length > 0 || + generateRequestCollectionFetcher.state !== 'idle', + action: () => + generateRequestCollectionFetcher.submit( + {}, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/generate-request-collection`, + method: 'POST', + } + ), + }, + { + id: 'toggle-preview', + name: 'Toggle preview', + icon: , + action: () => setIsSpecPaneOpen(!isSpecPaneOpen), + }, + ]; + + const disabledKeys = specActionList + .filter(item => item.isDisabled) + .map(item => item.id); + const gitVersion = useGitVCSVersion(); const syncVersion = useActiveApiSpecSyncVCSVersion(); const uniquenessKey = `${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${syncVersion}`; 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. -

+
+
+ Spec + + + {({ isSelected }) => ( + <> + + Preview + + )} + + + + + { + const item = specActionList.find( + item => item.id === key + ); + if (item) { + item.action(); + } + }} + items={specActionList} + className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none" + > + {item => ( + + {item.icon} + {item.name} + + )} + + + +
+
+ {/* Info */} + {info && ( +
+ + {/* Info */} + {expandedKeys.includes('info') && ( + navigateToPath(key.toString())}> + + Title: {info.title} + + + + Description: {info.description} + + + + Version: {info.version} + + + + License: {info.license?.name} + + + + )}
)} - > - -
- -
- - ) : ( - - A spec navigator will render here -
- -
-
- ) + {/* Servers */} + {servers && ( +
+
+ +
+ {expandedKeys.includes('servers') && ( + ({ + path: index, + ...server, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.url} + + )} + + )} +
+ )} + {/* Paths */} + {paths && ( +
+
+ +
+ {expandedKeys.includes('paths') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + +
+ {item.path} + + {getMethodsFromOpenApiPathItem(item).map(method => ( + + ))} +
+
+ )} +
+ )} +
+ )} + {/* RequestBodies */} + {requestBodies && ( +
+
+ +
+ {expandedKeys.includes('requestBodies') && ( + ({ + ...item, + id: path, + path, + }) + )} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Responses */} + {responses && ( +
+
+ +
+ {expandedKeys.includes('responses') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Parameters */} + {parameters && ( +
+
+ +
+ {expandedKeys.includes('parameters') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Headers */} + {headers && ( +
+
+ +
+ {expandedKeys.includes('headers') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Schemas */} + {schemas && ( +
+
+ +
+ {expandedKeys.includes('schemas') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Security */} + {securitySchemes && ( +
+
+ +
+ {expandedKeys.includes('security') && ( + ({ + ...item, + id: path, + path, + }) + )} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} +
+ +
} - renderPaneTwo={showRightPane && } + renderPaneTwo={isSpecPaneOpen && } 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 : ( + { + updateApiSpecFetcher.submit( + { + contents: value, + fromSync: 'true', + }, + { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}/update`, + method: 'post', + } + ); + }} /> )} - {apiSpec.contents ? ( - - { - -

- Using ruleset from -

- {rulesetPath} - - ) : ( - -

Using default OAS ruleset.

-

- To use a custom ruleset add a{' '} - .spectral.yaml file to the root of your - git repository -

-
- ) - } - > - - {' '} - Ruleset - -
- } - -
- ) : null}
- ) : null +
+
+ + + +
+ {rulesetPath ? ( + +

Using ruleset from

+ {rulesetPath} +
+ ) : ( + +

Using default OAS ruleset.

+

+ To use a custom ruleset add a{' '} + .spectral.yaml file to + the root of your git repository +

+
+ )} +
+
+
+ {lintErrors.length > 0 && ( +
+ + {lintErrors.length} +
+ )} + {lintWarnings.length > 0 && ( +
+ + {lintWarnings.length} +
+ )} + {lintMessages.length === 0 && apiSpec.contents && ( +
+ + No lint problems +
+ )} + + {lintMessages.length > 0 && ( + + )} +
+ {isLintPaneOpen && ( + { + const listIndex = parseInt(index.toString(), 10); + const lintMessage = lintMessages[listIndex]; + handleScrollToLintMessage(lintMessage); + }} + items={lintMessages.map((message, index) => ({ + ...message, + id: index, + value: message, + }))} + > + {item => ( + + + {item.message} + + [Ln {item.line}] + + + )} + + )} +
+
} /> );