Skip to content

feat(workspaces): move tab rendering to the plugins COMPASS-9413 #6997

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 17, 2025
42 changes: 24 additions & 18 deletions packages/compass-collection/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react';
import CollectionTab from './components/collection-tab';
import { activatePlugin as activateCollectionTabPlugin } from './stores/collection-tab';
import { registerHadronPlugin } from 'hadron-app-registry';
Expand All @@ -7,27 +8,32 @@ import {
type DataService,
} from '@mongodb-js/compass-connections/provider';
import { collectionModelLocator } from '@mongodb-js/compass-app-stores/provider';
import type { WorkspaceComponent } from '@mongodb-js/compass-workspaces';
import type { WorkspacePlugin } from '@mongodb-js/compass-workspaces';
import { workspacesServiceLocator } from '@mongodb-js/compass-workspaces/provider';
import {
CollectionWorkspaceTitle,
CollectionPluginTitleComponent,
} from './plugin-tab-title';

export const CollectionTabPlugin = registerHadronPlugin(
{
name: 'CollectionTab',
component: CollectionTab,
activate: activateCollectionTabPlugin,
},
{
dataService: dataServiceLocator as DataServiceLocator<keyof DataService>,
collection: collectionModelLocator,
workspaces: workspacesServiceLocator,
}
);

