Improve UI for test view (#6477)

* test-results

* cleanup

* improve types

* fix some stuff
This commit is contained in:
James Gatz
2023-09-11 17:55:46 +02:00
committed by GitHub
parent ea77b6d663
commit 90b4a875b4
14 changed files with 625 additions and 744 deletions

View File

@@ -1,3 +1,5 @@
import type { TestResults } from 'insomnia-testing';
import { database as db } from '../common/database';
import type { BaseModel } from './index';
@@ -12,7 +14,7 @@ export const canDuplicate = false;
export const canSync = false;
export interface BaseUnitTestResult {
results: Record<string, any>;
results: TestResults;
}
export type UnitTestResult = BaseModel & BaseUnitTestResult;

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react';
import { FocusScope } from 'react-aria';
import { Button, Input } from 'react-aria-components';
export const EditableInput = ({
value,
ariaLabel,
name,
onChange,
}: {
value: string;
ariaLabel?: string;
name?: string;
onChange: (value: string) => void;
}) => {
const [isEditable, setIsEditable] = useState(false);
useEffect(() => {
if (!isEditable) {
return;
}
const keysToLock = [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Tab',
' ',
];
function lockKeyDownToInput(e: KeyboardEvent) {
if (keysToLock.includes(e.key)) {
e.stopPropagation();
}
}
window.addEventListener('keydown', lockKeyDownToInput, { capture: true });
return () => {
window.removeEventListener('keydown', lockKeyDownToInput, {
capture: true,
});
};
}, [isEditable]);
return (
<>
<Button
className={`items-center truncate justify-center px-2 data-[pressed]:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all ${
isEditable ? 'hidden' : ''
}`}
onPress={() => {
setIsEditable(true);
}}
name={name}
aria-label={ariaLabel}
value={value}
>
<span className="truncate">{value}</span>
</Button>
{isEditable && (
<FocusScope contain restoreFocus autoFocus>
<Input
className="px-2 truncate"
name={name}
aria-label={ariaLabel}
defaultValue={value}
onKeyDown={e => {
const value = e.currentTarget.value;
if (e.key === 'Enter') {
e.stopPropagation();
onChange(value);
setIsEditable(false);
}
if (e.key === 'Escape') {
e.stopPropagation();
setIsEditable(false);
}
}}
onBlur={e => {
const value = e.currentTarget.value;
onChange(value);
setIsEditable(false);
}}
/>
</FocusScope>
)}
</>
);
};

View File

@@ -1,7 +0,0 @@
export { ListGroupItem, type ListGroupItemProps } from './list-group-item';
export { ListGroup, type ListGroupProps } from './list-group';
export { UnitTestItem, type UnitTestItemProps, type TestItem } from './unit-test-item';
export { UnitTestRequestSelector, type UnitTestRequestSelectorProps } from './unit-test-request-selector';
export { UnitTestResultBadge, type UnitTestResultBadgeProps } from './unit-test-result-badge';
export { UnitTestResultItem, type UnitTestResultItemProps } from './unit-test-result-item';
export { UnitTestResultTimestamp, type UnitTestResultTimestampProps } from './unit-test-result-timestamp';

View File

@@ -1,36 +0,0 @@
import React, { FC } from 'react';
import styled, { css } from 'styled-components';
export interface ListGroupItemProps extends React.LiHTMLAttributes<HTMLLIElement> {
isSelected?: boolean;
selectable?: boolean;
indentLevel?: number;
}
const StyledListGroupItem = styled.li<ListGroupItemProps>`
border-bottom: 1px solid var(--hl-xs);
padding: var(--padding-sm) var(--padding-sm);
${({ selectable }) =>
selectable &&
css`
&:hover {
background-color: var(--hl-sm) !important;
}
`}
${({ isSelected }) =>
isSelected &&
css`
background-color: var(--hl-xs) !important;
font-weight: bold;
`}
${({ indentLevel }) =>
indentLevel &&
css`
padding-left: calc(var(--padding-sm) + var(--padding-md) * ${indentLevel});
`};
`;
export const ListGroupItem: FC<ListGroupItemProps> = props => <StyledListGroupItem {...props} />;

View File

@@ -1,20 +0,0 @@
import { ReactNode } from 'react';
import styled from 'styled-components';
export interface ListGroupProps {
children?: ReactNode;
bordered?: boolean;
}
export const ListGroup = styled.ul<ListGroupProps>`
list-style-type: none;
margin: 0;
padding: 0;
${({ bordered }) =>
bordered &&
`border: 1px solid var(--hl-sm);
border-radius: var(--radius-sm);
li:last-of-type {border-bottom:none;};
`}
`;

View File

@@ -1,118 +0,0 @@
import React, { FunctionComponent, ReactNode } from 'react';
import useToggle from 'react-use/lib/useToggle';
import styled from 'styled-components';
import { Request } from '../../../models/request';
import { SvgIcon } from '../svg-icon';
import { Button } from '../themed-button';
import { ListGroupItem } from './list-group-item';
import { UnitTestRequestSelector } from './unit-test-request-selector';
export interface TestItem {
_id: string;
}
export interface UnitTestItemProps {
item: TestItem;
children?: ReactNode;
onDeleteTest?: () => void;
onRunTest?: () => void;
testNameEditable?: ReactNode;
testsRunning?: boolean;
onSetActiveRequest: React.ChangeEventHandler<HTMLSelectElement>;
selectedRequestId?: string | null;
selectableRequests: Request[];
}
const StyledResultListItem = styled(ListGroupItem)`
&& {
padding: 0 var(--padding-sm);
> div:first-of-type {
display: flex;
flex-direction: row;
align-items: flex-start;
align-items: center;
}
svg {
fill: var(--hl-xl);
}
h2 {
font-size: var(--font-size-md);
font-weight: var(--font-weight-normal);
margin: 0px;
}
button {
padding: 0px var(--padding-sm);
}
}
`;
const StyledUnitTestContent = styled.div`
display: block;
height: 0px;
overflow: hidden;
`;
export const UnitTestItem: FunctionComponent<UnitTestItemProps> = ({
children,
onDeleteTest,
onRunTest,
testNameEditable,
testsRunning,
onSetActiveRequest,
selectedRequestId,
selectableRequests,
}) => {
const [isToggled, toggle] = useToggle(false);
const toggleIconRotation = -90;
return (
<StyledResultListItem>
<div>
<Button
onClick={toggle}
variant="text"
style={isToggled ? {} : { transform: `rotate(${toggleIconRotation}deg)` }}
>
<SvgIcon icon="chevron-down" />
</Button>
<Button variant="text" disabled>
<SvgIcon icon="file" />
</Button>
<h2>{testNameEditable}</h2>
<UnitTestRequestSelector
selectedRequestId={selectedRequestId}
selectableRequests={selectableRequests}
onSetActiveRequest={onSetActiveRequest}
/>
<Button variant="text" onClick={onDeleteTest}>
<SvgIcon icon="trashcan" />
</Button>
<Button
variant="text"
onClick={onRunTest}
disabled={testsRunning}
>
<SvgIcon icon="play" />
</Button>
</div>
<StyledUnitTestContent
style={{
height: isToggled ? '100%' : '0px',
}}
>
{isToggled && <div>{children}</div>}
</StyledUnitTestContent>
</StyledResultListItem>
);
};

View File

@@ -1,59 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
import { Request } from '../../../models/request';
export interface UnitTestRequestSelectorProps {
onSetActiveRequest: React.ChangeEventHandler<HTMLSelectElement>;
selectedRequestId?: string | null;
selectableRequests: Request[];
}
const StyledUnitTestRequestSelector = styled.div`
padding: 0 var(--padding-sm);
border: 1px solid var(--hl-md);
border-radius: var(--radius-sm);
margin: var(--padding-sm) var(--padding-sm) var(--padding-sm) auto;
max-width: 18rem;
flex: 0 0 auto;
select {
max-width: 18rem;
height: var(--line-height-xs);
border: none !important;
background: none !important;
color: var(--font-color);
&:focus {
outline: 0;
}
}
* {
margin: 0px;
}
`;
export const UnitTestRequestSelector: FunctionComponent<UnitTestRequestSelectorProps> = ({
onSetActiveRequest,
selectedRequestId,
selectableRequests,
}) => {
return (
<StyledUnitTestRequestSelector>
<select
name="request"
id="request"
onChange={onSetActiveRequest}
defaultValue={selectedRequestId || '__NULL__'}
>
<option value="__NULL__">
{selectableRequests.length ? '-- Select Request --' : '-- No Requests --'}
</option>
{selectableRequests.map(({ name, _id }) => (
<option key={_id} value={_id}>
{name}
</option>
))}
</select>
</StyledUnitTestRequestSelector>
);
};

View File

@@ -1,39 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
export interface UnitTestResultBadgeProps {
failed?: boolean;
}
const StyledBadge = styled.span`
padding: var(--padding-xs) var(--padding-sm);
border: 1px solid var(--color-success);
background-color: var(--color-bg);
color: var(--color-success);
font-weight: var(--font-weight-bold);
border-radius: var(--radius-sm);
flex-basis: 3.5em;
flex-shrink: 0;
text-align: center;
text-transform: capitalize;
`;
const StyledFailedBadge = styled(StyledBadge)`
&& {
border-color: var(--color-danger);
color: var(--color-danger);
}
`;
const StyledPassedBadge = styled(StyledBadge)`
&& {
border-color: var(--color-success);
color: var(--color-success);
}
`;
export const UnitTestResultBadge: FunctionComponent<UnitTestResultBadgeProps> = ({ failed }) => failed ? (
<StyledFailedBadge>Failed</StyledFailedBadge>
) : (
<StyledPassedBadge>Passed</StyledPassedBadge>
);

View File

@@ -1,65 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
import { ListGroupItem } from './list-group-item';
import { UnitTestResultBadge } from './unit-test-result-badge';
import { UnitTestResultTimestamp } from './unit-test-result-timestamp';
export interface UnitTestResultItemProps {
item: {
duration: string;
err?: {
message: string;
};
title: string;
};
}
const StyledResultListItem = styled(ListGroupItem)`
&& {
> div:first-of-type {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
> *:not(:first-child) {
margin: var(--padding-xs) 0 var(--padding-xs) var(--padding-sm);
}
}
code {
background-color: var(--hl-xs);
padding: var(--padding-sm) var(--padding-md) var(--padding-sm) var(--padding-md);
color: var(--color-danger);
display: block;
margin: var(--padding-sm) 0 0 0;
}
p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
}
}
`;
export const UnitTestResultItem: FunctionComponent<UnitTestResultItemProps> = ({
item: {
err = {},
title,
duration,
},
}) => {
return (
<StyledResultListItem>
<div>
<UnitTestResultBadge failed={Boolean(err.message)} />
<p>{title}</p>
<UnitTestResultTimestamp timeMs={duration} />
</div>
{err.message && <code>{err.message}</code>}
</StyledResultListItem>
);
};

View File

@@ -1,33 +0,0 @@
import React, { FunctionComponent } from 'react';
import styled from 'styled-components';
import { SvgIcon } from '../svg-icon';
export interface UnitTestResultTimestampProps {
timeMs: String;
}
const StyledTimestamp = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
flex-shrink: 0;
font-size: var(--font-size-xs);
color: var(--hl-xl);
svg {
fill: var(--hl-xl);
margin-right: var(--padding-xxs);
}
`;
export const UnitTestResultTimestamp: FunctionComponent<UnitTestResultTimestampProps> = ({ timeMs }) => {
return (
<StyledTimestamp>
{' '}
<SvgIcon icon="clock" />
<div>{timeMs} ms</div>
</StyledTimestamp>
);
};

View File

@@ -3,7 +3,6 @@ import styled from 'styled-components';
import { ProtoDirectory } from '../../../models/proto-directory';
import type { ProtoFile } from '../../../models/proto-file';
import { ListGroup, ListGroupItem } from '../list-group';
import { Button } from '../themed-button';
export type SelectProtoFileHandler = (id: string) => void;
@@ -11,7 +10,7 @@ export type DeleteProtoFileHandler = (protofile: ProtoFile) => void;
export type DeleteProtoDirectoryHandler = (protoDirectory: ProtoDirectory) => void;
export type UpdateProtoFileHandler = (protofile: ProtoFile) => Promise<void>;
export type RenameProtoFileHandler = (protoFile: ProtoFile, name?: string) => Promise<void>;
export const ProtoListItem = styled(ListGroupItem).attrs(() => ({
export const ProtoListItem = styled('li').attrs(() => ({
className: 'row-spaced',
}))`
button i.fa {
@@ -42,10 +41,14 @@ const recursiveRender = (
handleUpdate: UpdateProtoFileHandler,
handleDelete: DeleteProtoFileHandler,
handleDeleteDirectory: DeleteProtoDirectoryHandler,
selectedId?: string,
): React.ReactNode => ([
selectedId?: string
): React.ReactNode => [
dir && (
<ProtoListItem indentLevel={indent}>
<ProtoListItem
style={{
paddingLeft: `${indent * 1}rem`,
}}
>
<span className="wide">
<i className="fa fa-folder-open-o pad-right-sm" />
{dir.name}
@@ -65,14 +68,12 @@ const recursiveRender = (
</Button>
</div>
)}
</ProtoListItem>),
</ProtoListItem>
),
...files.map(f => (
<ProtoListItem
key={f._id}
selectable
isSelected={f._id === selectedId}
onClick={() => handleSelect(f._id)}
indentLevel={indent + 1}
>
<>
<span className="wide">
@@ -106,29 +107,34 @@ const recursiveRender = (
</>
</ProtoListItem>
)),
...subDirs.map(sd => recursiveRender(
indent + 1,
sd,
handleSelect,
handleUpdate,
handleDelete,
handleDeleteDirectory,
selectedId,
))]);
...subDirs.map(sd =>
recursiveRender(
indent + 1,
sd,
handleSelect,
handleUpdate,
handleDelete,
handleDeleteDirectory,
selectedId
)
),
];
export const ProtoFileList: FunctionComponent<Props> = props => (
<ListGroup bordered>
<ul className="divide-y divide-solid divide-[--hl]">
{!props.protoDirectories.length && (
<ListGroupItem>No proto files exist for this workspace</ListGroupItem>
<li>No proto files exist for this workspace</li>
)}
{props.protoDirectories.map(dir => recursiveRender(
0,
dir,
props.handleSelect,
props.handleUpdate,
props.handleDelete,
props.handleDeleteDirectory,
props.selectedId
))}
</ListGroup>
{props.protoDirectories.map(dir =>
recursiveRender(
0,
dir,
props.handleSelect,
props.handleUpdate,
props.handleDelete,
props.handleDeleteDirectory,
props.selectedId
)
)}
</ul>
);

