From e683a02b160cce65b37080ac48dd9707a437dbba Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 5 Aug 2021 20:38:58 +0300 Subject: [PATCH] [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 --- packages/insomnia-app/app/common/constants.ts | 39 +- packages/insomnia-app/app/common/misc.ts | 10 +- packages/insomnia-app/app/common/sorting.ts | 46 ++- .../dropdowns/space-sort-dropdown.tsx | 27 ++ .../sidebar/sidebar-sort-dropdown.tsx | 2 +- .../app/ui/components/workspace-card.tsx | 146 +++++++ .../app/ui/components/wrapper-home.tsx | 375 +++++++++--------- .../ui/redux/modules/__tests__/global.test.ts | 34 ++ .../app/ui/redux/modules/global.tsx | 44 +- .../app/ui/redux/modules/space.ts | 2 +- .../insomnia-app/app/ui/redux/selectors.ts | 5 + 11 files changed, 526 insertions(+), 204 deletions(-) create mode 100644 packages/insomnia-app/app/ui/components/dropdowns/space-sort-dropdown.tsx create mode 100644 packages/insomnia-app/app/ui/components/workspace-card.tsx diff --git a/packages/insomnia-app/app/common/constants.ts b/packages/insomnia-app/app/common/constants.ts index efd75b4a0..311a6a8f0 100644 --- a/packages/insomnia-app/app/common/constants.ts +++ b/packages/insomnia-app/app/common/constants.ts @@ -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 = { [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 = { + [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]; diff --git a/packages/insomnia-app/app/common/misc.ts b/packages/insomnia-app/app/common/misc.ts index c40300a8b..8012da968 100644 --- a/packages/insomnia-app/app/common/misc.ts +++ b/packages/insomnia-app/app/common/misc.ts @@ -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( + value: ValueType | null | undefined +): value is ValueType { + if (value === null || value === undefined) return false; + + return true; +} \ No newline at end of file diff --git a/packages/insomnia-app/app/common/sorting.ts b/packages/insomnia-app/app/common/sorting.ts index cb179e67d..c9e403957 100644 --- a/packages/insomnia-app/app/common/sorting.ts +++ b/packages/insomnia-app/app/common/sorting.ts @@ -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 = (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> = (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> = (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> = (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> = (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 = (a, b) => { return a < b ? -1 : 1; }; -export const descendingNumberSort = (a: number, b: number): number => { +export const descendingNumberSort: SortFunction = (a, b) => { return ascendingNumberSort(b, a); }; -// @ts-expect-error -- TSCONVERSION appears to be a genuine error -export const sortMethodMap: Record = { +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, diff --git a/packages/insomnia-app/app/ui/components/dropdowns/space-sort-dropdown.tsx b/packages/insomnia-app/app/ui/components/dropdowns/space-sort-dropdown.tsx new file mode 100644 index 000000000..e7b4e96f8 --- /dev/null +++ b/packages/insomnia-app/app/ui/components/dropdowns/space-sort-dropdown.tsx @@ -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 = ({ onSelect }) => { + return ( + + + + } + > + {SPACE_SORT_ORDERS.map(order => ( + + {spaceSortOrderName[order]} + + ))} + + ); +}; \ No newline at end of file diff --git a/packages/insomnia-app/app/ui/components/sidebar/sidebar-sort-dropdown.tsx b/packages/insomnia-app/app/ui/components/sidebar/sidebar-sort-dropdown.tsx index d85e73b5d..478b60ceb 100644 --- a/packages/insomnia-app/app/ui/components/sidebar/sidebar-sort-dropdown.tsx +++ b/packages/insomnia-app/app/ui/components/sidebar/sidebar-sort-dropdown.tsx @@ -13,7 +13,7 @@ const SidebarSortDropdown: FunctionComponent = ({ handleSort }) => ( {SORT_ORDERS.map(order => ( - handleSort(order)} key={order}> + {sortOrderName[order]} ))} diff --git a/packages/insomnia-app/app/ui/components/workspace-card.tsx b/packages/insomnia-app/app/ui/components/workspace-card.tsx new file mode 100644 index 000000000..d5c216712 --- /dev/null +++ b/packages/insomnia-app/app/ui/components/workspace-card.tsx @@ -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 | null; + specFormat: 'openapi' | 'swagger' | null; + specFormatVersion: string | null; + hasUnsavedChanges: boolean; + onSelect: (workspaceId: string, activity: GlobalActivity) => void; +} + +const WorkspaceCard: FC = ({ + apiSpec, + filter, + lastActiveBranch, + lastModifiedTimestamp, + workspace, + activeSpace, + lastCommitTime, + modifiedLocally, + lastCommitAuthor, + spec, + specFormat, + specFormatVersion, + hasUnsavedChanges, + onSelect, +}) => { + let branch = lastActiveBranch; + + let log = ; + + if (hasUnsavedChanges) { + // Show locally unsaved changes for spec + // NOTE: this doesn't work for non-spec workspaces + branch = lastActiveBranch + '*'; + if (modifiedLocally) { + log = ( + + {' '} + (unsaved) + + ); + } + } else if (lastCommitTime) { + // Show last commit time and author + log = ( + + {' '} + {lastCommitAuthor && `by ${lastCommitAuthor}`} + + ); + } + const docMenu = ( + + ); + + const version = spec?.info?.version || ''; + let label: string = strings.collection.singular; + let format = ''; + let labelIcon = ; + let defaultActivity = ACTIVITY_DEBUG; + let title = workspace.name; + + if (isDesign(workspace)) { + label = strings.document.singular; + labelIcon = ; + + 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 ( + : undefined + } + docTitle={title ? : undefined} + docVersion={ + version ? : undefined + } + tagLabel={ + label ? ( + <> + {labelIcon} + + + ) : undefined + } + docLog={log} + docMenu={docMenu} + docFormat={format} + onClick={() => onSelect(workspace._id, defaultActivity)} + /> + ); +}; + +export default WorkspaceCard; diff --git a/packages/insomnia-app/app/ui/components/wrapper-home.tsx b/packages/insomnia-app/app/ui/components/wrapper-home.tsx index fca193233..d55396f71 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-home.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper-home.tsx @@ -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 { +interface Props + extends ReturnType, + ReturnType { wrapperProps: WrapperProps; } @@ -61,6 +60,100 @@ interface State { filter: string; } +function orderSpaceCards(orderBy: SpaceSortOrder) { + return (cardA: Pick, cardB: Pick) => { + 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 { state: State = { @@ -86,7 +179,10 @@ class WrapperHome extends PureComponent { } _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 { 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 { }); } - 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 = ; - 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 = ( - - (unsaved) - - ); - } else if (lastCommitTime) { - // Show last commit time and author - branch = lastActiveBranch; - log = ( - - {lastCommitAuthor && `by ${lastCommitAuthor}`} - - ); - } - - const docMenu = ; - const version = spec?.info?.version || ''; - let label: string = strings.collection.singular; - let format = ''; - let labelIcon = ; - let title = workspace.name; - - if (isDesign(workspace)) { - label = strings.document.singular; - labelIcon = ; - - 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 = ( - : undefined} - docTitle={title ? : undefined} - docVersion={version ? : undefined} - tagLabel={ - label ? ( - <> - {labelIcon} - - - ) : undefined - } - docLog={log} - docMenu={docMenu} - docFormat={format} - onClick={() => this.props.handleActivateWorkspace(workspace)} - /> - ); - const renderedCard: RenderedCard = { - card, - lastModifiedTimestamp, - }; - return renderedCard; - } - renderCreateMenu() { const button = (