diff --git a/configs/testing-library-compass/src/index.tsx b/configs/testing-library-compass/src/index.tsx index e6983f7b1b1..a9cad9faa4a 100644 --- a/configs/testing-library-compass/src/index.tsx +++ b/configs/testing-library-compass/src/index.tsx @@ -81,9 +81,12 @@ type TestConnectionsOptions = { */ preferences?: Partial; /** - * Initial list of connections to be "loaded" to the application + * Initial list of connections to be "loaded" to the application. Empty list + * by default. You can explicitly pass `no-preload` to disable initial + * preloading of the connections, otherwise connections are always preloaded + * before rendering when using helper methods */ - connections?: ConnectionInfo[]; + connections?: ConnectionInfo[] | 'no-preload'; /** * Connection function that returns DataService when connecting to a * connection with the connections store. Second argument is a constructor @@ -245,6 +248,12 @@ const EmptyWrapper = ({ children }: { children: React.ReactElement }) => { return <>{children}; }; +function getConnectionsFromConnectionsOption( + connections: TestConnectionsOptions['connections'] +): Exclude { + return connections === 'no-preload' ? undefined : connections ?? []; +} + const TEST_ENV_CURRENT_CONNECTION = { info: { id: 'TEST', @@ -266,6 +275,7 @@ function createWrapper( TestingLibraryWrapper: ComponentWithChildren = EmptyWrapper, container?: HTMLElement ) { + const connections = getConnectionsFromConnectionsOption(options.connections); const wrapperState = { globalAppRegistry: new AppRegistry(), localAppRegistry: new AppRegistry(), @@ -274,7 +284,7 @@ function createWrapper( logger: createNoopLogger(), connectionStorage: options.connectionStorage ?? - (new InMemoryConnectionStorage(options.connections) as ConnectionStorage), + (new InMemoryConnectionStorage(connections) as ConnectionStorage), connectionsStore: { getState: undefined as unknown as () => State, actions: {} as ReturnType, @@ -359,7 +369,7 @@ function createWrapper( onAutoconnectInfoRequest={ options.onAutoconnectInfoRequest } - preloadStorageConnectionInfos={options.connections} + preloadStorageConnectionInfos={connections} > { + ( + getConnectionsFromConnectionsOption(connectionsOptions.connections) ?? [] + ).every((info) => { return !!wrapperState.connectionsStore.getState().connections.byId[ info.id ]; @@ -510,7 +522,10 @@ async function renderWithActiveConnection( const renderResult = renderWithConnections(ui, { ...options, wrapper: ConnectionInfoWrapper, - connections: [connectionInfo, ...(connections ?? [])], + connections: [ + connectionInfo, + ...(getConnectionsFromConnectionsOption(connections) ?? []), + ], }); await waitForConnect(renderResult.connectionsStore, connectionInfo); return renderResult; @@ -532,7 +547,10 @@ async function renderHookWithActiveConnection( const renderHookResult = renderHookWithConnections(cb, { ...options, wrapper: ConnectionInfoWrapper, - connections: [connectionInfo, ...(connections ?? [])], + connections: [ + connectionInfo, + ...(getConnectionsFromConnectionsOption(connections) ?? []), + ], }); await waitForConnect(renderHookResult.connectionsStore, connectionInfo); return renderHookResult; diff --git a/packages/compass-connections/src/provider.ts b/packages/compass-connections/src/provider.ts index c91c45bad5a..c3f97fb6168 100644 --- a/packages/compass-connections/src/provider.ts +++ b/packages/compass-connections/src/provider.ts @@ -62,6 +62,7 @@ export { useConnectionsList, useConnectionsListRef, connectionsLocator, + useConnectionsListLoadingStatus, } from './stores/store-context'; export type { ConnectionsService } from './stores/store-context'; diff --git a/packages/compass-connections/src/stores/connections-store-redux.spec.tsx b/packages/compass-connections/src/stores/connections-store-redux.spec.tsx index e54b48226b3..48afde9fac4 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.spec.tsx +++ b/packages/compass-connections/src/stores/connections-store-redux.spec.tsx @@ -67,6 +67,7 @@ describe('CompassConnections store', function () { .rejects(new Error('loadAll failed')); renderCompassConnections({ + connections: 'no-preload', connectionStorage, onFailToLoadConnections: onFailToLoadConnectionsSpy, }); diff --git a/packages/compass-connections/src/stores/connections-store-redux.ts b/packages/compass-connections/src/stores/connections-store-redux.ts index 1f622f0ca69..0e9c97d7c4c 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.ts +++ b/packages/compass-connections/src/stores/connections-store-redux.ts @@ -469,8 +469,17 @@ const INITIAL_STATE: State = { }; export function getInitialConnectionsStateForConnectionInfos( - connectionInfos: ConnectionInfo[] = [] + connectionInfos?: ConnectionInfo[] ): State['connections'] { + if (!connectionInfos) { + // Keep initial state if we're not preloading any connections + return { + byId: {}, + ids: [], + status: 'initial', + error: null, + }; + } const byId = Object.fromEntries( connectionInfos.map((info) => { return [info.id, createDefaultConnectionState(info)]; @@ -479,8 +488,7 @@ export function getInitialConnectionsStateForConnectionInfos( return { byId, ids: getSortedIdsForConnections(Object.values(byId)), - // Keep initial state if we're not preloading any connections - status: connectionInfos.length > 0 ? 'ready' : 'initial', + status: 'ready', error: null, }; } @@ -2126,7 +2134,7 @@ export const openSettingsModal = ( }; export function configureStore( - preloadConnectionInfos: ConnectionInfo[] = [], + preloadConnectionInfos: ConnectionInfo[] | undefined, thunkArg: ThunkExtraArg ) { return createStore( diff --git a/packages/compass-connections/src/stores/store-context.tsx b/packages/compass-connections/src/stores/store-context.tsx index 0caf00a55e8..d78c037f4c3 100644 --- a/packages/compass-connections/src/stores/store-context.tsx +++ b/packages/compass-connections/src/stores/store-context.tsx @@ -364,3 +364,14 @@ export function useConnectionsColorList(): { }); }, isEqual); } + +export function useConnectionsListLoadingStatus() { + return useSelector((state) => { + const status = state.connections.status; + return { + status, + error: state.connections.error?.message ?? null, + isInitialLoad: status === 'initial' || status === 'loading', + }; + }, isEqual); +} diff --git a/packages/compass-e2e-tests/helpers/commands/connect.ts b/packages/compass-e2e-tests/helpers/commands/connect.ts index 6ad7fb1e910..ce5bc3f3050 100644 --- a/packages/compass-e2e-tests/helpers/commands/connect.ts +++ b/packages/compass-e2e-tests/helpers/commands/connect.ts @@ -134,7 +134,12 @@ export async function waitForConnectionResult( ): Promise { const waitOptions = typeof timeout !== 'undefined' ? { timeout } : undefined; - if (await browser.$(Selectors.SidebarFilterInput).isDisplayed()) { + if ( + (await browser.$(Selectors.SidebarFilterInput).isDisplayed()) && + (await browser + .$(Selectors.SidebarFilterInput) + .getAttribute('aria-disabled')) !== 'true' + ) { // Clear the filter to make sure every connection shows await browser.clickVisible(Selectors.SidebarFilterInput); await browser.setValueVisible(Selectors.SidebarFilterInput, ''); diff --git a/packages/compass-e2e-tests/helpers/commands/disconnect.ts b/packages/compass-e2e-tests/helpers/commands/disconnect.ts index c68859907b8..d3e17ef9acf 100644 --- a/packages/compass-e2e-tests/helpers/commands/disconnect.ts +++ b/packages/compass-e2e-tests/helpers/commands/disconnect.ts @@ -15,7 +15,12 @@ async function resetForDisconnect( // and therefore be rendered. await browser.clickVisible(Selectors.CollapseConnectionsButton); - if (await browser.$(Selectors.SidebarFilterInput).isDisplayed()) { + if ( + (await browser.$(Selectors.SidebarFilterInput).isDisplayed()) && + (await browser + .$(Selectors.SidebarFilterInput) + .getAttribute('aria-disabled')) !== 'true' + ) { // Clear the filter to make sure every connection shows await browser.clickVisible(Selectors.SidebarFilterInput); await browser.setValueVisible(Selectors.SidebarFilterInput, ''); diff --git a/packages/compass-e2e-tests/helpers/commands/remove-connections.ts b/packages/compass-e2e-tests/helpers/commands/remove-connections.ts index f9b6940d0cf..8a513f07123 100644 --- a/packages/compass-e2e-tests/helpers/commands/remove-connections.ts +++ b/packages/compass-e2e-tests/helpers/commands/remove-connections.ts @@ -8,7 +8,12 @@ async function resetForRemove(browser: CompassBrowser) { // and therefore be rendered. await browser.clickVisible(Selectors.CollapseConnectionsButton); - if (await browser.$(Selectors.SidebarFilterInput).isDisplayed()) { + if ( + (await browser.$(Selectors.SidebarFilterInput).isDisplayed()) && + (await browser + .$(Selectors.SidebarFilterInput) + .getAttribute('aria-disabled')) !== 'true' + ) { // Clear the filter to make sure every connection shows await browser.clickVisible(Selectors.SidebarFilterInput); await browser.setValueVisible(Selectors.SidebarFilterInput, ''); diff --git a/packages/compass-e2e-tests/helpers/commands/sidebar-connection.ts b/packages/compass-e2e-tests/helpers/commands/sidebar-connection.ts index 444e2f08cf0..2187bfb5bfa 100644 --- a/packages/compass-e2e-tests/helpers/commands/sidebar-connection.ts +++ b/packages/compass-e2e-tests/helpers/commands/sidebar-connection.ts @@ -93,7 +93,12 @@ export async function removeConnection( connectionName: string ): Promise { // make sure there's no filter because if the connection is not displayed then we can't remove it - if (await browser.$(Selectors.SidebarFilterInput).isExisting()) { + if ( + (await browser.$(Selectors.SidebarFilterInput).isExisting()) && + (await browser + .$(Selectors.SidebarFilterInput) + .getAttribute('aria-disabled')) !== 'true' + ) { await browser.clickVisible(Selectors.SidebarFilterInput); await browser.setValueVisible(Selectors.SidebarFilterInput, ''); diff --git a/packages/compass-sidebar/src/components/connections-filter-popover.tsx b/packages/compass-sidebar/src/components/connections-filter-popover.tsx index 973a79fc5bb..bf387304224 100644 --- a/packages/compass-sidebar/src/components/connections-filter-popover.tsx +++ b/packages/compass-sidebar/src/components/connections-filter-popover.tsx @@ -43,6 +43,7 @@ type ConnectionsFilterPopoverProps = PropsWithChildren<{ onFilterChange( updater: (filter: ConnectionsFilter) => ConnectionsFilter ): void; + disabled?: boolean; }>; export default function ConnectionsFilterPopover({ @@ -50,6 +51,7 @@ export default function ConnectionsFilterPopover({ setOpen, filter, onFilterChange, + disabled = false, }: ConnectionsFilterPopoverProps) { const onExcludeInactiveChange = useCallback( (excludeInactive: boolean) => { @@ -103,6 +105,7 @@ export default function ConnectionsFilterPopover({ active={open} aria-label="Filter connections" ref={ref} + disabled={disabled} > {isActivated && ( diff --git a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx index 6203a9a984f..8efee16c79e 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx @@ -18,6 +18,8 @@ import { Button, Icon, ButtonVariant, + cx, + Placeholder, } from '@mongodb-js/compass-components'; import { ConnectionsNavigationTree } from '@mongodb-js/compass-connections-navigation'; import type { MapDispatchToProps, MapStateToProps } from 'react-redux'; @@ -38,6 +40,7 @@ import type { RootState, SidebarThunkAction } from '../../modules'; import { type useConnectionsWithStatus, ConnectionStatus, + useConnectionsListLoadingStatus, } from '@mongodb-js/compass-connections/provider'; import { useOpenWorkspace, @@ -89,6 +92,10 @@ const connectionCountStyles = css({ marginLeft: spacing[100], }); +const connectionCountDisabledStyles = css({ + opacity: 0.6, +}); + const noDeploymentStyles = css({ paddingLeft: spacing[400], paddingRight: spacing[400], @@ -305,7 +312,7 @@ const ConnectionsNavigation: React.FC = ({ } return actions; - }, [supportsConnectionImportExport]); + }, [supportsConnectionImportExport, enableCreatingNewConnections]); const onConnectionItemAction = useCallback( ( @@ -491,6 +498,17 @@ const ConnectionsNavigation: React.FC = ({ const isAtlasConnectionStorage = useContext(AtlasClusterConnectionsOnly); + const { isInitialLoad: isInitialConnectionsLoad } = + useConnectionsListLoadingStatus(); + + const connectionsCount = isInitialConnectionsLoad ? ( + + (…) + + ) : connections.length !== 0 ? ( + ({connections.length}) + ) : undefined; + return (
= ({ > {isAtlasConnectionStorage ? 'Clusters' : 'Connections'} - {connections.length !== 0 && ( - - ({connections.length}) - - )} + {connectionsCount} iconSize="xsmall" @@ -514,27 +528,25 @@ const ConnectionsNavigation: React.FC = ({ collapseAfter={2} >
- {connections.length > 0 && ( - <> - - - - )} - {connections.length === 0 && ( + + {isInitialConnectionsLoad ? ( + + ) : connections.length > 0 ? ( + + ) : connections.length === 0 ? (
You have not connected to any deployments. @@ -550,11 +562,37 @@ const ConnectionsNavigation: React.FC = ({ )}
- )} + ) : null}
); }; +const placeholderListStyles = css({ + display: 'grid', + gridTemplateColumns: '1fr', + // placeholder height that visually matches font size (16px) + vertical + // spacing (12px) to align it visually with real items + gridAutoRows: spacing[400] + spacing[300], + alignItems: 'center', + // navigation list padding + "empty" caret icon space (4px) to align it + // visually with real items + paddingLeft: spacing[400] + spacing[100], + paddingRight: spacing[400], +}); + +function ConnectionsPlaceholder() { + return ( +
+ {Array.from({ length: 3 }, (_, index) => ( + + ))} +
+ ); +} + const onRefreshDatabases = (connectionId: string): SidebarThunkAction => { return (_dispatch, getState, { globalAppRegistry }) => { globalAppRegistry.emit('refresh-databases', { connectionId }); diff --git a/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx b/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx index 46c733c58dc..8b054331613 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx @@ -5,7 +5,6 @@ import type { RenderWithConnectionsHookResult } from '@mongodb-js/testing-librar import { createPluginTestHelpers, screen, - cleanup, waitFor, within, userEvent, @@ -94,7 +93,7 @@ describe('Multiple Connections Sidebar Component', function () { function doRender( activeWorkspace: WorkspaceTab | null = null, - connections: ConnectionInfo[] = [savedFavoriteConnection], + connections: ConnectionInfo[] | 'no-preload' = [savedFavoriteConnection], atlasClusterConnectionsOnly: boolean | undefined = undefined ) { workspace = sinon.spy({ @@ -153,7 +152,6 @@ describe('Multiple Connections Sidebar Component', function () { } afterEach(function () { - cleanup(); sinon.restore(); }); @@ -235,6 +233,18 @@ describe('Multiple Connections Sidebar Component', function () { }); describe('connections list', function () { + it('should display a loading state while connections are not loaded yet', function () { + doRender(null, 'no-preload'); + expect(screen.getByTestId('connections-placeholder')).to.be.visible; + expect(screen.getByRole('searchbox', { name: 'Search' })).to.have.attr( + 'aria-disabled', + 'true' + ); + expect( + screen.getByRole('button', { name: 'Filter connections' }) + ).to.have.attr('aria-disabled', 'true'); + }); + context('when there are no connections', function () { it('should display an empty state with a CTA to add new connection', function () { doRender(undefined, []); diff --git a/packages/compass-sidebar/src/components/navigation-items-filter.tsx b/packages/compass-sidebar/src/components/navigation-items-filter.tsx index 558ba8948e5..a655ff17a48 100644 --- a/packages/compass-sidebar/src/components/navigation-items-filter.tsx +++ b/packages/compass-sidebar/src/components/navigation-items-filter.tsx @@ -30,6 +30,7 @@ export default function NavigationItemsFilter({ title = 'Search', filter, onFilterChange, + disabled = false, }: { placeholder?: string; ariaLabel?: string; @@ -38,6 +39,7 @@ export default function NavigationItemsFilter({ onFilterChange( updater: (filter: ConnectionsFilter) => ConnectionsFilter ): void; + disabled?: boolean; }): React.ReactElement { const onChange = useCallback>( (event) => { @@ -66,12 +68,14 @@ export default function NavigationItemsFilter({ title={title} onChange={onChange} className={textInputStyles} + disabled={disabled} /> );