View File

@@ -1,11 +1,12 @@
import React, { FC } from 'react';
import { Heading } from 'react-aria-components';
import { LoaderFunction, redirect, useRouteLoaderData } from 'react-router-dom';
import { database } from '../../common/database';
import * as models from '../../models';
import { UnitTestResult } from '../../models/unit-test-result';
import { invariant } from '../../utils/invariant';
import { ListGroup, UnitTestResultItem } from '../components/list-group';
import { Icon } from '../components/icon';
interface TestResultsData {
testResult: UnitTestResult;
@@ -43,29 +44,63 @@ export const loader: LoaderFunction = async ({
export const TestRunStatus: FC = () => {
const { testResult } = useRouteLoaderData(':testResultId') as TestResultsData;
if (!testResult) {
return null;
}
const { stats, tests } = testResult.results;
return (
<div className="unit-tests__results">
{testResult && (
<div key={testResult._id}>
<div className="unit-tests__top-header">
{stats.failures ? (
<h2 className="warning">
Tests Failed {stats.failures}/{stats.tests}
</h2>
) : (
<h2 className="success">
Tests Passed {stats.passes}/{stats.tests}
</h2>
)}
</div>
<ListGroup>
{tests.map((t: any, i: number) => (
<UnitTestResultItem key={i} item={t} />
))}
</ListGroup>
</div>
)}
<div
key={testResult._id}
className="w-full flex-1 flex flex-col h-full divide-solid divide-y divide-[--hl-md]"
>
<Heading
className={`text-lg flex-shrink-0 flex items-center gap-2 w-full p-[--padding-md] ${
stats.failures > 0
? 'text-[--color-danger]'
: 'text-[--color-success]'
}`}
>
<Icon
icon={stats.failures > 0 ? 'exclamation-triangle' : 'check-square'}
/>
<span className="truncate">
{stats.failures > 0 ? 'Tests failed' : 'Tests passed'}{' '}
</span>
{stats.failures > 0 ? stats.failures : stats.passes}/{stats.tests}
</Heading>
<div
className="w-full flex-1 overflow-y-auto divide-solid divide-y divide-[--hl-md] flex flex-col"
aria-label="Test results"
>
{tests.map((test, i) => {
const errorMessage = 'message' in test.err ? test.err.message : '';
return (
<div key={i} className="flex flex-col">
<div className="flex gap-2 p-[--padding-sm] items-center">
<div className="flex flex-shrink-0">
<span
className={`w-20 flex-shrink-0 flex rounded-sm border border-solid border-current ${
errorMessage
? 'text-[--color-danger]'
: 'text-[--color-success]'
} items-center justify-center`}
>
{errorMessage ? 'Failed' : 'Passed'}
</span>
</div>
<div className="flex-1 truncate">{test.title}</div>
<div className="flex flex-shrink-0">{test.duration} ms</div>
</div>
{errorMessage && (
<div className="w-full px-[--padding-sm] pb-[--padding-sm]">
<code className="w-full">{errorMessage}</code>
</div>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,4 +1,13 @@
import React, { useRef } from 'react';
import React, { Fragment, useRef, useState } from 'react';
import {
Button,
Heading,
Item,
ListBox,
Popover,
Select,
SelectValue,
} from 'react-aria-components';
import {
LoaderFunction,
redirect,
@@ -6,7 +15,6 @@ import {
useParams,
useRouteLoaderData,
} from 'react-router-dom';
import styled from 'styled-components';
import { database } from '../../common/database';
import { documentationLinks } from '../../common/documentation';
@@ -15,34 +23,23 @@ import { isRequest, Request } from '../../models/request';
import { isUnitTest, UnitTest } from '../../models/unit-test';
import { UnitTestSuite } from '../../models/unit-test-suite';
import { invariant } from '../../utils/invariant';
import { Editable } from '../components/base/editable';
import { CodeEditor, CodeEditorHandle } from '../components/codemirror/code-editor';
import { ListGroup, UnitTestItem } from '../components/list-group';
import { showModal, showPrompt } from '../components/modals';
import { SelectModal } from '../components/modals/select-modal';
import { EmptyStatePane } from '../components/panes/empty-state-pane';
import { SvgIcon } from '../components/svg-icon';
import { Button } from '../components/themed-button';
import { UnitTestEditable } from '../components/unit-test-editable';
const HeaderButton = styled(Button)({
'&&': {
marginRight: 'var(--padding-md)',
},
});
import {
CodeEditor,
CodeEditorHandle,
} from '../components/codemirror/code-editor';
import { EditableInput } from '../components/editable-input';
import { Icon } from '../components/icon';
const UnitTestItemView = ({
unitTest,
testsRunning,
}: {
unitTest: UnitTest;
testsRunning: boolean;
}) => {
const editorRef = useRef<CodeEditorHandle>(null);
const { projectId, workspaceId, testSuiteId, organizationId } = useParams() as {
const { projectId, workspaceId, organizationId } = useParams() as {
workspaceId: string;
projectId: string;
testSuiteId: string;
organizationId: string;
};
const { unitTestSuite, requests } = useRouteLoaderData(
@@ -70,55 +67,170 @@ const UnitTestItemView = ({
esversion: 8, // ES8 syntax (async/await, etc)
};
const [isOpen, setIsOpen] = useState(false);
return (
<UnitTestItem
key={unitTest._id}
item={unitTest}
onSetActiveRequest={event =>
updateUnitTestFetcher.submit(
{
code: unitTest.code,
name: unitTest.name,
requestId:
event.currentTarget.value === '__NULL__'
? ''
: event.currentTarget.value,
},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/${unitTest._id}/update`,
method: 'post',
}
)
}
onDeleteTest={() =>
deleteUnitTestFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${testSuiteId}/test/${unitTest._id}/delete`,
method: 'post',
}
)
}
onRunTest={() =>
runTestFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${testSuiteId}/test/${unitTest._id}/run`,
method: 'post',
}
)
}
testsRunning={testsRunning || runTestFetcher.state === 'submitting'}
selectedRequestId={unitTest.requestId}
selectableRequests={requests}
testNameEditable={
<UnitTestEditable
onSubmit={name =>
name &&
<div className="p-[--padding-sm] flex-shrink-0 overflow-hidden">
<div className="flex items-center gap-2 w-full">
<Button
className="flex flex-shrink-0 flex-nowrap items-center justify-center aspect-square h-full aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() => setIsOpen(!isOpen)}
>
<Icon icon={isOpen ? 'chevron-down' : 'chevron-right'} />
</Button>
<Heading className="flex-1 truncate">
<EditableInput
onChange={name => {
if (name) {
updateUnitTestFetcher.submit(
{
code: unitTest.code,
name,
requestId: unitTest.requestId || '',
},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/${unitTest._id}/update`,
method: 'POST',
}
);
}
}}
value={unitTest.name}
/>
</Heading>
<Select
className="flex-shrink-0"
aria-label="Select a request"
onSelectionChange={requestId => {
updateUnitTestFetcher.submit(
{
code: unitTest.code,
name,
name: unitTest.name,
requestId,
},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/${unitTest._id}/update`,
method: 'post',
}
);
}}
selectedKey={unitTest.requestId}
items={requests.map(request => ({
...request,
id: request._id,
key: request._id,
}))}
>
<Button className="px-4 py-1 flex flex-1 h-6 items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
<SelectValue<Request> className="flex truncate items-center justify-center gap-2">
{({ isPlaceholder, selectedItem }) => {
if (isPlaceholder || !selectedItem) {
return <span>Select a request</span>;
}
console.log(selectedItem);
return <Fragment>{selectedItem.name}</Fragment>;
}}
</SelectValue>
<Icon icon="caret-down" />
</Button>
<Popover className="min-w-max">
<ListBox<Request> 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-[50vh] focus:outline-none">
{item => (
<Item
className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors"
aria-label={item.name}
textValue={item.name}
value={item}
>
{({ isSelected }) => (
<Fragment>
<span>{item.name}</span>
{isSelected && (
<Icon
icon="check"
className="text-[--color-success] justify-self-end"
/>
)}
</Fragment>
)}
</Item>
)}
</ListBox>
</Popover>
</Select>
<Button
className="flex flex-shrink-0 items-center justify-center aspect-square h-6 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() => {
deleteUnitTestFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/${unitTest._id}/delete`,
method: 'POST',
}
);
}}
>
<Icon icon="trash" />
</Button>
<Button
className="flex flex-shrink-0 items-center justify-center aspect-square h-6 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() => {
runTestFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/${unitTest._id}/run`,
method: 'post',
}
);
}}
>
<Icon icon="play" />
</Button>
</div>
{isOpen && (
<CodeEditor
id="unit-test-editor"
ref={editorRef}
dynamicHeight
showPrettifyButton
defaultValue={unitTest ? unitTest.code : ''}
getAutocompleteSnippets={() => {
const value = editorRef.current?.getValue() || '';
const variables = value
.split('const ')
.filter(x => x)
.map(x => x.split(' ')[0]);
const numbers = variables
.map(x => parseInt(x.match(/(\d+)/)?.[0] || ''))
?.filter(x => !isNaN(x));
const highestNumberedConstant = Math.max(...numbers);
const variableName = 'response' + (highestNumberedConstant + 1);
return [
{
name: 'Send: Current request',
displayValue: '',
value:
`const ${variableName} = await insomnia.send();\n` +
`expect(${variableName}.status).to.equal(200);`,
},
...requests.map(({ name, _id }) => ({
name: `Send: ${name}`,
displayValue: '',
value:
`const ${variableName} = await insomnia.send('${_id}');\n` +
`expect(${variableName}.status).to.equal(200);`,
})),
];
}}
lintOptions={lintOptions}
onChange={code =>
updateUnitTestFetcher.submit(
{
code,
name: unitTest.name,
requestId: unitTest.requestId || '',
},
{
@@ -127,75 +239,11 @@ const UnitTestItemView = ({
}
)
}
value={`${updateUnitTestFetcher.formData?.get('name') ?? ''}` || unitTest.name}
mode="javascript"
placeholder=""
/>
}
>
<CodeEditor
id="unit-test-editor"
ref={editorRef}
dynamicHeight
showPrettifyButton
defaultValue={unitTest ? unitTest.code : ''}
getAutocompleteSnippets={() => {
const value = editorRef.current?.getValue() || '';
const variables = value.split('const ').filter(x => x).map(x => x.split(' ')[0]);
const numbers = variables.map(x => parseInt(x.match(/(\d+)/)?.[0] || ''))?.filter(x => !isNaN(x));
const highestNumberedConstant = Math.max(...numbers);
const variableName = 'response' + (highestNumberedConstant + 1);
return [
{
name: 'Send Current Request',
displayValue: '',
value: `const ${variableName} = await insomnia.send();\n` +
`expect(${variableName}.status).to.equal(200);`,
},
{
name: 'Send Request By ID',
displayValue: '',
value: async () => {
return new Promise(resolve => {
showModal(SelectModal, {
title: 'Select Request',
message: 'Select a request to fill',
value: '__NULL__',
options: [
{
name: '-- Select Request --',
value: '__NULL__',
},
...requests.map(({ name, _id }) => ({
name,
displayValue: '',
value: `const ${variableName} = await insomnia.send('${_id}');\n` +
`expect(${variableName}.status).to.equal(200);`,
})),
],
onDone: (value: string | null) => resolve(value),
});
});
},
},
];
}}
lintOptions={lintOptions}
onChange={code =>
updateUnitTestFetcher.submit(
{
code,
name: unitTest.name,
requestId: unitTest.requestId || '',
},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/${unitTest._id}/update`,
method: 'post',
}
)
}
mode="javascript"
placeholder=""
/>
</UnitTestItem>
)}
</div>
);
};
@@ -207,7 +255,9 @@ export const indexLoader: LoaderFunction = async ({ params }) => {
const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId);
if (workspaceMeta?.activeUnitTestSuiteId) {
const unitTestSuite = await models.unitTestSuite.getById(workspaceMeta.activeUnitTestSuiteId);
const unitTestSuite = await models.unitTestSuite.getById(
workspaceMeta.activeUnitTestSuiteId
);
if (unitTestSuite) {
return redirect(
@@ -230,7 +280,9 @@ interface LoaderData {
unitTestSuite: UnitTestSuite;
requests: Request[];
}
export const loader: LoaderFunction = async ({ params }): Promise<LoaderData> => {
export const loader: LoaderFunction = async ({
params,
}): Promise<LoaderData> => {
const { workspaceId, testSuiteId } = params;
invariant(workspaceId, 'Workspace ID is required');
@@ -273,7 +325,9 @@ const TestSuiteRoute = () => {
workspaceId: string;
testSuiteId: string;
};
const { unitTestSuite, unitTests } = useRouteLoaderData(':testSuiteId') as LoaderData;
const { unitTestSuite, unitTests } = useRouteLoaderData(
':testSuiteId'
) as LoaderData;
const createUnitTestFetcher = useFetcher();
const runAllTestsFetcher = useFetcher();
@@ -281,90 +335,109 @@ const TestSuiteRoute = () => {
const testsRunning = runAllTestsFetcher.state === 'submitting';
const testSuiteName = renameTestSuiteFetcher.formData?.get('name')?.toString() ?? unitTestSuite.name;
const testSuiteName =
renameTestSuiteFetcher.formData?.get('name')?.toString() ??
unitTestSuite.name;
return (
<div className="unit-tests theme--pane__body">
<div className="unit-tests__top-header">
<h2>
<Editable
singleClick
onSubmit={name => name && renameTestSuiteFetcher.submit(
{ name },
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/rename`,
method: 'post',
}
)
<div className="flex flex-col h-full w-full overflow-hidden divide-solid divide-y divide-[--hl-md]">
<div className="flex flex-shrink-0 gap-2 p-[--padding-md]">
<Heading className="text-lg flex-shrink-0 flex items-center gap-2 w-full truncate flex-1">
<EditableInput
onChange={name =>
name &&
renameTestSuiteFetcher.submit(
{ name },
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/rename`,
method: 'POST',
}
)
}
value={testSuiteName}
/>
</h2>
<HeaderButton
variant="outlined"
onClick={() => {
showPrompt({
title: 'New Test',
defaultValue: 'Returns 200',
submitName: 'New Test',
label: 'Test Name',
selectText: true,
onComplete: name => {
createUnitTestFetcher.submit(
{
name,
},
{
method: 'post',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/new`,
}
);
</Heading>
<Button
aria-label="New test"
className="px-4 py-1 flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() =>
createUnitTestFetcher.submit(
{
name: 'Returns 200',
},
});
}}
{
method: 'POST',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/test/new`,
}
)
}
>
New Test
</HeaderButton>
<HeaderButton
variant="contained"
bg="surprise"
onClick={() => {
<Icon icon="plus" />
<span>New test</span>
</Button>
<Button
aria-label="Project actions"
className="px-4 py-1 flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() => {
runAllTestsFetcher.submit(
{},
{
method: 'post',
method: 'POST',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite._id}/run-all-tests`,
}
);
}}
size="default"
disabled={testsRunning}
>
{testsRunning ? 'Running... ' : 'Run Tests'}
{testsRunning ? 'Running... ' : 'Run tests'}
<i className="fa fa-play space-left" />
</HeaderButton>
</Button>
</div>
{unitTests.length === 0 ? (
<div style={{ height: '100%' }}>
<EmptyStatePane
icon={<SvgIcon icon="vial" />}
documentationLinks={[
documentationLinks.unitTesting,
documentationLinks.introductionToInsoCLI,
]}
title="Add unit tests to verify your API"
secondaryAction="You can run these tests in CI with Inso CLI"
/>
{unitTests.length === 0 && (
<div className="h-full w-full flex-1 overflow-y-auto divide-solid divide-y divide-[--hl-md] p-[--padding-md] flex flex-col items-center gap-2 overflow-hidden text-[--hl-lg]">
<Heading className="text-lg p-[--padding-sm] font-bold flex-1 flex items-center flex-col gap-2">
<Icon icon="vial" className="flex-1 w-28" />
<span>Add unit tests to verify your API</span>
</Heading>
<div className="flex-1 w-full flex flex-col justify-evenly items-center gap-2 p-[--padding-sm]">
<p className="flex items-center gap-2">
<Icon icon="lightbulb" />
<span className="truncate">
You can run these tests in CI with Inso CLI
</span>
</p>
<ul className="flex flex-col gap-2">
<li>
<a
className="font-bold flex items-center gap-2 text-sm hover:text-[--hl] focus:text-[--hl] transition-colors"
href={documentationLinks.unitTesting.url}
>
<span className="truncate">Unit testing in Insomnia</span>
<Icon icon="external-link" />
</a>
</li>
<li>
<a
className="font-bold flex items-center gap-2 text-sm hover:text-[--hl] focus:text-[--hl] transition-colors"
href={documentationLinks.introductionToInsoCLI.url}
>
<span className="truncate">Introduction to Inso CLI</span>
<Icon icon="external-link" />
</a>
</li>
</ul>
</div>
</div>
) : null}
<ListGroup>
{unitTests.map(unitTest => (
<UnitTestItemView
key={unitTest._id}
unitTest={unitTest}
testsRunning={testsRunning}
/>
))}
</ListGroup>
)}
{unitTests.length > 0 && (
<ul className="flex-1 flex flex-col divide-y divide-solid divide-[--hl-md] overflow-y-auto">
{unitTests.map(unitTest => (
<UnitTestItemView
key={unitTest._id}
unitTest={unitTest}
testsRunning={testsRunning}
/>
))}
</ul>
)}
</div>
);
};

View File

@@ -1,5 +1,14 @@
import classnames from 'classnames';
import { IconName } from '@fortawesome/fontawesome-svg-core';
import React, { FC, Suspense } from 'react';
import {
Button,
GridList,
Heading,
Item,
Menu,
MenuTrigger,
Popover,
} from 'react-aria-components';
import {
LoaderFunction,
Route,
@@ -14,12 +23,11 @@ import {
import * as models from '../../models';
import type { UnitTestSuite } from '../../models/unit-test-suite';
import { invariant } from '../../utils/invariant';
import { Dropdown, DropdownButton, DropdownItem, ItemContent } from '../components/base/dropdown';
import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dropdown';
import { EditableInput } from '../components/editable-input';
import { ErrorBoundary } from '../components/error-boundary';
import { showPrompt } from '../components/modals';
import { Icon } from '../components/icon';
import { SidebarFooter, SidebarLayout } from '../components/sidebar-layout';
import { Button } from '../components/themed-button';
import { TestRunStatus } from './test-results';
import TestSuiteRoute from './test-suite';
@@ -45,15 +53,17 @@ export const loader: LoaderFunction = async ({
const TestRoute: FC = () => {
const { unitTestSuites } = useLoaderData() as LoaderData;
const { organizationId, projectId, workspaceId, testSuiteId } = useParams() as {
organizationId: string;
projectId: string;
workspaceId: string;
testSuiteId: string;
};
const { organizationId, projectId, workspaceId, testSuiteId } =
useParams() as {
organizationId: string;
projectId: string;
workspaceId: string;
testSuiteId: string;
};
const createUnitTestSuiteFetcher = useFetcher();
const deleteUnitTestSuiteFetcher = useFetcher();
const renameTestSuiteFetcher = useFetcher();
const runAllTestsFetcher = useFetcher();
const runningTests = useFetchers()
.filter(
@@ -65,102 +75,149 @@ const TestRoute: FC = () => {
const navigate = useNavigate();
const testSuiteActionList: {
id: string;
name: string;
icon: IconName;
action: (suiteId: string) => void;
}[] = [
{
id: 'run-tests',
name: 'Run tests',
icon: 'play',
action: suiteId => {
runAllTestsFetcher.submit(
{},
{
method: 'POST',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${suiteId}/run-all-tests`,
}
);
},
},
{
id: 'delete-suite',
name: 'Delete suite',
icon: 'trash',
action: suiteId => {
deleteUnitTestSuiteFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${suiteId}/delete`,
method: 'POST',
}
);
},
},
];
return (
<SidebarLayout
renderPageSidebar={
<ErrorBoundary showAlert>
<div className="unit-tests__sidebar">
<div className="pad-sm">
<div className="flex flex-1 flex-col overflow-hidden divide-solid divide-y divide-[--hl-md]">
<div className="p-[--padding-sm]">
<Button
variant="outlined"
onClick={() => {
showPrompt({
title: 'New Test Suite',
defaultValue: 'New Suite',
submitName: 'Create Suite',
label: 'Test Suite Name',
selectText: true,
onComplete: async name => {
createUnitTestSuiteFetcher.submit(
{
name,
},
{
method: 'post',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/new`,
}
);
className="px-4 py-1 flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() => {
createUnitTestSuiteFetcher.submit(
{
name: 'New Suite',
},
});
{
method: 'post',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/new`,
}
);
}}
>
New Test Suite
<Icon icon="plus" />
New test suite
</Button>
</div>
<ul>
{unitTestSuites.map(suite => (
<li
key={suite._id}
className={classnames({
active: suite._id === testSuiteId,
})}
>
<button
onClick={e => {
e.preventDefault();
navigate(
`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${suite._id}`
);
}}
<GridList
aria-label="Projects"
items={unitTestSuites.map(suite => ({
id: suite._id,
key: suite._id,
...suite,
}))}
className="overflow-y-auto flex-1 data-[empty]:py-0 py-[--padding-sm]"
disallowEmptySelection
selectedKeys={[testSuiteId]}
selectionMode="single"
onSelectionChange={keys => {
if (keys !== 'all') {
const value = keys.values().next().value;
navigate({
pathname: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${value}`,
});
}
}}
>
{item => {
return (
<Item
key={item._id}
id={item._id}
textValue={item.name}
className="group outline-none select-none w-full"
>
{suite.name}
</button>
<Dropdown
aria-label='Test Suite Actions'
triggerButton={
<DropdownButton className="unit-tests__sidebar__action">
<i className="fa fa-caret-down" />
</DropdownButton>
}
>
<DropdownItem aria-label='Run Tests'>
<ItemContent
stayOpenAfterClick
isDisabled={runAllTestsFetcher.state === 'submitting'}
label={runAllTestsFetcher.state === 'submitting'
? 'Running... '
: 'Run Tests'}
onClick={() => {
runAllTestsFetcher.submit(
{},
<div className="flex select-none outline-none group-aria-selected:text-[--color-font] relative group-hover:bg-[--hl-xs] group-focus:bg-[--hl-sm] transition-colors gap-2 px-4 items-center h-[--line-height-xs] w-full overflow-hidden text-[--hl]">
<span className="group-aria-selected:bg-[--color-surprise] transition-colors top-0 left-0 absolute h-full w-[2px] bg-transparent" />
<EditableInput
value={item.name}
name="name"
ariaLabel="Test suite name"
onChange={name => {
renameTestSuiteFetcher.submit(
{ name },
{
method: 'post',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${suite._id}/run-all-tests`,
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${item._id}/rename`,
method: 'POST',
}
);
}}
/>
</DropdownItem>
<DropdownItem aria-label='Delete Suite'>
<ItemContent
label="Delete Suite"
withPrompt
onClick={() =>
deleteUnitTestSuiteFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${suite._id}/delete`,
method: 'post',
}
)
}
/>
</DropdownItem>
</Dropdown>
</li>
))}
</ul>
<span className="flex-1" />
<MenuTrigger>
<Button
aria-label="Project Actions"
className="opacity-0 items-center hover:opacity-100 focus:opacity-100 data-[pressed]:opacity-100 flex group-focus:opacity-100 group-hover:opacity-100 justify-center h-6 aspect-square data-[pressed]:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="caret-down" />
</Button>
<Popover className="min-w-max">
<Menu
aria-label="Project Actions Menu"
selectionMode="single"
onAction={key => {
testSuiteActionList
.find(({ id }) => key === id)
?.action(item._id);
}}
items={testSuiteActionList}
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
key={item.id}
id={item.id}
className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors"
aria-label={item.name}
>
<Icon icon={item.icon} />
<span>{item.name}</span>
</Item>
)}
</Menu>
</Popover>
</MenuTrigger>
</div>
</Item>
);
}}
</GridList>
</div>
<SidebarFooter>
<WorkspaceSyncDropdown />
@@ -180,9 +237,7 @@ const TestRoute: FC = () => {
<Route
path="*"
element={
<div className="unit-tests pad theme--pane__body">
No test suite selected
</div>
<div className="p-[--padding-md]">No test suite selected</div>
}
/>
</Routes>
@@ -193,11 +248,9 @@ const TestRoute: FC = () => {
path="test-suite/:testSuiteId/test-result/:testResultId"
element={
runningTests ? (
<div className="unit-tests__results">
<div className="unit-tests__top-header">
<h2>Running Tests...</h2>
</div>
</div>
<Heading className="text-lg flex-shrink-0 flex items-center gap-2 w-full p-[--padding-md] border-solid border-b border-b-[--hl-md]">
<Icon icon="spinner" className="fa-pulse" /> Running tests...
</Heading>
) : (
<TestRunStatus />
)
@@ -206,19 +259,16 @@ const TestRoute: FC = () => {
<Route
path="*"
element={
runningTests ? (
<div className="unit-tests__results">
<div className="unit-tests__top-header">
<h2>Running Tests...</h2>
</div>
</div>
) : (
<div className="unit-tests__results">
<div className="unit-tests__top-header">
<h2>No Results</h2>
</div>
</div>
)
<Heading className="text-lg flex-shrink-0 flex items-center gap-2 w-full p-[--padding-md] border-solid border-b border-b-[--hl-md]">
{runningTests ? (
<>
<Icon icon="spinner" className="fa-pulse" /> Running
tests...
</>
) : (
'No test results'
)}
</Heading>
}
/>
</Routes>