[feat] Sort Dashboard Cards (#3850)

* Add sort order for space items

* Add ability to sort dashboard cards

* Fix TS errors

* Fix lint errors

* Add sort options for modified time

* Move workspace-card to a new file and use redux global state for persistence

* Remove ordering from the space model

* Use currying for the workspace mapping function and clean up types

* Expand testcase for activespace initialization

* Fix lint errors

* Remove date defaults and check if modifedLocally is defined

* Fixup: Use global selector for space order

* Fixup: Decouple space order initialization from active space

* Fixup: Use named exports in SpaceSortDropdown and rename handler in WrapperHome
This commit is contained in:
James Gatz 2021-08-05 20:38:58 +03:00 committed by GitHub
parent 4dda066254
commit e683a02b16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 526 additions and 204 deletions

View File

@ -299,13 +299,15 @@ export type SortOrder =
| 'http-method'
| 'type-desc'
| 'type-asc';
export const SORT_NAME_ASC: SortOrder = 'name-asc';
export const SORT_NAME_DESC: SortOrder = 'name-desc';
export const SORT_CREATED_ASC: SortOrder = 'created-asc';
export const SORT_CREATED_DESC: SortOrder = 'created-desc';
export const SORT_HTTP_METHOD: SortOrder = 'http-method';
export const SORT_TYPE_DESC: SortOrder = 'type-desc';
export const SORT_TYPE_ASC: SortOrder = 'type-asc';
export const SORT_NAME_ASC = 'name-asc';
export const SORT_NAME_DESC = 'name-desc';
export const SORT_CREATED_ASC = 'created-asc';
export const SORT_CREATED_DESC = 'created-desc';
export const SORT_MODIFIED_ASC = 'modified-asc';
export const SORT_MODIFIED_DESC = 'modified-desc';
export const SORT_HTTP_METHOD = 'http-method';
export const SORT_TYPE_DESC = 'type-desc';
export const SORT_TYPE_ASC = 'type-asc';
export const SORT_ORDERS = [
SORT_NAME_ASC,
SORT_NAME_DESC,
@ -325,6 +327,29 @@ export const sortOrderName: Record<SortOrder, string> = {
[SORT_TYPE_ASC]: 'Requests First',
};
export type SpaceSortOrder =
| 'name-asc'
| 'name-desc'
| 'created-asc'
| 'created-desc'
| 'modified-desc'
export const SPACE_SORT_ORDERS = [
SORT_MODIFIED_DESC,
SORT_NAME_ASC,
SORT_NAME_DESC,
SORT_CREATED_ASC,
SORT_CREATED_DESC,
];
export const spaceSortOrderName: Record<SpaceSortOrder, string> = {
[SORT_NAME_ASC]: 'Name Ascending',
[SORT_NAME_DESC]: 'Name Descending',
[SORT_CREATED_ASC]: 'Oldest First',
[SORT_CREATED_DESC]: 'Newest First',
[SORT_MODIFIED_DESC]: 'Last Modified',
};
export function getPreviewModeName(previewMode, useLong = false) {
if (previewModeMap.hasOwnProperty(previewMode)) {
return useLong ? previewModeMap[previewMode][1] : previewModeMap[previewMode][0];

View File

@ -445,6 +445,10 @@ export function snapNumberToLimits(value: number, min?: number, max?: number) {
return value;
}
export function isNotNullOrUndefined(obj: any | null | undefined) {
return obj !== null && obj !== undefined;
}
export function isNotNullOrUndefined<ValueType>(
value: ValueType | null | undefined
): value is ValueType {
if (value === null || value === undefined) return false;
return true;
}

View File

@ -6,25 +6,26 @@ import {
SORT_CREATED_ASC,
SORT_CREATED_DESC,
SORT_HTTP_METHOD,
SORT_MODIFIED_ASC,
SORT_MODIFIED_DESC,
SORT_NAME_ASC,
SORT_NAME_DESC,
SORT_TYPE_ASC,
SORT_TYPE_DESC,
SortOrder,
} from './constants';
type SortableModel = Request | RequestGroup | GrpcRequest;
type SortFunction = (a: SortableModel, b: SortableModel) => number;
type SortFunction<SortableType> = (a: SortableType, b: SortableType) => number;
const ascendingNameSort: SortFunction = (a, b) => {
const ascendingNameSort: SortFunction<{name: string}> = (a, b) => {
return a.name.localeCompare(b.name);
};
const descendingNameSort: SortFunction = (a, b) => {
const descendingNameSort: SortFunction<{name: string}> = (a, b) => {
return b.name.localeCompare(a.name);
};
const createdFirstSort: SortFunction = (a, b) => {
const createdFirstSort: SortFunction<{created: number}> = (a, b) => {
if (a.created === b.created) {
return 0;
}
@ -32,7 +33,7 @@ const createdFirstSort: SortFunction = (a, b) => {
return a.created < b.created ? -1 : 1;
};
const createdLastSort: SortFunction = (a, b) => {
const createdLastSort: SortFunction<{created: number}> = (a, b) => {
if (a.created === b.created) {
return 0;
}
@ -40,7 +41,23 @@ const createdLastSort: SortFunction = (a, b) => {
return a.created > b.created ? -1 : 1;
};
const httpMethodSort: SortFunction = (a, b) => {
const ascendingModifiedSort: SortFunction<{lastModifiedTimestamp: number}> = (a, b) => {
if (a.lastModifiedTimestamp === b.lastModifiedTimestamp) {
return 0;
}
return a.lastModifiedTimestamp < b.lastModifiedTimestamp ? -1 : 1;
};
const descendingModifiedSort: SortFunction<{lastModifiedTimestamp: number}> = (a, b) => {
if (a.lastModifiedTimestamp === b.lastModifiedTimestamp) {
return 0;
}
return a.lastModifiedTimestamp > b.lastModifiedTimestamp ? -1 : 1;
};
const httpMethodSort: SortFunction<Pick<SortableModel, 'type' | 'metaSortKey' | '_id'>> = (a, b) => {
// Sort Requests and GrpcRequests to top, in that order
if (a.type !== b.type) {
if (isRequest(a) || isRequest(b)) {
@ -75,7 +92,7 @@ const httpMethodSort: SortFunction = (a, b) => {
return metaSortKeySort(a, b);
};
const ascendingTypeSort: SortFunction = (a, b) => {
const ascendingTypeSort: SortFunction<Pick<SortableModel, 'type' | 'metaSortKey' | '_id'>> = (a, b) => {
if (a.type !== b.type && (isRequestGroup(a) || isRequestGroup(b))) {
return isRequestGroup(b) ? -1 : 1;
}
@ -83,7 +100,7 @@ const ascendingTypeSort: SortFunction = (a, b) => {
return metaSortKeySort(a, b);
};
const descendingTypeSort: SortFunction = (a, b) => {
const descendingTypeSort: SortFunction<Pick<SortableModel, 'type' | 'metaSortKey' | '_id'>> = (a, b) => {
if (a.type !== b.type && (isRequestGroup(a) || isRequestGroup(b))) {
return isRequestGroup(a) ? -1 : 1;
}
@ -91,7 +108,7 @@ const descendingTypeSort: SortFunction = (a, b) => {
return metaSortKeySort(a, b);
};
export const metaSortKeySort: SortFunction = (a, b) => {
export const metaSortKeySort: SortFunction<Pick<SortableModel, '_id' | 'metaSortKey'>> = (a, b) => {
if (a.metaSortKey === b.metaSortKey) {
return a._id > b._id ? -1 : 1;
}
@ -99,20 +116,21 @@ export const metaSortKeySort: SortFunction = (a, b) => {
return a.metaSortKey < b.metaSortKey ? -1 : 1;
};
export const ascendingNumberSort = (a: number, b: number): number => {
export const ascendingNumberSort: SortFunction<number> = (a, b) => {
return a < b ? -1 : 1;
};
export const descendingNumberSort = (a: number, b: number): number => {
export const descendingNumberSort: SortFunction<number> = (a, b) => {
return ascendingNumberSort(b, a);
};
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
export const sortMethodMap: Record<SortOrder, SortFunction> = {
export const sortMethodMap = {
[SORT_NAME_ASC]: ascendingNameSort,
[SORT_NAME_DESC]: descendingNameSort,
[SORT_CREATED_ASC]: createdFirstSort,
[SORT_CREATED_DESC]: createdLastSort,
[SORT_MODIFIED_ASC]: ascendingModifiedSort,
[SORT_MODIFIED_DESC]: descendingModifiedSort,
[SORT_HTTP_METHOD]: httpMethodSort,
[SORT_TYPE_DESC]: descendingTypeSort,
[SORT_TYPE_ASC]: ascendingTypeSort,

View File

@ -0,0 +1,27 @@
import { Button, Dropdown, DropdownItem } from 'insomnia-components';
import React, { FC } from 'react';
import { SPACE_SORT_ORDERS, SpaceSortOrder, spaceSortOrderName } from '../../../common/constants';
interface SpaceSortDropdownProps {
onSelect: (value: SpaceSortOrder) => void;
}
export const SpaceSortDropdown: FC<SpaceSortDropdownProps> = ({ onSelect }) => {
return (
<Dropdown
className="margin-left"
renderButton={
<Button>
<i className="fa fa-sort" />
</Button>
}
>
{SPACE_SORT_ORDERS.map(order => (
<DropdownItem value={order} onClick={onSelect} key={order}>
{spaceSortOrderName[order]}
</DropdownItem>
))}
</Dropdown>
);
};

View File

@ -13,7 +13,7 @@ const SidebarSortDropdown: FunctionComponent<Props> = ({ handleSort }) => (
<i className="fa fa-sort" />
</DropdownButton>
{SORT_ORDERS.map(order => (
<DropdownItem onClick={() => handleSort(order)} key={order}>
<DropdownItem value={order} onClick={handleSort} key={order}>
{sortOrderName[order]}
</DropdownItem>
))}

View File

@ -0,0 +1,146 @@
import { Card } from 'insomnia-components';
import React, { Fragment } from 'react';
import { FC } from 'react';
import {
ACTIVITY_DEBUG,
ACTIVITY_SPEC,
GlobalActivity,
} from '../../common/constants';
import { fuzzyMatchAll } from '../../common/misc';
import { strings } from '../../common/strings';
import { ApiSpec } from '../../models/api-spec';
import { Space } from '../../models/space';
import { isDesign, Workspace } from '../../models/workspace';
import Highlight from './base/highlight';
import { WorkspaceCardDropdown } from './dropdowns/workspace-card-dropdown';
import TimeFromNow from './time-from-now';
export interface WorkspaceCardProps {
apiSpec: ApiSpec;
workspace: Workspace;
filter: string;
activeSpace: Space;
lastActiveBranch?: string | null;
lastModifiedTimestamp: number;
lastCommitTime?: number | null;
lastCommitAuthor?: string | null;
modifiedLocally?: number;
spec: Record<string, any> | null;
specFormat: 'openapi' | 'swagger' | null;
specFormatVersion: string | null;
hasUnsavedChanges: boolean;
onSelect: (workspaceId: string, activity: GlobalActivity) => void;
}
const WorkspaceCard: FC<WorkspaceCardProps> = ({
apiSpec,
filter,
lastActiveBranch,
lastModifiedTimestamp,
workspace,
activeSpace,
lastCommitTime,
modifiedLocally,
lastCommitAuthor,
spec,
specFormat,
specFormatVersion,
hasUnsavedChanges,
onSelect,
}) => {
let branch = lastActiveBranch;
let log = <TimeFromNow timestamp={lastModifiedTimestamp} />;
if (hasUnsavedChanges) {
// Show locally unsaved changes for spec
// NOTE: this doesn't work for non-spec workspaces
branch = lastActiveBranch + '*';
if (modifiedLocally) {
log = (
<Fragment>
<TimeFromNow
className="text-danger"
timestamp={modifiedLocally}
/>{' '}
(unsaved)
</Fragment>
);
}
} else if (lastCommitTime) {
// Show last commit time and author
log = (
<Fragment>
<TimeFromNow timestamp={lastCommitTime} />{' '}
{lastCommitAuthor && `by ${lastCommitAuthor}`}
</Fragment>
);
}
const docMenu = (
<WorkspaceCardDropdown
apiSpec={apiSpec}
workspace={workspace}
space={activeSpace}
/>
);
const version = spec?.info?.version || '';
let label: string = strings.collection.singular;
let format = '';
let labelIcon = <i className="fa fa-bars" />;
let defaultActivity = ACTIVITY_DEBUG;
let title = workspace.name;
if (isDesign(workspace)) {
label = strings.document.singular;
labelIcon = <i className="fa fa-file-o" />;
if (specFormat === 'openapi') {
format = `OpenAPI ${specFormatVersion}`;
} else if (specFormat === 'swagger') {
// NOTE: This is not a typo, we're labeling Swagger as OpenAPI also
format = `OpenAPI ${specFormatVersion}`;
}
defaultActivity = ACTIVITY_SPEC;
title = apiSpec.fileName || title;
}
// Filter the card by multiple different properties
const matchResults = fuzzyMatchAll(filter, [title, label, branch, version], {
splitSpace: true,
loose: true,
});
// Return null if we don't match the filter
if (filter && !matchResults) {
return null;
}
return (
<Card
docBranch={
branch ? <Highlight search={filter} text={branch} /> : undefined
}
docTitle={title ? <Highlight search={filter} text={title} /> : undefined}
docVersion={
version ? <Highlight search={filter} text={`v${version}`} /> : undefined
}
tagLabel={
label ? (
<>
<span className="margin-right-xs">{labelIcon}</span>
<Highlight search={filter} text={label} />
</>
) : undefined
}
docLog={log}
docMenu={docMenu}
docFormat={format}
onClick={() => onSelect(workspace._id, defaultActivity)}
/>
);
};
export default WorkspaceCard;

View File

@ -4,56 +4,55 @@ import { autoBindMethodsForReact } from 'class-autobind-decorator';
import {
Breadcrumb,
Button,
Card,
CardContainer,
Dropdown,
DropdownDivider,
DropdownItem,
Header,
} from 'insomnia-components';
import React, { Fragment, PureComponent, ReactNode } from 'react';
import React, { Fragment, PureComponent } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { unreachableCase } from 'ts-assert-unreachable';
import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs';
import {
AUTOBIND_CFG,
SpaceSortOrder,
} from '../../common/constants';
import { hotKeyRefs } from '../../common/hotkeys';
import { executeHotKey } from '../../common/hotkeys-listener';
import { fuzzyMatchAll, isNotNullOrUndefined } from '../../common/misc';
import { descendingNumberSort } from '../../common/sorting';
import { isNotNullOrUndefined } from '../../common/misc';
import { descendingNumberSort, sortMethodMap } from '../../common/sorting';
import { strings } from '../../common/strings';
import { ApiSpec } from '../../models/api-spec';
import { isRemoteSpace } from '../../models/space';
import { isDesign, Workspace, WorkspaceScopeKeys } from '../../models/workspace';
import { WorkspaceMeta } from '../../models/workspace-meta';
import { MemClient } from '../../sync/git/mem-client';
import { initializeLocalProjectAndMarkForSync } from '../../sync/vcs/initialize-project';
import coreLogo from '../images/insomnia-core-logo.png';
import { cloneGitRepository } from '../redux/modules/git';
import { setSpaceSortOrder } from '../redux/modules/global';
import { ForceToWorkspace } from '../redux/modules/helpers';
import { importClipBoard, importFile, importUri } from '../redux/modules/import';
import { activateWorkspace, createWorkspace } from '../redux/modules/workspace';
import Highlight from './base/highlight';
import { selectSpaceSortOrder } from '../redux/selectors';
import SettingsButton from './buttons/settings-button';
import AccountDropdown from './dropdowns/account-dropdown';
import { RemoteWorkspacesDropdown } from './dropdowns/remote-workspaces-dropdown';
import { SpaceDropdown } from './dropdowns/space-dropdown';
import { WorkspaceCardDropdown } from './dropdowns/workspace-card-dropdown';
import { SpaceSortDropdown } from './dropdowns/space-sort-dropdown';
import KeydownBinder from './keydown-binder';
import { showPrompt } from './modals';
import Notice from './notice';
import PageLayout from './page-layout';
import TimeFromNow from './time-from-now';
import type {
WrapperProps,
} from './wrapper';
import WorkspaceCard, { WorkspaceCardProps } from './workspace-card';
import type { WrapperProps } from './wrapper';
interface RenderedCard {
card: ReactNode;
lastModifiedTimestamp?: number | null;
}
interface Props extends ReturnType<typeof mapDispatchToProps> {
interface Props
extends ReturnType<typeof mapDispatchToProps>,
ReturnType<typeof mapStateToProps> {
wrapperProps: WrapperProps;
}
@ -61,6 +60,100 @@ interface State {
filter: string;
}
function orderSpaceCards(orderBy: SpaceSortOrder) {
return (cardA: Pick<WorkspaceCardProps, 'workspace' | 'lastModifiedTimestamp'>, cardB: Pick<WorkspaceCardProps, 'workspace' | 'lastModifiedTimestamp'>) => {
switch (orderBy) {
case 'modified-desc':
return sortMethodMap['modified-desc'](cardA, cardB);
case 'name-asc':
return sortMethodMap['name-asc'](cardA.workspace, cardB.workspace);
case 'name-desc':
return sortMethodMap['name-desc'](cardA.workspace, cardB.workspace);
case 'created-asc':
return sortMethodMap['created-asc'](cardA.workspace, cardB.workspace);
case 'created-desc':
return sortMethodMap['created-desc'](cardA.workspace, cardB.workspace);
default:
return unreachableCase(orderBy, `Space Ordering "${orderBy}" is invalid`);
}
};
}
const mapWorkspaceToWorkspaceCard = ({
apiSpecs,
workspaceMetas,
}: {
apiSpecs: ApiSpec[];
workspaceMetas: WorkspaceMeta[];
}) => (workspace: Workspace) => {
const apiSpec = apiSpecs.find((s) => s.parentId === workspace._id);
// an apiSpec model will always exist because a migration in the workspace forces it to
if (!apiSpec) {
return null;
}
let spec: ParsedApiSpec['contents'] = null;
let specFormat: ParsedApiSpec['format'] = null;
let specFormatVersion: ParsedApiSpec['formatVersion'] = null;
try {
const result = parseApiSpec(apiSpec.contents);
spec = result.contents;
specFormat = result.format;
specFormatVersion = result.formatVersion;
} catch (err) {
// Assume there is no spec
// TODO: Check for parse errors if it's an invalid spec
}
// Get cached branch from WorkspaceMeta
const workspaceMeta = workspaceMetas?.find(
(wm) => wm.parentId === workspace._id
);
const lastActiveBranch = workspaceMeta?.cachedGitRepositoryBranch;
const lastCommitAuthor = workspaceMeta?.cachedGitLastAuthor;
// WorkspaceMeta is a good proxy for last modified time
const workspaceModified = workspaceMeta?.modified || workspace.modified;
const modifiedLocally = isDesign(workspace)
? apiSpec.modified
: workspaceModified;
// Span spec, workspace and sync related timestamps for card last modified label and sort order
const lastModifiedFrom = [
workspace?.modified,
workspaceMeta?.modified,
apiSpec.modified,
workspaceMeta?.cachedGitLastCommitTime,
];
const lastModifiedTimestamp = lastModifiedFrom
.filter(isNotNullOrUndefined)
.sort(descendingNumberSort)[0];
const hasUnsavedChanges = Boolean(
isDesign(workspace) && workspaceMeta?.cachedGitLastCommitTime && apiSpec.modified > workspaceMeta?.cachedGitLastCommitTime
);
return {
hasUnsavedChanges,
lastModifiedTimestamp,
modifiedLocally,
lastCommitTime: workspaceMeta?.cachedGitLastCommitTime,
lastCommitAuthor,
lastActiveBranch,
spec,
specFormat,
apiSpec,
specFormatVersion,
workspace,
};
};
@autoBindMethodsForReact(AUTOBIND_CFG)
class WrapperHome extends PureComponent<Props, State> {
state: State = {
@ -86,7 +179,10 @@ class WrapperHome extends PureComponent<Props, State> {
}
_handleCollectionCreate() {
const { handleCreateWorkspace, wrapperProps: { activeSpace, vcs, isLoggedIn } } = this.props;
const {
handleCreateWorkspace,
wrapperProps: { activeSpace, vcs, isLoggedIn },
} = this.props;
handleCreateWorkspace({
scope: WorkspaceScopeKeys.collection,
@ -117,7 +213,7 @@ class WrapperHome extends PureComponent<Props, State> {
submitName: 'Fetch and Import',
label: 'URL',
placeholder: 'https://website.com/insomnia-import.json',
onComplete: uri => {
onComplete: (uri) => {
this.props.handleImportUri(uri, {
forceToWorkspace: ForceToWorkspace.new,
});
@ -139,140 +235,6 @@ class WrapperHome extends PureComponent<Props, State> {
});
}
renderCard(workspace: Workspace) {
const {
activeSpace,
apiSpecs,
workspaceMetas,
} = this.props.wrapperProps;
const { filter } = this.state;
const apiSpec = apiSpecs.find(s => s.parentId === workspace._id);
// an apiSpec model will always exist because a migration in the workspace forces it to
if (!apiSpec) {
return null;
}
let spec: ParsedApiSpec['contents'] = null;
let specFormat: ParsedApiSpec['format'] = null;
let specFormatVersion: ParsedApiSpec['formatVersion'] = null;
try {
const result = parseApiSpec(apiSpec.contents);
spec = result.contents;
specFormat = result.format;
specFormatVersion = result.formatVersion;
} catch (err) {
// Assume there is no spec
// TODO: Check for parse errors if it's an invalid spec
}
// Get cached branch from WorkspaceMeta
const workspaceMeta = workspaceMetas?.find(wm => wm.parentId === workspace._id);
const lastActiveBranch = workspaceMeta ? workspaceMeta.cachedGitRepositoryBranch : null;
const lastCommitAuthor = workspaceMeta ? workspaceMeta.cachedGitLastAuthor : null;
const lastCommitTime = workspaceMeta ? workspaceMeta.cachedGitLastCommitTime : null;
// WorkspaceMeta is a good proxy for last modified time
const workspaceModified = workspaceMeta ? workspaceMeta.modified : workspace.modified;
const modifiedLocally = isDesign(workspace) ? apiSpec.modified : workspaceModified;
// Span spec, workspace and sync related timestamps for card last modified label and sort order
const lastModifiedFrom = [
workspace?.modified,
workspaceMeta?.modified,
apiSpec.modified,
workspaceMeta?.cachedGitLastCommitTime,
];
const lastModifiedTimestamp = lastModifiedFrom
.filter(isNotNullOrUndefined)
.sort(descendingNumberSort)[0];
// @ts-expect-error -- TSCONVERSION appears to be genuine
let log = <TimeFromNow timestamp={lastModifiedTimestamp} />;
let branch = lastActiveBranch;
if (
isDesign(workspace) &&
lastCommitTime &&
apiSpec.modified > lastCommitTime
) {
// Show locally unsaved changes for spec
// NOTE: this doesn't work for non-spec workspaces
branch = lastActiveBranch + '*';
log = (
<Fragment>
<TimeFromNow className="text-danger" timestamp={modifiedLocally} /> (unsaved)
</Fragment>
);
} else if (lastCommitTime) {
// Show last commit time and author
branch = lastActiveBranch;
log = (
<Fragment>
<TimeFromNow timestamp={lastCommitTime} /> {lastCommitAuthor && `by ${lastCommitAuthor}`}
</Fragment>
);
}
const docMenu = <WorkspaceCardDropdown apiSpec={apiSpec} workspace={workspace} space={activeSpace} />;
const version = spec?.info?.version || '';
let label: string = strings.collection.singular;
let format = '';
let labelIcon = <i className="fa fa-bars" />;
let title = workspace.name;
if (isDesign(workspace)) {
label = strings.document.singular;
labelIcon = <i className="fa fa-file-o" />;
if (specFormat === 'openapi') {
format = `OpenAPI ${specFormatVersion}`;
} else if (specFormat === 'swagger') {
// NOTE: This is not a typo, we're labeling Swagger as OpenAPI also
format = `OpenAPI ${specFormatVersion}`;
}
title = apiSpec.fileName || title;
}
// Filter the card by multiple different properties
const matchResults = fuzzyMatchAll(filter, [title, label, branch, version], {
splitSpace: true,
loose: true,
});
// Return null if we don't match the filter
if (filter && !matchResults) {
return null;
}
const card = (
<Card
key={apiSpec._id}
docBranch={branch ? <Highlight search={filter} text={branch} /> : undefined}
docTitle={title ? <Highlight search={filter} text={title} /> : undefined}
docVersion={version ? <Highlight search={filter} text={`v${version}`} /> : undefined}
tagLabel={
label ? (
<>
<span className="margin-right-xs">{labelIcon}</span>
<Highlight search={filter} text={label} />
</>
) : undefined
}
docLog={log}
docMenu={docMenu}
docFormat={format}
onClick={() => this.props.handleActivateWorkspace(workspace)}
/>
);
const renderedCard: RenderedCard = {
card,
lastModifiedTimestamp,
};
return renderedCard;
}
renderCreateMenu() {
const button = (
<Button variant="contained" bg="surprise" className="margin-left">
@ -283,25 +245,41 @@ class WrapperHome extends PureComponent<Props, State> {
return (
<Dropdown renderButton={button}>
<DropdownDivider>New</DropdownDivider>
<DropdownItem icon={<i className="fa fa-file-o" />} onClick={this._handleDocumentCreate}>
<DropdownItem
icon={<i className="fa fa-file-o" />}
onClick={this._handleDocumentCreate}
>
Design Document
</DropdownItem>
<DropdownItem icon={<i className="fa fa-bars" />} onClick={this._handleCollectionCreate}>
<DropdownItem
icon={<i className="fa fa-bars" />}
onClick={this._handleCollectionCreate}
>
Request Collection
</DropdownItem>
<DropdownDivider>Import From</DropdownDivider>
<DropdownItem icon={<i className="fa fa-plus" />} onClick={this._handleImportFile}>
<DropdownItem
icon={<i className="fa fa-plus" />}
onClick={this._handleImportFile}
>
File
</DropdownItem>
<DropdownItem icon={<i className="fa fa-link" />} onClick={this._handleImportUri}>
<DropdownItem
icon={<i className="fa fa-link" />}
onClick={this._handleImportUri}
>
URL
</DropdownItem>
<DropdownItem
icon={<i className="fa fa-clipboard" />}
onClick={this._handleImportClipBoard}>
onClick={this._handleImportClipBoard}
>
Clipboard
</DropdownItem>
<DropdownItem icon={<i className="fa fa-code-fork" />} onClick={this._handleWorkspaceClone}>
<DropdownItem
icon={<i className="fa fa-code-fork" />}
onClick={this._handleWorkspaceClone}
>
Git Clone
</DropdownItem>
</Dropdown>
@ -309,14 +287,16 @@ class WrapperHome extends PureComponent<Props, State> {
}
renderDashboardMenu() {
const { vcs } = this.props.wrapperProps;
const { wrapperProps, handleSetSpaceSortOrder } = this.props;
const { vcs } = wrapperProps;
return (
<div className="row row--right pad-left wide">
<div
className="form-control form-control--outlined no-margin"
style={{
maxWidth: '400px',
}}>
}}
>
<KeydownBinder onKeydown={this._handleKeyDown}>
<input
ref={this._setFilterInputRef}
@ -328,6 +308,7 @@ class WrapperHome extends PureComponent<Props, State> {
<span className="fa fa-search filter-icon" />
</KeydownBinder>
</div>
<SpaceSortDropdown onSelect={handleSetSpaceSortOrder} />
<RemoteWorkspacesDropdown vcs={vcs} className="margin-left" />
{this.renderCreateMenu()}
</div>
@ -335,16 +316,38 @@ class WrapperHome extends PureComponent<Props, State> {
}
render() {
const { workspaces, isLoading, vcs } = this.props.wrapperProps;
const { sortOrder, wrapperProps, handleActivateWorkspace } = this.props;
const {
workspaces,
isLoading,
vcs,
activeSpace,
workspaceMetas,
apiSpecs,
} = wrapperProps;
const { filter } = this.state;
// Render each card, removing all the ones that don't match the filter
const cards = workspaces
.map(this.renderCard)
.map(
mapWorkspaceToWorkspaceCard({
workspaceMetas,
apiSpecs,
})
)
.filter(isNotNullOrUndefined)
// @ts-expect-error -- TSCONVERSION appears to be a genuine error
.sort((a: RenderedCard, b: RenderedCard) => descendingNumberSort(a.lastModifiedTimestamp, b.lastModifiedTimestamp))
.map(c => c?.card);
const countLabel = cards.length === 1 ? strings.document.singular : strings.document.plural;
.sort(orderSpaceCards(sortOrder))
.map((props) => (
<WorkspaceCard
{...props}
key={props.apiSpec._id}
activeSpace={activeSpace}
onSelect={() => handleActivateWorkspace(props.workspace)}
filter={filter}
/>
));
const countLabel =
cards.length === 1 ? strings.document.singular : strings.document.plural;
return (
<PageLayout
wrapperProps={this.props.wrapperProps}
@ -354,8 +357,17 @@ class WrapperHome extends PureComponent<Props, State> {
gridLeft={
<Fragment>
<img src={coreLogo} alt="Insomnia" width="24" height="24" />
<Breadcrumb crumbs={[{ id: 'space', node: <SpaceDropdown vcs={vcs || undefined} /> }]} />
{isLoading ? <i className="fa fa-refresh fa-spin space-left" /> : null}
<Breadcrumb
crumbs={[
{
id: 'space',
node: <SpaceDropdown vcs={vcs || undefined} />,
},
]}
/>
{isLoading ? (
<i className="fa fa-refresh fa-spin space-left" />
) : null}
</Fragment>
}
gridRight={
@ -392,15 +404,23 @@ class WrapperHome extends PureComponent<Props, State> {
}
}
const mapStateToProps = (state) => ({
sortOrder: selectSpaceSortOrder(state),
});
const mapDispatchToProps = (dispatch) => {
const bound = bindActionCreators({
createWorkspace,
cloneGitRepository,
importFile,
importClipBoard,
importUri,
activateWorkspace,
}, dispatch);
const bound = bindActionCreators(
{
createWorkspace,
cloneGitRepository,
importFile,
importClipBoard,
importUri,
setSpaceSortOrder,
activateWorkspace,
},
dispatch
);
return ({
handleCreateWorkspace: bound.createWorkspace,
@ -408,8 +428,9 @@ const mapDispatchToProps = (dispatch) => {
handleImportFile: bound.importFile,
handleImportUri: bound.importUri,
handleImportClipboard: bound.importClipBoard,
handleSetSpaceSortOrder: bound.setSpaceSortOrder,
handleActivateWorkspace: bound.activateWorkspace,
});
};
export default connect(null, mapDispatchToProps)(WrapperHome);
export default connect(mapStateToProps, mapDispatchToProps)(WrapperHome);

View File

@ -14,6 +14,7 @@ import {
ACTIVITY_UNIT_TEST,
DEPRECATED_ACTIVITY_INSOMNIA,
GlobalActivity,
SORT_MODIFIED_DESC,
} from '../../../../common/constants';
import { getDesignerDataDir } from '../../../../common/electron-helpers';
import * as models from '../../../../models';
@ -23,10 +24,12 @@ import {
initActiveActivity,
initActiveSpace,
initActiveWorkspace,
initSpaceSortOrder,
LOCALSTORAGE_PREFIX,
SET_ACTIVE_ACTIVITY,
SET_ACTIVE_SPACE,
SET_ACTIVE_WORKSPACE,
SET_SPACE_SORT_ORDER,
setActiveActivity,
setActiveSpace,
setActiveWorkspace,
@ -272,6 +275,37 @@ describe('global', () => {
});
});
describe('initSpaceSortOrder', () => {
it('should initialize from local storage', () => {
const sortOrder = SORT_MODIFIED_DESC;
global.localStorage.setItem(
`${LOCALSTORAGE_PREFIX}::space-sort-order`,
JSON.stringify(sortOrder),
);
const expectedEvent = {
'payload': {
sortOrder,
},
'type': SET_SPACE_SORT_ORDER,
};
expect(initSpaceSortOrder()).toStrictEqual(expectedEvent);
});
it('should default to modified-desc if not exist', async () => {
const expectedEvent = {
'payload': {
sortOrder: SORT_MODIFIED_DESC,
},
'type': SET_SPACE_SORT_ORDER,
};
expect(initSpaceSortOrder()).toStrictEqual(expectedEvent);
});
});
describe('initActiveActivity', () => {
it.each([
ACTIVITY_SPEC,

View File

@ -7,7 +7,7 @@ import { combineReducers, Dispatch } from 'redux';
import { unreachableCase } from 'ts-assert-unreachable';
import { trackEvent } from '../../../common/analytics';
import { GlobalActivity } from '../../../common/constants';
import type { GlobalActivity, SpaceSortOrder } from '../../../common/constants';
import {
ACTIVITY_ANALYTICS,
ACTIVITY_DEBUG,
@ -57,6 +57,7 @@ export const LOAD_STOP = 'global/load-stop';
const LOAD_REQUEST_START = 'global/load-request-start';
const LOAD_REQUEST_STOP = 'global/load-request-stop';
export const SET_ACTIVE_SPACE = 'global/activate-space';
export const SET_SPACE_SORT_ORDER = 'global/space-sort-order';
export const SET_ACTIVE_WORKSPACE = 'global/activate-workspace';
export const SET_ACTIVE_ACTIVITY = 'global/activate-activity';
const COMMAND_ALERT = 'app/alert';
@ -89,6 +90,16 @@ function activeSpaceReducer(state: string = BASE_SPACE_ID, action) {
}
}
function spaceSortOrderReducer(state: SpaceSortOrder = 'modified-desc', action) {
switch (action.type) {
case SET_SPACE_SORT_ORDER:
return action.payload.sortOrder;
default:
return state;
}
}
function activeWorkspaceReducer(state: string | null = null, action) {
switch (action.type) {
case SET_ACTIVE_WORKSPACE:
@ -142,6 +153,7 @@ function loginStateChangeReducer(state = false, action) {
export interface GlobalState {
isLoading: boolean;
activeSpaceId: string;
spaceSortOrder: SpaceSortOrder;
activeWorkspaceId: string | null;
activeActivity: GlobalActivity | null,
isLoggedIn: boolean;
@ -150,6 +162,7 @@ export interface GlobalState {
export const reducer = combineReducers<GlobalState>({
isLoading: loadingReducer,
spaceSortOrder: spaceSortOrderReducer,
loadingRequestIds: loadingRequestsReducer,
activeSpaceId: activeSpaceReducer,
activeWorkspaceId: activeWorkspaceReducer,
@ -374,6 +387,17 @@ export const setActiveSpace = (spaceId: string) => {
};
};
export const setSpaceSortOrder = (sortOrder: SpaceSortOrder) => {
const key = `${LOCALSTORAGE_PREFIX}::space-sort-order`;
window.localStorage.setItem(key, JSON.stringify(sortOrder));
return {
type: SET_SPACE_SORT_ORDER,
payload: {
sortOrder,
},
};
};
export const setActiveWorkspace = (workspaceId: string | null) => {
const key = `${LOCALSTORAGE_PREFIX}::activeWorkspaceId`;
window.localStorage.setItem(key, JSON.stringify(workspaceId));
@ -647,6 +671,23 @@ export function initActiveSpace() {
return setActiveSpace(spaceId || BASE_SPACE_ID);
}
export function initSpaceSortOrder() {
let spaceSortOrder: SpaceSortOrder = 'modified-desc';
try {
const spaceSortOrderKey = `${LOCALSTORAGE_PREFIX}::space-sort-order`;
const stringifiedSpaceSortOrder = window.localStorage.getItem(spaceSortOrderKey);
if (stringifiedSpaceSortOrder) {
spaceSortOrder = JSON.parse(stringifiedSpaceSortOrder);
}
} catch (e) {
// Nothing here...
}
return setSpaceSortOrder(spaceSortOrder);
}
export function initActiveWorkspace() {
let workspaceId: string | null = null;
@ -728,6 +769,7 @@ export const initActiveActivity = () => (dispatch, getState) => {
export const init = () => [
initActiveSpace(),
initSpaceSortOrder(),
initActiveWorkspace(),
initActiveActivity(),
];

View File

@ -43,4 +43,4 @@ export const removeSpace = (space: Space) => dispatch => {
trackSegmentEvent('Local Space Deleted');
},
});
};
};

View File

@ -84,6 +84,11 @@ export const selectActiveSpace = createSelector(
},
);
export const selectSpaceSortOrder = createSelector(
selectGlobal,
global => global.spaceSortOrder
);
export const selectAllWorkspaces = createSelector(
selectEntitiesLists,
entities => entities.workspaces,