diff --git a/packages/compass-components/src/components/leafygreen.tsx b/packages/compass-components/src/components/leafygreen.tsx index 96f8df17870..7cbf9d75001 100644 --- a/packages/compass-components/src/components/leafygreen.tsx +++ b/packages/compass-components/src/components/leafygreen.tsx @@ -53,8 +53,11 @@ import { TableBody, flexRender, useLeafyGreenTable, + useLeafyGreenVirtualTable, + type LeafyGreenVirtualItem, getExpandedRowModel, getFilteredRowModel, + type TableProps, } from '@leafygreen-ui/table'; import type { Row as LgTableRowType } from '@tanstack/table-core'; // TODO(COMPASS-8437): import from LG @@ -197,6 +200,9 @@ export { InfoSprinkle, flexRender, useLeafyGreenTable, + useLeafyGreenVirtualTable, + type LeafyGreenVirtualItem, + type TableProps, getExpandedRowModel, getFilteredRowModel, type LgTableRowType, diff --git a/packages/databases-collections-list/src/collections.tsx b/packages/databases-collections-list/src/collections.tsx index 0519d6d26e1..405ca92e8f3 100644 --- a/packages/databases-collections-list/src/collections.tsx +++ b/packages/databases-collections-list/src/collections.tsx @@ -1,20 +1,76 @@ -import React from 'react'; -import { css, spacing } from '@mongodb-js/compass-components'; +import React, { useCallback } from 'react'; +import { + Badge, + type BadgeVariant, + cx, + css, + type GlyphName, + Icon, + spacing, + type LGColumnDef, + Tooltip, +} from '@mongodb-js/compass-components'; import { compactBytes, compactNumber } from './format'; -import type { BadgeProp } from './namespace-card'; -import { NamespaceItemCard } from './namespace-card'; -import { ItemsGrid } from './items-grid'; +import { ItemsTable } from './items-table'; import type { CollectionProps } from 'mongodb-collection-model'; import { usePreference } from 'compass-preferences-model/provider'; -const COLLECTION_CARD_WIDTH = spacing[1600] * 4; +type BadgeProp = { + id: string; + name: string; + variant?: BadgeVariant; + icon?: GlyphName; + hint?: React.ReactNode; +}; -const COLLECTION_CARD_HEIGHT = 238; -const COLLECTION_CARD_WITHOUT_STATS_HEIGHT = COLLECTION_CARD_HEIGHT - 150; +const cardBadgesStyles = css({ + display: 'flex', + gap: spacing[200], + // Preserving space for when cards with and without badges are mixed in a + // single row + minHeight: 20, +}); -const COLLECTION_CARD_LIST_HEIGHT = 118; -const COLLECTION_CARD_LIST_WITHOUT_STATS_HEIGHT = - COLLECTION_CARD_LIST_HEIGHT - 50; +const CardBadges: React.FunctionComponent = ({ children }) => { + return
{children}
; +}; + +const cardBadgeStyles = css({ + gap: spacing[100], +}); + +const CardBadge: React.FunctionComponent = ({ + id, + name, + icon, + variant, + hint, +}) => { + const badge = useCallback( + ({ className, children, ...props } = {}) => { + return ( + + {icon && } + {name} + {/* Tooltip will be rendered here */} + {children} + + ); + }, + [id, icon, name, variant] + ); + + if (hint) { + return {hint}; + } + + return badge(); +}; function collectionPropertyToBadge({ id, @@ -62,18 +118,114 @@ function collectionPropertyToBadge({ } } -const pageContainerStyles = css({ - height: 'auto', - width: '100%', +const collectionNameStyles = css({ display: 'flex', - flexDirection: 'column', + gap: spacing[100], + flexWrap: 'wrap', + alignItems: 'anchor-center', }); +function collectionColumns( + enableDbAndCollStats: boolean +): LGColumnDef[] { + return [ + { + accessorKey: 'name', + header: 'Collection name', + enableSorting: true, + size: 300, + cell: (info) => { + const name = info.getValue() as string; + + const badges = info.row.original.properties + .filter((prop) => prop.id !== 'read-only') + .map((prop) => { + return collectionPropertyToBadge(prop); + }); + + return ( +
+ {name} + + {badges.map((badge) => { + return ; + })} + +
+ ); + }, + }, + { + accessorKey: 'calculated_storage_size', + header: 'Storage size', + enableSorting: true, + cell: (info) => { + const type = info.row.original.type as string; + if (type === 'view') { + return '-'; + } + const size = info.getValue() as number | undefined; + return enableDbAndCollStats && size !== undefined + ? compactBytes(size) + : '-'; + }, + }, + { + accessorKey: 'avg_document_size', + header: 'Avg. document size', + enableSorting: true, + cell: (info) => { + const type = info.row.original.type as string; + if (type === 'view' || type === 'timeseries') { + return '-'; + } + + const size = info.getValue() as number | undefined; + return enableDbAndCollStats && size !== undefined + ? compactBytes(size) + : '-'; + }, + }, + { + accessorKey: 'Indexes', + header: 'Indexes', + enableSorting: true, + cell: (info) => { + const type = info.row.original.type as string; + if (type === 'view' || type === 'timeseries') { + return '-'; + } + + const index_count = info.getValue() as number | undefined; + return enableDbAndCollStats && index_count !== undefined + ? compactNumber(index_count) + : '-'; + }, + }, + { + accessorKey: 'index_size', + header: 'Total index size', + enableSorting: true, + cell: (info) => { + const type = info.row.original.type as string; + if (type === 'view' || type === 'timeseries') { + return '-'; + } + + const size = info.getValue() as number | undefined; + return enableDbAndCollStats && size !== undefined + ? compactBytes(size) + : '-'; + }, + }, + ]; +} + +// TODO: we removed delete click functionality, we removed the header hint functionality const CollectionsList: React.FunctionComponent<{ namespace: string; collections: CollectionProps[]; onCollectionClick: (id: string) => void; - onDeleteCollectionClick?: (id: string) => void; onCreateCollectionClick?: () => void; onRefreshClick?: () => void; }> = ({ @@ -81,148 +233,23 @@ const CollectionsList: React.FunctionComponent<{ collections, onCollectionClick, onCreateCollectionClick, - onDeleteCollectionClick, onRefreshClick, }) => { const enableDbAndCollStats = usePreference('enableDbAndCollStats'); + const columns = React.useMemo( + () => collectionColumns(enableDbAndCollStats), + [enableDbAndCollStats] + ); return ( -
- { - const data = - coll.type === 'view' - ? [{ label: 'View on', value: coll.source?.name }] - : coll.type === 'timeseries' - ? [ - { - label: 'Storage', - value: - coll.calculated_storage_size !== undefined - ? compactBytes(coll.calculated_storage_size) - : 'N/A', - hint: - coll.calculated_storage_size !== undefined && - coll.storage_size !== undefined && - coll.free_storage_size !== undefined && - 'Storage Data: Disk space allocated to this collection for document storage.\n' + - `Total storage: ${compactBytes(coll.storage_size)}\n` + - `Free storage: ${compactBytes(coll.free_storage_size)}`, - }, - { - label: 'Uncompressed data', - value: - coll.document_size !== undefined - ? compactBytes(coll.document_size) - : 'N/A', - hint: - coll.document_size !== undefined && - 'Uncompressed Data Size: Total size of the uncompressed data held in this collection.', - }, - ] - : [ - { - label: 'Storage', - value: - coll.calculated_storage_size !== undefined - ? compactBytes(coll.calculated_storage_size) - : 'N/A', - hint: - coll.calculated_storage_size !== undefined && - 'Storage Data: Disk space allocated to this collection for document storage.', - }, - { - label: 'Uncompressed data', - value: - coll.document_size !== undefined - ? compactBytes(coll.document_size) - : 'N/A', - hint: - coll.document_size !== undefined && - 'Uncompressed Data Size: Total size of the uncompressed data held in this collection.', - }, - { - label: 'Documents', - value: - coll.document_count !== undefined - ? compactNumber(coll.document_count) - : 'N/A', - }, - { - label: 'Avg. document size', - value: - coll.avg_document_size !== undefined - ? compactBytes(coll.avg_document_size) - : 'N/A', - }, - { - label: 'Indexes', - value: - coll.index_count !== undefined - ? compactNumber(coll.index_count) - : 'N/A', - }, - { - label: 'Total index size', - value: - coll.index_size !== undefined - ? compactBytes(coll.index_size) - : 'N/A', - }, - ]; - - const badges = coll.properties.map((prop) => { - return collectionPropertyToBadge(prop); - }); - - return ( - - ); - }} - > -
+ ); }; diff --git a/packages/databases-collections-list/src/databases.tsx b/packages/databases-collections-list/src/databases.tsx index ec4e1556f51..b1c2c1228b9 100644 --- a/packages/databases-collections-list/src/databases.tsx +++ b/packages/databases-collections-list/src/databases.tsx @@ -1,24 +1,62 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { PerformanceSignals, spacing } from '@mongodb-js/compass-components'; +// TODO: don't forget about performance insights? +//import { PerformanceSignals, spacing } from '@mongodb-js/compass-components'; import { compactBytes, compactNumber } from './format'; -import { NamespaceItemCard } from './namespace-card'; -import { ItemsGrid } from './items-grid'; +import { ItemsTable } from './items-table'; import type { DatabaseProps } from 'mongodb-database-model'; import { usePreference } from 'compass-preferences-model/provider'; +import type { LGColumnDef } from '@mongodb-js/compass-components'; -const DATABASE_CARD_WIDTH = spacing[1600] * 4; - -const DATABASE_CARD_HEIGHT = 154; -const DATABASE_CARD_WITHOUT_STATS_HEIGHT = DATABASE_CARD_HEIGHT - 85; - -const DATABASE_CARD_LIST_HEIGHT = 118; -const DATABASE_CARD_LIST_WITHOUT_STATS_HEIGHT = DATABASE_CARD_LIST_HEIGHT - 50; +function databaseColumns( + enableDbAndCollStats: boolean +): LGColumnDef[] { + return [ + { + accessorKey: 'name', + header: 'Database name', + enableSorting: true, + }, + { + accessorKey: 'calculated_storage_size', + header: 'Storage size', + enableSorting: true, + cell: (info) => { + // TODO: shouldn't this just have the right type rather than unknown? + const size = info.getValue() as number | undefined; + return enableDbAndCollStats && size !== undefined + ? compactBytes(size) + : '-'; + }, + }, + { + accessorKey: 'collectionsLength', + header: 'Collections', + enableSorting: true, + cell: (info) => { + return enableDbAndCollStats + ? compactNumber(info.getValue() as number) + : '-'; + }, + }, + { + accessorKey: 'index_count', + header: 'Indexes', + enableSorting: true, + cell: (info) => { + const index_count = info.getValue() as number | undefined; + return enableDbAndCollStats && index_count !== undefined + ? compactNumber(index_count) + : '-'; + }, + }, + ]; +} +// TODO: we removed delete click functionality, we removed the header hint functionality const DatabasesList: React.FunctionComponent<{ databases: DatabaseProps[]; onDatabaseClick: (id: string) => void; - onDeleteDatabaseClick?: (id: string) => void; onCreateDatabaseClick?: () => void; onRefreshClick?: () => void; renderLoadSampleDataBanner?: () => React.ReactNode; @@ -26,104 +64,24 @@ const DatabasesList: React.FunctionComponent<{ databases, onDatabaseClick, onCreateDatabaseClick, - onDeleteDatabaseClick, onRefreshClick, renderLoadSampleDataBanner, }) => { const enableDbAndCollStats = usePreference('enableDbAndCollStats'); + const columns = React.useMemo( + () => databaseColumns(enableDbAndCollStats), + [enableDbAndCollStats] + ); return ( - { - return ( - = 10_000 - ? PerformanceSignals.get('too-many-collections') - : undefined, - }, - { - label: 'Indexes', - value: - enableDbAndCollStats && db.index_count !== undefined - ? compactNumber(db.index_count) - : 'N/A', - }, - ]} - onItemClick={onItemClick} - onItemDeleteClick={onDeleteItemClick} - {...props} - > - ); - }} renderLoadSampleDataBanner={renderLoadSampleDataBanner} - > + > ); }; diff --git a/packages/databases-collections-list/src/items-table.tsx b/packages/databases-collections-list/src/items-table.tsx new file mode 100644 index 00000000000..62d0263895d --- /dev/null +++ b/packages/databases-collections-list/src/items-table.tsx @@ -0,0 +1,342 @@ +import React, { Fragment, useMemo } from 'react'; +import { + css, + cx, + spacing, + WorkspaceContainer, + Button, + Icon, + Breadcrumbs, + Table, + TableHead, + TableBody, + useLeafyGreenVirtualTable, + type LGColumnDef, + type HeaderGroup, + HeaderRow, + HeaderCell, + flexRender, + ExpandedContent, + Row, + Cell, + //type LeafyGreenTableRow, + type LeafyGreenVirtualItem, +} from '@mongodb-js/compass-components'; +import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; +import { useConnectionInfo } from '@mongodb-js/compass-connections/provider'; +import toNS from 'mongodb-ns'; +import { getConnectionTitle } from '@mongodb-js/connection-info'; +import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; +import { usePreferences } from 'compass-preferences-model/provider'; + +type Item = { _id: string } & Record; + +export const createButtonStyles = css({ + whiteSpace: 'nowrap', +}); + +type ItemsGridProps = { + namespace?: string; + itemType: 'collection' | 'database'; + columns: LGColumnDef[]; + items: T[]; + onItemClick: (id: string) => void; + onCreateItemClick?: () => void; + onRefreshClick?: () => void; + renderLoadSampleDataBanner?: () => React.ReactNode; +}; + +const controlsContainerStyles = css({ + paddingTop: spacing[200], + paddingRight: spacing[400], + paddingBottom: spacing[400], + paddingLeft: spacing[400], + + display: 'grid', + gridTemplate: '1fr / 100%', + gap: spacing[200], +}); + +const controlRowStyles = css({ + display: 'flex', + alignItems: 'center', + gap: spacing[200], +}); + +const controlStyles = css({ + flex: 'none', +}); + +const breadcrumbContainerStyles = css({ + display: 'flex', + minWidth: 0, + paddingTop: spacing[200], + paddingBottom: spacing[200], +}); + +const pushRightStyles = css({ + marginLeft: 'auto', +}); + +const bannerRowStyles = css({ + paddingTop: spacing[200], +}); + +function buildChartsUrl( + groupId: string, + clusterName: string, + namespace?: string +) { + const { database } = toNS(namespace ?? ''); + const url = new URL(`/charts/${groupId}`, window.location.origin); + url.searchParams.set('sourceType', 'cluster'); + url.searchParams.set('name', clusterName); + if (database) { + url.searchParams.set('database', database); + } + return url.toString(); +} + +const TableControls: React.FunctionComponent<{ + namespace?: string; + itemType: string; + onCreateItemClick?: () => void; + onRefreshClick?: () => void; + renderLoadSampleDataBanner?: () => React.ReactNode; +}> = ({ + namespace, + itemType, + onCreateItemClick, + onRefreshClick, + renderLoadSampleDataBanner, +}) => { + const connectionInfo = useConnectionInfo(); + const connectionTitle = getConnectionTitle(connectionInfo); + const { + openDatabasesWorkspace, + openCollectionsWorkspace, + openShellWorkspace, + } = useOpenWorkspace(); + const track = useTelemetry(); + const { enableShell: showOpenShellButton } = usePreferences(['enableShell']); + + const breadcrumbs = useMemo(() => { + const { database } = toNS(namespace ?? ''); + const items = [ + { + name: connectionTitle, + onClick: () => { + openDatabasesWorkspace(connectionInfo.id); + }, + }, + ]; + + if (database) { + items.push({ + name: database, + onClick: () => { + openCollectionsWorkspace(connectionInfo.id, database); + }, + }); + } + + return items; + }, [ + connectionInfo.id, + connectionTitle, + namespace, + openCollectionsWorkspace, + openDatabasesWorkspace, + ]); + + const banner = renderLoadSampleDataBanner?.(); + + return ( +
+
+
+ +
+ +
+ {showOpenShellButton && ( + + )} + + {connectionInfo.atlasMetadata && ( + + )} + + {onCreateItemClick && ( +
+ +
+ )} + + {onRefreshClick && ( +
+ +
+ )} +
+
+ {banner &&
{banner}
} +
+ ); +}; + +const itemsGridContainerStyles = css({ + width: '100%', + height: '100%', +}); + +const virtualScrollingContainerHeight = css({ + height: 'calc(100vh - 100px)', + padding: `0 ${spacing[400]}px`, +}); + +export const ItemsTable = ({ + namespace, + itemType, + columns, + items, + onItemClick, + onCreateItemClick, + onRefreshClick, + renderLoadSampleDataBanner, +}: ItemsGridProps): React.ReactElement => { + const tableContainerRef = React.useRef(null); + + const table = useLeafyGreenVirtualTable({ + containerRef: tableContainerRef, + data: items, + columns, + virtualizerOptions: { + estimateSize: () => 50, + overscan: 10, + }, + }); + + return ( +
+ + } + > + + + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.virtual.getVirtualItems() && + table.virtual + .getVirtualItems() + .map((virtualRow: LeafyGreenVirtualItem) => { + const row = virtualRow.row; + const isExpandedContent = row.isExpandedContent ?? false; + + return ( + + {!isExpandedContent && ( + // row is required + + onItemClick(row.original._id as string) + } + > + {row.getVisibleCells().map((cell: any) => { + return ( + // cell is required + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + )} + {isExpandedContent && } + + ); + })} + +
+
+
+ ); +};