diff --git a/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx b/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx index 0951af836a2..82ea611ad62 100644 --- a/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx +++ b/packages/compass-connections-navigation/src/connections-navigation-tree.spec.tsx @@ -126,6 +126,7 @@ const connections: Connection[] = [ const props: React.ComponentProps = { connections, expanded: { turtles: { bar: true } }, + expandedGroups: {}, activeWorkspace: { connectionId: 'connection_ready', namespace: 'db_ready.meow', @@ -161,6 +162,155 @@ describe('ConnectionsNavigationTree', function () { afterEach(cleanup); + context('with connection groups enabled', function () { + const groupedConnections: Connection[] = [ + { + ...connections[0], + connectionInfo: { + ...connections[0].connectionInfo, + favorite: { name: 'turtles', groupId: 'prod' }, + }, + }, + connections[2], // disconnected, no group + ]; + + it('renders a group header and indents grouped connections', async function () { + await renderConnectionsNavigationTree( + { connections: groupedConnections, expandedGroups: {} }, + { enableConnectionGroups: true } + ); + const group = screen.getByTestId('group:prod'); + expect(group).to.exist; + expect(within(group).getByText('prod')).to.exist; + expect(screen.getByText('turtles')).to.exist; + }); + + it('does not render group headers when the flag is disabled', async function () { + await renderConnectionsNavigationTree({ + connections: groupedConnections, + expandedGroups: {}, + }); + expect(screen.queryByTestId('group:prod')).to.be.null; + }); + + it('resolves the group header name and color from the groupsById prop', async function () { + await renderConnectionsNavigationTree( + { + connections: groupedConnections, + expandedGroups: {}, + groupsById: { + prod: { id: 'prod', name: 'Production', color: 'color1' }, + }, + }, + { enableConnectionGroups: true } + ); + const group = screen.getByTestId('group:prod'); + expect(within(group).getByText('Production')).to.exist; + expect(within(group).queryByText('prod')).to.be.null; + }); + + it('notifies on group expand toggle', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree( + { + connections: groupedConnections, + expandedGroups: {}, + onItemExpand: spy, + }, + { enableConnectionGroups: true } + ); + const group = screen.getByTestId('group:prod'); + userEvent.click(within(group).getByLabelText('Collapse')); + expect(spy).to.be.calledOnce; + const [[item, isExpanded]] = spy.args; + expect(item.type).to.equal('group'); + expect(item.groupName).to.equal('prod'); + expect(isExpanded).to.equal(false); + }); + + it('toggles the group from its row default action', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree( + { + connections: groupedConnections, + expandedGroups: {}, + onItemExpand: spy, + }, + { enableConnectionGroups: true } + ); + const group = screen.getByTestId('group:prod'); + + // Clicking the row (not the Collapse chevron) routes through + // onDefaultAction, which must not be blocked by the disabled-connection + // guard for group items. + userEvent.click(within(group).getByText('prod')); + + expect(spy).to.be.calledOnce; + const [[item, isExpanded]] = spy.args; + expect(item.type).to.equal('group'); + expect(item.groupName).to.equal('prod'); + expect(isExpanded).to.equal(false); + }); + + it('hides connections of a collapsed group', async function () { + await renderConnectionsNavigationTree( + { + connections: groupedConnections, + expandedGroups: { prod: false }, + }, + { enableConnectionGroups: true } + ); + expect(screen.getByTestId('group:prod')).to.exist; + expect(screen.queryByText('turtles')).to.be.null; + }); + + it('fires edit-group from the group actions', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree( + { + connections: groupedConnections, + expandedGroups: {}, + onItemAction: spy, + }, + { enableConnectionGroups: true } + ); + const group = screen.getByTestId('group:prod'); + + userEvent.hover(within(group).getByText('prod')); + userEvent.click(within(group).getByTitle('Show actions')); + userEvent.click(screen.getByText('Edit group')); + + expect(spy).to.be.calledOnce; + const [[item, action]] = spy.args; + expect(item.type).to.equal('group'); + expect(item.groupName).to.equal('prod'); + expect(action).to.equal('edit-group'); + }); + + it('fires delete-group from the group actions', async function () { + const spy = Sinon.spy(); + await renderConnectionsNavigationTree( + { + connections: groupedConnections, + expandedGroups: {}, + onItemAction: spy, + }, + { enableConnectionGroups: true } + ); + const group = screen.getByTestId('group:prod'); + + userEvent.hover(within(group).getByText('prod')); + userEvent.click(within(group).getByTitle('Show actions')); + userEvent.click(screen.getByText('Delete group')); + + expect(spy).to.be.calledOnce; + const [[item, action]] = spy.args; + expect(item.type).to.equal('group'); + expect(item.groupName).to.equal('prod'); + expect(action).to.equal('delete-group'); + }); + }); + context('when the rename collection feature flag is enabled', () => { it('shows the Rename Collection action', async function () { await renderConnectionsNavigationTree({ diff --git a/packages/compass-connections-navigation/src/connections-navigation-tree.tsx b/packages/compass-connections-navigation/src/connections-navigation-tree.tsx index 3c5aa4dc7f6..2cafb655817 100644 --- a/packages/compass-connections-navigation/src/connections-navigation-tree.tsx +++ b/packages/compass-connections-navigation/src/connections-navigation-tree.tsx @@ -31,6 +31,8 @@ import { databaseContextMenuActions, notConnectedConnectionItemActions, connectionContextMenuActions, + groupItemActions, + groupContextMenuActions, } from './item-actions'; import { itemActionsToContextMenuGroups } from './context-menus'; @@ -43,6 +45,8 @@ export interface ConnectionsNavigationTreeProps { connections: Connection[]; activeWorkspace: WorkspaceTab | null; expanded: Record>; + expandedGroups: Record; + groupsById?: Record; onItemExpand(item: SidebarActionableItem, isExpanded: boolean): void; onItemAction(item: SidebarActionableItem, action: Actions): void; } @@ -53,6 +57,8 @@ const ConnectionsNavigationTree: React.FunctionComponent< connections, activeWorkspace, expanded, + expandedGroups, + groupsById = {}, onItemExpand, onItemAction, }) => { @@ -62,12 +68,14 @@ const ConnectionsNavigationTree: React.FunctionComponent< readOnly: preferencesReadOnly, readWrite: preferencesReadWrite, showDisabledConnections, + enableConnectionGroups, } = usePreferences([ 'enableShell', 'readOnly', 'readWrite', 'enableRenameCollectionModal', 'showDisabledConnections', + 'enableConnectionGroups', ]); const isRenameCollectionEnabled = enableRenameCollectionModal && !preferencesReadWrite; @@ -78,14 +86,20 @@ const ConnectionsNavigationTree: React.FunctionComponent< const treeData = useMemo(() => { return getVirtualTreeItems({ connections, + groupsById, expandedItems: expanded, + expandedGroups, + enableConnectionGroups, preferencesReadOnly, preferencesReadWrite, preferencesShellEnabled, }); }, [ connections, + groupsById, expanded, + expandedGroups, + enableConnectionGroups, preferencesReadOnly, preferencesReadWrite, preferencesShellEnabled, @@ -93,13 +107,22 @@ const ConnectionsNavigationTree: React.FunctionComponent< const onDefaultAction: OnDefaultAction = useCallback( (item, evt) => { - if (showDisabledConnections) { + // A group is a container, not a connection, so the disabled-connection + // guard below must not short-circuit its default (row) action. Note that + // onDefaultAction never receives placeholder items (they are not + // actionable), so no placeholder check is needed here. + if (showDisabledConnections && item.type !== 'group') { const connectionId = getConnectionId(item); if (!getConnectable(connectionId)) { return; } } + if (item.type === 'group') { + onItemExpand(item, !item.isExpanded); + return; + } + if (item.type === 'connection') { if (item.connectionStatus === 'connected') { onItemAction(item, 'select-connection'); @@ -114,7 +137,7 @@ const ConnectionsNavigationTree: React.FunctionComponent< } } }, - [onItemAction, getConnectable, showDisabledConnections] + [onItemAction, onItemExpand, getConnectable, showDisabledConnections] ); const activeItemId = useMemo(() => { @@ -176,7 +199,11 @@ const ConnectionsNavigationTree: React.FunctionComponent< const getItemActionsAndConfig = useCallback( (item: SidebarTreeItem) => { - if (showDisabledConnections) { + if ( + showDisabledConnections && + item.type !== 'placeholder' && + item.type !== 'group' + ) { const connectionId = getConnectionId(item); if (!getConnectable(connectionId)) { return { @@ -189,6 +216,10 @@ const ConnectionsNavigationTree: React.FunctionComponent< return { actions: [], }; + case 'group': + return { + actions: groupItemActions(), + }; case 'connection': { if (item.connectionStatus === 'connected') { const actions = connectedConnectionItemActions({ @@ -249,6 +280,13 @@ const ConnectionsNavigationTree: React.FunctionComponent< switch (item.type) { case 'placeholder': return []; + case 'group': + return itemActionsToContextMenuGroups( + 'Group Tree Item', + item, + onItemAction, + groupContextMenuActions() + ); case 'connection': return itemActionsToContextMenuGroups( 'Connection Tree Item', diff --git a/packages/compass-connections-navigation/src/constants.tsx b/packages/compass-connections-navigation/src/constants.tsx index 5258f33a5b3..bfa2ff2f914 100644 --- a/packages/compass-connections-navigation/src/constants.tsx +++ b/packages/compass-connections-navigation/src/constants.tsx @@ -39,4 +39,7 @@ export type Actions = | 'open-in-new-tab' | 'duplicate-view' | 'modify-view' - | 'rename-collection'; + | 'rename-collection' + // group item related actions + | 'edit-group' + | 'delete-group'; diff --git a/packages/compass-connections-navigation/src/item-actions.ts b/packages/compass-connections-navigation/src/item-actions.ts index 3d5867677cd..3dd95118eb5 100644 --- a/packages/compass-connections-navigation/src/item-actions.ts +++ b/packages/compass-connections-navigation/src/item-actions.ts @@ -399,6 +399,37 @@ export const databaseContextMenuActions = ({ ]); }; +export const groupItemActions = (): NavigationItemActions => + stripNullActions([ + { + action: 'edit-group', + label: 'Edit group', + icon: 'Edit', + }, + { + action: 'delete-group', + label: 'Delete group', + icon: 'Trash', + variant: 'destructive', + }, + ]); + +export const groupContextMenuActions = (): NavigationItemActions => + stripNullActions([ + { + action: 'edit-group', + label: 'Edit group', + icon: 'Edit', + }, + { separator: true }, + { + action: 'delete-group', + label: 'Delete group', + icon: 'Trash', + variant: 'destructive', + }, + ]); + export const collectionContextMenuActions = ({ hasWriteActionsDisabled, canEditCollection, diff --git a/packages/compass-connections-navigation/src/navigation-item-icon.tsx b/packages/compass-connections-navigation/src/navigation-item-icon.tsx index 4080b3ecc92..2964f44515b 100644 --- a/packages/compass-connections-navigation/src/navigation-item-icon.tsx +++ b/packages/compass-connections-navigation/src/navigation-item-icon.tsx @@ -34,6 +34,9 @@ const IconWithTooltip = ({ }; export const NavigationItemIcon = ({ item }: { item: SidebarTreeItem }) => { + if (item.type === 'group') { + return ; + } if (item.type === 'database') { if (item.inferredFromPrivileges) { return ( diff --git a/packages/compass-connections-navigation/src/navigation-item.tsx b/packages/compass-connections-navigation/src/navigation-item.tsx index f634dcf8964..02e50f3f685 100644 --- a/packages/compass-connections-navigation/src/navigation-item.tsx +++ b/packages/compass-connections-navigation/src/navigation-item.tsx @@ -152,6 +152,11 @@ export function NavigationItem({ if (item.type === 'placeholder') { return {}; } + if (item.type === 'group') { + return { + 'data-group-name': item.groupName, + }; + } if (item.type === 'connection') { return { 'data-is-active': isActive.toString(), diff --git a/packages/compass-connections-navigation/src/styled-navigation-item.tsx b/packages/compass-connections-navigation/src/styled-navigation-item.tsx index 8c9043c50e0..d60bd1ac566 100644 --- a/packages/compass-connections-navigation/src/styled-navigation-item.tsx +++ b/packages/compass-connections-navigation/src/styled-navigation-item.tsx @@ -27,7 +27,7 @@ export default function StyledNavigationItem({ const isDarkMode = useDarkMode(); const { connectionColorToHex, connectionColorToHexActive } = useConnectionColor(); - const { colorCode } = item; + const colorCode = item.colorCode; const inactiveColor = useMemo( () => (isDarkMode ? palette.gray.light1 : palette.gray.dark1), [isDarkMode] diff --git a/packages/compass-connections-navigation/src/tree-data.spec.ts b/packages/compass-connections-navigation/src/tree-data.spec.ts new file mode 100644 index 00000000000..572ec07139d --- /dev/null +++ b/packages/compass-connections-navigation/src/tree-data.spec.ts @@ -0,0 +1,149 @@ +import { expect } from 'chai'; +import { getVirtualTreeItems } from './tree-data'; +import type { NotConnectedConnection } from './tree-data'; + +function makeConnection( + id: string, + name: string, + groupId?: string +): NotConnectedConnection { + return { + name, + connectionStatus: 'disconnected', + connectionInfo: { + id, + connectionOptions: { connectionString: `mongodb://${name}` }, + favorite: { name, ...(groupId ? { groupId } : {}) }, + savedConnectionType: 'favorite', + }, + }; +} + +const defaultsForGetItems = { + preferencesReadOnly: false, + preferencesReadWrite: false, + preferencesShellEnabled: true, +}; + +describe('getVirtualTreeItems with connection groups', function () { + it('renders a flat list when grouping is disabled', function () { + const items = getVirtualTreeItems({ + connections: [ + makeConnection('c1', 'alpha', 'g1'), + makeConnection('c2', 'beta'), + ], + groupsById: { g1: { id: 'g1', name: 'prod' } }, + expandedItems: {}, + expandedGroups: {}, + enableConnectionGroups: false, + ...defaultsForGetItems, + }); + expect(items.map((item) => item.type)).to.deep.equal([ + 'connection', + 'connection', + ]); + }); + + it('groups by groupId and resolves name + colorCode from the groups map', function () { + const items = getVirtualTreeItems({ + connections: [makeConnection('c1', 'a', 'g1'), makeConnection('c2', 'b')], + groupsById: { g1: { id: 'g1', name: 'prod', color: 'color1' } }, + expandedItems: {}, + expandedGroups: {}, + enableConnectionGroups: true, + ...defaultsForGetItems, + }); + const header = items.find((i) => i.type === 'group') as any; + expect(header.id).to.equal('group:g1'); + expect(header.groupId).to.equal('g1'); + expect(header.name).to.equal('prod'); + expect(header.groupName).to.equal('prod'); + expect(header.colorCode).to.equal('color1'); + // ungrouped connection 'b' still at root + expect(items.find((i: any) => i.name === 'b')?.level).to.equal(1); + // grouped connection 'a' indented under the group + expect(items.find((i: any) => i.name === 'a')?.level).to.equal(2); + }); + + it('sorts groups by resolved name and hides a collapsed group', function () { + const items = getVirtualTreeItems({ + connections: [ + makeConnection('c1', 'a', 'g2'), + makeConnection('c2', 'b', 'g1'), + ], + groupsById: { + g1: { id: 'g1', name: 'alpha' }, + g2: { id: 'g2', name: 'zeta' }, + }, + expandedItems: {}, + expandedGroups: { g2: false }, + enableConnectionGroups: true, + ...defaultsForGetItems, + }); + const headers = items.filter((i) => i.type === 'group') as any[]; + expect(headers.map((h) => h.name)).to.deep.equal(['alpha', 'zeta']); // sorted by resolved name + // g2 collapsed → its connection 'a' hidden + expect(items.find((i: any) => i.name === 'a')).to.be.undefined; + }); + + it('renders a flat list when grouping disabled', function () { + const items = getVirtualTreeItems({ + connections: [makeConnection('c1', 'a', 'g1')], + groupsById: { g1: { id: 'g1', name: 'prod' } }, + expandedItems: {}, + expandedGroups: {}, + enableConnectionGroups: false, + ...defaultsForGetItems, + }); + expect(items.every((i) => i.type !== 'group')).to.equal(true); + }); + + it('indents grouped connections one level deeper', function () { + const items = getVirtualTreeItems({ + connections: [ + makeConnection('c1', 'grouped', 'g1'), + makeConnection('c2', 'plain'), + ], + groupsById: { g1: { id: 'g1', name: 'prod' } }, + expandedItems: {}, + expandedGroups: {}, + enableConnectionGroups: true, + ...defaultsForGetItems, + }); + const grouped = items.find((i: any) => i.name === 'grouped') as any; + const plain = items.find((i: any) => i.name === 'plain') as any; + const header = items.find((i) => i.type === 'group') as any; + expect(header.level).to.equal(1); + expect(grouped.level).to.equal(2); + expect(plain.level).to.equal(1); + }); + + it('hides connections of a collapsed group', function () { + const items = getVirtualTreeItems({ + connections: [makeConnection('c1', 'grouped', 'g1')], + groupsById: { g1: { id: 'g1', name: 'prod' } }, + expandedItems: {}, + expandedGroups: { g1: false }, + enableConnectionGroups: true, + ...defaultsForGetItems, + }); + expect(items).to.have.lengthOf(1); + expect(items[0].type).to.equal('group'); + expect((items[0] as any).isExpanded).to.equal(false); + }); + + it('falls back to the groupId as the header name when the group is not in the map', function () { + const items = getVirtualTreeItems({ + connections: [makeConnection('c1', 'grouped', 'g-unknown')], + groupsById: {}, + expandedItems: {}, + expandedGroups: {}, + enableConnectionGroups: true, + ...defaultsForGetItems, + }); + const header = items.find((i) => i.type === 'group') as any; + expect(header.id).to.equal('group:g-unknown'); + expect(header.name).to.equal('g-unknown'); + expect(header.colorCode).to.equal(undefined); + }); +}); diff --git a/packages/compass-connections-navigation/src/tree-data.ts b/packages/compass-connections-navigation/src/tree-data.ts index 9404479b067..f02624ad199 100644 --- a/packages/compass-connections-navigation/src/tree-data.ts +++ b/packages/compass-connections-navigation/src/tree-data.ts @@ -120,7 +120,17 @@ export type CollectionTreeItem = VirtualTreeItem & { inferredFromPrivileges: boolean; }; +export type ConnectionGroupTreeItem = VirtualTreeItem & { + name: string; + type: 'group'; + groupId: string; + groupName: string; + colorCode?: string; + isExpanded: boolean; +}; + export type SidebarActionableItem = + | ConnectionGroupTreeItem | NotConnectedConnectionTreeItem | ConnectedConnectionTreeItem | DatabaseTreeItem @@ -129,7 +139,7 @@ export type SidebarActionableItem = export type SidebarTreeItem = PlaceholderTreeItem | SidebarActionableItem; export function getConnectionId(item: SidebarTreeItem): string { - if (item.type === 'placeholder') { + if (item.type === 'placeholder' || item.type === 'group') { return ''; } else if (item.type === 'connection') { return item.connectionInfo.id; @@ -142,15 +152,17 @@ const notConnectedConnectionToItems = ({ connection: { name, connectionInfo, connectionStatus }, connectionIndex, connectionsLength, + level, }: { connection: NotConnectedConnection; connectionIndex: number; connectionsLength: number; + level: number; }): SidebarTreeItem[] => { return [ { id: connectionInfo.id, - level: 1, + level, name, type: 'connection' as const, setSize: connectionsLength, @@ -180,6 +192,7 @@ const connectedConnectionToItems = ({ }, connectionIndex, connectionsLength, + level, expandedItems = {}, preferencesReadOnly, preferencesReadWrite, @@ -188,6 +201,7 @@ const connectedConnectionToItems = ({ connection: ConnectedConnection; connectionIndex: number; connectionsLength: number; + level: number; expandedItems: Record>; preferencesReadOnly: boolean; preferencesReadWrite: boolean; @@ -199,7 +213,7 @@ const connectedConnectionToItems = ({ preferencesReadOnly || isDataLake || !isWritable; const connectionTI: ConnectedConnectionTreeItem = { id: connectionInfo.id, - level: 1, + level, name, type: 'connection' as const, setSize: connectionsLength, @@ -234,7 +248,7 @@ const connectedConnectionToItems = ({ if (!areDatabasesReady) { return sidebarData.concat( Array.from({ length: placeholdersLength }, (_, index) => ({ - level: 2, + level: level + 1, type: 'placeholder' as const, colorCode, id: `${connectionInfo.id}.placeholder.${index}`, @@ -249,7 +263,7 @@ const connectedConnectionToItems = ({ connectionItem: connectionTI, database, expandedItems: expandedItems[connectionInfo.id] || {}, - level: 2, + level: level + 1, colorCode, databasesLength, databaseIndex, @@ -369,24 +383,36 @@ const databaseToItems = ({ */ export function getVirtualTreeItems({ connections, + groupsById = {}, expandedItems = {}, + expandedGroups = {}, + enableConnectionGroups = false, preferencesReadOnly, preferencesReadWrite, preferencesShellEnabled, }: { connections: (NotConnectedConnection | ConnectedConnection)[]; + groupsById?: Record; expandedItems: Record>; + expandedGroups?: Record; + enableConnectionGroups?: boolean; preferencesReadOnly: boolean; preferencesReadWrite: boolean; preferencesShellEnabled: boolean; }): SidebarTreeItem[] { - return connections.flatMap((connection, connectionIndex) => { + const connectionToItems = ( + connection: NotConnectedConnection | ConnectedConnection, + connectionIndex: number, + connectionsLength: number, + level: number + ): SidebarTreeItem[] => { if (connection.connectionStatus === ConnectionStatus.Connected) { return connectedConnectionToItems({ connection, expandedItems, connectionIndex, - connectionsLength: connections.length, + connectionsLength, + level, preferencesReadOnly, preferencesReadWrite, preferencesShellEnabled, @@ -394,9 +420,93 @@ export function getVirtualTreeItems({ } else { return notConnectedConnectionToItems({ connection, - connectionsLength: connections.length, + connectionsLength, connectionIndex, + level, + }); + } + }; + + if (!enableConnectionGroups) { + return connections.flatMap((connection, connectionIndex) => + connectionToItems(connection, connectionIndex, connections.length, 1) + ); + } + + // Group connections by their groupId preserving the incoming connection + // order inside each group. Grouped connections are rendered first (groups + // sorted by resolved name), ungrouped ones keep the root level below them. + const grouped = new Map< + string, + (NotConnectedConnection | ConnectedConnection)[] + >(); + const ungrouped: (NotConnectedConnection | ConnectedConnection)[] = []; + for (const connection of connections) { + const groupId = connection.connectionInfo.favorite?.groupId; + if (groupId) { + const list = grouped.get(groupId) ?? []; + list.push(connection); + grouped.set(groupId, list); + } else { + ungrouped.push(connection); + } + } + + const resolvedGroupName = (groupId: string) => + groupsById[groupId]?.name ?? groupId; + + const groupIds = [...grouped.keys()].sort((a, b) => + resolvedGroupName(a).localeCompare(resolvedGroupName(b)) + ); + const rootSetSize = groupIds.length + ungrouped.length; + const items: SidebarTreeItem[] = []; + + groupIds.forEach((groupId, groupIndex) => { + const groupConnections = grouped.get(groupId) as ( + | NotConnectedConnection + | ConnectedConnection + )[]; + const group = groupsById[groupId]; + const groupName = group?.name ?? groupId; + // Groups are expanded by default: only an explicit `false` collapses. + const isExpanded = expandedGroups[groupId] !== false; + items.push({ + id: `group:${groupId}`, + name: groupName, + type: 'group' as const, + groupId, + groupName, + colorCode: group?.color, + level: 1, + setSize: rootSetSize, + posInSet: groupIndex + 1, + isExpandable: true, + isExpanded, + }); + if (isExpanded) { + groupConnections.forEach((connection, connectionIndex) => { + items.push( + ...connectionToItems( + connection, + connectionIndex, + groupConnections.length, + 2 + ) + ); }); } }); + + ungrouped.forEach((connection, connectionIndex) => { + items.push( + ...connectionToItems( + connection, + groupIds.length + connectionIndex, + rootSetSize, + 1 + ) + ); + }); + + return items; } diff --git a/packages/compass-connections-navigation/src/tree-item.tsx b/packages/compass-connections-navigation/src/tree-item.tsx index 9700b543451..1edfc36e780 100644 --- a/packages/compass-connections-navigation/src/tree-item.tsx +++ b/packages/compass-connections-navigation/src/tree-item.tsx @@ -47,6 +47,7 @@ export const ExpandButton: React.FunctionComponent<{ // collapse it using keyboard, so the button is only valuable when // using a mouse tabIndex={-1} + aria-label={isExpanded ? 'Collapse' : 'Expand'} onClick={onClick} className={cx(buttonReset, expandButton, { [expandedStyles]: isExpanded, diff --git a/packages/compass-connections/src/hooks/use-connection-form-preferences.tsx b/packages/compass-connections/src/hooks/use-connection-form-preferences.tsx index 72b56bea79c..61256728a6f 100644 --- a/packages/compass-connections/src/hooks/use-connection-form-preferences.tsx +++ b/packages/compass-connections/src/hooks/use-connection-form-preferences.tsx @@ -1,5 +1,7 @@ import { usePreference } from 'compass-preferences-model/provider'; import { useMemo } from 'react'; +import { UUID } from 'bson'; +import { useConnectionGroups, useConnectionActions } from '../provider'; export function useConnectionFormPreferences() { const protectConnectionStrings = usePreference('protectConnectionStrings'); @@ -13,6 +15,9 @@ export function useConnectionFormPreferences() { const protectConnectionStringsForNewConnections = usePreference( 'protectConnectionStringsForNewConnections' ); + const enableConnectionGroups = usePreference('enableConnectionGroups'); + const connectionGroups = useConnectionGroups(); + const { createGroup, updateGroup } = useConnectionActions(); return useMemo( () => ({ @@ -23,6 +28,20 @@ export function useConnectionFormPreferences() { enableOidc, enableDebugUseCsfleSchemaMap, protectConnectionStringsForNewConnections, + showConnectionGroups: enableConnectionGroups, + connectionGroups, + onCreateGroup: async (name: string, color?: string) => { + const group = { id: new UUID().toString(), name, color }; + await createGroup(group); + return group; + }, + onUpdateGroup: async (group: { + id: string; + name: string; + color?: string; + }) => { + await updateGroup(group); + }, }), [ protectConnectionStrings, @@ -32,6 +51,10 @@ export function useConnectionFormPreferences() { enableOidc, enableDebugUseCsfleSchemaMap, protectConnectionStringsForNewConnections, + enableConnectionGroups, + connectionGroups, + createGroup, + updateGroup, ] ); } diff --git a/packages/compass-connections/src/index.tsx b/packages/compass-connections/src/index.tsx index a780900fe56..aed147e27fa 100644 --- a/packages/compass-connections/src/index.tsx +++ b/packages/compass-connections/src/index.tsx @@ -11,6 +11,7 @@ import { configureStore, disconnect, loadConnections, + loadGroups, } from './stores/connections-store-redux'; import { cloneDeep } from 'lodash'; import { ConnectionsStoreContext } from './stores/store-context'; @@ -50,6 +51,7 @@ const CompassConnectionsPlugin = registerCompassPlugin( setTimeout(() => { void store.dispatch(loadConnections()); + void store.dispatch(loadGroups()); if (initialProps.onAutoconnectInfoRequest) { void store.dispatch( autoconnectCheck( diff --git a/packages/compass-connections/src/provider.ts b/packages/compass-connections/src/provider.ts index e33b35c4dc5..29547883430 100644 --- a/packages/compass-connections/src/provider.ts +++ b/packages/compass-connections/src/provider.ts @@ -63,6 +63,7 @@ export { useConnectionsListRef, connectionsLocator, useConnectionsListLoadingStatus, + useConnectionGroups, } 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 7712c757f28..ef84059190e 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.spec.tsx +++ b/packages/compass-connections/src/stores/connections-store-redux.spec.tsx @@ -9,8 +9,21 @@ import { render, } from '@mongodb-js/testing-library-compass'; import React from 'react'; -import { getDataServiceForConnection } from './connections-store-redux'; +import { + getDataServiceForConnection, + configureStore, + loadGroups, + selectGroups, + createGroup, + deleteGroup, +} from './connections-store-redux'; +import type { ThunkExtraArg } from './connections-store-redux'; import { type ConnectionInfo } from '@mongodb-js/connection-info'; +import { InMemoryConnectionStorage } from '@mongodb-js/connection-storage/provider'; +import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; +import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider'; +import { createSandboxFromDefaultPreferences } from 'compass-preferences-model'; +import AppRegistry from '@mongodb-js/compass-app-registry'; const mockConnections = [ { @@ -788,3 +801,65 @@ describe('CompassConnections store', function () { }); }); }); + +describe('groups slice', function () { + async function createThunkArg( + connectionStorage = new InMemoryConnectionStorage([]) + ): Promise { + return { + appName: 'TEST', + preferences: await createSandboxFromDefaultPreferences(), + connectionStorage, + track: createNoopTrack(), + logger: createNoopLogger(), + getExtraConnectionData: () => Promise.resolve([{}, null] as any), + globalAppRegistry: new AppRegistry(), + compassAssistant: {} as ThunkExtraArg['compassAssistant'], + }; + } + + it('loads groups from storage on loadGroups', async function () { + const groups = [{ id: 'g1', name: 'prod', color: 'color1' }]; + const connectionStorage = new InMemoryConnectionStorage([]); + for (const g of groups) await connectionStorage.saveGroup({ group: g }); + const store = configureStore( + undefined, + await createThunkArg(connectionStorage) + ); + await store.dispatch(loadGroups()); + expect(selectGroups(store.getState())).to.deep.equal(groups); + }); + + it('creates and persists a group', async function () { + const connectionStorage = new InMemoryConnectionStorage([]); + const store = configureStore( + undefined, + await createThunkArg(connectionStorage) + ); + const group = { id: 'g1', name: 'prod', color: 'color1' }; + await store.dispatch(createGroup(group)); + expect(selectGroups(store.getState())).to.deep.include(group); + expect(await connectionStorage.loadGroups()).to.deep.include(group); + }); + + it('deleting a group unsets groupId on its member connections', async function () { + const conn = { + id: 'c1', + connectionOptions: { connectionString: 'mongodb://x' }, + favorite: { name: 'x', color: 'color5', groupId: 'g1' }, + savedConnectionType: 'favorite' as const, + }; + const connectionStorage = new InMemoryConnectionStorage([conn]); + await connectionStorage.saveGroup({ group: { id: 'g1', name: 'prod' } }); + const store = configureStore( + [conn], + await createThunkArg(connectionStorage) + ); + await store.dispatch(loadGroups()); + await store.dispatch(deleteGroup('g1')); + expect(selectGroups(store.getState())).to.have.lengthOf(0); + const saved = await connectionStorage.load({ id: 'c1' }); + expect(saved?.favorite?.groupId).to.be.undefined; + expect(saved?.favorite?.color).to.equal('color5'); + }); +}); diff --git a/packages/compass-connections/src/stores/connections-store-redux.ts b/packages/compass-connections/src/stores/connections-store-redux.ts index e6b46447cd5..1a0d077cbef 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.ts +++ b/packages/compass-connections/src/stores/connections-store-redux.ts @@ -8,7 +8,10 @@ import { getConnectionTitle, type ConnectionInfo, } from '@mongodb-js/connection-info'; -import type { ConnectionStorage } from '@mongodb-js/connection-storage/provider'; +import type { + ConnectionStorage, + ConnectionGroup, +} from '@mongodb-js/connection-storage/provider'; import type { TrackFunction } from '@mongodb-js/compass-telemetry/provider'; import type { Logger } from '@mongodb-js/compass-logging/provider'; import type { @@ -182,6 +185,18 @@ type NormalizedConnectionsList = { byId: Record; }; +/** + * Connection groups list stored following Redux data normalization + * guidelines + * @see {@link https://redux.js.org/usage/structuring-reducers/normalizing-state-shape#designing-a-normalized-state} + */ +type NormalizedGroupsList = { + ids: string[]; + byId: Record; + status: 'initial' | 'loading' | 'ready' | 'error'; + error: Error | null; +}; + export type State = { // State of all connections currently known to the application state. // Populated from the connection storage initially, but also keeps reference @@ -197,11 +212,15 @@ export type State = { | { status: 'refreshing-error' | 'loading-error'; error: Error } ); + // State of all connection groups currently known to the application, + // populated from the connection storage + groups: NormalizedGroupsList; + editingConnectionInfoId: ConnectionId | null; isEditingConnectionInfoModalOpen: boolean; }; -type ThunkExtraArg = { +export type ThunkExtraArg = { appName: string; preferences: PreferencesAccess; connectionStorage: ConnectionStorage; @@ -231,6 +250,16 @@ export const ActionTypes = { ConnectionsRefreshSuccess: 'ConnectionsRefreshSuccess', ConnectionsRefreshError: 'ConnectionsRefreshError', + // Actions related to getting connection groups from the persistent store + GroupsLoadStart: 'GroupsLoadStart', + GroupsLoadSuccess: 'GroupsLoadSuccess', + GroupsLoadError: 'GroupsLoadError', + + // Actions related to creating, updating, and deleting connection groups + CreateGroup: 'CreateGroup', + UpdateGroup: 'UpdateGroup', + DeleteGroup: 'DeleteGroup', + // Desktop-only connections import feature ConnectionsImportParsingStart: 'ConnectionsImportParsingStart', ConnectionsImportParsingFinish: 'ConnectionsImportParsingFinish', @@ -291,6 +320,35 @@ type ConnectionsLoadErrorAction = { error: Error; }; +type GroupsLoadStartAction = { + type: typeof ActionTypes.GroupsLoadStart; +}; + +type GroupsLoadSuccessAction = { + type: typeof ActionTypes.GroupsLoadSuccess; + groups: ConnectionGroup[]; +}; + +type GroupsLoadErrorAction = { + type: typeof ActionTypes.GroupsLoadError; + error: Error; +}; + +type CreateGroupAction = { + type: typeof ActionTypes.CreateGroup; + group: ConnectionGroup; +}; + +type UpdateGroupAction = { + type: typeof ActionTypes.UpdateGroup; + group: ConnectionGroup; +}; + +type DeleteGroupAction = { + type: typeof ActionTypes.DeleteGroup; + groupId: string; +}; + type ConnectionsRefreshStartAction = { type: typeof ActionTypes.ConnectionsRefreshStart; }; @@ -475,6 +533,12 @@ const INITIAL_STATE: State = { status: 'initial', error: null, }, + groups: { + ids: [], + byId: {}, + status: 'initial', + error: null, + }, editingConnectionInfoId: null, isEditingConnectionInfoModalOpen: false, }; @@ -761,6 +825,68 @@ const reducer: Reducer = (state = INITIAL_STATE, action) => { }, }; } + if (isAction(action, ActionTypes.GroupsLoadStart)) { + return { + ...state, + groups: { + ...state.groups, + status: 'loading', + error: null, + }, + }; + } + if ( + isAction(action, ActionTypes.GroupsLoadSuccess) + ) { + return { + ...state, + groups: { + status: 'ready', + error: null, + ids: action.groups.map((g) => g.id), + byId: Object.fromEntries(action.groups.map((g) => [g.id, g])), + }, + }; + } + if (isAction(action, ActionTypes.GroupsLoadError)) { + return { + ...state, + groups: { + ...state.groups, + status: 'error', + error: action.error, + }, + }; + } + if ( + isAction(action, ActionTypes.CreateGroup) || + isAction(action, ActionTypes.UpdateGroup) + ) { + const { group } = action; + const ids = state.groups.ids.includes(group.id) + ? state.groups.ids + : [...state.groups.ids, group.id]; + return { + ...state, + groups: { + ...state.groups, + ids, + byId: { ...state.groups.byId, [group.id]: group }, + }, + }; + } + if (isAction(action, ActionTypes.DeleteGroup)) { + const { groupId } = action; + const { [groupId]: _removed, ...byId } = state.groups.byId; + return { + ...state, + groups: { + ...state.groups, + ids: state.groups.ids.filter((id) => id !== groupId), + byId, + }, + }; + } if ( isAction( action, @@ -1236,6 +1362,101 @@ export const loadConnections = (): ConnectionsThunkAction< }; }; +export const loadGroups = (): ConnectionsThunkAction< + Promise, + GroupsLoadStartAction | GroupsLoadSuccessAction | GroupsLoadErrorAction +> => { + return async (dispatch, getState, { connectionStorage }) => { + if (getState().groups.status !== 'initial') { + return; + } + dispatch({ type: ActionTypes.GroupsLoadStart }); + try { + const groups = (await connectionStorage.loadGroups?.()) ?? []; + dispatch({ type: ActionTypes.GroupsLoadSuccess, groups }); + } catch (error) { + dispatch({ type: ActionTypes.GroupsLoadError, error: error as Error }); + } + }; +}; + +export const createGroup = ( + group: ConnectionGroup +): ConnectionsThunkAction< + Promise, + CreateGroupAction +> => { + return async ( + dispatch, + _getState, + { connectionStorage, logger: { debug } } + ) => { + try { + await connectionStorage.saveGroup?.({ group }); + dispatch({ type: ActionTypes.CreateGroup, group }); + return group; + } catch (err) { + debug('error creating group', err); + return null; + } + }; +}; + +export const updateGroup = ( + group: ConnectionGroup +): ConnectionsThunkAction, UpdateGroupAction> => { + return async ( + dispatch, + _getState, + { connectionStorage, logger: { debug } } + ) => { + try { + await connectionStorage.saveGroup?.({ group }); + dispatch({ type: ActionTypes.UpdateGroup, group }); + } catch (err) { + debug('error updating group', err); + } + }; +}; + +export const deleteGroup = ( + groupId: string +): ConnectionsThunkAction> => { + return async ( + dispatch, + getState, + { connectionStorage, logger: { debug } } + ) => { + // Best-effort cascade: if persisting a member fails partway, earlier members + // stay cleared and the group is not deleted (recoverable on retry). No rollback. + try { + // Cascade: unset groupId on member connections and persist them. + const members = Object.values(getState().connections.byId) + .map((c) => c.info) + .filter((info) => info.favorite?.groupId === groupId); + for (const info of members) { + const next: ConnectionInfo = { + ...info, + favorite: { + ...info.favorite, + name: info.favorite?.name ?? '', + groupId: undefined, + }, + }; + await connectionStorage.save?.({ connectionInfo: next }); + dispatch({ + type: ActionTypes.SaveConnectionInfo, + connectionInfo: next, + }); + } + await connectionStorage.deleteGroup?.({ id: groupId }); + dispatch({ type: ActionTypes.DeleteGroup, groupId }); + } catch (err) { + debug('error deleting group', err); + } + }; +}; + export const refreshConnections = (): ConnectionsThunkAction< Promise, | ConnectionsRefreshStartAction @@ -2334,6 +2555,10 @@ export const openSettingsModal = ( }; }; +export const selectGroups = (state: State): ConnectionGroup[] => { + return state.groups.ids.map((id) => state.groups.byId[id]); +}; + export function configureStore( preloadConnectionInfos: ConnectionInfo[] | undefined, thunkArg: ThunkExtraArg diff --git a/packages/compass-connections/src/stores/store-context.tsx b/packages/compass-connections/src/stores/store-context.tsx index b1b47a9898a..43f2d2f1c06 100644 --- a/packages/compass-connections/src/stores/store-context.tsx +++ b/packages/compass-connections/src/stores/store-context.tsx @@ -34,6 +34,10 @@ import { toggleConnectionFavoritedStatus, importConnections, refreshConnections, + selectGroups, + createGroup, + updateGroup, + deleteGroup, } from './connections-store-redux'; import type { Store } from 'redux'; import { @@ -42,7 +46,10 @@ import { } from '@mongodb-js/connection-info'; import { createServiceLocator } from '@mongodb-js/compass-app-registry'; import { isEqual, memoize } from 'lodash'; -import type { ImportConnectionOptions } from '@mongodb-js/connection-storage/provider'; +import type { + ImportConnectionOptions, + ConnectionGroup, +} from '@mongodb-js/connection-storage/provider'; import { useInitialValue } from '@mongodb-js/compass-components'; type ConnectionsStore = ReturnType extends Store< @@ -143,6 +150,15 @@ function getConnectionsActions(dispatch: ConnectionsStore['dispatch']) { refreshConnections: () => { return dispatch(refreshConnections()); }, + createGroup: (group: ConnectionGroup) => { + return dispatch(createGroup(group)); + }, + updateGroup: (group: ConnectionGroup) => { + return dispatch(updateGroup(group)); + }, + deleteGroup: (groupId: string) => { + return dispatch(deleteGroup(groupId)); + }, }; } @@ -380,6 +396,13 @@ export function useConnectionsColorList(): { }, isEqual); } +/** + * Returns the list of connection groups and subscribes to changes + */ +export function useConnectionGroups(): ConnectionGroup[] { + return useSelector(selectGroups, (a, b) => isEqual(a, b)); +} + export function useConnectionsListLoadingStatus() { return useSelector((state) => { const status = state.connections.status; diff --git a/packages/compass-preferences-model/src/feature-flags.ts b/packages/compass-preferences-model/src/feature-flags.ts index 4f246a5d8a6..389449205b6 100644 --- a/packages/compass-preferences-model/src/feature-flags.ts +++ b/packages/compass-preferences-model/src/feature-flags.ts @@ -271,6 +271,18 @@ export const FEATURE_FLAG_DEFINITIONS = [ short: 'Enable sorted syntax for search indexes schema', }, }, + + /** + * Feature flag for grouping connections in the sidebar. + */ + { + name: 'enableConnectionGroups', + stage: 'development', + description: { + short: 'Group connections in the sidebar', + long: 'Allows organizing saved connections into named groups rendered as collapsible sections in the sidebar.', + }, + }, ] as const satisfies ReadonlyArray; type FeatureFlagDefinitions = typeof FEATURE_FLAG_DEFINITIONS; diff --git a/packages/compass-sidebar/package.json b/packages/compass-sidebar/package.json index 66dfdee6b39..22878db8923 100644 --- a/packages/compass-sidebar/package.json +++ b/packages/compass-sidebar/package.json @@ -59,6 +59,7 @@ "@mongodb-js/compass-maybe-protect-connection-string": "^1.1.0", "@mongodb-js/compass-telemetry": "^1.31.0", "@mongodb-js/compass-workspaces": "^1.1.0", + "@mongodb-js/connection-form": "^1.84.1", "@mongodb-js/connection-info": "^0.28.1", "@mongodb-js/mongodb-constants": "^0.32.1", "@mongodb-js/workspace-info": "^1.9.1", 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 bd589963177..4a5b4f0aea8 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/connections-navigation.tsx @@ -5,6 +5,7 @@ import React, { useContext, useEffect, useMemo, + useState, } from 'react'; import { connect } from 'react-redux'; import { @@ -21,6 +22,7 @@ import { cx, Placeholder, useContextMenuGroups, + showConfirmation, } from '@mongodb-js/compass-components'; import { ConnectionsNavigationTree } from '@mongodb-js/compass-connections-navigation'; import type { MapDispatchToProps, MapStateToProps } from 'react-redux'; @@ -40,6 +42,8 @@ import { type useConnectionsWithStatus, ConnectionStatus, useConnectionsListLoadingStatus, + useConnectionGroups, + useConnectionActions, } from '@mongodb-js/compass-connections/provider'; import { useOpenWorkspace, @@ -55,6 +59,7 @@ import { useFilteredConnections, } from '../use-filtered-connections'; import NavigationItemsFilter from '../navigation-items-filter'; +import EditGroupModal from './edit-group-modal'; import { type ConnectionImportExportAction, useOpenConnectionImportExportModal, @@ -218,6 +223,18 @@ const ConnectionsNavigation: React.FC = ({ } = useOpenWorkspace(); const { hasWorkspacePlugin } = useWorkspacePlugins(); const track = useTelemetry(); + const connectionGroups = useConnectionGroups(); + const { updateGroup, deleteGroup } = useConnectionActions(); + const [editingGroup, setEditingGroup] = useState<{ + id: string; + name: string; + color?: string; + } | null>(null); + const groupsById = useMemo( + () => + Object.fromEntries(connectionGroups.map((group) => [group.id, group])), + [connectionGroups] + ); const connections = useMemo(() => { const connections: SidebarConnection[] = []; @@ -282,9 +299,11 @@ const ConnectionsNavigation: React.FC = ({ const { filtered, expanded, + expandedGroups, onCollapseAll, onConnectionToggle, onDatabaseToggle, + onGroupToggle, } = useFilteredConnections({ connections, filter, @@ -330,6 +349,35 @@ const ConnectionsNavigation: React.FC = ({ const onItemAction = useCallback( (item: SidebarItem, action: Actions) => { + if (item.type === 'group') { + switch (action) { + case 'edit-group': + setEditingGroup({ + id: item.groupId, + name: item.groupName, + color: item.colorCode, + }); + return; + case 'delete-group': + void showConfirmation({ + title: `Delete group "${item.groupName}"?`, + description: + 'Connections in this group will be ungrouped. The connections themselves are not deleted.', + buttonText: 'Delete group', + variant: 'danger', + }).then((confirmed) => { + if (confirmed) { + void deleteGroup(item.groupId); + } + }); + return; + default: + // A group has no connection info or namespace, and no other + // action is associated with it. + return; + } + } + const getConnectionInfo = (item: SidebarItem) => { switch (item.type) { case 'connection': @@ -348,7 +396,7 @@ const ConnectionsNavigation: React.FC = ({ }; const getNamespace = (item: SidebarItem) => { - if (item.type === 'connection') { + if (item.type === 'connection' || item.type === 'group') { throw new Error( `Item type ${item.type} does not have a namespace for action ${action}` ); @@ -514,18 +562,22 @@ const ConnectionsNavigation: React.FC = ({ openCollectionWorkspace, openEditViewWorkspace, _onNamespaceAction, + deleteGroup, + setEditingGroup, ] ); const onItemExpand = useCallback( (item: SidebarItem, isExpanded: boolean) => { - if (item.type === 'connection') { + if (item.type === 'group') { + onGroupToggle(item.groupId, isExpanded); + } else if (item.type === 'connection') { onConnectionToggle(item.connectionInfo.id, isExpanded); } else if (item.type === 'database') { onDatabaseToggle(item.connectionId, item.dbName, isExpanded); } }, - [onConnectionToggle, onDatabaseToggle] + [onGroupToggle, onConnectionToggle, onDatabaseToggle] ); const onConnectionListTitleAction = useCallback( @@ -633,6 +685,8 @@ const ConnectionsNavigation: React.FC = ({ onItemAction={onItemAction} onItemExpand={onItemExpand} expanded={expanded} + expandedGroups={expandedGroups} + groupsById={groupsById} /> ) ) : connections.length === 0 ? ( @@ -652,6 +706,17 @@ const ConnectionsNavigation: React.FC = ({ )} ) : null} + setEditingGroup(null)} + onSubmit={({ name, color }) => { + if (editingGroup) { + void updateGroup({ id: editingGroup.id, name, color }); + } + setEditingGroup(null); + }} + /> ); }; diff --git a/packages/compass-sidebar/src/components/multiple-connections/edit-group-modal.spec.tsx b/packages/compass-sidebar/src/components/multiple-connections/edit-group-modal.spec.tsx new file mode 100644 index 00000000000..65e4a0d50f7 --- /dev/null +++ b/packages/compass-sidebar/src/components/multiple-connections/edit-group-modal.spec.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { + render, + screen, + userEvent, + within, +} from '@mongodb-js/testing-library-compass'; +import EditGroupModal from './edit-group-modal'; + +describe('EditGroupModal', function () { + afterEach(function () { + sinon.restore(); + }); + + it('is not rendered when isOpen is false', function () { + render( + {}} + onClose={() => {}} + /> + ); + + expect(screen.queryByText('Edit group')).to.not.exist; + }); + + it('shows a name input prefilled with the group name and a color select prefilled with the group color', function () { + render( + {}} + onClose={() => {}} + /> + ); + + expect(screen.getByText('Edit group')).to.be.visible; + + const nameInput = screen.getByTestId( + 'edit-group-name-input' + ); + expect(nameInput.value).to.equal('Production'); + + const colorSelect = screen.getByTestId('edit-group-color-input'); + expect(within(colorSelect).getByText('Green')).to.be.visible; + }); + + it('calls onSubmit with the edited name and the existing color when Save is clicked', function () { + const onSubmit = sinon.spy(); + render( + {}} + /> + ); + + const nameInput = screen.getByTestId('edit-group-name-input'); + userEvent.clear(nameInput); + userEvent.type(nameInput, 'Renamed group'); + + userEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).to.have.been.calledOnceWithExactly({ + name: 'Renamed group', + color: 'color1', + }); + }); + + it('disables Save when the name is empty', function () { + render( + {}} + onClose={() => {}} + /> + ); + + const nameInput = screen.getByTestId('edit-group-name-input'); + userEvent.clear(nameInput); + + expect( + screen.getByRole('button', { name: 'Save' }).getAttribute('aria-disabled') + ).to.equal('true'); + }); + + it('calls onSubmit with the newly selected color', function () { + const onSubmit = sinon.spy(); + render( + {}} + /> + ); + + const colorSelectButton = screen.getByTestId('edit-group-color-input'); + userEvent.click(colorSelectButton); + + const menuId = colorSelectButton.getAttribute('aria-controls'); + const listbox = document.querySelector( + `[id="${menuId}"][role="listbox"]` + ) as HTMLElement; + userEvent.click(within(listbox).getByText('Teal')); + + userEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).to.have.been.calledOnceWithExactly({ + name: 'Production', + color: 'color2', + }); + }); + + it('calls onClose when Cancel is clicked', function () { + const onClose = sinon.spy(); + render( + {}} + onClose={onClose} + /> + ); + + userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onClose).to.have.been.calledOnce; + }); + + it('re-seeds the name and color fields when the group prop changes', function () { + const { rerender } = render( + {}} + onClose={() => {}} + /> + ); + + rerender( + {}} + onClose={() => {}} + /> + ); + + const nameInput = screen.getByTestId( + 'edit-group-name-input' + ); + expect(nameInput.value).to.equal('Staging'); + expect( + within(screen.getByTestId('edit-group-color-input')).getByText('Teal') + ).to.be.visible; + }); +}); diff --git a/packages/compass-sidebar/src/components/multiple-connections/edit-group-modal.tsx b/packages/compass-sidebar/src/components/multiple-connections/edit-group-modal.tsx new file mode 100644 index 00000000000..6f045a0895c --- /dev/null +++ b/packages/compass-sidebar/src/components/multiple-connections/edit-group-modal.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { + FormFieldContainer, + FormModal, + TextInput, + Select, + Option, + useSyncStateOnPropChange, +} from '@mongodb-js/compass-components'; +import { + ColorCircleGlyph, + useConnectionColor, +} from '@mongodb-js/connection-form'; + +export type EditGroupModalProps = { + isOpen: boolean; + group: { id: string; name: string; color?: string } | null; + onSubmit: (values: { name: string; color?: string }) => void; + onClose: () => void; +}; + +const NO_COLOR_VALUE = 'no-color'; + +const EditGroupModal: React.FunctionComponent = ({ + isOpen, + group, + onSubmit, + onClose, +}) => { + const { connectionColorCodes, connectionColorToHex, connectionColorToName } = + useConnectionColor(); + + const [name, setName] = useState(group?.name ?? ''); + const [color, setColor] = useState(group?.color); + + useSyncStateOnPropChange(() => { + setName(group?.name ?? ''); + setColor(group?.color); + }, [group]); + + const isSubmitDisabled = !name; + + const onSubmitForm = () => { + if (!isSubmitDisabled) { + onSubmit({ name, color }); + } + }; + + return ( + + + { + setName(event.target.value); + }} + /> + + + + + + ); +}; + +export default EditGroupModal; 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 bc9f2d9f895..5f3fbc12b7a 100644 --- a/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx +++ b/packages/compass-sidebar/src/components/multiple-connections/sidebar.spec.tsx @@ -22,6 +22,7 @@ import { } from '../../index'; import type { ConnectionInfo } from '@mongodb-js/compass-connections/provider'; import type AppRegistry from '@mongodb-js/compass-app-registry'; +import type { AllPreferences } from 'compass-preferences-model'; const savedFavoriteConnection: ConnectionInfo = { id: '12345', @@ -94,7 +95,8 @@ describe('Multiple Connections Sidebar Component', function () { function doRender( activeWorkspace: WorkspaceTab | null = null, connections: ConnectionInfo[] | 'no-preload' = [savedFavoriteConnection], - atlasClusterConnectionsOnly: boolean | undefined = undefined + atlasClusterConnectionsOnly: boolean | undefined = undefined, + preferences: Partial | undefined = undefined ) { workspace = sinon.spy({ openMyQueriesWorkspace: () => undefined, @@ -141,6 +143,7 @@ describe('Multiple Connections Sidebar Component', function () { const result = renderWithConnections(component, { connections, + preferences, connectFn() { return { currentOp() { @@ -718,4 +721,84 @@ describe('Multiple Connections Sidebar Component', function () { }); }); }); + + describe('connection groups', function () { + const groupedFavoriteConnection: ConnectionInfo = { + id: 'grouped-connection', + connectionOptions: { + connectionString: 'mongodb://localhost:22222/', + }, + favorite: { + name: 'grouped-connection', + groupId: 'g1', + }, + savedConnectionType: 'favorite', + }; + + // This exercises the full onItemExpand -> onGroupToggle(item.groupId) -> + // useFilteredConnections -> re-render pipeline with a real ConnectionInfo + // that only has a favorite.groupId (no ConnectionGroup entities are + // seeded here, since `useConnectionGroups()` reads from a store slice + // that this test harness has no seeding hook for). The group header + // therefore falls back to displaying the raw groupId as its name, which + // means this test cannot distinguish "keyed by groupId" from "keyed by + // the resolved group name" (they coincide when unresolved). That more + // specific distinction is covered at the unit level in + // tree-data.spec.ts and connections-navigation-tree.spec.tsx, which do + // seed groupsById with a name that differs from the id. + it('collapses the group and hides its connections when its groupId is toggled off', async function () { + doRender(undefined, [groupedFavoriteConnection], undefined, { + enableConnectionGroups: true, + }); + + const group = await waitFor(() => screen.getByTestId('group:g1')); + expect(screen.getByText('grouped-connection')).to.be.visible; + + userEvent.click(within(group).getByLabelText('Collapse')); + + await waitFor(() => { + expect(screen.queryByText('grouped-connection')).to.be.null; + }); + }); + + it('opens the edit group modal from the group actions menu', async function () { + doRender(undefined, [groupedFavoriteConnection], undefined, { + enableConnectionGroups: true, + }); + + const group = await waitFor(() => screen.getByTestId('group:g1')); + userEvent.hover(within(group).getByText('g1')); + userEvent.click(within(group).getByLabelText('Show actions')); + userEvent.click(screen.getByText('Edit group')); + + expect(screen.getByTestId('edit-group-modal')).to.be.visible; + }); + + it('asks for confirmation and ungroups the connection when the group is deleted', async function () { + doRender(undefined, [groupedFavoriteConnection], undefined, { + enableConnectionGroups: true, + }); + + const group = await waitFor(() => screen.getByTestId('group:g1')); + userEvent.hover(within(group).getByText('g1')); + userEvent.click(within(group).getByLabelText('Show actions')); + userEvent.click(screen.getByText('Delete group')); + + const confirmationModal = await screen.findByTestId('confirmation-modal'); + expect(confirmationModal.textContent).to.contain('Delete group "g1"?'); + + userEvent.click( + within(confirmationModal).getByRole('button', { + name: 'Delete group', + }) + ); + + // The group is gone and its connection is no longer grouped, but it's + // still in the connections list (deleteGroup only ungroups members). + await waitFor(() => { + expect(screen.queryByTestId('group:g1')).to.be.null; + }); + expect(screen.getByText('grouped-connection')).to.be.visible; + }); + }); }); 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 854f5229066..aa602dbb6e5 100644 --- a/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts +++ b/packages/compass-sidebar/src/components/use-filtered-connections.spec.ts @@ -930,4 +930,123 @@ describe('useFilteredConnections', function () { }); }); }); + + describe('connection groups expand state', function () { + const groupedConnection = { + id: 'grouped_connection_1', + connectionOptions: { + connectionString: 'mongodb://grouped_connection_1', + }, + favorite: { + name: 'grouped_connection_1', + groupId: 'g1', + }, + }; + + const groupedSidebarConnections: SidebarConnection[] = [ + { + name: 'grouped_connection_1', + connectionInfo: groupedConnection, + connectionStatus: 'connected', + isReady: true, + isWritable: true, + isPerformanceTabAvailable: true, + isPerformanceTabSupported: true, + isGenuineMongoDB: true, + isDataLake: false, + databases: [], + databasesStatus: 'ready', + databasesLength: 0, + }, + ]; + + let mockGroupedSidebarConnections: SidebarConnection[]; + beforeEach(function () { + mockGroupedSidebarConnections = _.cloneDeep(groupedSidebarConnections); + }); + + it('reports groups as expanded by default and toggles them', async function () { + const { result } = renderHookWithContext(useFilteredConnections, { + initialProps: { + connections: mockGroupedSidebarConnections, + filter: { regex: null, excludeInactive: false }, + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + }, + }); + + // no explicit collapse => expanded by default (absent from the map) + await waitFor(() => { + expect(result.current.expandedGroups).to.deep.equal({}); + }); + + result.current.onGroupToggle('g1', false); + await waitFor(() => { + expect(result.current.expandedGroups.g1).to.equal(false); + }); + + result.current.onGroupToggle('g1', true); + await waitFor(() => { + expect(result.current.expandedGroups.g1).to.equal(true); + }); + }); + + it('collapses groups on collapse all', async function () { + const { result } = renderHookWithContext(useFilteredConnections, { + initialProps: { + connections: mockGroupedSidebarConnections, + filter: { regex: null, excludeInactive: false }, + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + }, + }); + + result.current.onCollapseAll(); + await waitFor(() => { + expect(result.current.expandedGroups.g1).to.equal(false); + }); + }); + + it('re-expands a collapsed group while its connections match the filter', async function () { + const { result, rerender } = renderHookWithContext( + useFilteredConnections, + { + initialProps: { + connections: mockGroupedSidebarConnections, + filter: { regex: null as RegExp | null, excludeInactive: false }, + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + }, + } + ); + + result.current.onGroupToggle('g1', false); + await waitFor(() => { + expect(result.current.expandedGroups.g1).to.equal(false); + }); + + rerender({ + connections: mockGroupedSidebarConnections, + filter: { + regex: new RegExp('grouped_connection_1', 'i'), + excludeInactive: false, + }, + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + }); + await waitFor(() => { + expect(result.current.expandedGroups.g1).to.equal(true); + }); + + rerender({ + connections: mockGroupedSidebarConnections, + filter: { regex: null, excludeInactive: false }, + fetchAllCollections: fetchAllCollectionsStub, + onDatabaseExpand: onDatabaseExpandStub, + }); + await waitFor(() => { + expect(result.current.expandedGroups.g1).to.equal(false); + }); + }); + }); }); diff --git a/packages/compass-sidebar/src/components/use-filtered-connections.ts b/packages/compass-sidebar/src/components/use-filtered-connections.ts index 0075906dc9d..9f4e661462f 100644 --- a/packages/compass-sidebar/src/components/use-filtered-connections.ts +++ b/packages/compass-sidebar/src/components/use-filtered-connections.ts @@ -22,6 +22,7 @@ type ExpandedConnections = Record< databases: ExpandedDatabases; } >; +type ExpandedGroups = Record; interface Match { isMatch?: boolean; @@ -194,6 +195,20 @@ const revertTemporaryExpanded = ( return cleared; }; +/** + * Reverts 'temporarilyExpand' for groups, bringing them back to collapsed state + */ +const revertTemporaryExpandedGroups = ( + expandedGroups: ExpandedGroups +): ExpandedGroups => { + return Object.fromEntries( + Object.entries(expandedGroups).map(([groupId, state]) => [ + groupId, + state === 'tempExpanded' ? 'collapsed' : state, + ]) + ); +}; + const collapseAll = ( activeConnections: ConnectionInfo[] ): ExpandedConnections => { @@ -209,6 +224,7 @@ const collapseAll = ( interface ConnectionsState { expanded: ExpandedConnections; + expandedGroups: ExpandedGroups; filtered: SidebarConnection[] | undefined; } @@ -234,6 +250,13 @@ interface ToggleConnectionAction { expand: boolean; } +const GROUP_TOGGLE = 'sidebar/active-connections/GROUP_TOGGLE' as const; +interface ToggleGroupAction { + type: typeof GROUP_TOGGLE; + groupId: string; + expand: boolean; +} + const DATABASE_TOGGLE = 'sidebar/active-connections/DATABASE_TOGGLE' as const; interface ToggleDatabaseAction { type: typeof DATABASE_TOGGLE; @@ -254,6 +277,7 @@ const COLLAPSE_ALL = 'sidebar/active-connections/COLLAPSE_ALL' as const; interface CollapseAllAction { type: typeof COLLAPSE_ALL; connections: ConnectionInfo[]; + groupIds: string[]; } type ConnectionsAction = @@ -261,6 +285,7 @@ type ConnectionsAction = | ClearConnectionsFilterAction | ToggleConnectionAction | ToggleDatabaseAction + | ToggleGroupAction | ConnectionsChangedAction | CollapseAllAction; @@ -273,6 +298,9 @@ const connectionsReducer = ( return { ...state, expanded: collapseAll(action.connections), + expandedGroups: Object.fromEntries( + action.groupIds.map((id) => [id, 'collapsed' as const]) + ), }; } case FILTER_CONNECTIONS: { @@ -282,10 +310,20 @@ const connectionsReducer = ( action.excludeInactive ); const persistingExpanded = revertTemporaryExpanded(state.expanded); + const expandedGroups = { + ...revertTemporaryExpandedGroups(state.expandedGroups), + }; + for (const connection of filtered) { + const groupId = connection.connectionInfo.favorite?.groupId; + if (groupId && expandedGroups[groupId] === 'collapsed') { + expandedGroups[groupId] = 'tempExpanded'; + } + } return { ...state, filtered, expanded: temporarilyExpand(persistingExpanded, filtered), + expandedGroups, }; } case CLEAR_FILTER: @@ -293,7 +331,18 @@ const connectionsReducer = ( ...state, filtered: undefined, expanded: revertTemporaryExpanded(state.expanded), + expandedGroups: revertTemporaryExpandedGroups(state.expandedGroups), }; + case GROUP_TOGGLE: { + const { groupId, expand } = action; + return { + ...state, + expandedGroups: { + ...state.expandedGroups, + [groupId]: expand ? undefined : 'collapsed', + }, + }; + } case CONNECTION_TOGGLE: { const { connectionId, expand } = action; const currentState = state.expanded[connectionId]?.state; @@ -352,6 +401,7 @@ const connectionsReducer = ( type UseFilteredConnectionsHookResult = { filtered: SidebarConnection[] | undefined; expanded: ConnectionsNavigationTreeProps['expanded']; + expandedGroups: Record; onCollapseAll(this: void): void; onConnectionToggle( this: void, @@ -364,6 +414,7 @@ type UseFilteredConnectionsHookResult = { databaseId: string, isExpanded: boolean ): void; + onGroupToggle(this: void, groupId: string, isExpanded: boolean): void; }; function filteredConnectionsToSidebarConnection( @@ -408,10 +459,14 @@ export const useFilteredConnections = ({ fetchAllCollections: () => void; onDatabaseExpand: (connectionId: string, databaseId: string) => void; }): UseFilteredConnectionsHookResult => { - const [{ filtered, expanded }, dispatch] = useReducer(connectionsReducer, { - filtered: undefined, - expanded: {}, - }); + const [{ filtered, expanded, expandedGroups }, dispatch] = useReducer( + connectionsReducer, + { + filtered: undefined, + expanded: {}, + expandedGroups: {}, + } + ); const activeConnections = useMemo(() => { return connections @@ -421,6 +476,15 @@ export const useFilteredConnections = ({ .map(({ connectionInfo }) => connectionInfo); }, [connections]); + const groupIds = useMemo(() => { + const ids = new Set(); + for (const { connectionInfo } of connections) { + const groupId = connectionInfo.favorite?.groupId; + if (groupId) ids.add(groupId); + } + return [...ids]; + }, [connections]); + // get rid of stale connection related metadata in the state useEffect(() => { dispatch({ type: CONNECTIONS_CHANGED, connections: activeConnections }); @@ -462,6 +526,12 @@ export const useFilteredConnections = ({ [] ); + const onGroupToggle = useCallback( + (groupId: string, expand: boolean) => + dispatch({ type: GROUP_TOGGLE, groupId, expand }), + [] + ); + // We are creating a ref for expanded and onDatabaseExpand because we would // like to keep a stable reference of onDatabaseToggle const expandedRef = useRef(expanded); @@ -485,8 +555,12 @@ export const useFilteredConnections = ({ ); const onCollapseAll = useCallback(() => { - dispatch({ type: COLLAPSE_ALL, connections: activeConnections }); - }, [activeConnections]); + dispatch({ + type: COLLAPSE_ALL, + connections: activeConnections, + groupIds, + }); + }, [activeConnections, groupIds]); const expandedMemo: ConnectionsNavigationTreeProps['expanded'] = useMemo(() => { @@ -514,6 +588,14 @@ export const useFilteredConnections = ({ return result; }, [expanded, connections]); + const expandedGroupsMemo: Record = useMemo(() => { + const result: Record = Object.create(null); + for (const [groupId, state] of Object.entries(expandedGroups)) { + result[groupId] = state !== 'collapsed'; + } + return result; + }, [expandedGroups]); + // This is done to strip the isMatch that we attach on filtered items const filteredMemo = useMemo(() => { if (!filtered) { @@ -525,8 +607,10 @@ export const useFilteredConnections = ({ return { filtered: filteredMemo, expanded: expandedMemo, + expandedGroups: expandedGroupsMemo, onCollapseAll, onConnectionToggle, onDatabaseToggle, + onGroupToggle, }; }; diff --git a/packages/compass-user-data/src/user-data.ts b/packages/compass-user-data/src/user-data.ts index 4832fde9b63..9929c1cba82 100644 --- a/packages/compass-user-data/src/user-data.ts +++ b/packages/compass-user-data/src/user-data.ts @@ -17,6 +17,7 @@ const validUserDataTypes = [ 'AppPreferences', 'Users', 'Connections', + 'ConnectionGroups', 'AtlasState', 'ShellHistory', ] as const; diff --git a/packages/connection-form/src/components/connection-form.spec.tsx b/packages/connection-form/src/components/connection-form.spec.tsx index 314e8bb63c5..26f3d3c903c 100644 --- a/packages/connection-form/src/components/connection-form.spec.tsx +++ b/packages/connection-form/src/components/connection-form.spec.tsx @@ -516,6 +516,376 @@ describe('ConnectionForm Component', function () { expect(onCancel).to.have.been.called; }); + describe('connection group combobox', function () { + const groups = [{ id: 'g1', name: 'prod', color: 'color1' }]; + + function getGroupCombobox() { + return within( + screen.getByTestId('personalization-group-input') + ).getByRole('combobox'); + } + + it('does not show the group combobox by default', function () { + expect(screen.queryByTestId('personalization-group-input')).to.be.null; + }); + + it('lists existing groups as options when enabled', function () { + renderForm({ showConnectionGroups: true, connectionGroups: groups }); + userEvent.click(getGroupCombobox()); + expect(screen.getByText('prod')).to.exist; + }); + + it('selecting an existing group saves its id', async function () { + const onSaveClicked = Sinon.stub().resolves(); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onSaveClicked, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c' }, + savedConnectionType: 'favorite', + }, + }); + userEvent.click(getGroupCombobox()); + userEvent.click(screen.getByText('prod')); + userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSaveClicked).to.have.been.calledOnce); + expect(onSaveClicked.firstCall.args[0].favorite.groupId).to.equal('g1'); + }); + + it('does not create a duplicate group when an existing group is selected and the combobox blurs', async function () { + const onCreateGroup = Sinon.stub().resolves({ + id: 'dup', + name: 'prod', + }); + const onSaveClicked = Sinon.stub().resolves(); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onCreateGroup, + onSaveClicked, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c' }, + savedConnectionType: 'favorite', + }, + }); + userEvent.click(getGroupCombobox()); + userEvent.click(screen.getByText('prod')); + // Clicking Save blurs the combobox; the shared combobox re-fires + // onChange with the displayed group *name*, which must resolve to the + // already selected persisted group instead of creating a duplicate. + userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSaveClicked).to.have.been.calledOnce); + expect(onSaveClicked.firstCall.args[0].favorite.groupId).to.equal('g1'); + expect(onCreateGroup).to.not.have.been.called; + }); + + it('resolves a typed name of an existing group without creating a duplicate', async function () { + const onCreateGroup = Sinon.stub().resolves({ + id: 'dup', + name: 'prod', + }); + const onSaveClicked = Sinon.stub().resolves(); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onCreateGroup, + onSaveClicked, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c' }, + savedConnectionType: 'favorite', + }, + }); + userEvent.click(getGroupCombobox()); + userEvent.type( + within(screen.getByTestId('personalization-group-input')).getByRole( + 'textbox' + ), + 'prod' + ); + // Blur with the full name of an existing group typed in: it must be + // treated as selecting that group, not as creating a new one. Tab + // first so the blur happens with the options menu closed — otherwise + // the menu swallows the first click on Save. + userEvent.tab(); + userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSaveClicked).to.have.been.calledOnce); + expect(onSaveClicked.lastCall.args[0].favorite.groupId).to.equal('g1'); + expect(onCreateGroup).to.not.have.been.called; + }); + + it('creating a new group calls onCreateGroup and assigns the new id', async function () { + const created = { id: 'g2', name: 'staging', color: 'color3' }; + const onCreateGroup = Sinon.stub().resolves(created); + const onSaveClicked = Sinon.stub().resolves(); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onCreateGroup, + onSaveClicked, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c' }, + savedConnectionType: 'favorite', + }, + }); + userEvent.click(getGroupCombobox()); + userEvent.type( + within(screen.getByTestId('personalization-group-input')).getByRole( + 'textbox' + ), + 'staging' + ); + // select the "create" custom option that appears for the typed value + userEvent.click( + screen.getByRole('option', { name: 'Create "staging"' }) + ); + // Wait for the async group creation to resolve and the form state to + // pick up the new group id before saving, mirroring how a user would + // only click Save once the combobox reflects the created group. + await waitFor(() => expect(onCreateGroup).to.have.been.called); + await waitFor( + () => expect(screen.getByDisplayValue('staging')).to.exist + ); + + userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSaveClicked).to.have.been.calledOnce); + expect(onCreateGroup.firstCall.args[0]).to.equal('staging'); + expect(onSaveClicked.firstCall.args[0].favorite.groupId).to.equal('g2'); + // The combobox re-fires onChange from its onBlur with the typed value; + // the create branch must be idempotent so the group is created only once. + expect(onCreateGroup.callCount).to.equal(1); + }); + + it('creates a new group exactly once even after re-opening the combobox', async function () { + const created = { id: 'g2', name: 'staging', color: 'color3' }; + const onCreateGroup = Sinon.stub().resolves(created); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onCreateGroup, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c' }, + savedConnectionType: 'favorite', + }, + }); + userEvent.click(getGroupCombobox()); + userEvent.type( + within(screen.getByTestId('personalization-group-input')).getByRole( + 'textbox' + ), + 'staging' + ); + userEvent.click( + screen.getByRole('option', { name: 'Create "staging"' }) + ); + await waitFor(() => expect(onCreateGroup).to.have.been.calledOnce); + await waitFor( + () => expect(screen.getByDisplayValue('staging')).to.exist + ); + + // Re-open the combobox: the new group must show up exactly once, and + // no further onCreateGroup calls may happen from blur re-fires. + userEvent.click(getGroupCombobox()); + expect(screen.getAllByText('staging')).to.have.lengthOf(1); + expect(onCreateGroup.callCount).to.equal(1); + }); + + it('does not render a description under the new group color select', function () { + renderForm({ showConnectionGroups: true, connectionGroups: groups }); + expect( + screen.queryByText('Color used if a new group is created') + ).to.be.null; + }); + + it('creates a group without a color when the color select is untouched', async function () { + const created = { id: 'g2', name: 'staging' }; + const onCreateGroup = Sinon.stub().resolves(created); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onCreateGroup, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c' }, + savedConnectionType: 'favorite', + }, + }); + userEvent.click(getGroupCombobox()); + userEvent.type( + within(screen.getByTestId('personalization-group-input')).getByRole( + 'textbox' + ), + 'staging' + ); + userEvent.click( + screen.getByRole('option', { name: 'Create "staging"' }) + ); + await waitFor(() => expect(onCreateGroup).to.have.been.calledOnce); + expect(onCreateGroup.firstCall.args[1]).to.be.undefined; + }); + + it('recolors the just-created group when the color select changes', async function () { + const created = { id: 'g2', name: 'staging' }; + const onCreateGroup = Sinon.stub().resolves(created); + const onUpdateGroup = Sinon.stub().resolves(); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onCreateGroup, + onUpdateGroup, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c' }, + savedConnectionType: 'favorite', + }, + }); + userEvent.click(getGroupCombobox()); + userEvent.type( + within(screen.getByTestId('personalization-group-input')).getByRole( + 'textbox' + ), + 'staging' + ); + userEvent.click( + screen.getByRole('option', { name: 'Create "staging"' }) + ); + await waitFor(() => expect(onCreateGroup).to.have.been.calledOnce); + await waitFor( + () => expect(screen.getByDisplayValue('staging')).to.exist + ); + + // Picking a color after the group was already created must recolor + // that group instead of only affecting future group creations. + const colorSelectButton = screen.getByTestId( + 'personalization-group-color-input' + ); + userEvent.click(colorSelectButton); + const menuId = colorSelectButton.getAttribute('aria-controls'); + const listbox = document.querySelector( + `[id="${menuId}"][role="listbox"]` + ) as HTMLElement; + userEvent.click(within(listbox).getByText('Blue')); + + await waitFor(() => expect(onUpdateGroup).to.have.been.calledOnce); + expect(onUpdateGroup.firstCall.args[0]).to.deep.equal({ + id: 'g2', + name: 'staging', + color: 'color3', + }); + }); + + it('labels the group color select "Group color"', function () { + renderForm({ showConnectionGroups: true, connectionGroups: groups }); + expect(screen.getByText('Group color')).to.exist; + expect(screen.queryByText('New group color')).to.be.null; + }); + + it('prefills the color of the selected existing group and disables editing it', function () { + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c', groupId: 'g1' }, + savedConnectionType: 'favorite', + }, + }); + const colorSelectButton = screen.getByTestId( + 'personalization-group-color-input' + ); + // g1 is 'color1' → Green; an existing group's color is only + // editable from the sidebar's "Edit group", so the select is disabled. + expect(within(colorSelectButton).getByText('Green')).to.exist; + expect(colorSelectButton.getAttribute('aria-disabled')).to.equal( + 'true' + ); + }); + + it('prefills the group from the connection groupId', function () { + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c', groupId: 'g1' }, + savedConnectionType: 'favorite', + }, + }); + // the combobox shows the selected group's name + expect(screen.getByTestId('personalization-group-input')).to.exist; + expect(screen.getByDisplayValue('prod')).to.exist; + }); + + it('clears an existing group and saves an undefined groupId', async function () { + const onSaveClicked = Sinon.stub().resolves(); + renderForm({ + showConnectionGroups: true, + connectionGroups: groups, + onSaveClicked, + initialConnectionInfo: { + id: 't', + connectionOptions: { + connectionString: 'mongodb://localhost:27017', + }, + favorite: { name: 'c', groupId: 'g1' }, + savedConnectionType: 'favorite', + }, + }); + const groupInput = within( + screen.getByTestId('personalization-group-input') + ).getByRole('textbox'); + expect(groupInput.value).to.equal('prod'); + + // The clearable combobox exposes a "Clear selection" button while a + // group is selected; clicking it fires onChange(null), which clears + // the groupId in the form state. + userEvent.click( + within(screen.getByTestId('personalization-group-input')).getByRole( + 'button', + { name: /clear selection/i } + ) + ); + await waitFor(() => expect(groupInput.value).to.equal('')); + + userEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSaveClicked).to.have.been.called); + // The save issued after the group was cleared must carry no groupId. + expect(onSaveClicked.lastCall.args[0].favorite.groupId).to.be.undefined; + }); + }); + describe('name input', function () { it('should sync with the href of the connection string unless it has been edited', async function () { const connectionString = diff --git a/packages/connection-form/src/components/connection-form.tsx b/packages/connection-form/src/components/connection-form.tsx index 2cbb4c19a8a..e6d9f62bab3 100644 --- a/packages/connection-form/src/components/connection-form.tsx +++ b/packages/connection-form/src/components/connection-form.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { ConnectionInfo, ConnectionFavoriteOptions, @@ -21,6 +21,8 @@ import { useDarkMode, Button, Icon, + ComboboxWithCustomOption, + ComboboxOption, } from '@mongodb-js/compass-components'; import { cloneDeep } from 'lodash'; import ConnectionStringInput from './connection-string-input'; @@ -204,6 +206,14 @@ const personalizationSectionLayoutStyles = css({ marginBottom: spacing[600], }); +const personalizationSectionLayoutWithGroupStyles = css({ + gridTemplateAreas: ` + 'name-input color-input' + 'group-input group-color-input' + 'favorite-marker favorite-marker' + `, +}); + const personalizationNameInputStyles = css({ gridArea: 'name-input', }); @@ -212,6 +222,14 @@ const personalizationColorInputStyles = css({ gridArea: 'color-input', }); +const personalizationGroupInputStyles = css({ + gridArea: 'group-input', +}); + +const personalizationGroupColorInputStyles = css({ + gridArea: 'group-color-input', +}); + const personalizationFavoriteMarkerStyles = css({ gridArea: 'favorite-marker', }); @@ -250,6 +268,176 @@ function ConnectionPersonalizationForm({ [updateConnectionFormField, personalizationOptions] ); + const showConnectionGroups = useConnectionFormSetting('showConnectionGroups'); + const connectionGroups = useConnectionFormSetting('connectionGroups'); + const onCreateGroup = useConnectionFormSetting('onCreateGroup'); + const onUpdateGroup = useConnectionFormSetting('onUpdateGroup'); + + const { connectionColorToHex, connectionColorToName, connectionColorCodes } = + useConnectionColor(); + + const [newGroupColor, setNewGroupColor] = useState( + undefined + ); + + // Groups created from this form during the current session. The parent's + // connectionGroups list is expected to eventually include newly created + // groups too (e.g. once the backing store refreshes); until then we track + // them here so the combobox can display the new group and so a re-selection + // resolves to the created id instead of creating the group again. + const [locallyCreatedGroups, setLocallyCreatedGroups] = useState< + { id: string; name: string; color?: string }[] + >([]); + + // Names of groups whose creation is currently in-flight. The shared combobox + // re-fires onChange from its own onBlur using the typed string, so without + // this guard a blur that happens before onCreateGroup resolves would create + // the same group again. + const creatingGroupNamesRef = useRef>(new Set()); + + // Only the persisted groups are exposed as options here. A just-created group + // is displayed by the shared combobox's own internal custom option (keyed by + // the typed name); re-adding it here would render it a second time. Options + // are keyed by group id so a normal selection yields the id directly. + const groupComboboxOptions = useMemo( + () => + connectionGroups.map((group) => ({ + value: group.id, + name: group.name, + color: group.color, + })), + [connectionGroups] + ); + + // The value the combobox should show as selected. Persisted groups are keyed + // by id; a locally-created group not yet reflected in connectionGroups is + // only represented by the combobox's internal option (keyed by name), so + // surface its name so that option stays selected/displayed. + const selectedGroupComboboxValue = useMemo(() => { + const { groupId } = personalizationOptions; + if (!groupId) { + return null; + } + if (connectionGroups.some((group) => group.id === groupId)) { + return groupId; + } + const localGroup = locallyCreatedGroups.find( + (group) => group.id === groupId + ); + return localGroup ? localGroup.name : groupId; + }, [personalizationOptions, connectionGroups, locallyCreatedGroups]); + + const onClearGroup = useCallback(() => { + updateConnectionFormField({ + type: 'update-connection-personalization', + ...personalizationOptions, + groupId: undefined, + }); + }, [updateConnectionFormField, personalizationOptions]); + + const onChangeGroup = useCallback( + (value: string | null) => { + if (!value) { + onClearGroup(); + return; + } + + // Resolve against existing groups as well as ones just created in this + // session, by id AND name. The combobox re-fires onChange from onBlur + // with the displayed name (not the resolved id), so matching by name + // here makes that re-fire resolve to the already existing group instead + // of creating a duplicate. + const existingGroup = + connectionGroups.find( + (group) => group.id === value || group.name === value + ) ?? + locallyCreatedGroups.find( + (group) => group.id === value || group.name === value + ); + if (existingGroup) { + updateConnectionFormField({ + type: 'update-connection-personalization', + ...personalizationOptions, + groupId: existingGroup.id, + }); + return; + } + + // Guard the in-flight window: a second blur re-fire before the first + // create resolves must not start a duplicate creation. + if (creatingGroupNamesRef.current.has(value)) { + return; + } + creatingGroupNamesRef.current.add(value); + + void onCreateGroup(value, newGroupColor) + .then((createdGroup) => { + setLocallyCreatedGroups((groups) => [...groups, createdGroup]); + updateConnectionFormField({ + type: 'update-connection-personalization', + ...personalizationOptions, + groupId: createdGroup.id, + }); + }) + .finally(() => { + creatingGroupNamesRef.current.delete(value); + }); + }, + [ + onClearGroup, + updateConnectionFormField, + personalizationOptions, + connectionGroups, + locallyCreatedGroups, + onCreateGroup, + newGroupColor, + ] + ); + + // The color select mirrors the selected group. A pre-existing group's color + // is only editable from the sidebar's "Edit group", so the select is + // disabled for it; a group created from this form stays editable; with no + // group selected the select holds the color for a group about to be created. + const selectedLocalGroup = personalizationOptions.groupId + ? locallyCreatedGroups.find( + (group) => group.id === personalizationOptions.groupId + ) + : undefined; + const selectedPersistedGroup = personalizationOptions.groupId + ? connectionGroups.find( + (group) => group.id === personalizationOptions.groupId + ) + : undefined; + const selectedGroup = selectedLocalGroup ?? selectedPersistedGroup; + const groupColorValue = selectedGroup + ? selectedGroup.color ?? 'no-color' + : newGroupColor ?? 'no-color'; + const isGroupColorDisabled = !!selectedPersistedGroup && !selectedLocalGroup; + + // Changing the color after the group was already created (creation happens + // as soon as the name is confirmed in the combobox) must recolor that group, + // otherwise the selection would only affect future group creations. Only + // groups created from this form are recolored; existing groups are edited + // from the sidebar instead. + const onChangeNewGroupColor = useCallback( + (value: string) => { + const color = value === 'no-color' ? undefined : value; + setNewGroupColor(color); + const { groupId } = personalizationOptions; + const localGroup = + groupId && locallyCreatedGroups.find((group) => group.id === groupId); + if (!localGroup) { + return; + } + const updatedGroup = { ...localGroup, color }; + setLocallyCreatedGroups((groups) => + groups.map((group) => (group.id === groupId ? updatedGroup : group)) + ); + void onUpdateGroup(updatedGroup); + }, + [personalizationOptions, locallyCreatedGroups, onUpdateGroup] + ); + const onChangeFavorite = useCallback( (ev: React.ChangeEvent) => { updateConnectionFormField({ @@ -261,11 +449,13 @@ function ConnectionPersonalizationForm({ [updateConnectionFormField, personalizationOptions] ); - const { connectionColorToHex, connectionColorToName, connectionColorCodes } = - useConnectionColor(); - return ( -
+
))} + {showConnectionGroups && ( + <> + ( + + ) + } + /> + )} + /> + + + )} {showFavoriteActions && ( { const value = useMemo( @@ -690,6 +949,10 @@ const ConnectionFormWithSettings: React.FunctionComponent< showCSFLE, showProxySettings, saveAndConnectLabel, + showConnectionGroups, + connectionGroups, + onCreateGroup, + onUpdateGroup, }), [ showFavoriteActions, @@ -707,6 +970,10 @@ const ConnectionFormWithSettings: React.FunctionComponent< showCSFLE, showProxySettings, saveAndConnectLabel, + showConnectionGroups, + connectionGroups, + onCreateGroup, + onUpdateGroup, ] ); diff --git a/packages/connection-form/src/hooks/use-connect-form-settings.tsx b/packages/connection-form/src/hooks/use-connect-form-settings.tsx index 1d6d35aec02..86c620b7274 100644 --- a/packages/connection-form/src/hooks/use-connect-form-settings.tsx +++ b/packages/connection-form/src/hooks/use-connect-form-settings.tsx @@ -16,6 +16,17 @@ export type ConnectionFormSettings = { showCSFLE: boolean; showProxySettings: boolean; saveAndConnectLabel: string; + showConnectionGroups: boolean; + connectionGroups: { id: string; name: string; color?: string }[]; + onCreateGroup: ( + name: string, + color?: string + ) => Promise<{ id: string; name: string; color?: string }>; + onUpdateGroup: (group: { + id: string; + name: string; + color?: string; + }) => Promise; }; const defaultSettings = { @@ -34,6 +45,11 @@ const defaultSettings = { showCSFLE: true, showProxySettings: true, saveAndConnectLabel: 'Save & Connect', + showConnectionGroups: false, + connectionGroups: [], + onCreateGroup: (name: string, color?: string) => + Promise.resolve({ id: name, name, color }), + onUpdateGroup: () => Promise.resolve(), }; export const ConnectionFormSettingsContext = createContext< diff --git a/packages/connection-form/src/hooks/use-connect-form.ts b/packages/connection-form/src/hooks/use-connect-form.ts index 129d53f696d..def0b3bad32 100644 --- a/packages/connection-form/src/hooks/use-connect-form.ts +++ b/packages/connection-form/src/hooks/use-connect-form.ts @@ -77,6 +77,7 @@ import { isAtlas } from 'mongodb-build-info'; export type ConnectionPersonalizationOptions = { name: string; color?: string; + groupId?: string; isFavorite: boolean; isNameDirty: boolean; }; @@ -145,6 +146,7 @@ interface UpdateConnectionPersonalizationAction { type: 'update-connection-personalization'; name: string; color?: string; + groupId?: string; isFavorite: boolean; isNameDirty: boolean; } @@ -274,6 +276,7 @@ function buildStateFromConnectionInfo( isDirty: false, personalizationOptions: { color: initialConnectionInfo.favorite?.color || undefined, + groupId: initialConnectionInfo.favorite?.groupId || undefined, name: initialConnectionInfo.favorite?.name || '', isNameDirty: !!initialConnectionInfo.favorite?.name, isFavorite: initialConnectionInfo.savedConnectionType === 'favorite', @@ -363,11 +366,18 @@ export function handleConnectionFormUpdateForPersonalization( const isNameDirty = action.isNameDirty || personalization.isNameDirty; const name = action.name !== undefined ? action.name : personalization.name; const color = action.color || personalization.color; + // A personalization action always carries the full intended personalization + // (the form spreads the current options before overriding a field), so the + // action's groupId is authoritative. Take it directly rather than falling + // back to the previous value, otherwise an explicit clear (groupId set to + // undefined) would be ignored. + const groupId = action.groupId; const isFavorite = action.isFavorite; return { name, color, + groupId, isFavorite, isNameDirty, }; diff --git a/packages/connection-info/src/connection-info.ts b/packages/connection-info/src/connection-info.ts index f98ac8f4a94..1f4aeaeece6 100644 --- a/packages/connection-info/src/connection-info.ts +++ b/packages/connection-info/src/connection-info.ts @@ -157,4 +157,9 @@ export interface ConnectionFavoriteOptions { * Hex-code of the user-defined color. */ color?: string; + + /** + * Id of the ConnectionGroup this connection belongs to. + */ + groupId?: string; } diff --git a/packages/connection-storage/src/compass-main-connection-storage.spec.ts b/packages/connection-storage/src/compass-main-connection-storage.spec.ts index bea311adb50..e9879b5d8ac 100644 --- a/packages/connection-storage/src/compass-main-connection-storage.spec.ts +++ b/packages/connection-storage/src/compass-main-connection-storage.spec.ts @@ -1419,4 +1419,20 @@ describe('ConnectionStorage', function () { }); }); }); + + describe('connection groups storage', function () { + it('saves, loads and deletes groups', async function () { + const group = { + id: new UUID().toString(), + name: 'prod', + color: 'color1', + }; + await connectionStorage.saveGroup!({ group }); + const loaded = await connectionStorage.loadGroups!(); + expect(loaded).to.deep.include(group); + await connectionStorage.deleteGroup!({ id: group.id }); + const after = await connectionStorage.loadGroups!(); + expect(after.find((g) => g.id === group.id)).to.be.undefined; + }); + }); }); diff --git a/packages/connection-storage/src/compass-main-connection-storage.ts b/packages/connection-storage/src/compass-main-connection-storage.ts index c8dc21984f1..cb983e32e2c 100644 --- a/packages/connection-storage/src/compass-main-connection-storage.ts +++ b/packages/connection-storage/src/compass-main-connection-storage.ts @@ -29,6 +29,8 @@ import type { ConnectionStorage, AutoConnectPreferences, } from './connection-storage'; +import { ConnectionGroupSchema } from './connection-group'; +import type { ConnectionGroup } from './connection-group'; import { createIpcTrack } from '@mongodb-js/compass-telemetry'; const { log, mongoLogId } = createLogger('CONNECTION-STORAGE'); @@ -87,6 +89,7 @@ const ConnectionSchema: z.Schema = z class CompassMainConnectionStorage implements ConnectionStorage { private readonly userData: FileUserData; + private readonly groupsData: FileUserData; private readonly version = 1; private readonly maxAllowedRecentConnections = 10; @@ -96,6 +99,11 @@ class CompassMainConnectionStorage implements ConnectionStorage { this.userData = new FileUserData(ConnectionSchema, 'Connections', { basePath, }); + this.groupsData = new FileUserData( + ConnectionGroupSchema, + 'ConnectionGroups', + { basePath } + ); this.ipcMain.createHandle( 'ConnectionStorage', this, @@ -109,6 +117,9 @@ class CompassMainConnectionStorage implements ConnectionStorage { 'deserializeConnections', 'exportConnections', 'importConnections', + 'loadGroups', + 'saveGroup', + 'deleteGroup', ] ); } @@ -426,6 +437,19 @@ class CompassMainConnectionStorage implements ConnectionStorage { return serializeConnections(exportConnections, restOfOptions); } + async loadGroups(): Promise { + const { data } = await this.groupsData.readAll(); + return data; + } + + async saveGroup({ group }: { group: ConnectionGroup }): Promise { + await this.groupsData.write(group.id, group); + } + + async deleteGroup({ id }: { id: string }): Promise { + await this.groupsData.delete(id); + } + async migrateToSafeStorage(): Promise { // If user lands on this version of Compass with legacy connections (using storage mixin), // we will ignore those and only migrate connections that have connectionInfo. diff --git a/packages/connection-storage/src/compass-renderer-connection-storage.ts b/packages/connection-storage/src/compass-renderer-connection-storage.ts index a57300d83b0..67f6094e68d 100644 --- a/packages/connection-storage/src/compass-renderer-connection-storage.ts +++ b/packages/connection-storage/src/compass-renderer-connection-storage.ts @@ -8,6 +8,7 @@ import type { ExportConnectionOptions, ImportConnectionOptions, } from './import-export-connection'; +import type { ConnectionGroup } from './connection-group'; export type ConnectionStorageIPCInterface = Required< Omit @@ -39,6 +40,9 @@ class CompassRendererConnectionStorage implements ConnectionStorage { | 'deserializeConnections' | 'exportConnections' | 'importConnections' + | 'loadGroups' + | 'saveGroup' + | 'deleteGroup' >('ConnectionStorage', [ 'loadAll', 'load', @@ -49,6 +53,9 @@ class CompassRendererConnectionStorage implements ConnectionStorage { 'deserializeConnections', 'exportConnections', 'importConnections', + 'loadGroups', + 'saveGroup', + 'deleteGroup', ])); if (!ipc) { throw new Error('IPC not available'); @@ -117,6 +124,26 @@ class CompassRendererConnectionStorage implements ConnectionStorage { }): Promise { await this.ipc.importConnections(args); } + + loadGroups(options?: { + signal?: AbortSignal | undefined; + }): Promise { + return this.ipc.loadGroups(options); + } + + async saveGroup(options: { + group: ConnectionGroup; + signal?: AbortSignal | undefined; + }): Promise { + await this.ipc.saveGroup(options); + } + + async deleteGroup(options: { + id: string; + signal?: AbortSignal | undefined; + }): Promise { + await this.ipc.deleteGroup(options); + } } export { CompassRendererConnectionStorage }; diff --git a/packages/connection-storage/src/connection-group.ts b/packages/connection-storage/src/connection-group.ts new file mode 100644 index 00000000000..013785b7842 --- /dev/null +++ b/packages/connection-storage/src/connection-group.ts @@ -0,0 +1,16 @@ +import { z } from '@mongodb-js/compass-user-data'; + +export interface ConnectionGroup { + id: string; + name: string; + /** ColorCode ('color1'..'color10'), same palette as favorite.color on connections. */ + color?: string; +} + +export const ConnectionGroupSchema: z.Schema = z + .object({ + id: z.string().uuid(), + name: z.string(), + color: z.string().optional(), + }) + .passthrough(); diff --git a/packages/connection-storage/src/connection-storage.ts b/packages/connection-storage/src/connection-storage.ts index 023e12b9478..52bb2624263 100644 --- a/packages/connection-storage/src/connection-storage.ts +++ b/packages/connection-storage/src/connection-storage.ts @@ -7,6 +7,7 @@ import { type ImportConnectionOptions, } from './import-export-connection'; import type { AllPreferences } from 'compass-preferences-model'; +import type { ConnectionGroup } from './connection-group'; export type { ConnectionInfo, AtlasClusterMetadata }; @@ -61,4 +62,13 @@ export interface ConnectionStorage { options?: ImportConnectionOptions; signal?: AbortSignal; }): Promise; + + loadGroups?(options?: { signal?: AbortSignal }): Promise; + + saveGroup?(options: { + group: ConnectionGroup; + signal?: AbortSignal; + }): Promise; + + deleteGroup?(options: { id: string; signal?: AbortSignal }): Promise; } diff --git a/packages/connection-storage/src/in-memory-connection-storage.ts b/packages/connection-storage/src/in-memory-connection-storage.ts index 49b618a5312..5d18c1f4e9a 100644 --- a/packages/connection-storage/src/in-memory-connection-storage.ts +++ b/packages/connection-storage/src/in-memory-connection-storage.ts @@ -1,8 +1,10 @@ import { type ConnectionInfo } from '@mongodb-js/connection-info'; import { type ConnectionStorage } from './connection-storage'; +import { type ConnectionGroup } from './connection-group'; export class InMemoryConnectionStorage implements ConnectionStorage { private connections: ConnectionInfo[]; + private groups: ConnectionGroup[] = []; constructor(connections: ConnectionInfo[] = []) { this.connections = [...connections]; } @@ -69,4 +71,20 @@ export class InMemoryConnectionStorage implements ConnectionStorage { deserializeConnections() { return Promise.resolve([]); } + + async loadGroups(): Promise { + return Promise.resolve([...this.groups]); + } + + async saveGroup({ group }: { group: ConnectionGroup }): Promise { + const i = this.groups.findIndex((g) => g.id === group.id); + if (i !== -1) this.groups.splice(i, 1, group); + else this.groups.push(group); + return Promise.resolve(); + } + + async deleteGroup({ id }: { id: string }): Promise { + this.groups = this.groups.filter((g) => g.id !== id); + return Promise.resolve(); + } } diff --git a/packages/connection-storage/src/main.ts b/packages/connection-storage/src/main.ts index a2830d6c3d9..a67528e59d6 100644 --- a/packages/connection-storage/src/main.ts +++ b/packages/connection-storage/src/main.ts @@ -7,3 +7,5 @@ export type { ExportConnectionOptions, ImportConnectionOptions, } from './import-export-connection'; +export type { ConnectionGroup } from './connection-group'; +export { ConnectionGroupSchema } from './connection-group'; diff --git a/packages/connection-storage/src/provider.ts b/packages/connection-storage/src/provider.ts index 4df0505e885..851377de51b 100644 --- a/packages/connection-storage/src/provider.ts +++ b/packages/connection-storage/src/provider.ts @@ -10,6 +10,8 @@ import { InMemoryConnectionStorage } from './in-memory-connection-storage'; export { InMemoryConnectionStorage }; export type { ConnectionStorage, ConnectionInfo, AtlasClusterMetadata }; +export type { ConnectionGroup } from './connection-group'; +export { ConnectionGroupSchema } from './connection-group'; export const ConnectionStorageContext = createContext( null diff --git a/packages/connection-storage/src/renderer.ts b/packages/connection-storage/src/renderer.ts index 07da58764fb..8d916b2f4d1 100644 --- a/packages/connection-storage/src/renderer.ts +++ b/packages/connection-storage/src/renderer.ts @@ -3,3 +3,4 @@ export type { AtlasClusterMetadata, } from '@mongodb-js/connection-info'; export * from './compass-renderer-connection-storage'; +export type { ConnectionGroup } from './connection-group';