Improve export modal UX (#6978)

* improve styles for export modal

* fix e2e test
This commit is contained in:
James Gatz 2024-01-10 16:57:11 +02:00 committed by GitHub
parent e20c257260
commit 20491d9728
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 230 additions and 216 deletions

View File

@ -21,7 +21,6 @@ test('can send requests', async ({ app, page }) => {
await page.getByRole('button', { name: 'Workspace actions menu button' }).click();
await page.getByRole('menuitem', { name: 'Export' }).click();
await page.getByRole('dialog').getByRole('checkbox').nth(1).uncheck();
await page.getByRole('button', { name: 'Export' }).click();
await page.getByText('Which format would you like to export as?').click();
await page.locator('.app').press('Escape');

View File

@ -203,7 +203,7 @@ export const WorkspaceCardDropdown: FC<Props> = props => {
{isExportModalOpen && (
<ExportRequestsModal
workspace={workspace}
onHide={() => setIsExportModalOpen(false)}
onClose={() => setIsExportModalOpen(false)}
/>
)}
{isSettingsModalOpen && (

View File

@ -251,7 +251,7 @@ export const WorkspaceDropdown: FC = () => {
{isExportModalOpen && (
<ExportRequestsModal
workspace={activeWorkspace}
onHide={() => setIsExportModalOpen(false)}
onClose={() => setIsExportModalOpen(false)}
/>
)}
{isSettingsModalOpen && (

View File

@ -1,58 +0,0 @@
import classnames from 'classnames';
import React, { FC, ReactNode, useRef } from 'react';
import type { RequestGroup } from '../../../models/request-group';
interface Props {
children?: ReactNode;
handleSetItemSelected: (...args: any[]) => any;
handleSetRequestGroupCollapsed: (...args: any[]) => any;
isCollapsed: boolean;
requestGroup: RequestGroup;
selectedRequests: number;
totalRequests: number;
}
export const RequestGroupRow: FC<Props> = ({
children,
handleSetItemSelected,
handleSetRequestGroupCollapsed,
isCollapsed,
requestGroup,
selectedRequests,
totalRequests,
}) => {
const isSelected = selectedRequests === totalRequests;
const checkboxRef = useRef<HTMLInputElement>(null);
if (checkboxRef.current) {
// Partial or indeterminate checkbox.
checkboxRef.current.indeterminate = selectedRequests > 0 && selectedRequests < totalRequests;
}
return (
<li key={requestGroup._id} className="tree__row">
<div className="tree__item tree__item--big">
<div className="tree__item__checkbox tree__indent">
<input
ref={checkboxRef}
type="checkbox"
checked={isSelected}
onChange={e => handleSetItemSelected(requestGroup._id, e.currentTarget.checked)}
/>
</div>
<button onClick={() => handleSetRequestGroupCollapsed(requestGroup._id, !isCollapsed)}>
<i className={classnames('tree__item__icon', 'fa', `fa-folder${isCollapsed ? '' : '-open'}`)} />
{requestGroup.name}
<span className="total-requests">{totalRequests} requests</span>
</button>
</div>
<ul
className={classnames('tree__list', {
'tree__list--collapsed': isCollapsed,
})}
>
{!isCollapsed ? children : null}
</ul>
</li>
);
};

View File

@ -1,40 +0,0 @@
import React, { FC, SyntheticEvent, useCallback } from 'react';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import { isRequest, Request } from '../../../models/request';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { GrpcTag } from '../tags/grpc-tag';
import { MethodTag } from '../tags/method-tag';
import { WebSocketTag } from '../tags/websocket-tag';
interface Props {
handleSetItemSelected: (...args: any[]) => any;
isSelected: boolean;
request: Request | WebSocketRequest | GrpcRequest;
}
export const RequestRow: FC<Props> = ({
handleSetItemSelected,
request,
isSelected,
}) => {
const onChange = useCallback((event: SyntheticEvent<HTMLInputElement>) => {
handleSetItemSelected(request._id, event?.currentTarget.checked);
}, [handleSetItemSelected, request._id]);
return (
<li className="tree__row">
<div className="tree__item tree__item--request">
<div className="tree__item__checkbox tree__indent">
<input type="checkbox" checked={isSelected} onChange={onChange} />
</div>
<button className="wide">
{isRequest(request) ? <MethodTag method={request.method} /> : null}
{isGrpcRequest(request) ? <GrpcTag /> : null}
{isWebSocketRequest(request) ? <WebSocketTag /> : null}
<span className="inline-block">{request.name}</span>
</button>
</div>
</li>
);
};

View File

@ -1,56 +0,0 @@
import React, { FC } from 'react';
import { isGrpcRequest } from '../../../models/grpc-request';
import { isRequest } from '../../../models/request';
import { isWebSocketRequest } from '../../../models/websocket-request';
import type { Node } from '../modals/export-requests-modal';
import { RequestGroupRow } from './request-group-row';
import { RequestRow } from './request-row';
interface Props {
root?: Node | null;
handleSetRequestGroupCollapsed: (...args: any[]) => any;
handleSetItemSelected: (...args: any[]) => any;
}
export const Tree: FC<Props> = ({ root, handleSetRequestGroupCollapsed, handleSetItemSelected }) => {
const renderChildren = (node?: Node | null) => {
if (node == null) {
return null;
}
if (isRequest(node.doc) || isWebSocketRequest(node.doc) || isGrpcRequest(node.doc)) {
return (
<RequestRow
key={node.doc._id}
handleSetItemSelected={handleSetItemSelected}
isSelected={node.selectedRequests === node.totalRequests}
request={node.doc}
/>
);
}
if (node.totalRequests === 0) {
// Don't show empty folders.
return null;
}
return (
<RequestGroupRow
key={node.doc._id}
handleSetRequestGroupCollapsed={handleSetRequestGroupCollapsed}
handleSetItemSelected={handleSetItemSelected}
isCollapsed={node.collapsed}
totalRequests={node.totalRequests}
selectedRequests={node.selectedRequests}
requestGroup={node.doc}
>
{node.children.map(child => renderChildren(child))}
</RequestGroupRow>
);
};
return (
<ul className="tree__list tree__list-root theme--tree__list">{renderChildren(root)}</ul>
);
};

View File

@ -1,20 +1,17 @@
import React, { useEffect, useRef, useState } from 'react';
import { OverlayContainer } from 'react-aria';
import React, { FC, ReactNode, useEffect, useState } from 'react';
import { Button, Checkbox, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components';
import { useFetcher, useParams } from 'react-router-dom';
import { exportRequestsToFile } from '../../../common/export';
import * as models from '../../../models';
import { requestGroup } from '../../../models';
import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request';
import { isRequest, Request } from '../../../models/request';
import { RequestGroup } from '../../../models/request-group';
import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request';
import { Workspace } from '../../../models/workspace';
import { Child, WorkspaceLoaderData } from '../../routes/workspace';
import { Modal, type ModalHandle, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalFooter } from '../base/modal-footer';
import { ModalHeader } from '../base/modal-header';
import { Tree } from '../export-requests/tree';
import { Icon } from '../icon';
import { getMethodShortHand } from '../tags/method-tag';
export interface Node {
doc: Request | WebSocketRequest | GrpcRequest | RequestGroup;
@ -24,15 +21,160 @@ export interface Node {
selectedRequests: number;
}
export interface State {
treeRoot: Node | null;
}
export const RequestGroupRow: FC<{
children?: ReactNode;
handleSetItemSelected: (...args: any[]) => any;
handleSetRequestGroupCollapsed: (...args: any[]) => any;
isCollapsed: boolean;
requestGroup: RequestGroup;
selectedRequests: number;
totalRequests: number;
}> = ({
children,
handleSetItemSelected,
handleSetRequestGroupCollapsed,
isCollapsed,
requestGroup,
selectedRequests,
totalRequests,
}) => {
const isSelected = selectedRequests === totalRequests;
const isIndeterminate = selectedRequests > 0 && selectedRequests < totalRequests;
export const ExportRequestsModal = ({ workspace, onHide }: { workspace: Workspace } & ModalProps) => {
const modalRef = useRef<ModalHandle>(null);
return (
<li key={requestGroup._id} className="flex flex-col">
<div className="flex items-center gap-2 p-2">
<Checkbox isIndeterminate={isIndeterminate} slot={null} isSelected={isSelected} onChange={isSelected => handleSetItemSelected(requestGroup._id, isSelected)} className="group p-0 flex items-center h-full">
<div className="w-4 h-4 rounded flex items-center justify-center transition-colors group-data-[selected]:bg-[--hl-xs] group-focus:ring-2 ring-1 ring-[--hl-sm]">
<Icon icon={isIndeterminate ? 'minus' : 'check'} className='opacity-0 group-data-[selected]:opacity-100 group-data-[indeterminate]:opacity-100 group-data-[selected]:text-[--color-success] w-3 h-3' />
</div>
</Checkbox>
<Button className="flex items-center gap-2" onPress={() => handleSetRequestGroupCollapsed(requestGroup._id, !isCollapsed)}>
<Icon icon={isCollapsed ? 'folder' : 'folder-open'} />
{requestGroup.name}
<span className="text-sm text-[--hl]">{totalRequests} requests</span>
</Button>
</div>
<ul
className="flex flex-col pl-5"
>
{!isCollapsed ? children : null}
</ul>
</li>
);
};
export const RequestRow: FC<{
handleSetItemSelected: (...args: any[]) => any;
isSelected: boolean;
request: Request | WebSocketRequest | GrpcRequest;
}> = ({
handleSetItemSelected,
request,
isSelected,
}) => {
return (
<li className="flex items-center gap-2 p-2">
<Checkbox
slot={null}
isSelected={isSelected}
onChange={isSelected => {
handleSetItemSelected(request._id, isSelected);
}}
className="group p-0 flex items-center h-full"
>
<div className="w-4 h-4 rounded flex items-center justify-center transition-colors group-data-[selected]:bg-[--hl-xs] group-focus:ring-2 ring-1 ring-[--hl-sm]">
<Icon icon='check' className='opacity-0 group-data-[selected]:opacity-100 group-data-[selected]:text-[--color-success] w-3 h-3' />
</div>
</Checkbox>
<div className="w-full flex items-center gap-2">
{isRequest(request) && (
<span
className={
`w-10 flex-shrink-0 flex text-[0.65rem] rounded-sm border border-solid border-[--hl-sm] items-center justify-center
${{
'GET': 'text-[--color-font-surprise] bg-[rgba(var(--color-surprise-rgb),0.5)]',
'POST': 'text-[--color-font-success] bg-[rgba(var(--color-success-rgb),0.5)]',
'HEAD': 'text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]',
'OPTIONS': 'text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]',
'DELETE': 'text-[--color-font-danger] bg-[rgba(var(--color-danger-rgb),0.5)]',
'PUT': 'text-[--color-font-warning] bg-[rgba(var(--color-warning-rgb),0.5)]',
'PATCH': 'text-[--color-font-notice] bg-[rgba(var(--color-notice-rgb),0.5)]',
}[request.method] || 'text-[--color-font] bg-[--hl-md]'}`
}
>
{getMethodShortHand(request)}
</span>
)}
{isWebSocketRequest(request) && (
<span className="w-10 flex-shrink-0 flex text-[0.65rem] rounded-sm border border-solid border-[--hl-sm] items-center justify-center text-[--color-font-notice] bg-[rgba(var(--color-notice-rgb),0.5)]">
WS
</span>
)}
{isGrpcRequest(request) && (
<span className="w-10 flex-shrink-0 flex text-[0.65rem] rounded-sm border border-solid border-[--hl-sm] items-center justify-center text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]">
gRPC
</span>
)}
<span>{request.name}</span>
</div>
</li>
);
};
export const Tree: FC<{
root?: Node | null;
handleSetRequestGroupCollapsed: (...args: any[]) => any;
handleSetItemSelected: (...args: any[]) => any;
}> = ({ root, handleSetRequestGroupCollapsed, handleSetItemSelected }) => {
const renderChildren = (node?: Node | null) => {
if (node == null) {
return null;
}
if (isRequest(node.doc) || isWebSocketRequest(node.doc) || isGrpcRequest(node.doc)) {
return (
<RequestRow
key={node.doc._id}
handleSetItemSelected={handleSetItemSelected}
isSelected={node.selectedRequests === node.totalRequests}
request={node.doc}
/>
);
}
if (node.totalRequests === 0) {
// Don't show empty folders.
return null;
}
return (
<RequestGroupRow
key={node.doc._id}
handleSetRequestGroupCollapsed={handleSetRequestGroupCollapsed}
handleSetItemSelected={handleSetItemSelected}
isCollapsed={node.collapsed}
totalRequests={node.totalRequests}
selectedRequests={node.selectedRequests}
requestGroup={node.doc}
>
{node.children.map(child => renderChildren(child))}
</RequestGroupRow>
);
};
return (
<ul className="flex flex-col">{renderChildren(root)}</ul>
);
};
export const ExportRequestsModal = ({ workspace, onClose }: { workspace: Workspace; onClose: () => void }) => {
const { organizationId, projectId } = useParams() as { organizationId: string; projectId: string };
const workspaceFetcher = useFetcher();
const [state, setState] = useState<State>();
const [state, setState] = useState<{
treeRoot: Node | null;
}>();
useEffect(() => {
const isIdleAndUninitialized = workspaceFetcher.state === 'idle' && !workspaceFetcher.data;
@ -60,9 +202,9 @@ export const ExportRequestsModal = ({ workspace, onHide }: { workspace: Workspac
setState({
treeRoot: {
doc: {
...models.requestGroup.init(),
...requestGroup.init(),
_id: 'all',
type: models.requestGroup.type,
type: requestGroup.type,
name: 'All requests',
parentId: '',
modified: 0,
@ -77,9 +219,10 @@ export const ExportRequestsModal = ({ workspace, onHide }: { workspace: Workspac
});
}, [workspaceLoaderData?.requestTree]);
useEffect(() => {
modalRef.current?.show();
}, []);
if (!workspaceLoaderData) {
return null;
}
const getSelectedRequestIds = (node: Node): string[] => {
const docIsRequest = isRequest(node.doc) || isWebSocketRequest(node.doc) || isGrpcRequest(node.doc);
if (docIsRequest && node.selectedRequests === node.totalRequests) {
@ -114,47 +257,73 @@ export const ExportRequestsModal = ({ workspace, onHide }: { workspace: Workspac
};
const isExportDisabled = state?.treeRoot?.selectedRequests === 0 || false;
return (
<OverlayContainer onClick={e => e.stopPropagation()}>
<Modal ref={modalRef} tall onHide={onHide}>
<ModalHeader>Select Requests to Export</ModalHeader>
<ModalBody>
<div className="requests-tree">
<Tree
root={state?.treeRoot}
handleSetRequestGroupCollapsed={(requestGroupId: string, isCollapsed: boolean) => {
if (state?.treeRoot && setRequestGroupCollapsed(state?.treeRoot, isCollapsed, requestGroupId)) {
setState({ treeRoot: state?.treeRoot });
}
}}
handleSetItemSelected={(itemId: string, isSelected: boolean) => {
if (state?.treeRoot && setItemSelected(state?.treeRoot, isSelected, itemId)) {
setState({ treeRoot: state?.treeRoot });
}
}}
/>
<ModalOverlay
isOpen
onOpenChange={isOpen => {
!isOpen && onClose();
}}
isDismissable
className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-center justify-center bg-black/30"
>
<Modal
onOpenChange={isOpen => {
!isOpen && onClose();
}}
className="flex flex-col max-w-4xl w-full rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] max-h-full bg-[--color-bg] text-[--color-font]"
>
<Dialog
className="outline-none flex-1 h-full flex flex-col overflow-hidden"
>
{({ close }) => (
<div className='flex-1 flex flex-col gap-4 overflow-hidden'>
<div className='flex gap-2 items-center justify-between'>
<Heading slot="title" className='text-2xl'>Export requests</Heading>
<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={close}
>
<Icon icon="x" />
</Button>
</div>
<div className='rounded w-full border border-solid border-[--hl-sm] select-none overflow-y-auto min-h-[20rem] max-h-96'>
<Tree
root={state?.treeRoot}
handleSetRequestGroupCollapsed={(requestGroupId: string, isCollapsed: boolean) => {
if (state?.treeRoot && setRequestGroupCollapsed(state?.treeRoot, isCollapsed, requestGroupId)) {
setState({ treeRoot: state?.treeRoot });
}
}}
handleSetItemSelected={(itemId: string, isSelected: boolean) => {
if (state?.treeRoot && setItemSelected(state?.treeRoot, isSelected, itemId)) {
setState({ treeRoot: state?.treeRoot });
}
}}
/>
</div>
<div className="flex flex-shrink-0 flex-1 justify-end gap-2 items-center">
<Button
onPress={close}
className="hover:no-underline flex items-center gap-2 hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font] transition-colors rounded-sm"
>
Cancel
</Button>
<Button
onPress={() => {
state?.treeRoot && exportRequestsToFile(getSelectedRequestIds(state.treeRoot));
close();
}}
isDisabled={isExportDisabled}
className="hover:no-underline flex items-center gap-2 bg-[--color-surprise] hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font-surprise] transition-colors rounded-sm"
>
<Icon icon="save" /> Export
</Button>
</div>
</ModalBody>
<ModalFooter>
<div>
<button className="btn" onClick={() => modalRef.current?.hide()}>
Cancel
</button>
<button
className="btn"
onClick={() => {
if (state?.treeRoot && state?.treeRoot.selectedRequests > 0) {
exportRequestsToFile(getSelectedRequestIds(state?.treeRoot));
modalRef.current?.hide();
}
}}
disabled={isExportDisabled}
>
Export
</button>
</div>
</ModalFooter>
</div>
)}
</Dialog>
</Modal>
</OverlayContainer>
</ModalOverlay>
);
};

View File

@ -438,7 +438,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
{isExportModalOpen && workspaceData?.activeWorkspace && (
<ExportRequestsModal
workspace={workspaceData.activeWorkspace}
onHide={() => setIsExportModalOpen(false)}
onClose={() => setIsExportModalOpen(false)}
/>
)}
</Fragment>