diff --git a/package-lock.json b/package-lock.json index a618c0e628c..c59a9f514ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47793,6 +47793,8 @@ "@mongodb-js/compass-workspaces": "^0.31.0", "@mongodb-js/connection-info": "^0.11.0", "compass-preferences-model": "^2.33.0", + "mongodb-collection-model": "^5.25.0", + "mongodb-database-model": "^2.25.0", "mongodb-ns": "^2.4.2", "react": "^17.0.2" }, @@ -58585,6 +58587,8 @@ "depcheck": "^1.4.1", "eslint": "^7.25.0", "mocha": "^10.2.0", + "mongodb-collection-model": "^5.25.0", + "mongodb-database-model": "^2.25.0", "mongodb-ns": "^2.4.2", "nyc": "^15.1.0", "prettier": "^2.7.1", diff --git a/packages/collection-model/index.d.ts b/packages/collection-model/index.d.ts index a1059c38dcc..482d9232617 100644 --- a/packages/collection-model/index.d.ts +++ b/packages/collection-model/index.d.ts @@ -78,9 +78,11 @@ interface CollectionProps { index_size: number; isTimeSeries: boolean; isView: boolean; + /** Only relevant for a view and identifies collection/view from which this view was created. */ sourceName: string | null; source: Collection; - properties: { id: string; options?: unknown }[]; + properties: { id: string; options?: Record }[]; + is_non_existent: boolean; } type CollectionDataService = Pick; diff --git a/packages/collection-model/lib/model.js b/packages/collection-model/lib/model.js index 8b7ab68fd55..90cc0636a8d 100644 --- a/packages/collection-model/lib/model.js +++ b/packages/collection-model/lib/model.js @@ -102,8 +102,9 @@ function pickCollectionInfo({ validation, clustered, fle2, + is_non_existent, }) { - return { type, readonly, view_on, collation, pipeline, validation, clustered, fle2 }; + return { type, readonly, view_on, collation, pipeline, validation, clustered, fle2, is_non_existent }; } /** @@ -124,6 +125,7 @@ const CollectionModel = AmpersandModel.extend(debounceActions(['fetch']), { statusError: { type: 'string', default: null }, // Normalized values from collectionInfo command + is_non_existent: 'boolean', readonly: 'boolean', clustered: 'boolean', fle2: 'boolean', @@ -250,6 +252,16 @@ const CollectionModel = AmpersandModel.extend(debounceActions(['fetch']), { ...collStats, ...(collectionInfo && pickCollectionInfo(collectionInfo)), }); + // If the collection is not unprovisioned `is_non_existent` anymore, + // let's update the parent database model to reflect the change. + // This happens when a user tries to insert first document into a + // collection that doesn't exist yet or creates a new collection + // for an unprovisioned database. + if (!this.is_non_existent) { + getParentByType(this, 'Database').set({ + is_non_existent: false, + }); + } } catch (err) { this.set({ status: 'error', statusError: err.message }); throw err; diff --git a/packages/compass-components/src/components/workspace-tabs/tab.tsx b/packages/compass-components/src/components/workspace-tabs/tab.tsx index 93eb17de931..38d1e747c5d 100644 --- a/packages/compass-components/src/components/workspace-tabs/tab.tsx +++ b/packages/compass-components/src/components/workspace-tabs/tab.tsx @@ -206,6 +206,7 @@ function Tab({ tabContentId, iconGlyph, tabTheme, + className: tabClassName, ...props }: TabProps & React.HTMLProps) { const darkMode = useDarkMode(); @@ -250,7 +251,8 @@ function Tab({ themeClass, isSelected && selectedTabStyles, isSelected && tabTheme && selectedThemedTabStyles, - isDragging && draggingTabStyles + isDragging && draggingTabStyles, + tabClassName )} aria-selected={isSelected} role="tab" diff --git a/packages/compass-connections-navigation/src/navigation-item-icon.tsx b/packages/compass-connections-navigation/src/navigation-item-icon.tsx new file mode 100644 index 00000000000..6b5dc4ed9ae --- /dev/null +++ b/packages/compass-connections-navigation/src/navigation-item-icon.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import type { SidebarTreeItem } from './tree-data'; +import { css, Icon, ServerIcon, Tooltip } from '@mongodb-js/compass-components'; +import type { GlyphName } from '@mongodb-js/compass-components'; +import { WithStatusMarker } from './with-status-marker'; +import { isLocalhost } from 'mongodb-build-info'; + +const NON_EXISTANT_NAMESPACE_TEXT = + 'Your privileges grant you access to this namespace, but it does not currently exist'; + +const tooltipTriggerStyles = css({ + display: 'flex', +}); +const IconWithTooltip = ({ + text, + glyph, +}: { + text: string; + glyph: GlyphName; +}) => { + return ( + + + + } + > + {text} + + ); +}; + +export const NavigationItemIcon = ({ item }: { item: SidebarTreeItem }) => { + if (item.type === 'database') { + if (item.isNonExistent) { + return ( + + ); + } + return ; + } + if (item.type === 'collection') { + if (item.isNonExistent) { + return ( + + ); + } + return ; + } + if (item.type === 'view') { + return ; + } + if (item.type === 'timeseries') { + return ; + } + if (item.type === 'connection') { + const isFavorite = item.connectionInfo.savedConnectionType === 'favorite'; + if (isFavorite) { + return ( + + + + ); + } + if (isLocalhost(item.connectionInfo.connectionOptions.connectionString)) { + return ( + + + + ); + } + return ( + + + + ); + } + return null; +}; diff --git a/packages/compass-connections-navigation/src/navigation-item.tsx b/packages/compass-connections-navigation/src/navigation-item.tsx index 932407b2109..46b576b9576 100644 --- a/packages/compass-connections-navigation/src/navigation-item.tsx +++ b/packages/compass-connections-navigation/src/navigation-item.tsx @@ -1,8 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { isLocalhost } from 'mongodb-build-info'; import { - Icon, - ServerIcon, cx, css, palette, @@ -17,8 +14,8 @@ import type { NavigationItemActions } from './item-actions'; import type { SidebarTreeItem, SidebarActionableItem } from './tree-data'; import { getTreeItemStyles } from './utils'; import { ConnectionStatus } from '@mongodb-js/compass-connections/provider'; -import { WithStatusMarker } from './with-status-marker'; import type { Actions } from './constants'; +import { NavigationItemIcon } from './navigation-item-icon'; const nonGenuineBtnStyles = css({ color: palette.yellow.dark2, @@ -115,43 +112,6 @@ export function NavigationItem({ getItemActions, }: NavigationItemProps) { const isDarkMode = useDarkMode(); - const itemIcon = useMemo(() => { - if (item.type === 'database') { - return ; - } - if (item.type === 'collection') { - return ; - } - if (item.type === 'view') { - return ; - } - if (item.type === 'timeseries') { - return ; - } - if (item.type === 'connection') { - const isFavorite = item.connectionInfo.savedConnectionType === 'favorite'; - if (isFavorite) { - return ( - - - - ); - } - if (isLocalhost(item.connectionInfo.connectionOptions.connectionString)) { - return ( - - - - ); - } - return ( - - - - ); - } - }, [item]); - const onAction = useCallback( (action: Actions) => { if (item.type !== 'placeholder') { @@ -258,7 +218,7 @@ export function NavigationItem({ hasDefaultAction={ item.type !== 'connection' || item.connectionStatus === 'connected' } - icon={itemIcon} + icon={} name={item.name} style={style} dataAttributes={itemDataProps} diff --git a/packages/compass-connections-navigation/src/styled-navigation-item.tsx b/packages/compass-connections-navigation/src/styled-navigation-item.tsx index 288d43ae416..bf6e39b6549 100644 --- a/packages/compass-connections-navigation/src/styled-navigation-item.tsx +++ b/packages/compass-connections-navigation/src/styled-navigation-item.tsx @@ -5,13 +5,13 @@ import { } from '@mongodb-js/connection-form'; import { palette, useDarkMode } from '@mongodb-js/compass-components'; import type { SidebarTreeItem } from './tree-data'; -import { ConnectionStatus } from '@mongodb-js/compass-connections/provider'; type AcceptedStyles = { '--item-bg-color'?: string; '--item-bg-color-hover'?: string; '--item-bg-color-active'?: string; '--item-color'?: string; + '--item-color-active'?: string; }; export default function StyledNavigationItem({ @@ -25,12 +25,18 @@ export default function StyledNavigationItem({ const { connectionColorToHex, connectionColorToHexActive } = useConnectionColor(); const { colorCode } = item; - const isDisconnectedConnection = - item.type === 'connection' && - item.connectionStatus !== ConnectionStatus.Connected; + const inactiveColor = useMemo( + () => (isDarkMode ? palette.gray.light1 : palette.gray.dark1), + [isDarkMode] + ); const style: React.CSSProperties & AcceptedStyles = useMemo(() => { const style: AcceptedStyles = {}; + const isDisconnectedConnection = + item.type === 'connection' && item.connectionStatus !== 'connected'; + const isNonExistentNamespace = + (item.type === 'database' || item.type === 'collection') && + item.isNonExistent; if (colorCode && colorCode !== DefaultColorCode) { style['--item-bg-color'] = connectionColorToHex(colorCode); @@ -38,15 +44,18 @@ export default function StyledNavigationItem({ style['--item-bg-color-active'] = connectionColorToHexActive(colorCode); } - if (isDisconnectedConnection) { - style['--item-color'] = isDarkMode - ? palette.gray.light1 - : palette.gray.dark1; + if (isDisconnectedConnection || isNonExistentNamespace) { + style['--item-color'] = inactiveColor; + } + + // For a non-existent namespace, even if its active, we show it as inactive + if (isNonExistentNamespace) { + style['--item-color-active'] = inactiveColor; } return style; }, [ - isDarkMode, - isDisconnectedConnection, + inactiveColor, + item, colorCode, connectionColorToHex, connectionColorToHexActive, diff --git a/packages/compass-connections-navigation/src/tree-data.ts b/packages/compass-connections-navigation/src/tree-data.ts index 6da458cc352..2d3476f2649 100644 --- a/packages/compass-connections-navigation/src/tree-data.ts +++ b/packages/compass-connections-navigation/src/tree-data.ts @@ -54,6 +54,7 @@ export type Database = { collectionsStatus: DatabaseOrCollectionStatus; collectionsLength: number; collections: Collection[]; + isNonExistent: boolean; }; type PlaceholderTreeItem = VirtualPlaceholderItem & { @@ -67,6 +68,7 @@ export type Collection = { type: 'view' | 'collection' | 'timeseries'; sourceName: string | null; pipeline: unknown[]; + isNonExistent: boolean; }; export type NotConnectedConnectionTreeItem = VirtualTreeItem & { @@ -100,6 +102,7 @@ export type DatabaseTreeItem = VirtualTreeItem & { connectionId: string; dbName: string; hasWriteActionsDisabled: boolean; + isNonExistent: boolean; }; export type CollectionTreeItem = VirtualTreeItem & { @@ -110,6 +113,7 @@ export type CollectionTreeItem = VirtualTreeItem & { connectionId: string; namespace: string; hasWriteActionsDisabled: boolean; + isNonExistent: boolean; }; export type SidebarActionableItem = @@ -245,6 +249,7 @@ const databaseToItems = ({ collections, collectionsLength, collectionsStatus, + isNonExistent, }, connectionId, expandedItems = {}, @@ -277,6 +282,7 @@ const databaseToItems = ({ dbName: id, isExpandable: true, hasWriteActionsDisabled, + isNonExistent, }; const sidebarData: SidebarTreeItem[] = [databaseTI]; @@ -304,19 +310,22 @@ const databaseToItems = ({ } return sidebarData.concat( - collections.map(({ _id: id, name, type }, collectionIndex) => ({ - id: `${connectionId}.${id}`, // id is the namespace of the collection, so includes db as well - level: level + 1, - name, - type, - setSize: collectionsLength, - posInSet: collectionIndex + 1, - colorCode, - connectionId, - namespace: id, - hasWriteActionsDisabled, - isExpandable: false, - })) + collections.map( + ({ _id: id, name, type, isNonExistent }, collectionIndex) => ({ + id: `${connectionId}.${id}`, // id is the namespace of the collection, so includes db as well + level: level + 1, + name, + type, + setSize: collectionsLength, + posInSet: collectionIndex + 1, + colorCode, + connectionId, + namespace: id, + hasWriteActionsDisabled, + isExpandable: false, + isNonExistent, + }) + ) ); }; diff --git a/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts b/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts index 8a9aa3e71e3..42b93b40aa5 100644 --- a/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts +++ b/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts @@ -51,10 +51,12 @@ const sidebarConnections: SidebarConnection[] = [ type: 'collection', sourceName: '', pipeline: [], + isNonExistent: false, }, ], collectionsLength: 1, collectionsStatus: 'ready', + isNonExistent: false, }, { _id: 'db_ready_1_2', @@ -66,10 +68,12 @@ const sidebarConnections: SidebarConnection[] = [ type: 'collection', sourceName: '', pipeline: [], + isNonExistent: false, }, ], collectionsLength: 1, collectionsStatus: 'ready', + isNonExistent: false, }, ], databasesStatus: 'ready', @@ -96,6 +100,7 @@ const sidebarConnections: SidebarConnection[] = [ type: 'collection', sourceName: '', pipeline: [], + isNonExistent: false, }, { _id: 'coll_ready_2_2', @@ -103,10 +108,12 @@ const sidebarConnections: SidebarConnection[] = [ type: 'collection', sourceName: '', pipeline: [], + isNonExistent: false, }, ], collectionsLength: 1, collectionsStatus: 'ready', + isNonExistent: false, }, ], databasesStatus: 'ready', diff --git a/packages/compass-sidebar/src/modules/databases.spec.ts b/packages/compass-sidebar/src/modules/databases.spec.ts index 77311007354..9648e703a44 100644 --- a/packages/compass-sidebar/src/modules/databases.spec.ts +++ b/packages/compass-sidebar/src/modules/databases.spec.ts @@ -7,12 +7,20 @@ import { createInstance } from '../../test/helpers'; const CONNECTION_ID = 'webscale'; function createDatabases(dbs: any[] = []) { - return createInstance(dbs).databases.map((db) => { + const data = createInstance(dbs).databases.map((db) => { return { ...db.toJSON(), collections: db.collections.toJSON(), }; }); + return data.map(({ is_non_existent, collections, ...rest }) => ({ + ...rest, + isNonExistent: is_non_existent, + collections: collections.map(({ is_non_existent, ...coll }) => ({ + ...coll, + isNonExistent: is_non_existent, + })), + })); } describe('sidebar databases', function () { diff --git a/packages/compass-sidebar/src/modules/databases.ts b/packages/compass-sidebar/src/modules/databases.ts index aafb967676b..e879bec1859 100644 --- a/packages/compass-sidebar/src/modules/databases.ts +++ b/packages/compass-sidebar/src/modules/databases.ts @@ -39,16 +39,21 @@ export type DatabasesAction = | FetchAllCollectionsAction | ExpandDatabaseAction; -type DatabaseRaw = MongoDBInstance['databases'][number]; +export type InstanceDatabase = MongoDBInstance['databases'][number]; export type Database = Pick< - DatabaseRaw, + InstanceDatabase, '_id' | 'name' | 'collectionsStatus' | 'collectionsLength' > & { - collections: Pick< - DatabaseRaw['collections'][number], - '_id' | 'name' | 'type' | 'sourceName' | 'pipeline' - >[]; + isNonExistent: boolean; + collections: Array< + Pick< + InstanceDatabase['collections'][number], + '_id' | 'name' | 'type' | 'sourceName' | 'pipeline' + > & { + isNonExistent: boolean; + } + >; }; export type AllDatabasesState = Record< ConnectionInfo['id'], diff --git a/packages/compass-sidebar/src/modules/instance.ts b/packages/compass-sidebar/src/modules/instance.ts index a115165a527..b9c81eafbdc 100644 --- a/packages/compass-sidebar/src/modules/instance.ts +++ b/packages/compass-sidebar/src/modules/instance.ts @@ -2,7 +2,7 @@ import type { MongoDBInstance } from 'mongodb-instance-model'; import type { RootAction, SidebarThunkAction } from '.'; import { type ConnectionInfo } from '@mongodb-js/connection-info'; import throttle from 'lodash/throttle'; -import { type Database, changeDatabases } from './databases'; +import { type InstanceDatabase, changeDatabases } from './databases'; import { changeConnectionOptions } from './connection-options'; import { setIsPerformanceTabSupported } from './is-performance-tab-supported'; import type { MongoServerError } from 'mongodb'; @@ -126,22 +126,24 @@ export const setupInstance = { leading: true, trailing: true } ); - function getDatabaseInfo(db: Database) { + function getDatabaseInfo(db: InstanceDatabase) { return { _id: db._id, name: db.name, collectionsStatus: db.collectionsStatus, collectionsLength: db.collectionsLength, + isNonExistent: db.is_non_existent, }; } - function getCollectionInfo(coll: Database['collections'][number]) { + function getCollectionInfo(coll: InstanceDatabase['collections'][number]) { return { _id: coll._id, name: coll.name, type: coll.type, sourceName: coll.sourceName, pipeline: coll.pipeline, + isNonExistent: coll.is_non_existent, }; } diff --git a/packages/compass-sidebar/test/helpers.ts b/packages/compass-sidebar/test/helpers.ts index 54587d56626..e679b782f36 100644 --- a/packages/compass-sidebar/test/helpers.ts +++ b/packages/compass-sidebar/test/helpers.ts @@ -16,9 +16,11 @@ export function createInstance( databases: dbs.map((db) => { return { _id: db._id, + is_non_existent: false, collections: (db.collections || []).map((coll) => { return { _id: `${db._id}.${coll}`, + is_non_existent: false, }; }), }; diff --git a/packages/compass-workspaces/src/components/workspaces.tsx b/packages/compass-workspaces/src/components/workspaces.tsx index 5e6be8be8b0..ca54a2c04d4 100644 --- a/packages/compass-workspaces/src/components/workspaces.tsx +++ b/packages/compass-workspaces/src/components/workspaces.tsx @@ -5,12 +5,14 @@ import { MongoDBLogoMark, WorkspaceTabs, css, + palette, rafraf, spacing, useDarkMode, } from '@mongodb-js/compass-components'; import type { CollectionTabInfo, + DatabaseTabInfo, OpenWorkspaceOptions, WorkspaceTab, WorkspacesState, @@ -125,6 +127,7 @@ type CompassWorkspacesProps = { tabs: WorkspaceTab[]; activeTab?: WorkspaceTab | null; collectionInfo: Record; + databaseInfo: Record; openOnEmptyWorkspace?: OpenWorkspaceOptions | null; onSelectTab(at: number): void; @@ -139,10 +142,15 @@ type CompassWorkspacesProps = { ): void; }; +const nonExistantStyles = css({ + color: palette.gray.base, +}); + const CompassWorkspaces: React.FunctionComponent = ({ tabs, activeTab, collectionInfo, + databaseInfo, openOnEmptyWorkspace, onSelectTab, onSelectNextTab, @@ -223,6 +231,8 @@ const CompassWorkspaces: React.FunctionComponent = ({ const connectionName = getConnectionById(tab.connectionId)?.title || ''; const database = tab.namespace; + const namespaceId = `${tab.connectionId}.${database}`; + const { isNonExistent } = databaseInfo[namespaceId] ?? {}; return { id: tab.id, connectionName, @@ -232,15 +242,19 @@ const CompassWorkspaces: React.FunctionComponent = ({ ['Connection', connectionName || ''], ['Database', database], ] as Tooltip, - iconGlyph: 'Database', + iconGlyph: isNonExistent ? 'EmptyDatabase' : 'Database', 'data-namespace': tab.namespace, tabTheme: getThemeOf(tab.connectionId), + ...(isNonExistent && { + className: nonExistantStyles, + }), } as const; } case 'Collection': { const { database, collection, ns } = toNS(tab.namespace); - const info = collectionInfo[ns] ?? {}; - const { isTimeSeries, isReadonly, sourceName } = info; + const namespaceId = `${tab.connectionId}.${ns}`; + const info = collectionInfo[namespaceId] ?? {}; + const { isTimeSeries, isReadonly, sourceName, isNonExistent } = info; const connectionName = getConnectionById(tab.connectionId)?.title || ''; const collectionType = isTimeSeries @@ -273,14 +287,19 @@ const CompassWorkspaces: React.FunctionComponent = ({ ? 'Visibility' : collectionType === 'timeseries' ? 'TimeSeries' + : isNonExistent + ? 'EmptyFolder' : 'Folder', 'data-namespace': ns, tabTheme: getThemeOf(tab.connectionId), + ...(isNonExistent && { + className: nonExistantStyles, + }), } as const; } } }); - }, [tabs, collectionInfo, getThemeOf, getConnectionById]); + }, [tabs, collectionInfo, databaseInfo, getThemeOf, getConnectionById]); const activeTabIndex = tabs.findIndex((tab) => tab === activeTab); @@ -410,6 +429,7 @@ export default connect( tabs: state.tabs, activeTab: activeTab, collectionInfo: state.collectionInfo, + databaseInfo: state.databaseInfo, }; }, { diff --git a/packages/compass-workspaces/src/index.ts b/packages/compass-workspaces/src/index.ts index f1cfd4f7cc5..870991a0d85 100644 --- a/packages/compass-workspaces/src/index.ts +++ b/packages/compass-workspaces/src/index.ts @@ -15,6 +15,8 @@ import workspacesReducer, { getLocalAppRegistryForTab, cleanupLocalAppRegistries, connectionDisconnected, + updateDatabaseInfo, + updateCollectionInfo, } from './stores/workspaces'; import Workspaces from './components'; import { applyMiddleware, createStore } from 'redux'; @@ -60,6 +62,7 @@ export function configureStore( tabs: initialTabs, activeTabId: initialTabs[initialTabs.length - 1]?.id ?? null, collectionInfo: {}, + databaseInfo: {}, }, applyMiddleware(thunk.withExtraArgument(services)) ); @@ -88,12 +91,38 @@ export function activateWorkspacePlugin( addCleanup(cleanupLocalAppRegistries); - const setupInstanceListeners = (instance: MongoDBInstance) => { + const setupInstanceListeners = ( + connectionId: string, + instance: MongoDBInstance + ) => { on(instance, 'change:collections._id', (collection: Collection) => { const { _id: from } = collection.previousAttributes(); store.dispatch(collectionRenamed(from, collection.ns)); }); + on(instance, 'change:databases.is_non_existent', (database: Database) => { + const namespaceId = `${connectionId}.${database._id}`; + const databaseInfo = { + isNonExistent: database.is_non_existent, + }; + store.dispatch(updateDatabaseInfo(namespaceId, databaseInfo)); + }); + + on( + instance, + 'change:collections.is_non_existent', + (collection: Collection) => { + const namespaceId = `${connectionId}.${collection._id}`; + const collectionInfo = { + isTimeSeries: collection.isTimeSeries, + isReadonly: collection.readonly ?? collection.isView, + sourceName: collection.sourceName, + isNonExistent: collection.is_non_existent, + }; + store.dispatch(updateCollectionInfo(namespaceId, collectionInfo)); + } + ); + on(instance, 'remove:collections', (collection: Collection) => { store.dispatch(collectionRemoved(collection.ns)); }); @@ -103,9 +132,8 @@ export function activateWorkspacePlugin( }); }; - const existingInstances = instancesManager.listMongoDBInstances(); - for (const instance of existingInstances.values()) { - setupInstanceListeners(instance); + for (const [connId, instance] of instancesManager.listMongoDBInstances()) { + setupInstanceListeners(connId, instance); } on( @@ -115,7 +143,7 @@ export function activateWorkspacePlugin( connectionInfoId: ConnectionInfo['id'], instance: MongoDBInstance ) { - setupInstanceListeners(instance); + setupInstanceListeners(connectionInfoId, instance); } ); diff --git a/packages/compass-workspaces/src/stores/workspaces.spec.ts b/packages/compass-workspaces/src/stores/workspaces.spec.ts index df32cbedcbe..86bc9c4f0aa 100644 --- a/packages/compass-workspaces/src/stores/workspaces.spec.ts +++ b/packages/compass-workspaces/src/stores/workspaces.spec.ts @@ -516,6 +516,7 @@ describe('_bulkTabsClose', function () { ], activeTabId: 'active', collectionInfo: {}, + databaseInfo: {}, }, isToBeClosed: (tab: WorkspaceTab) => tab.type === 'Databases', }); @@ -540,6 +541,7 @@ describe('_bulkTabsClose', function () { ], activeTabId: 'active', collectionInfo: {}, + databaseInfo: {}, }, isToBeClosed: (tab: WorkspaceTab) => tab.type === 'My Queries', }); @@ -573,6 +575,7 @@ describe('_bulkTabsClose', function () { ], activeTabId: 'active', collectionInfo: {}, + databaseInfo: {}, }, isToBeClosed: (tab: WorkspaceTab) => tab.type === 'Databases', }); @@ -599,6 +602,7 @@ describe('_bulkTabsClose', function () { ], activeTabId: 'active', collectionInfo: {}, + databaseInfo: {}, }, isToBeClosed: () => true, }); diff --git a/packages/compass-workspaces/src/stores/workspaces.ts b/packages/compass-workspaces/src/stores/workspaces.ts index 312f59949ad..570636da802 100644 --- a/packages/compass-workspaces/src/stores/workspaces.ts +++ b/packages/compass-workspaces/src/stores/workspaces.ts @@ -71,6 +71,7 @@ export enum WorkspacesActions { DatabaseRemoved = 'compass-workspaces/DatabaseRemoved', ConnectionDisconnected = 'compass-workspaces/ConnectionDisconnected', FetchCollectionTabInfo = 'compass-workspaces/FetchCollectionTabInfo', + FetchDatabaseTabInfo = 'compass-workspaces/FetchDatabaseTabInfo', CollectionSubtabSelected = 'compass-workspaces/CollectionSubtabSelected', } @@ -100,6 +101,11 @@ export type CollectionTabInfo = { isTimeSeries: boolean; isReadonly: boolean; sourceName?: string | null; + isNonExistent: boolean; +}; + +export type DatabaseTabInfo = { + isNonExistent: boolean; }; export type WorkspacesState = { @@ -116,6 +122,11 @@ export type WorkspacesState = { * icon) */ collectionInfo: Record; + /** + * Extra info for the collections tab namespace (where we show collections + * of a database) + */ + databaseInfo: Record; }; const getTabId = () => { @@ -236,6 +247,7 @@ const getInitialState = () => { tabs: [] as WorkspaceTab[], activeTabId: null, collectionInfo: {}, + databaseInfo: {}, }; }; @@ -568,7 +580,22 @@ const reducer: Reducer = ( ...state, collectionInfo: { ...state.collectionInfo, - [action.namespace]: action.info, + [action.namespaceId]: action.info, + }, + }; + } + + if ( + isAction( + action, + WorkspacesActions.FetchDatabaseTabInfo + ) + ) { + return { + ...state, + databaseInfo: { + ...state.databaseInfo, + [action.namespaceId]: action.info, }, }; } @@ -639,10 +666,18 @@ type OpenWorkspaceAction = { type FetchCollectionInfoAction = { type: WorkspacesActions.FetchCollectionTabInfo; - namespace: string; + // This uniquely identifies the collection tab for a given connection + namespaceId: string; info: CollectionTabInfo; }; +type FetchDatabaseInfoAction = { + type: WorkspacesActions.FetchDatabaseTabInfo; + // This uniquely identifies the database tab for a given connection + namespaceId: string; + info: DatabaseTabInfo; +}; + export type TabOptions = { /** * Optional. If set to `true`, always opens workspace in a new tab, otherwise @@ -652,6 +687,15 @@ export type TabOptions = { newTab?: boolean; }; +export const updateCollectionInfo = ( + namespaceId: string, + info: CollectionTabInfo +): FetchCollectionInfoAction => ({ + type: WorkspacesActions.FetchCollectionTabInfo, + namespaceId, + info, +}); + const fetchCollectionInfo = ( workspaceOptions: Extract ): WorkspacesThunkAction, FetchCollectionInfoAction> => { @@ -660,7 +704,8 @@ const fetchCollectionInfo = ( getState, { connections, instancesManager, logger } ) => { - if (getState().collectionInfo[workspaceOptions.namespace]) { + const namespaceId = `${workspaceOptions.connectionId}.${workspaceOptions.namespace}`; + if (getState().collectionInfo[namespaceId]) { return; } @@ -683,15 +728,13 @@ const fetchCollectionInfo = ( if (coll) { await coll.fetch({ dataService }); - dispatch({ - type: WorkspacesActions.FetchCollectionTabInfo, - namespace: workspaceOptions.namespace, - info: { - isTimeSeries: coll.isTimeSeries, - isReadonly: coll.readonly ?? coll.isView, - sourceName: coll.sourceName, - }, - }); + const info = { + isTimeSeries: coll.isTimeSeries, + isReadonly: coll.readonly ?? coll.isView, + sourceName: coll.sourceName, + isNonExistent: coll.is_non_existent, + }; + dispatch(updateCollectionInfo(namespaceId, info)); } } catch (err) { logger.debug( @@ -705,6 +748,58 @@ const fetchCollectionInfo = ( }; }; +export const updateDatabaseInfo = ( + namespaceId: string, + info: DatabaseTabInfo +): FetchDatabaseInfoAction => ({ + type: WorkspacesActions.FetchDatabaseTabInfo, + namespaceId, + info, +}); + +const fetchDatabaseInfo = ( + workspaceOptions: Extract +): WorkspacesThunkAction, FetchDatabaseInfoAction> => { + return async ( + dispatch, + getState, + { connections, instancesManager, logger } + ) => { + const { databaseInfo } = getState(); + const namespaceId = `${workspaceOptions.connectionId}.${workspaceOptions.namespace}`; + if (databaseInfo[namespaceId]) { + return; + } + + try { + const dataService = connections.getDataServiceForConnection( + workspaceOptions.connectionId + ); + + const instance = instancesManager.getMongoDBInstanceForConnection( + workspaceOptions.connectionId + ); + + const db = instance.databases.get(workspaceOptions.namespace); + if (db) { + await db.fetch({ dataService }); + const info = { + isNonExistent: db.is_non_existent, + }; + dispatch(updateDatabaseInfo(namespaceId, info)); + } + } catch (err) { + logger.debug( + 'Database Metadata', + logger.mongoLogId(1_001_000_339), + 'Error fetching database metadata for tab', + { namespace: workspaceOptions.namespace }, + err + ); + } + }; +}; + export const openWorkspace = ( workspaceOptions: OpenWorkspaceOptions, tabOptions?: TabOptions @@ -717,6 +812,11 @@ export const openWorkspace = ( void dispatch(fetchCollectionInfo(workspaceOptions)); } + if (workspaceOptions.type === 'Collections') { + // Fetching extra metadata for database should not block tab opening + void dispatch(fetchDatabaseInfo(workspaceOptions)); + } + dispatch({ type: WorkspacesActions.OpenWorkspace, workspace: workspaceOptions, diff --git a/packages/data-service/src/data-service.spec.ts b/packages/data-service/src/data-service.spec.ts index bad3d369259..83e08bc38a5 100644 --- a/packages/data-service/src/data-service.spec.ts +++ b/packages/data-service/src/data-service.spec.ts @@ -22,6 +22,7 @@ import { runCommand } from './run-command'; import { mochaTestServer } from '@mongodb-js/compass-test-server'; import type { SearchIndex } from './search-index-detail-helper'; import { range } from 'lodash'; +import ConnectionString from 'mongodb-connection-string-url'; const { expect } = chai; chai.use(chaiAsPromised); @@ -535,6 +536,91 @@ describe('DataService', function () { ); expect(collections).to.have.nested.property('[0].type', 'collection'); }); + + context('with non existant collections', function () { + let dataService: DataServiceImpl; + beforeEach(async function () { + await mongoClient.db('imdb').command({ + createRole: 'moderator', + privileges: [ + { + resource: { db: 'imdb', collection: 'movies' }, + actions: ['find'], + }, + { + resource: { db: 'imdb', collection: 'reviews' }, + actions: ['find'], + }, + { + resource: { db: 'imdb', collection: 'users' }, + actions: ['find'], + }, + ], + roles: [], + }); + + await mongoClient.db('admin').command({ + createUser: 'new_user', + pwd: 'new_password', + roles: [{ role: 'moderator', db: 'imdb' }], + }); + const cs = new ConnectionString(connectionOptions.connectionString); + cs.username = 'new_user'; + cs.password = 'new_password'; + const newConnectionOptions = { + connectionString: cs.toString(), + }; + + dataService = new DataServiceImpl(newConnectionOptions); + await dataService.connect(); + }); + + afterEach(async function () { + await Promise.all([ + mongoClient.db('admin').command({ + dropUser: 'new_user', + }), + mongoClient.db('imdb').command({ + dropRole: 'moderator', + }), + mongoClient.db('imdb').dropDatabase(), + ]).catch(() => null); + await dataService?.disconnect(); + }); + + it('returns collections from user privileges', async function () { + const collections = await dataService.listCollections('imdb'); + const mappedCollections = collections.map( + ({ _id, name, is_non_existent }) => ({ _id, name, is_non_existent }) + ); + + const expectedCollections = [ + { _id: 'imdb.movies', name: 'movies', is_non_existent: true }, + { _id: 'imdb.reviews', name: 'reviews', is_non_existent: true }, + { _id: 'imdb.users', name: 'users', is_non_existent: true }, + ]; + expect(mappedCollections).to.deep.include.members( + expectedCollections + ); + }); + + it('gives precedence to the provisioned collections', async function () { + await dataService.createCollection('imdb.movies', {}); + const collections = await dataService.listCollections('imdb'); + const mappedCollections = collections.map( + ({ _id, name, is_non_existent }) => ({ _id, name, is_non_existent }) + ); + + const expectedCollections = [ + { _id: 'imdb.movies', name: 'movies', is_non_existent: false }, + { _id: 'imdb.reviews', name: 'reviews', is_non_existent: true }, + { _id: 'imdb.users', name: 'users', is_non_existent: true }, + ]; + expect(mappedCollections).to.deep.include.members( + expectedCollections + ); + }); + }); }); describe('#updateCollection', function () { @@ -688,6 +774,98 @@ describe('DataService', function () { } expect(databaseNames).to.contain(`${testDatabaseName}`); }); + + context('with non existant databases', function () { + let dataService: DataServiceImpl; + beforeEach(async function () { + await mongoClient.db('imdb').command({ + createRole: 'moderator', + privileges: [ + { + resource: { db: 'imdb', collection: 'movies' }, + actions: ['find'], + }, + { + resource: { db: 'imdb', collection: 'reviews' }, + actions: ['find'], + }, + ], + roles: [], + }); + + await mongoClient.db('admin').command({ + createUser: 'new_user', + pwd: 'new_password', + roles: [ + { role: 'readWrite', db: 'sample_airbnb' }, + { role: 'read', db: 'sample_wiki' }, + { role: 'moderator', db: 'imdb' }, + ], + }); + const cs = new ConnectionString(connectionOptions.connectionString); + cs.username = 'new_user'; + cs.password = 'new_password'; + const newConnectionOptions = { + connectionString: cs.toString(), + }; + + dataService = new DataServiceImpl(newConnectionOptions); + await dataService.connect(); + }); + + afterEach(async function () { + await Promise.all([ + mongoClient.db('admin').command({ + dropUser: 'new_user', + }), + mongoClient.db('imdb').command({ + dropRole: 'moderator', + }), + mongoClient.db('imdb').dropDatabase(), + ]).catch(() => null); + await dataService?.disconnect(); + }); + + it('returns databases from user roles and privileges', async function () { + const databases = await dataService.listDatabases(); + const mappedDatabases = databases.map( + ({ _id, name, is_non_existent }) => ({ _id, name, is_non_existent }) + ); + + const expectedDatabases = [ + // Based on roles + { + _id: 'sample_airbnb', + name: 'sample_airbnb', + is_non_existent: true, + }, + { _id: 'sample_wiki', name: 'sample_wiki', is_non_existent: true }, + // Based on privileges + { _id: 'imdb', name: 'imdb', is_non_existent: true }, + ]; + expect(mappedDatabases).to.deep.include.members(expectedDatabases); + }); + + it('gives precedence to the provisioned databases', async function () { + await dataService.createCollection('imdb.movies', {}); + await dataService.createCollection('sample_airbnb.whatever', {}); + const databases = await dataService.listDatabases(); + const mappedDatabases = databases.map( + ({ _id, name, is_non_existent }) => ({ _id, name, is_non_existent }) + ); + + const expectedDatabases = [ + { + _id: 'sample_airbnb', + name: 'sample_airbnb', + is_non_existent: false, + }, + { _id: 'sample_wiki', name: 'sample_wiki', is_non_existent: true }, + { _id: 'imdb', name: 'imdb', is_non_existent: false }, + ]; + expect(mappedDatabases).to.deep.include.members(expectedDatabases); + }); + }); }); describe('#createCollection', function () { @@ -1609,8 +1787,15 @@ describe('DataService', function () { }, }, }); - const dbs = (await dataService.listDatabases()).map((db) => db.name); - expect(dbs).to.deep.eq(['pineapple', 'foo', 'buz', 'bar']); + const dbs = (await dataService.listDatabases()).map( + ({ name, is_non_existent }) => ({ name, is_non_existent }) + ); + expect(dbs).to.deep.eq([ + { name: 'pineapple', is_non_existent: true }, + { name: 'foo', is_non_existent: false }, + { name: 'buz', is_non_existent: true }, + { name: 'bar', is_non_existent: false }, + ]); }); it('returns result from privileges even if listDatabases threw any error', async function () { @@ -1629,8 +1814,10 @@ describe('DataService', function () { }, }, }); - const dbs = (await dataService.listDatabases()).map((db) => db.name); - expect(dbs).to.deep.eq(['foo']); + const dbs = (await dataService.listDatabases()).map( + ({ name, is_non_existent }) => ({ name, is_non_existent }) + ); + expect(dbs).to.deep.eq([{ name: 'foo', is_non_existent: true }]); }); }); @@ -1723,9 +1910,14 @@ describe('DataService', function () { }, }); const colls = (await dataService.listCollections('foo')).map( - (coll) => coll.name + ({ name, is_non_existent }) => ({ name, is_non_existent }) ); - expect(colls).to.deep.eq(['bar', 'buz', 'bla', 'meow']); + expect(colls).to.deep.eq([ + { name: 'bar', is_non_existent: true }, + { name: 'buz', is_non_existent: false }, + { name: 'bla', is_non_existent: false }, + { name: 'meow', is_non_existent: false }, + ]); }); it('returns result from privileges even if listCollections threw any error', async function () { @@ -1747,9 +1939,9 @@ describe('DataService', function () { }, }); const colls = (await dataService.listCollections('foo')).map( - (coll) => coll.name + ({ name, is_non_existent }) => ({ name, is_non_existent }) ); - expect(colls).to.deep.eq(['bar']); + expect(colls).to.deep.eq([{ name: 'bar', is_non_existent: true }]); }); }); diff --git a/packages/data-service/src/data-service.ts b/packages/data-service/src/data-service.ts index 195046b6776..f4d8e40d766 100644 --- a/packages/data-service/src/data-service.ts +++ b/packages/data-service/src/data-service.ts @@ -58,7 +58,11 @@ import type { ConnectionFleOptions, ConnectionOptions, } from './connection-options'; -import type { InstanceDetails } from './instance-detail-helper'; +import type { + CollectionDetails, + DatabaseDetails, + InstanceDetails, +} from './instance-detail-helper'; import { isNotAuthorized, isNotSupportedPipelineStage, @@ -312,7 +316,7 @@ export interface DataService { | ConnectionStatusWithPrivileges['authInfo']['authenticatedUserPrivileges'] | null; } - ): Promise[]>; + ): Promise; /** * Returns normalized collection info provided by listCollection command for a @@ -419,7 +423,7 @@ export interface DataService { roles?: | ConnectionStatusWithPrivileges['authInfo']['authenticatedUserRoles'] | null; - }): Promise<{ _id: string; name: string }[]>; + }): Promise[]>; /** * Get the stats for a database. @@ -1145,11 +1149,19 @@ class DataServiceImpl extends WithLogContext implements DataService { ); } catch (error) { const message = (error as Error).message; - // We ignore errors for fetching collStats when requesting on an - // unsupported collection type: either a view or a ADF if ( + // We ignore errors for fetching collStats when requesting on an + // unsupported collection type: either a view or a ADF message.includes('not valid for Data Lake') || - message.includes('is a view, not a collection') + message.includes('is a view, not a collection') || + // When trying to fetch collectionStats for a collection whose db + // does not exist or the collection itself does not exist, the + // server throws an error. This happens because we show collections + // to the user from their privileges. + message.includes(`Database [${databaseName}] not found`) || + message.includes( + `Collection [${databaseName}.${collectionName}] not found` + ) ) { return this._buildCollectionStats(databaseName, collectionName, {}); } @@ -1163,7 +1175,12 @@ class DataServiceImpl extends WithLogContext implements DataService { collName: string ): Promise | null> { const [collInfo] = await this._listCollections(dbName, { name: collName }); - return adaptCollectionInfo({ db: dbName, ...collInfo }) ?? null; + return ( + adaptCollectionInfo({ + db: dbName, + ...collInfo, + }) ?? null + ); } @op(mongoLogId(1_001_000_031)) @@ -1291,7 +1308,16 @@ class DataServiceImpl extends WithLogContext implements DataService { | ConnectionStatusWithPrivileges['authInfo']['authenticatedUserPrivileges'] | null; } = {} - ): Promise[]> { + ): Promise { + const listCollections = async () => { + const colls = await this._listCollections(databaseName, filter, { + nameOnly, + }); + return colls.map((coll) => ({ + is_non_existent: false, + ...coll, + })); + }; const getCollectionsFromPrivileges = async () => { const databases = getPrivilegesByDatabaseAndCollection( await this._getPrivilegesOrFallback(privileges), @@ -1307,11 +1333,11 @@ class DataServiceImpl extends WithLogContext implements DataService { // those registered as "real" collection names Boolean ) - .map((name) => ({ name })); + .map((name) => ({ name, is_non_existent: true })); }; const [listedCollections, collectionsFromPrivileges] = await Promise.all([ - this._listCollections(databaseName, filter, { nameOnly }), + listCollections(), // If the filter is not empty, we can't meaningfully derive collections // from privileges and filter them as the criteria might include any key // from the listCollections result object and there is no such info in @@ -1325,7 +1351,10 @@ class DataServiceImpl extends WithLogContext implements DataService { // if they were fetched successfully [...collectionsFromPrivileges, ...listedCollections], 'name' - ).map((coll) => adaptCollectionInfo({ db: databaseName, ...coll })); + ).map(({ is_non_existent, ...coll }) => ({ + is_non_existent, + ...adaptCollectionInfo({ db: databaseName, ...coll }), + })); return collections; } @@ -1348,7 +1377,7 @@ class DataServiceImpl extends WithLogContext implements DataService { roles?: | ConnectionStatusWithPrivileges['authInfo']['authenticatedUserRoles'] | null; - } = {}): Promise<{ _id: string; name: string }[]> { + } = {}): Promise[]> { const adminDb = this._database('admin', 'CRUD'); const listDatabases = async () => { @@ -1363,7 +1392,10 @@ class DataServiceImpl extends WithLogContext implements DataService { }, { enableUtf8Validation: false } ); - return databases; + return databases.map((x) => ({ + ...x, + is_non_existent: false, + })); } catch (err) { // Currently Compass should not fail if listDatabase failed for any // possible reason to preserve current behavior. We probably want this @@ -1395,7 +1427,7 @@ class DataServiceImpl extends WithLogContext implements DataService { // out Boolean ) - .map((name) => ({ name })); + .map((name) => ({ name, is_non_existent: true })); }; const getDatabasesFromRoles = async () => { @@ -1410,7 +1442,7 @@ class DataServiceImpl extends WithLogContext implements DataService { // have custom privileges that we can't currently fetch. ['read', 'readWrite', 'dbAdmin', 'dbOwner'] ); - return databases.map((name) => ({ name })); + return databases.map((name) => ({ name, is_non_existent: true })); }; const [listedDatabases, databasesFromPrivileges, databasesFromRoles] = @@ -1425,8 +1457,13 @@ class DataServiceImpl extends WithLogContext implements DataService { // if they were fetched successfully [...databasesFromRoles, ...databasesFromPrivileges, ...listedDatabases], 'name' - ).map((db) => { - return { _id: db.name, name: db.name, ...adaptDatabaseInfo(db) }; + ).map(({ name, is_non_existent, ...db }) => { + return { + _id: name, + name, + is_non_existent, + ...adaptDatabaseInfo(db), + }; }); return databases; diff --git a/packages/data-service/src/instance-detail-helper.ts b/packages/data-service/src/instance-detail-helper.ts index e9d04502324..2040d89aedd 100644 --- a/packages/data-service/src/instance-detail-helper.ts +++ b/packages/data-service/src/instance-detail-helper.ts @@ -54,7 +54,7 @@ type DataLakeDetails = { version: string | null; }; -type CollectionDetails = { +export type CollectionDetails = { _id: string; name: string; database: string; @@ -76,9 +76,10 @@ type CollectionDetails = { validationAction: string; validationLevel: string; } | null; + is_non_existent: boolean; }; -type DatabaseDetails = { +export type DatabaseDetails = { _id: string; name: string; collection_count: number; @@ -88,6 +89,7 @@ type DatabaseDetails = { index_count: number; index_size: number; collections: CollectionDetails[]; + is_non_existent: boolean; }; export type InstanceDetails = { @@ -358,7 +360,7 @@ function adaptBuildInfo(rawBuildInfo: Partial) { export function adaptDatabaseInfo( databaseStats: Partial & Partial -): Omit { +): Omit { return { collection_count: databaseStats.collections ?? 0, document_count: databaseStats.objects ?? 0, @@ -376,7 +378,9 @@ export function adaptCollectionInfo({ options, type, }: CollectionInfoNameOnly & - Partial & { db: string }): CollectionDetails { + Partial & { + db: string; + }): Omit { const ns = toNS(`${db}.${name}`); const { collection, diff --git a/packages/database-model/index.d.ts b/packages/database-model/index.d.ts index 98ac29542f7..5593cc622d4 100644 --- a/packages/database-model/index.d.ts +++ b/packages/database-model/index.d.ts @@ -16,6 +16,7 @@ interface DatabaseProps { index_size: number; collectionsLength: number; collections: CollectionCollection; + is_non_existent: boolean; } interface Database extends DatabaseProps { diff --git a/packages/database-model/lib/model.js b/packages/database-model/lib/model.js index 7467dda03ba..00d8e4a0e1b 100644 --- a/packages/database-model/lib/model.js +++ b/packages/database-model/lib/model.js @@ -105,7 +105,7 @@ const DatabaseModel = AmpersandModel.extend( statusError: { type: 'string', default: null }, collectionsStatus: { type: 'string', default: 'initial' }, collectionsStatusError: { type: 'string', default: null }, - + is_non_existent: 'boolean', collection_count: 'number', document_count: 'number', storage_size: 'number', @@ -236,7 +236,7 @@ const DatabaseCollection = AmpersandCollection.extend( roles: instanceModel.auth.roles }); - this.set(dbs.map(({ _id, name }) => ({ _id, name }))); + this.set(dbs.map(({ _id, name, is_non_existent }) => ({ _id, name, is_non_existent }))); }, toJSON(opts = { derived: true }) { diff --git a/packages/databases-collections-list/package.json b/packages/databases-collections-list/package.json index 55ac2d41f1d..6fcc6053e7a 100644 --- a/packages/databases-collections-list/package.json +++ b/packages/databases-collections-list/package.json @@ -54,6 +54,8 @@ "@mongodb-js/compass-workspaces": "^0.31.0", "@mongodb-js/connection-info": "^0.11.0", "compass-preferences-model": "^2.33.0", + "mongodb-collection-model": "^5.25.0", + "mongodb-database-model": "^2.25.0", "mongodb-ns": "^2.4.2", "react": "^17.0.2" }, diff --git a/packages/databases-collections-list/src/collections.tsx b/packages/databases-collections-list/src/collections.tsx index 792ec96bd75..03ece77793a 100644 --- a/packages/databases-collections-list/src/collections.tsx +++ b/packages/databases-collections-list/src/collections.tsx @@ -4,22 +4,7 @@ import { compactBytes, compactNumber } from './format'; import type { BadgeProp } from './namespace-card'; import { NamespaceItemCard } from './namespace-card'; import { ItemsGrid } from './items-grid'; - -type Collection = { - _id: string; - name: string; - type: string; - status: 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error'; - document_count: number; - document_size: number; - avg_document_size: number; - storage_size: number; - free_storage_size: number; - index_count: number; - index_size: number; - properties: { id: string }[]; - source?: Collection; -}; +import type { CollectionProps } from 'mongodb-collection-model'; const COLLECTION_CARD_WIDTH = spacing[6] * 4; @@ -82,7 +67,7 @@ const pageContainerStyles = css({ const CollectionsList: React.FunctionComponent<{ namespace: string; - collections: Collection[]; + collections: CollectionProps[]; onCollectionClick(id: string): void; onDeleteCollectionClick?: (id: string) => void; onCreateCollectionClick?: () => void; @@ -176,6 +161,7 @@ const CollectionsList: React.FunctionComponent<{ name={coll.name} type="collection" status={coll.status} + isNonExistent={coll.is_non_existent} data={data} badges={badges} onItemClick={onItemClick} diff --git a/packages/databases-collections-list/src/databases.tsx b/packages/databases-collections-list/src/databases.tsx index c61fad33da9..4008acdd46c 100644 --- a/packages/databases-collections-list/src/databases.tsx +++ b/packages/databases-collections-list/src/databases.tsx @@ -4,16 +4,7 @@ import { PerformanceSignals, spacing } from '@mongodb-js/compass-components'; import { compactBytes, compactNumber } from './format'; import { NamespaceItemCard } from './namespace-card'; import { ItemsGrid } from './items-grid'; - -type Database = { - _id: string; - name: string; - status: 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error'; - storage_size: number; - data_size: number; - index_count: number; - collectionsLength: number; -}; +import type { DatabaseProps } from 'mongodb-database-model'; const DATABASE_CARD_WIDTH = spacing[6] * 4; @@ -22,7 +13,7 @@ const DATABASE_CARD_HEIGHT = 154; const DATABASE_CARD_LIST_HEIGHT = 118; const DatabasesList: React.FunctionComponent<{ - databases: Database[]; + databases: DatabaseProps[]; onDatabaseClick(id: string): void; onDeleteDatabaseClick?: (id: string) => void; onCreateDatabaseClick?: () => void; @@ -68,6 +59,7 @@ const DatabasesList: React.FunctionComponent<{ type="database" viewType={viewType} status={db.status} + isNonExistent={db.is_non_existent} data={[ { label: 'Storage size', diff --git a/packages/databases-collections-list/src/items-grid.tsx b/packages/databases-collections-list/src/items-grid.tsx index a1a383b4bf3..2db562882c8 100644 --- a/packages/databases-collections-list/src/items-grid.tsx +++ b/packages/databases-collections-list/src/items-grid.tsx @@ -22,7 +22,7 @@ import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; import { useConnectionInfoRef } from '@mongodb-js/compass-connections/provider'; import { usePreferences } from 'compass-preferences-model/provider'; -type Item = { _id: string } & Record; +type Item = { _id: string } & Record; const rowStyles = css({ paddingLeft: spacing[3], diff --git a/packages/databases-collections-list/src/namespace-card.tsx b/packages/databases-collections-list/src/namespace-card.tsx index 2ba48cbe46c..10bf1638d5d 100644 --- a/packages/databases-collections-list/src/namespace-card.tsx +++ b/packages/databases-collections-list/src/namespace-card.tsx @@ -31,14 +31,34 @@ import { usePreference } from 'compass-preferences-model/provider'; const cardTitleGroup = css({ display: 'flex', alignItems: 'center', - gap: spacing[3], - marginBottom: spacing[2], + gap: spacing[400], }); const CardTitleGroup: React.FunctionComponent = ({ children }) => { return
{children}
; }; +const nonExistantLightStyles = css({ + color: palette.gray.dark1, +}); + +const nonExistantDarkStyles = css({ + color: palette.gray.base, +}); + +const inactiveCardStyles = css({ + borderStyle: 'dashed', + borderWidth: spacing[50], + '&:hover': { + borderStyle: 'dashed', + borderWidth: spacing[50], + }, +}); + +const tooltipTriggerStyles = css({ + display: 'flex', +}); + const cardNameWrapper = css({ // Workaround for uncollapsible text in flex children minWidth: 0, @@ -64,15 +84,21 @@ const cardName = css({ fontWeight: '600 !important' as unknown as number, }); -const CardName: React.FunctionComponent<{ children: string }> = ({ - children, -}) => { +const CardName: React.FunctionComponent<{ + children: string; + isNonExistent: boolean; +}> = ({ children, isNonExistent }) => { const darkMode = useDarkMode(); return (
{children} @@ -87,7 +113,7 @@ const cardActionContainer = css({ const cardBadges = css({ display: 'flex', - gap: spacing[2], + gap: spacing[200], // Preserving space for when cards with and without badges are mixed in a // single row minHeight: 20, @@ -98,7 +124,7 @@ const CardBadges: React.FunctionComponent = ({ children }) => { }; const cardBadge = css({ - gap: spacing[1], + gap: spacing[100], }); const cardBadgeLabel = css({}); @@ -145,7 +171,7 @@ const CardBadge: React.FunctionComponent = ({ }; const card = css({ - padding: spacing[3], + padding: spacing[400], }); export type DataProp = { @@ -163,14 +189,15 @@ export type NamespaceItemCardProps = { status: 'initial' | 'fetching' | 'refreshing' | 'ready' | 'error'; data: DataProp[]; badges?: BadgeProp[] | null; + isNonExistent: boolean; onItemClick(id: string): void; onItemDeleteClick?: (id: string) => void; }; const namespaceDataGroup = css({ display: 'flex', - gap: spacing[2], - marginTop: spacing[3], + gap: spacing[200], + marginTop: spacing[400], }); const column = css({ @@ -195,9 +222,11 @@ export const NamespaceItemCard: React.FunctionComponent< onItemDeleteClick, badges = null, viewType, + isNonExistent, ...props }) => { const readOnly = usePreference('readOnly'); + const darkMode = useDarkMode(); const [hoverProps, isHovered] = useHoverState(); const [focusProps, focusState] = useFocusState(); @@ -207,7 +236,7 @@ export const NamespaceItemCard: React.FunctionComponent< const hasDeleteHandler = !!onItemDeleteClick; const cardActions: ItemAction[] = useMemo(() => { - return readOnly || !hasDeleteHandler + return readOnly || !hasDeleteHandler || isNonExistent ? [] : [ { @@ -216,7 +245,7 @@ export const NamespaceItemCard: React.FunctionComponent< icon: 'Trash', }, ]; - }, [type, readOnly, hasDeleteHandler]); + }, [type, readOnly, isNonExistent, hasDeleteHandler]); const defaultActionProps = useDefaultAction(onDefaultAction); @@ -238,7 +267,16 @@ export const NamespaceItemCard: React.FunctionComponent< ); const cardProps = mergeProps( - { className: card }, + { + className: cx( + card, + isNonExistent && [ + !darkMode && nonExistantLightStyles, + darkMode && nonExistantDarkStyles, + inactiveCardStyles, + ] + ), + }, defaultActionProps, hoverProps, focusProps, @@ -262,7 +300,22 @@ export const NamespaceItemCard: React.FunctionComponent< {...cardProps} > - {name} + {name} + + {isNonExistent && ( + + +
+ } + > + Your privileges grant you access to this namespace, but it does not + currently exist + + )} {viewType === 'list' && badgesGroup} @@ -283,7 +336,7 @@ export const NamespaceItemCard: React.FunctionComponent<