mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-22 07:08:16 -04:00
Improve UI for test view (#6477)
* test-results * cleanup * improve types * fix some stuff
This commit is contained in:
@@ -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;
|
||||
|
||||
92
packages/insomnia/src/ui/components/editable-input.tsx
Normal file
92
packages/insomnia/src/ui/components/editable-input.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
@@ -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;};
|
||||
`}
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user