From 81e415699fea2e40239c144a60f1908e8f77484c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 12 Nov 2024 19:40:59 +0100 Subject: [PATCH] feat(sidebar): active connections toggle COMPASS-8114 (#6458) * Add ConnectedPlugs and DisconnectedPlug icons * Show icon button to the right of NavigationItemsFilter * Add "exclude inactive" to connections filtering * Turn IconButton active when filtering is active * fixup! Show icon button to the right of NavigationItemsFilter Use default align for the Tooltip * Fix existing tests * Add a new test * fixup! Add a new test Use queryByTestId to fix test * fixup! fixup! Add a new test Move asserts into a shared expectElementByTestId * Add useFilteredConnections tests * fixup! fixup! fixup! Add a new test Revert the shared helper * fixup! fixup! fixup! fixup! Add a new test Remove unused import --- .../src/components/icons/connected-plugs.tsx | 46 +++++++++ .../components/icons/disconnected-plug.tsx | 27 ++++++ packages/compass-components/src/index.ts | 2 + .../connections-navigation.tsx | 55 +++++++++-- .../multiple-connections/sidebar.spec.tsx | 30 ++++++ .../multiple-connections/sidebar.tsx | 6 ++ .../components/navigation-items-filter.tsx | 7 +- .../use-filtered-connections.spec.ts | 94 ++++++++++++++++++- .../components/use-filtered-connections.ts | 47 +++++++--- 9 files changed, 288 insertions(+), 26 deletions(-) create mode 100644 packages/compass-components/src/components/icons/connected-plugs.tsx create mode 100644 packages/compass-components/src/components/icons/disconnected-plug.tsx diff --git a/packages/compass-components/src/components/icons/connected-plugs.tsx b/packages/compass-components/src/components/icons/connected-plugs.tsx new file mode 100644 index 00000000000..a9e4912e384 --- /dev/null +++ b/packages/compass-components/src/components/icons/connected-plugs.tsx @@ -0,0 +1,46 @@ +import { palette } from '@leafygreen-ui/palette'; +import React, { useMemo } from 'react'; + +import { useDarkMode } from '../../hooks/use-theme'; + +const ConnectedPlugsIcon: React.FunctionComponent = () => { + const darkMode = useDarkMode(); + + const fillColor = useMemo( + () => (darkMode ? palette.white : palette.black), + [darkMode] + ); + + const sparkColor = palette.green.dark1; + + return ( + + + + + + + + + ); +}; + +export { ConnectedPlugsIcon }; diff --git a/packages/compass-components/src/components/icons/disconnected-plug.tsx b/packages/compass-components/src/components/icons/disconnected-plug.tsx new file mode 100644 index 00000000000..efd40a711d3 --- /dev/null +++ b/packages/compass-components/src/components/icons/disconnected-plug.tsx @@ -0,0 +1,27 @@ +import { palette } from '@leafygreen-ui/palette'; +import React, { useMemo } from 'react'; + +import { useDarkMode } from '../../hooks/use-theme'; + +const DisconnectedPlugIcon: React.FunctionComponent = () => { + const darkMode = useDarkMode(); + + const fillColor = useMemo( + () => (darkMode ? palette.white : palette.black), + [darkMode] + ); + + return ( + + + + ); +}; + +export { DisconnectedPlugIcon }; diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 3d80c9d7d2f..15e7af1afe8 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -59,6 +59,8 @@ export { DocumentIcon } from './components/icons/document-icon'; export { FavoriteIcon } from './components/icons/favorite-icon'; export { ServerIcon } from './components/icons/server-icon'; export { NoSavedItemsIcon } from './components/icons/no-saved-items-icon'; +export { ConnectedPlugsIcon } from './components/icons/connected-plugs'; +export { DisconnectedPlugIcon } from './components/icons/disconnected-plug'; export { GuideCue as LGGuideCue } from '@leafygreen-ui/guide-cue'; export { Variant as BadgeVariant } from '@leafygreen-ui/badge'; export { Variant as BannerVariant } from '@leafygreen-ui/banner'; 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 e727ec6d858..00438b6d9e7 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx @@ -12,6 +12,10 @@ import { Button, Icon, ButtonVariant, + IconButton, + ConnectedPlugsIcon, + DisconnectedPlugIcon, + Tooltip, } from '@mongodb-js/compass-components'; import { ConnectionsNavigationTree } from '@mongodb-js/compass-connections-navigation'; import type { MapDispatchToProps, MapStateToProps } from 'react-redux'; @@ -80,11 +84,19 @@ const connectionCountStyles = css({ marginLeft: spacing[100], }); -const searchStyles = css({ +const filterContainerStyles = css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: spacing[200], paddingLeft: spacing[400], paddingRight: spacing[400], }); +const searchFormStyles = css({ + flexGrow: 1, +}); + const noDeploymentStyles = css({ paddingLeft: spacing[400], paddingRight: spacing[400], @@ -112,7 +124,9 @@ type ConnectionsNavigationComponentProps = { connectionsWithStatus: ReturnType; activeWorkspace: WorkspaceTab | null; filterRegex: RegExp | null; + excludeInactive: boolean; onFilterChange(regex: RegExp | null): void; + onToggleExcludeInactive(): void; onConnect(info: ConnectionInfo): void; onNewConnection(): void; onEditConnection(info: ConnectionInfo): void; @@ -154,10 +168,12 @@ const ConnectionsNavigation: React.FC = ({ connectionsWithStatus, activeWorkspace, filterRegex, + excludeInactive, instances, databases, isPerformanceTabSupported, onFilterChange, + onToggleExcludeInactive, onConnect, onNewConnection, onEditConnection, @@ -257,6 +273,7 @@ const ConnectionsNavigation: React.FC = ({ filterRegex, fetchAllCollections, onDatabaseExpand, + excludeInactive, }); const connectionListTitleActions = @@ -502,11 +519,37 @@ const ConnectionsNavigation: React.FC = ({ {connections.length > 0 && ( <> - +
+ + + {excludeInactive ? ( + + ) : ( + + )} + + } + > + {excludeInactive + ? 'Showing active connections' + : 'Showing all connections'} + +
{ + await renderAndWaitForNavigationTree(); + + const favoriteConnectionId = savedFavoriteConnection.id; + const recentConnectionId = savedRecentConnection.id; + + const activeConnectionsToggleButton = screen.getByLabelText( + 'Showing all connections' + ); + + expect(screen.queryByTestId(favoriteConnectionId)).to.be.visible; + expect(screen.queryByTestId(recentConnectionId)).to.be.visible; + + userEvent.click(activeConnectionsToggleButton); + expect(activeConnectionsToggleButton.ariaLabel).equals( + 'Showing active connections' + ); + + expect(screen.queryByTestId(favoriteConnectionId)).to.be.null; + expect(screen.queryByTestId(recentConnectionId)).to.be.null; + + await connectAndNotifyInstanceManager(savedFavoriteConnection); + expect(screen.queryByTestId(favoriteConnectionId)).to.be.visible; + expect(screen.queryByTestId(recentConnectionId)).to.be.null; + + await connectAndNotifyInstanceManager(savedRecentConnection); + expect(screen.queryByTestId(favoriteConnectionId)).to.be.visible; + expect(screen.queryByTestId(recentConnectionId)).to.be.visible; + }); + context('and performing actions', function () { beforeEach(async function () { await renderAndWaitForNavigationTree({ diff --git a/packages/compass-sidebar/src/components/multiple-connections/sidebar.tsx b/packages/compass-sidebar/src/components/multiple-connections/sidebar.tsx index 3abebc22e78..d21a551b368 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/sidebar.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/sidebar.tsx @@ -103,6 +103,10 @@ export function MultipleConnectionSidebar({ useState(null); const [connectionInfoModalConnectionId, setConnectionInfoModalConnectionId] = useState(); + const [excludeInactive, setExcludeInactiveConnections] = useState(false); + const toggleExcludeInactiveConnections = useCallback(() => { + setExcludeInactiveConnections((previous) => !previous); + }, []); const formPreferences = useConnectionFormPreferences(); const maybeProtectConnectionString = useMaybeProtectConnectionString(); @@ -204,7 +208,9 @@ export function MultipleConnectionSidebar({ connectionsWithStatus={connectionsWithStatus} activeWorkspace={activeWorkspace} filterRegex={activeConnectionsFilterRegex} + excludeInactive={excludeInactive} onFilterChange={onActiveConnectionFilterChange} + onToggleExcludeInactive={toggleExcludeInactiveConnections} onConnect={(connectionInfo) => { void connect(connectionInfo); }} diff --git a/packages/compass-sidebar/src/components/navigation-items-filter.tsx b/packages/compass-sidebar/src/components/navigation-items-filter.tsx index 7601816238e..98c7abf4e57 100644 --- a/packages/compass-sidebar/src/components/navigation-items-filter.tsx +++ b/packages/compass-sidebar/src/components/navigation-items-filter.tsx @@ -6,13 +6,13 @@ export default function NavigationItemsFilter({ ariaLabel = 'Search', title = 'Search', onFilterChange, - searchInputClassName, + className, }: { placeholder?: string; ariaLabel?: string; title?: string; - searchInputClassName?: string; onFilterChange(regex: RegExp | null): void; + className?: string; }): React.ReactElement { const onChange = useCallback( (event) => { @@ -37,7 +37,7 @@ export default function NavigationItemsFilter({ }, []); return ( -
+ ); 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 dae61d4cf34..5d93c0b65e1 100644 --- a/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts +++ b/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts @@ -140,6 +140,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -155,6 +156,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -167,6 +169,38 @@ describe('useFilteredConnections', function () { }); }); + context('excluding inactive connections', function () { + it('should match only connected collections items', function () { + const { result, rerender } = renderHookWithContext( + useFilteredConnections, + { + initialProps: { + connections: mockSidebarConnections, + filterRegex: null, + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: true, + }, + } + ); + + expect(result.current.filtered).to.be.deep.equal([ + sidebarConnections[0], + sidebarConnections[1], + ]); + + rerender({ + connections: mockSidebarConnections, + filterRegex: null, + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, + }); + + expect(result.current.filtered).to.be.undefined; + }); + }); + context('and a connection is toggled', function () { it('should return the appropriate connection expanded state for the toggled connection', async function () { const { result } = renderHookWithContext(useFilteredConnections, { @@ -175,6 +209,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -206,6 +241,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -258,6 +294,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -294,6 +331,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -340,6 +378,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -373,6 +412,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, } ); @@ -407,6 +447,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }); // should remove the expanded state of connection 2 await waitFor(() => { @@ -423,6 +464,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }); await waitFor(() => { expect(result.current.expanded).to.deep.equal({ @@ -445,6 +487,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('_connection', 'i'), // match everything basically fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, } ); @@ -460,6 +503,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('disconnected_connection', 'i'), // match disconnected one fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }); await waitFor(() => { expect(result.current.filtered).to.be.deep.equal([ @@ -475,6 +519,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('db_ready_1_1', 'i'), // match first database basically fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -515,6 +560,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('Matching', 'i'), // this matches connection as well as database fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -532,6 +578,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('coll_ready_2_1', 'i'), // match second db's collection fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -560,6 +607,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('ready_2_1', 'i'), // this matches 1 database and 1 collection fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -578,6 +626,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('coll_ready_1_1', 'i'), fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -592,6 +641,40 @@ describe('useFilteredConnections', function () { }); }); + context('excluding inactive connections', function () { + it('should match only connected collections items', function () { + const { result, rerender } = renderHookWithContext( + useFilteredConnections, + { + initialProps: { + connections: mockSidebarConnections, + filterRegex: new RegExp('connection_1'), + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: true, + }, + } + ); + + expect(result.current.filtered).to.be.deep.equal([ + sidebarConnections[0], + ]); + + rerender({ + connections: mockSidebarConnections, + filterRegex: new RegExp('connection_1'), + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, + }); + + expect(result.current.filtered).to.be.deep.equal([ + sidebarConnections[0], + sidebarConnections[2], + ]); + }); + }); + context('and items are already collapsed', function () { it('should expand the items temporarily', async function () { const { result, rerender } = renderHookWithContext( @@ -599,9 +682,10 @@ describe('useFilteredConnections', function () { { initialProps: { connections: mockSidebarConnections, - filterRegex: null, + filterRegex: null as RegExp | null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, } ); @@ -620,6 +704,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('coll_ready_1_1', 'i'), fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }); await waitFor(() => { expect(result.current.expanded).to.deep.equal({ @@ -640,9 +725,10 @@ describe('useFilteredConnections', function () { { initialProps: { connections: mockSidebarConnections, - filterRegex: new RegExp('coll_ready_1_1', 'i'), + filterRegex: new RegExp('coll_ready_1_1', 'i') as RegExp | null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, } ); @@ -661,6 +747,7 @@ describe('useFilteredConnections', function () { filterRegex: null, fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }); await waitFor(() => { expect(result.current.expanded).to.deep.equal({ @@ -682,6 +769,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('coll_ready_1_1', 'i'), fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -714,6 +802,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('coll_ready_1_1', 'i'), fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); @@ -752,6 +841,7 @@ describe('useFilteredConnections', function () { filterRegex: new RegExp('coll_ready_1_1', 'i'), fetchAllCollections: fetchAllCollectionsStub, onDatabaseExpand: onDatabaseExpandStub, + excludeInactive: false, }, }); diff --git a/packages/compass-sidebar/src/components/use-filtered-connections.ts b/packages/compass-sidebar/src/components/use-filtered-connections.ts index 2d882324dee..a90c6589c9f 100644 --- a/packages/compass-sidebar/src/components/use-filtered-connections.ts +++ b/packages/compass-sidebar/src/components/use-filtered-connections.ts @@ -42,11 +42,19 @@ type FilteredConnection = ( const filterConnections = ( connections: SidebarConnection[], - regex: RegExp + regex: RegExp | null, + excludeInactive: boolean ): FilteredConnection[] => { const results: FilteredConnection[] = []; for (const connection of connections) { - const isMatch = regex.test(connection.name); + // Conditionally skip connections that aren't considered active + const inactive = + connection.connectionStatus !== 'connected' && + connection.connectionStatus !== 'connecting'; + if (excludeInactive && inactive) { + continue; + } + const isMatch = !regex || regex.test(connection.name); let childMatches: FilteredDatabase[] = []; if (connection.connectionStatus === 'connected') { childMatches = filterDatabases(connection.databases, regex); @@ -72,11 +80,11 @@ const filterConnections = ( const filterDatabases = ( databases: SidebarDatabase[], - regex: RegExp + regex: RegExp | null ): FilteredDatabase[] => { const results: FilteredDatabase[] = []; for (const db of databases) { - const isMatch = regex.test(db.name); + const isMatch = !regex || regex.test(db.name); const childMatches = filterCollections(db.collections, regex); if (isMatch || childMatches.length) { @@ -89,7 +97,7 @@ const filterDatabases = ( ? childMatches : db.collections.map((collection) => ({ ...collection, - isMatch: regex.test(collection.name), + isMatch: !regex || regex.test(collection.name), })); results.push({ ...db, @@ -103,10 +111,10 @@ const filterDatabases = ( const filterCollections = ( collections: SidebarCollection[], - regex: RegExp + regex: RegExp | null ): FilteredCollection[] => { return collections - .filter(({ name }) => regex.test(name)) + .filter(({ name }) => !regex || regex.test(name)) .map((collection) => ({ ...collection, isMatch: true })); }; @@ -205,7 +213,8 @@ const FILTER_CONNECTIONS = interface FilterConnectionsAction { type: typeof FILTER_CONNECTIONS; connections: SidebarConnection[]; - filterRegex: RegExp; + filterRegex: RegExp | null; + excludeInactive: boolean; } const CLEAR_FILTER = 'sidebar/active-connections/CLEAR_FILTER' as const; @@ -265,7 +274,8 @@ const connectionsReducer = ( case FILTER_CONNECTIONS: { const filtered = filterConnections( action.connections, - action.filterRegex + action.filterRegex, + action.excludeInactive ); const persistingExpanded = revertTemporaryExpanded(state.expanded); return { @@ -381,11 +391,13 @@ function filteredConnectionsToSidebarConnection( export const useFilteredConnections = ({ connections, filterRegex, + excludeInactive, fetchAllCollections, onDatabaseExpand, }: { connections: SidebarConnection[]; filterRegex: RegExp | null; + excludeInactive: boolean; fetchAllCollections: () => void; onDatabaseExpand: (connectionId: string, databaseId: string) => void; }): UseFilteredConnectionsHookResult => { @@ -410,11 +422,12 @@ export const useFilteredConnections = ({ // filter updates // connections change often, but the effect only uses connections if the filter is active // so we use this conditional dependency to avoid too many calls - const connectionsButOnlyIfFilterIsActive = filterRegex && connections; + const connectionsWhenFiltering = + (filterRegex || excludeInactive) && connections; useEffect(() => { - if (!filterRegex) { + if (!filterRegex && !excludeInactive) { dispatch({ type: CLEAR_FILTER }); - } else if (connectionsButOnlyIfFilterIsActive) { + } else if (connectionsWhenFiltering) { // the above check is extra just to please TS // When filtering, emit an event so that we can fetch all collections. This @@ -424,11 +437,17 @@ export const useFilteredConnections = ({ dispatch({ type: FILTER_CONNECTIONS, - connections: connectionsButOnlyIfFilterIsActive, + connections: connectionsWhenFiltering, filterRegex, + excludeInactive, }); } - }, [filterRegex, connectionsButOnlyIfFilterIsActive, fetchAllCollections]); + }, [ + filterRegex, + excludeInactive, + connectionsWhenFiltering, + fetchAllCollections, + ]); const onConnectionToggle = useCallback( (connectionId: string, expand: boolean) =>