export const WorkspaceTab: WorkspaceComponent<'Collection'> = {
name: 'Collection' as const,
component: CollectionTabPlugin,
export const WorkspaceTab: WorkspacePlugin<typeof CollectionWorkspaceTitle> = {
name: CollectionWorkspaceTitle,
provider: registerHadronPlugin(
{
name: CollectionWorkspaceTitle,
component: function CollectionProvider({ children }) {
return React.createElement(React.Fragment, null, children);
},
activate: activateCollectionTabPlugin,
},
{
dataService: dataServiceLocator as DataServiceLocator<keyof DataService>,
collection: collectionModelLocator,
workspaces: workspacesServiceLocator,
}
),
content: CollectionTab,
header: CollectionPluginTitleComponent,
};

export default CollectionTabPlugin;
export type { CollectionTabPluginMetadata } from './modules/collection-tab';
export { CollectionTabsProvider } from './components/collection-tab-provider';
90 changes: 90 additions & 0 deletions packages/compass-collection/src/plugin-tab-title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import { connect } from 'react-redux';
import toNS from 'mongodb-ns';
import {
useConnectionInfo,
useConnectionsListRef,
useTabConnectionTheme,
} from '@mongodb-js/compass-connections/provider';
import {
WorkspaceTab,
type WorkspaceTabCoreProps,
} from '@mongodb-js/compass-components';
import type { WorkspacePluginProps } from '@mongodb-js/compass-workspaces';

import { type CollectionState } from './modules/collection-tab';

export const CollectionWorkspaceTitle = 'Collection' as const;

type PluginTitleProps = {
isTimeSeries?: boolean;
isReadonly?: boolean;
sourceName?: string | null;
} & WorkspaceTabCoreProps &
WorkspacePluginProps<typeof CollectionWorkspaceTitle>;

function _PluginTitle({
editViewName,
isNonExistent,
isReadonly,
isTimeSeries,
sourceName,
namespace,
...tabProps
}: PluginTitleProps) {
const { getThemeOf } = useTabConnectionTheme();
const { getConnectionById } = useConnectionsListRef();
const { id: connectionId } = useConnectionInfo();

const { database, collection, ns } = toNS(namespace);
const connectionName = getConnectionById(connectionId)?.title || '';
Copy link
Collaborator

@gribnoysup gribnoysup Jun 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a small issue with useConnectionInfo implementation (and so the types are correctly hiding that title is already returned here due to the test value fallback), but you can clean this up a bit and just access title from connectionInfo returned by the hook (but feel free to keep this out of this PR to avoid more changes piling up)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. Tests are green now, I'll do that in a follow up!

const collectionType = isTimeSeries
? 'timeseries'
: isReadonly
? 'view'
: 'collection';
// Similar to what we have in the collection breadcrumbs.
const tooltip: [string, string][] = [
['Connection', connectionName || ''],
['Database', database],
];
if (sourceName) {
tooltip.push(['View', collection]);
tooltip.push(['Derived from', toNS(sourceName).collection]);
} else if (editViewName) {
tooltip.push(['View', toNS(editViewName).collection]);
tooltip.push(['Derived from', collection]);
} else {
tooltip.push(['Collection', collection]);
}

return (
<WorkspaceTab
{...tabProps}
connectionName={connectionName}
type={CollectionWorkspaceTitle}
title={collection}
tooltip={tooltip}
iconGlyph={
collectionType === 'view'
? 'Visibility'
: collectionType === 'timeseries'
? 'TimeSeries'
: isNonExistent
? 'EmptyFolder'
: 'Folder'
}
data-namespace={ns}
tabTheme={getThemeOf(connectionId)}
isNonExistent={isNonExistent}
/>
);
}

export const CollectionPluginTitleComponent = connect(
(state: CollectionState) => ({
isTimeSeries: state.metadata?.isTimeSeries,
isReadonly: state.metadata?.isReadonly,
sourceName: state.metadata?.sourceName,
})
)(_PluginTitle);
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { spacing } from '@leafygreen-ui/tokens';
import type { GlyphName } from '@leafygreen-ui/icon';
import { useSortable } from '@dnd-kit/sortable';
import { CSS as cssDndKit } from '@dnd-kit/utilities';
import { useId } from '@react-aria/utils';
import { useDarkMode } from '../../hooks/use-theme';
import { Icon, IconButton } from '../leafygreen';
import { mergeProps } from '../../utils/merge-props';
Expand Down Expand Up @@ -149,6 +150,10 @@ const draggingTabStyles = css({
cursor: 'grabbing !important',
});

const nonExistentStyles = css({
color: palette.gray.base,
});

const tabIconStyles = css({
color: 'currentColor',
marginLeft: spacing[300],
Expand Down Expand Up @@ -185,25 +190,34 @@ const workspaceTabTooltipStyles = css({
textWrap: 'wrap',
});

type TabProps = {
// The plugins provide these essential props use to render the tab.
// The workspace-tabs component provides the other parts of TabProps.
export type WorkspaceTabPluginProps = {
connectionName?: string;
type: string;
title: string;
title: React.ReactNode;
isNonExistent?: boolean;
iconGlyph: GlyphName | 'Logo' | 'Server';
tooltip?: [string, string][];
tabTheme?: Partial<TabTheme>;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could get rid of this prop and instead use a provider.
Here's a branch with that provider: main...tab-theme-context-provider-example
I didn't include it in these changes, as it is something we could do as a follow up.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me, and then we can also add it to all the shared workspace providers. Then we can also use these colors inside the workspaces UI easily if we need to

};

export type WorkspaceTabCoreProps = {
isSelected: boolean;
isDragging: boolean;
onSelect: () => void;
onClose: () => void;
iconGlyph: GlyphName | 'Logo' | 'Server';
tabContentId: string;
tooltip?: [string, string][];
tabTheme?: Partial<TabTheme>;
};

type TabProps = WorkspaceTabCoreProps & WorkspaceTabPluginProps;

function Tab({
connectionName,
type,
title,
tooltip,
isNonExistent,
isSelected,
isDragging,
onSelect,
Expand All @@ -213,7 +227,7 @@ function Tab({
tabTheme,
className: tabClassName,
...props
}: TabProps & React.HTMLProps<HTMLDivElement>) {
}: TabProps & Omit<React.HTMLProps<HTMLDivElement>, 'title'>) {
const darkMode = useDarkMode();
const defaultActionProps = useDefaultAction(onSelect);
const { listeners, setNodeRef, transform, transition } = useSortable({
Expand All @@ -240,6 +254,8 @@ function Tab({
cursor: 'grabbing !important',
};

const tabId = useId();

return (
<Tooltip
enabled={!!tooltip}
Expand All @@ -254,6 +270,7 @@ function Tab({
className={cx(
tabStyles,
themeClass,
isNonExistent && nonExistentStyles,
isSelected && selectedTabStyles,
isSelected && tabTheme && selectedThemedTabStyles,
isDragging && draggingTabStyles,
Expand All @@ -267,6 +284,7 @@ function Tab({
data-testid="workspace-tab-button"
data-connection-name={connectionName}
data-type={type}
id={tabId}
{...tabProps}
>
{iconGlyph === 'Logo' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ import { expect } from 'chai';
import sinon from 'sinon';

import { WorkspaceTabs } from './workspace-tabs';
import type { TabProps } from './workspace-tabs';
import { Tab, type WorkspaceTabCoreProps } from './tab';

function mockTab(tabId: number): TabProps {
function mockTab(tabId: number): {
id: string;
renderTab: (tabProps: WorkspaceTabCoreProps) => ReturnType<typeof Tab>;
} {
return {
type: 'Documents',
title: `mock-tab-${tabId}`,
id: `${tabId}-content`,
iconGlyph: 'Folder',
renderTab: (tabProps: WorkspaceTabCoreProps) => (
<Tab
{...tabProps}
type="Documents"
title={`mock-tab-${tabId}`}
id={`${tabId}-content`}
iconGlyph="Folder"
/>
),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import React, {
import { css, cx } from '@leafygreen-ui/emotion';
import { palette } from '@leafygreen-ui/palette';
import { spacing } from '@leafygreen-ui/tokens';
import type { GlyphName } from '@leafygreen-ui/icon';
import { rgba } from 'polished';

import {
Expand All @@ -28,7 +27,8 @@ import { useDarkMode } from '../../hooks/use-theme';
import { FocusState, useFocusState } from '../../hooks/use-focus-hover';
import { Icon, IconButton } from '../leafygreen';
import { mergeProps } from '../../utils/merge-props';
import { Tab } from './tab';
import type { Tab } from './tab';
import type { WorkspaceTabCoreProps } from './tab';
import { useHotkeys } from '../../hooks/use-hotkeys';

export const scrollbarThumbLightTheme = rgba(palette.gray.base, 0.65);
Expand Down Expand Up @@ -139,8 +139,13 @@ function useTabListKeyboardNavigation<HTMLDivElement>({
return [{ onKeyDown }];
}

type TabItem = {
id: string;
renderTab: (props: WorkspaceTabCoreProps) => ReturnType<typeof Tab>;
};

type SortableItemProps = {
tab: TabProps;
tab: TabItem;
index: number;
selectedTabIndex: number;
activeId: UniqueIdentifier | null;
Expand All @@ -149,7 +154,7 @@ type SortableItemProps = {
};

type SortableListProps = {
tabs: TabProps[];
tabs: TabItem[];
selectedTabIndex: number;
onMove: (oldTabIndex: number, newTabIndex: number) => void;
onSelect: (tabIndex: number) => void;
Expand All @@ -164,19 +169,10 @@ type WorkspaceTabsProps = {
onSelectPrevTab: () => void;
onCloseTab: (tabIndex: number) => void;
onMoveTab: (oldTabIndex: number, newTabIndex: number) => void;
tabs: TabProps[];
tabs: TabItem[];
selectedTabIndex: number;
};

export type TabProps = {
id: string;
type: string;
title: string;
tooltip?: [string, string][];
connectionId?: string;
iconGlyph: GlyphName | 'Logo' | 'Server';
} & Omit<React.HTMLProps<HTMLDivElement>, 'id' | 'title'>;

export function useRovingTabIndex<T extends HTMLElement = HTMLElement>({
currentTabbable,
}: {
Expand Down Expand Up @@ -263,7 +259,7 @@ const SortableList = ({
>
<SortableContext items={items} strategy={horizontalListSortingStrategy}>
<div className={sortableItemContainerStyles}>
{tabs.map((tab: TabProps, index: number) => (
{tabs.map((tab: TabItem, index: number) => (
<SortableItem
key={tab.id}
index={index}
Expand All @@ -281,15 +277,13 @@ const SortableList = ({
};

const SortableItem = ({
tab: tabProps,
tab: { id: tabId, renderTab },
index,
selectedTabIndex,
activeId,
onSelect,
onClose,
}: SortableItemProps) => {
const { id: tabId } = tabProps;

const onTabSelected = useCallback(() => {
onSelect(index);
}, [onSelect, index]);
Expand All @@ -305,16 +299,13 @@ const SortableItem = ({

const isDragging = useMemo(() => tabId === activeId, [tabId, activeId]);

return (
<Tab
{...tabProps}
isSelected={isSelected}
isDragging={isDragging}
tabContentId={tabId}
onSelect={onTabSelected}
onClose={onTabClosed}
/>
);
return renderTab({
isSelected,
isDragging,
tabContentId: tabId,
onSelect: onTabSelected,
onClose: onTabClosed,
});
};

function WorkspaceTabs({
Expand Down
6 changes: 5 additions & 1 deletion packages/compass-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ export {
import { ResizeHandle, ResizeDirection } from './components/resize-handle';
import { Accordion } from './components/accordion';
import { CollapsibleFieldSet } from './components/collapsible-field-set';
export { type TabTheme } from './components/workspace-tabs/tab';
export {
Tab as WorkspaceTab,
type TabTheme,
type WorkspaceTabCoreProps,
} from './components/workspace-tabs/tab';
import { WorkspaceTabs } from './components/workspace-tabs/workspace-tabs';
import ResizableSidebar, {
defaultSidebarWidth,
Expand Down
Loading
Loading