diff --git a/src/elements/common/types/SidebarNavigation.js.flow b/src/elements/common/types/SidebarNavigation.js.flow index 1dc910587e..96d3df1091 100644 --- a/src/elements/common/types/SidebarNavigation.js.flow +++ b/src/elements/common/types/SidebarNavigation.js.flow @@ -28,7 +28,7 @@ export type SidebarNavigation = { activeFeedEntryId?: string, fileVersionId?: string, filteredTemplateIds?: string, - sidebar: ViewTypeValues, + sidebar: ViewTypeValues | string, versionId?: string, }; diff --git a/src/elements/content-sidebar/ContentSidebar.js b/src/elements/content-sidebar/ContentSidebar.js index 9eca9e3158..1ee80d49f7 100644 --- a/src/elements/content-sidebar/ContentSidebar.js +++ b/src/elements/content-sidebar/ContentSidebar.js @@ -46,7 +46,7 @@ import type { WithLoggerProps } from '../../common/types/logging'; import type { ElementsXhrError, RequestOptions, ErrorContextProps } from '../../common/types/api'; import type { MetadataEditor } from '../../common/types/metadata'; import type { StringMap, Token, User, BoxItem } from '../../common/types/core'; -import type { AdditionalSidebarTab } from './flowTypes'; +import type { AdditionalSidebarTab, CustomSidebarPanel } from './flowTypes'; import type { FeatureConfig } from '../common/feature-checking'; // $FlowFixMe TypeScript file import type { Theme } from '../common/theming'; @@ -66,6 +66,7 @@ type Props = { className: string, clientName: string, currentUser?: User, + customSidebarPanels?: Array, defaultView: string, detailsSidebarProps: DetailsSidebarProps, docGenSidebarProps?: DocGenSidebarProps, @@ -358,6 +359,7 @@ class ContentSidebar extends React.Component { boxAISidebarProps, className, currentUser, + customSidebarPanels, defaultView, shouldFetchSidebarData, detailsSidebarProps, @@ -408,6 +410,7 @@ class ContentSidebar extends React.Component { className={className} currentUser={currentUser} shouldFetchSidebarData={shouldFetchSidebarData} + customSidebarPanels={customSidebarPanels} detailsSidebarProps={detailsSidebarProps} docGenSidebarProps={docGenSidebarProps} file={displayFile} diff --git a/src/elements/content-sidebar/Sidebar.js b/src/elements/content-sidebar/Sidebar.js index b8955151a1..1bdf94f62e 100644 --- a/src/elements/content-sidebar/Sidebar.js +++ b/src/elements/content-sidebar/Sidebar.js @@ -28,14 +28,14 @@ import type { DocGenSidebarProps } from './DocGenSidebar/DocGenSidebar'; import type { MetadataSidebarProps } from './MetadataSidebar'; import type { BoxAISidebarProps } from './BoxAISidebar'; import type { VersionsSidebarProps } from './versions'; -import type { AdditionalSidebarTab } from './flowTypes'; +import type { AdditionalSidebarTab, CustomSidebarPanel } from './flowTypes'; import type { MetadataEditor } from '../../common/types/metadata'; import type { BoxItem, User } from '../../common/types/core'; import type { SignSidebarProps } from './SidebarNavSign'; import type { Errors } from '../common/flowTypes'; // $FlowFixMe TypeScript file import type { Theme } from '../common/theming'; -import { SIDEBAR_VIEW_DOCGEN } from '../../constants'; +import { SIDEBAR_VIEW_DOCGEN, SIDEBAR_VIEW_BOXAI } from '../../constants'; import API from '../../api'; type Props = { @@ -46,6 +46,7 @@ type Props = { className: string, currentUser?: User, currentUserError?: Errors, + customSidebarPanels?: Array, detailsSidebarProps: DetailsSidebarProps, docGenSidebarProps: DocGenSidebarProps, features: FeatureConfig, @@ -300,6 +301,7 @@ class Sidebar extends React.Component { currentUser, currentUserError, shouldFetchSidebarData, + customSidebarPanels = [], detailsSidebarProps, docGenSidebarProps, file, @@ -319,7 +321,11 @@ class Sidebar extends React.Component { versionsSidebarProps, }: Props = this.props; const isOpen = this.isOpen(); - const hasBoxAI = SidebarUtils.canHaveBoxAISidebar(this.props); + + const hasCustomBoxAISidebar = customSidebarPanels.some(panel => panel.id === SIDEBAR_VIEW_BOXAI); + const isBoxAIEnabled = SidebarUtils.canHaveBoxAISidebar(this.props); + // Custom Box AI takes precedence over native when both exist + const hasNativeBoxAISidebar = isBoxAIEnabled && !hasCustomBoxAISidebar; const hasActivity = SidebarUtils.canHaveActivitySidebar(this.props); const hasDetails = SidebarUtils.canHaveDetailsSidebar(this.props); const hasMetadata = SidebarUtils.shouldRenderMetadataSidebar(this.props, metadataEditors); @@ -327,7 +333,7 @@ class Sidebar extends React.Component { const onVersionHistoryClick = hasVersions ? this.handleVersionHistoryClick : this.props.onVersionHistoryClick; const styleClassName = classNames('be bcs', className, { 'bcs-is-open': isOpen, - 'bcs-is-wider': hasBoxAI, + 'bcs-is-wider': hasNativeBoxAISidebar || hasCustomBoxAISidebar, }); const defaultPanel = this.getDefaultPanel(); @@ -343,11 +349,12 @@ class Sidebar extends React.Component { {hasNav && ( { currentUser={currentUser} currentUserError={currentUserError} shouldFetchSidebarData={shouldFetchSidebarData} + customSidebarPanels={customSidebarPanels} elementId={this.id} defaultPanel={defaultPanel} detailsSidebarProps={detailsSidebarProps} @@ -372,7 +380,7 @@ class Sidebar extends React.Component { getPreview={getPreview} getViewer={getViewer} hasActivity={hasActivity} - hasBoxAI={hasBoxAI} + hasNativeBoxAISidebar={hasNativeBoxAISidebar} hasDetails={hasDetails} hasDocGen={docGenSidebarProps.isDocGenTemplate} hasMetadata={hasMetadata} diff --git a/src/elements/content-sidebar/SidebarNav.js b/src/elements/content-sidebar/SidebarNav.js index f28c67f684..c25e3e40e2 100644 --- a/src/elements/content-sidebar/SidebarNav.js +++ b/src/elements/content-sidebar/SidebarNav.js @@ -46,7 +46,7 @@ import { SIDEBAR_VIEW_SKILLS, } from '../../constants'; import { useFeatureConfig } from '../common/feature-checking'; -import type { NavigateOptions, AdditionalSidebarTab } from './flowTypes'; +import type { NavigateOptions, AdditionalSidebarTab, CustomSidebarPanel } from './flowTypes'; import type { InternalSidebarNavigation, InternalSidebarNavigationHandler } from '../common/types/SidebarNavigation'; import './SidebarNav.scss'; import type { SignSidebarProps } from './SidebarNavSign'; @@ -117,13 +117,27 @@ const DocGenIconWrapper = ({ isActive, isPreviewModernizationEnabled }: IconWrap ); }; +/** + * Renders a custom panel icon. + * Handles both React elements and component types. + */ +const renderCustomPanelIcon = (icon: React.ComponentType | React.Element): React.Element => { + if (React.isValidElement(icon)) { + return (icon: any); + } + + const IconComponent: React.ComponentType = (icon: any); + return ; +}; + type Props = { additionalTabs?: Array, + customSidebarPanels: Array, elementId: string, fileId: string, hasActivity: boolean, hasAdditionalTabs: boolean, - hasBoxAI: boolean, + hasNativeBoxAISidebar: boolean, hasDetails: boolean, hasDocGen?: boolean, hasMetadata: boolean, @@ -140,11 +154,12 @@ type Props = { const SidebarNav = ({ additionalTabs, + customSidebarPanels, elementId, fileId, hasActivity, hasAdditionalTabs, - hasBoxAI, + hasNativeBoxAISidebar, hasDetails, hasMetadata, hasSkills, @@ -173,6 +188,149 @@ const SidebarNav = ({ focusPrompt(); } }; + const boxAiPanel = customSidebarPanels.find(panel => panel.id === SIDEBAR_VIEW_BOXAI); + const otherCustomPanels = customSidebarPanels.filter(panel => panel.id !== SIDEBAR_VIEW_BOXAI); + const hasOtherCustomPanels = otherCustomPanels.length > 0; + + const boxAiNavButtonProps = { + key: SIDEBAR_VIEW_BOXAI, + isPreviewModernizationEnabled, + 'data-resin-target': SIDEBAR_NAV_TARGETS.BOXAI, + 'data-target-id': 'SidebarNavButton-boxAI', + 'data-testid': 'sidebarboxai', + sidebarView: SIDEBAR_VIEW_BOXAI, + }; + + const sidebarTabs = [ + hasNativeBoxAISidebar && ( + + {isPreviewModernizationEnabled ? ( + + ) : ( + + )} + + ), + !hasNativeBoxAISidebar && boxAiPanel && ( + + {renderCustomPanelIcon(boxAiPanel.icon)} + + ), + hasActivity && ( + + + + ), + hasDetails && ( + + + + ), + hasSkills && ( + + + + ), + hasMetadata && ( + + + + ), + hasDocGen && ( + + + + ), + ]; + + const visibleTabs = sidebarTabs.filter(Boolean); + + const customPanelButtons = hasOtherCustomPanels + ? otherCustomPanels.map(customPanel => { + const { + id: customPanelId, + path: customPanelPath, + icon: customPanelIcon, + title: customPanelTitle, + navButtonProps, + } = customPanel; + + return ( + + {renderCustomPanelIcon(customPanelIcon)} + + ); + }) + : []; + + const allVisibleTabs = [...visibleTabs, ...customPanelButtons]; return (
- {hasBoxAI && ( - - {isPreviewModernizationEnabled ? ( - - ) : ( - - )} - - )} - {hasActivity && ( - - - - )} - {hasDetails && ( - - - - )} - {hasSkills && ( - - - - )} - {hasMetadata && ( - - - - )} - {hasDocGen && ( - - - - )} + {allVisibleTabs} {hasBoxSign && ( diff --git a/src/elements/content-sidebar/SidebarNavButton.js b/src/elements/content-sidebar/SidebarNavButton.js index 926401a841..c250771f2a 100644 --- a/src/elements/content-sidebar/SidebarNavButton.js +++ b/src/elements/content-sidebar/SidebarNavButton.js @@ -17,7 +17,7 @@ import type { } from '../common/types/SidebarNavigation'; import './SidebarNavButton.scss'; -type Props = { +export type Props = { 'data-resin-target'?: string, 'data-testid'?: string, children: React.Element, @@ -26,9 +26,9 @@ type Props = { internalSidebarNavigationHandler?: InternalSidebarNavigationHandler, isDisabled?: boolean, isOpen?: boolean, - onClick?: (sidebarView: ViewTypeValues) => void, + onClick?: (sidebarView: ViewTypeValues | string) => void, routerDisabled?: boolean, - sidebarView: ViewTypeValues, + sidebarView: ViewTypeValues | string, tooltip: React.Node, }; diff --git a/src/elements/content-sidebar/SidebarPanels.js b/src/elements/content-sidebar/SidebarPanels.js index f303da6240..3e0e67d0bb 100644 --- a/src/elements/content-sidebar/SidebarPanels.js +++ b/src/elements/content-sidebar/SidebarPanels.js @@ -16,6 +16,7 @@ import { getFeatureConfig, withFeatureConsumer, isFeatureEnabled } from '../comm import { withRouterAndRef } from '../common/routing'; import { ORIGIN_ACTIVITY_SIDEBAR, + ORIGIN_BOXAI_SIDEBAR, ORIGIN_DETAILS_SIDEBAR, ORIGIN_DOCGEN_SIDEBAR, ORIGIN_METADATA_SIDEBAR, @@ -23,14 +24,13 @@ import { ORIGIN_SKILLS_SIDEBAR, ORIGIN_VERSIONS_SIDEBAR, SIDEBAR_VIEW_ACTIVITY, + SIDEBAR_VIEW_BOXAI, SIDEBAR_VIEW_DETAILS, SIDEBAR_VIEW_METADATA, SIDEBAR_VIEW_SKILLS, SIDEBAR_VIEW_VERSIONS, SIDEBAR_VIEW_DOCGEN, SIDEBAR_VIEW_METADATA_REDESIGN, - SIDEBAR_VIEW_BOXAI, - ORIGIN_BOXAI_SIDEBAR, } from '../../constants'; import type { DetailsSidebarProps } from './DetailsSidebar'; import type { DocGenSidebarProps } from './DocGenSidebar/DocGenSidebar'; @@ -42,10 +42,12 @@ import type { User, BoxItem } from '../../common/types/core'; import type { Errors } from '../common/flowTypes'; import type { FeatureConfig } from '../common/feature-checking'; import type { BoxAISidebarCache } from './types/BoxAISidebarTypes'; +import type { CustomSidebarPanel } from './flowTypes'; type Props = { activitySidebarProps: ActivitySidebarProps, boxAISidebarProps: BoxAISidebarProps, + customSidebarPanels: Array, currentUser?: User, currentUserError?: Errors, defaultPanel?: string, @@ -58,7 +60,7 @@ type Props = { getPreview: Function, getViewer: Function, hasActivity: boolean, - hasBoxAI: boolean, + hasNativeBoxAISidebar: boolean, hasDetails: boolean, hasDocGen: boolean, hasMetadata: boolean, @@ -86,24 +88,33 @@ type ElementRefType = { // TODO: place into code splitting logic const BASE_EVENT_NAME = '_JS_LOADING'; -const MARK_NAME_JS_LOADING_DETAILS = `${ORIGIN_DETAILS_SIDEBAR}${BASE_EVENT_NAME}`; const MARK_NAME_JS_LOADING_ACTIVITY = `${ORIGIN_ACTIVITY_SIDEBAR}${BASE_EVENT_NAME}`; const MARK_NAME_JS_LOADING_BOXAI = `${ORIGIN_BOXAI_SIDEBAR}${BASE_EVENT_NAME}`; -const MARK_NAME_JS_LOADING_SKILLS = `${ORIGIN_SKILLS_SIDEBAR}${BASE_EVENT_NAME}`; +const MARK_NAME_JS_LOADING_DETAILS = `${ORIGIN_DETAILS_SIDEBAR}${BASE_EVENT_NAME}`; +const MARK_NAME_JS_LOADING_DOCGEN = `${ORIGIN_DOCGEN_SIDEBAR}${BASE_EVENT_NAME}`; const MARK_NAME_JS_LOADING_METADATA = `${ORIGIN_METADATA_SIDEBAR}${BASE_EVENT_NAME}`; const MARK_NAME_JS_LOADING_METADATA_REDESIGNED = `${ORIGIN_METADATA_SIDEBAR_REDESIGN}${BASE_EVENT_NAME}`; -const MARK_NAME_JS_LOADING_DOCGEN = `${ORIGIN_DOCGEN_SIDEBAR}${BASE_EVENT_NAME}`; +const MARK_NAME_JS_LOADING_SKILLS = `${ORIGIN_SKILLS_SIDEBAR}${BASE_EVENT_NAME}`; const MARK_NAME_JS_LOADING_VERSIONS = `${ORIGIN_VERSIONS_SIDEBAR}${BASE_EVENT_NAME}`; const URL_TO_FEED_ITEM_TYPE = { annotations: 'annotation', comments: 'comment', tasks: 'task' }; -const LoadableDetailsSidebar = SidebarUtils.getAsyncSidebarContent(SIDEBAR_VIEW_DETAILS, MARK_NAME_JS_LOADING_DETAILS); +// Default sidebar views in order (excluding BoxAI which is handled dynamically) +const DEFAULT_SIDEBAR_VIEWS = [ + SIDEBAR_VIEW_DOCGEN, + SIDEBAR_VIEW_SKILLS, + SIDEBAR_VIEW_ACTIVITY, + SIDEBAR_VIEW_DETAILS, + SIDEBAR_VIEW_METADATA, +]; + const LoadableActivitySidebar = SidebarUtils.getAsyncSidebarContent( SIDEBAR_VIEW_ACTIVITY, MARK_NAME_JS_LOADING_ACTIVITY, ); const LoadableBoxAISidebar = SidebarUtils.getAsyncSidebarContent(SIDEBAR_VIEW_BOXAI, MARK_NAME_JS_LOADING_BOXAI); -const LoadableSkillsSidebar = SidebarUtils.getAsyncSidebarContent(SIDEBAR_VIEW_SKILLS, MARK_NAME_JS_LOADING_SKILLS); +const LoadableDetailsSidebar = SidebarUtils.getAsyncSidebarContent(SIDEBAR_VIEW_DETAILS, MARK_NAME_JS_LOADING_DETAILS); +const LoadableDocGenSidebar = SidebarUtils.getAsyncSidebarContent(SIDEBAR_VIEW_DOCGEN, MARK_NAME_JS_LOADING_DOCGEN); const LoadableMetadataSidebar = SidebarUtils.getAsyncSidebarContent( SIDEBAR_VIEW_METADATA, MARK_NAME_JS_LOADING_METADATA, @@ -112,7 +123,7 @@ const LoadableMetadataSidebarRedesigned = SidebarUtils.getAsyncSidebarContent( SIDEBAR_VIEW_METADATA_REDESIGN, MARK_NAME_JS_LOADING_METADATA, ); -const LoadableDocGenSidebar = SidebarUtils.getAsyncSidebarContent(SIDEBAR_VIEW_DOCGEN, MARK_NAME_JS_LOADING_DOCGEN); +const LoadableSkillsSidebar = SidebarUtils.getAsyncSidebarContent(SIDEBAR_VIEW_SKILLS, MARK_NAME_JS_LOADING_SKILLS); const LoadableVersionsSidebar = SidebarUtils.getAsyncSidebarContent( SIDEBAR_VIEW_VERSIONS, MARK_NAME_JS_LOADING_VERSIONS, @@ -127,6 +138,8 @@ class SidebarPanels extends React.Component { detailsSidebar: ElementRefType = React.createRef(); + customSidebars: Map = new Map(); + initialPanel: { current: null | string } = React.createRef(); metadataSidebar: ElementRefType = React.createRef(); @@ -182,6 +195,18 @@ class SidebarPanels extends React.Component { this.boxAiSidebarCache[key] = value; }; + getCustomSidebarRef = (panelId: string): ElementRefType => { + if (!this.customSidebars.has(panelId)) { + this.customSidebars.set(panelId, React.createRef()); + } + // Flow doesn't understand that we just set the value above, so we need to assert it exists + const ref = this.customSidebars.get(panelId); + if (!ref) { + throw new Error(`Failed to get or create ref for panel ${panelId}`); + } + return ref; + }; + /** * Refreshes the contents of the active sidebar * @returns {void} @@ -205,6 +230,13 @@ class SidebarPanels extends React.Component { detailsSidebar.refresh(); } + // Refresh all custom sidebars (refresh is optional) + this.customSidebars.forEach(ref => { + if (ref.current && ref.current.refresh) { + ref.current.refresh(); + } + }); + if (metadataSidebar) { metadataSidebar.refresh(); } @@ -214,10 +246,27 @@ class SidebarPanels extends React.Component { } } + getPanelOrder = ( + customSidebarPanels: Array = [], + shouldBoxAIBeDefaultPanel: boolean, + hasNativeBoxAISidebar: boolean, + ): string[] => { + const customPanelPaths = customSidebarPanels.map(panel => panel.path); + const hasCustomBoxAIPanel = customSidebarPanels.some(({ id }) => id === SIDEBAR_VIEW_BOXAI); + const nonBoxAIPaths = customPanelPaths.filter(path => path !== SIDEBAR_VIEW_BOXAI); + + if (hasNativeBoxAISidebar || hasCustomBoxAIPanel) { + return [SIDEBAR_VIEW_BOXAI, ...DEFAULT_SIDEBAR_VIEWS, ...nonBoxAIPaths]; + } + + return [...DEFAULT_SIDEBAR_VIEWS, ...customPanelPaths]; + }; + render() { const { activitySidebarProps, boxAISidebarProps, + customSidebarPanels, currentUser, currentUserError, defaultPanel = '', @@ -231,7 +280,7 @@ class SidebarPanels extends React.Component { getPreview, getViewer, hasActivity, - hasBoxAI, + hasNativeBoxAISidebar, hasDetails, hasDocGen, hasMetadata, @@ -252,7 +301,14 @@ class SidebarPanels extends React.Component { const { shouldBeDefaultPanel: shouldBoxAIBeDefaultPanel, showOnlyNavButton: showOnlyBoxAINavButton } = getFeatureConfig(features, 'boxai.sidebar'); - const canShowBoxAISidebarPanel = hasBoxAI && !showOnlyBoxAINavButton; + const canShowBoxAISidebarPanel = hasNativeBoxAISidebar && !showOnlyBoxAINavButton; + + const hasCustomPanels = customSidebarPanels.length > 0; + + const customPanelsEligibility = {}; + customSidebarPanels.forEach(({ path, isDisabled }) => { + customPanelsEligibility[path] = !isDisabled; + }); const panelsEligibility = { [SIDEBAR_VIEW_BOXAI]: canShowBoxAISidebarPanel, @@ -261,16 +317,28 @@ class SidebarPanels extends React.Component { [SIDEBAR_VIEW_ACTIVITY]: hasActivity, [SIDEBAR_VIEW_DETAILS]: hasDetails, [SIDEBAR_VIEW_METADATA]: hasMetadata, + ...customPanelsEligibility, }; const showDefaultPanel: boolean = !!(defaultPanel && panelsEligibility[defaultPanel]); - if (!isOpen || (!hasBoxAI && !hasActivity && !hasDetails && !hasMetadata && !hasSkills && !hasVersions)) { + if ( + !isOpen || + (!hasActivity && + !canShowBoxAISidebarPanel && + !hasDetails && + !hasMetadata && + !hasSkills && + !hasDocGen && + !hasVersions && + !hasCustomPanels) + ) { return null; } return ( + {/* Native Box AI route - only shown when no custom Box AI panel exists */} {canShowBoxAISidebarPanel && ( { }} /> )} + {hasCustomPanels && + customSidebarPanels.map(customPanel => { + const { + id: customPanelId, + path: customPanelPath, + component: CustomPanelComponent, + isDisabled, + } = customPanel; + + return isDisabled ? null : ( + { + this.handlePanelRender(customPanelPath); + return ( + + ); + }} + /> + ); + })} {hasSkills && ( { if (showDefaultPanel) { redirect = defaultPanel; - } else if (canShowBoxAISidebarPanel && shouldBoxAIBeDefaultPanel) { - redirect = SIDEBAR_VIEW_BOXAI; - } else if (hasDocGen) { - redirect = SIDEBAR_VIEW_DOCGEN; - } else if (hasSkills) { - redirect = SIDEBAR_VIEW_SKILLS; - } else if (hasActivity) { - redirect = SIDEBAR_VIEW_ACTIVITY; - } else if (hasDetails) { - redirect = SIDEBAR_VIEW_DETAILS; - } else if (hasMetadata) { - redirect = SIDEBAR_VIEW_METADATA; - } else if (canShowBoxAISidebarPanel && !shouldBoxAIBeDefaultPanel) { - redirect = SIDEBAR_VIEW_BOXAI; + } else { + // Use panel order to determine redirect + const panelOrder = this.getPanelOrder( + customSidebarPanels, + shouldBoxAIBeDefaultPanel, + hasNativeBoxAISidebar, + ); + const firstEligiblePanel = panelOrder.find(panel => panelsEligibility[panel]); + if (firstEligiblePanel) { + redirect = firstEligiblePanel; + } } - return ; }} /> diff --git a/src/elements/content-sidebar/__tests__/SidebarNav.test.js b/src/elements/content-sidebar/__tests__/SidebarNav.test.js index 2922a5c164..700da37847 100644 --- a/src/elements/content-sidebar/__tests__/SidebarNav.test.js +++ b/src/elements/content-sidebar/__tests__/SidebarNav.test.js @@ -20,22 +20,39 @@ describe('elements/content-sidebar/SidebarNav', () => { }); const renderSidebarNav = ({ path = '/', props = {}, features = {} } = {}) => { + const defaultProps = { + customSidebarPanels: [], + }; return render( - + , ); }; + // Mock icon component for testing + const MockBoxAIIcon = () =>
BoxAI Icon
; + + // Helper function to create Box AI custom tab + const createBoxAIPanel = (overrides = {}) => ({ + id: 'boxai', + path: 'boxai', + title: 'Box AI', + icon: MockBoxAIIcon, + isDisabled: false, + navButtonProps: {}, + ...overrides, + }); + describe('individual tab rendering', () => { const TABS_CONFIG = { + boxai: { testId: 'sidebarboxai', propName: 'hasNativeBoxAISidebar' }, skills: { testId: 'sidebarskills', propName: 'hasSkills' }, details: { testId: 'sidebardetails', propName: 'hasDetails' }, activity: { testId: 'sidebaractivity', propName: 'hasActivity' }, metadata: { testId: 'sidebarmetadata', propName: 'hasMetadata' }, - boxai: { testId: 'sidebarboxai', propName: 'hasBoxAI' }, docgen: { testId: 'sidebardocgen', propName: 'hasDocGen' }, }; @@ -73,7 +90,7 @@ describe('elements/content-sidebar/SidebarNav', () => { renderSidebarNav({ features: { boxai: { sidebar: { disabledTooltip, showOnlyNavButton: true } } }, - props: { hasBoxAI: true }, + props: { hasNativeBoxAISidebar: true }, }); const button = screen.getByTestId('sidebarboxai'); @@ -92,7 +109,7 @@ describe('elements/content-sidebar/SidebarNav', () => { renderSidebarNav({ features: { boxai: { sidebar: { showOnlyNavButton: false } } }, - props: { hasBoxAI: true }, + props: { hasNativeBoxAISidebar: true }, }); const button = screen.getByTestId('sidebarboxai'); @@ -106,18 +123,13 @@ describe('elements/content-sidebar/SidebarNav', () => { }); }); - test('should call focusBoxAISidebarPrompt when clicked on Box AI Tab', async () => { + test('should call focusBoxAISidebarPrompt when clicked on native Box AI Tab', async () => { const user = userEvent(); renderSidebarNav({ - features: { - boxai: { - sidebar: { - showOnlyNavButton: false, - }, - }, + props: { + hasNativeBoxAISidebar: true, }, - props: { hasBoxAI: true }, }); const button = screen.getByTestId('sidebarboxai'); @@ -135,8 +147,8 @@ describe('elements/content-sidebar/SidebarNav', () => { renderSidebarNav({ path: '/activity', props: { + hasNativeBoxAISidebar: true, hasActivity: true, - hasBoxAI: true, hasMetadata: true, hasSkills: true, }, @@ -198,4 +210,304 @@ describe('elements/content-sidebar/SidebarNav', () => { const boxSignSection = screen.getByRole('button', { name: /sign/i }); expect(boxSignSection).toBeInTheDocument(); }); + + describe('hasNativeBoxAISidebar and customSidebarPanels interaction', () => { + test('should render native Box AI when hasNativeBoxAISidebar is true, ignoring custom boxai tab', () => { + const boxAiPanel = createBoxAIPanel({ title: 'Custom Box AI' }); + + renderSidebarNav({ + props: { + hasNativeBoxAISidebar: true, + customSidebarPanels: [boxAiPanel], + }, + }); + + // Should only render one Box AI tab (the native one) + const boxAiButtons = screen.getAllByTestId('sidebarboxai'); + expect(boxAiButtons).toHaveLength(1); + + // The native Box AI button should have the default tooltip, not the custom one + expect(boxAiButtons[0]).toHaveAttribute('aria-label', 'Box AI'); + }); + + test('should render custom boxai tab when hasNativeBoxAISidebar is false', () => { + const boxAiPanel = createBoxAIPanel({ title: 'Custom Box AI Title' }); + + renderSidebarNav({ + props: { + hasNativeBoxAISidebar: false, + customSidebarPanels: [boxAiPanel], + }, + }); + + const button = screen.getByTestId('sidebarboxai'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-label', 'Custom Box AI Title'); + }); + + test('should not render any boxai tab when hasNativeBoxAISidebar is false and no custom boxai tab provided', () => { + renderSidebarNav({ + props: { + hasNativeBoxAISidebar: false, + hasActivity: true, + customSidebarPanels: [], + }, + }); + + expect(screen.queryByTestId('sidebarboxai')).not.toBeInTheDocument(); + expect(screen.getByTestId('sidebaractivity')).toBeInTheDocument(); + }); + + test('should render native Box AI AND other custom tabs when hasNativeBoxAISidebar is true', () => { + // Mock icon component for custom tabs + const MockCustomIcon = () =>
Custom Icon
; + + const boxAiPanel = createBoxAIPanel({ title: 'Custom Box AI' }); + const analyticsTab = { + id: 'analytics', + path: 'analytics', + title: 'Analytics Tab', + icon: MockCustomIcon, + isDisabled: false, + navButtonProps: {}, + }; + + renderSidebarNav({ + props: { + hasNativeBoxAISidebar: true, + hasActivity: true, + customSidebarPanels: [boxAiPanel, analyticsTab], + }, + }); + + // Native Box AI should render (custom Box AI is ignored) + const boxAiButtons = screen.getAllByTestId('sidebarboxai'); + expect(boxAiButtons).toHaveLength(1); + expect(boxAiButtons[0]).toHaveAttribute('aria-label', 'Box AI'); + + // Other custom tabs should still render alongside native Box AI + expect(screen.getByTestId('sidebaranalytics')).toBeInTheDocument(); + expect(screen.getByTestId('sidebaractivity')).toBeInTheDocument(); + + // Verify order: Native Box AI first, then regular tabs, then custom tabs + const navButtons = screen.getAllByRole('tab'); + expect(navButtons).toHaveLength(3); + expect(navButtons[0]).toHaveAttribute('data-testid', 'sidebarboxai'); + expect(navButtons[1]).toHaveAttribute('data-testid', 'sidebaractivity'); + expect(navButtons[2]).toHaveAttribute('data-testid', 'sidebaranalytics'); + }); + }); + + describe('multiple customSidebarPanels rendering', () => { + // Mock icon component for custom tabs + const MockCustomIcon = ({ testId }) =>
Custom Icon
; + + // Helper function to create a generic custom tab (icon is required) + const createCustomTab = (id, overrides = {}) => { + const { icon = () => , ...rest } = overrides; + return { + id, + path: id, + title: `${id.charAt(0).toUpperCase()}${id.slice(1)} Tab`, + icon, + isDisabled: false, + navButtonProps: {}, + ...rest, + }; + }; + + test('should render Box AI first even when passed in different order', () => { + const customTab1 = createCustomTab('customtab1'); + const customTab2 = createCustomTab('customtab2'); + const boxAiPanel = createBoxAIPanel(); + + renderSidebarNav({ + props: { + customSidebarPanels: [customTab1, boxAiPanel, customTab2], + }, + }); + + const navButtons = screen.getAllByRole('tab'); + expect(navButtons).toHaveLength(3); + + expect(navButtons[0]).toHaveAttribute('data-testid', 'sidebarboxai'); + expect(navButtons[1]).toHaveAttribute('data-testid', 'sidebarcustomtab1'); + expect(navButtons[2]).toHaveAttribute('data-testid', 'sidebarcustomtab2'); + }); + + test('should render custom tabs with regular tabs', () => { + const customTab1 = createCustomTab('analytics'); + const boxAiPanel = createBoxAIPanel(); + + renderSidebarNav({ + props: { + hasActivity: true, + hasMetadata: true, + customSidebarPanels: [boxAiPanel, customTab1], + }, + }); + + expect(screen.getByTestId('sidebarboxai')).toBeInTheDocument(); + expect(screen.getByTestId('sidebaractivity')).toBeInTheDocument(); + expect(screen.getByTestId('sidebarmetadata')).toBeInTheDocument(); + expect(screen.getByTestId('sidebaranalytics')).toBeInTheDocument(); + + const navButtons = screen.getAllByRole('tab'); + expect(navButtons).toHaveLength(4); + + // Verify order: Box AI first, regular tabs, then custom tabs at the end + expect(navButtons[0]).toHaveAttribute('data-testid', 'sidebarboxai'); + expect(navButtons[3]).toHaveAttribute('data-testid', 'sidebaranalytics'); + }); + + test('should handle custom tabs with different properties', () => { + const disabledTab = createCustomTab('disabled', { + isDisabled: true, + title: 'Disabled Tab', + }); + const customTitleTab = createCustomTab('customtitle', { + title: 'Custom Title Tab', + }); + + renderSidebarNav({ + props: { + customSidebarPanels: [disabledTab, customTitleTab], + }, + }); + + const disabledButton = screen.getByTestId('sidebardisabled'); + const customTitleButton = screen.getByTestId('sidebarcustomtitle'); + + expect(disabledButton).toBeInTheDocument(); + expect(disabledButton).toHaveAttribute('aria-disabled', 'true'); + expect(disabledButton).toHaveAttribute('aria-label', 'Disabled Tab'); + + expect(customTitleButton).toBeInTheDocument(); + expect(customTitleButton).toHaveAttribute('aria-label', 'Custom Title Tab'); + }); + + test('should handle custom tabs without Box AI', () => { + const customTab1 = createCustomTab('reports'); + const customTab2 = createCustomTab('settings'); + + renderSidebarNav({ + props: { + hasActivity: true, + customSidebarPanels: [customTab1, customTab2], + }, + }); + + expect(screen.getByTestId('sidebaractivity')).toBeInTheDocument(); + expect(screen.getByTestId('sidebarreports')).toBeInTheDocument(); + expect(screen.getByTestId('sidebarsettings')).toBeInTheDocument(); + + expect(screen.queryByTestId('sidebarboxai')).not.toBeInTheDocument(); + + const navButtons = screen.getAllByRole('tab'); + expect(navButtons).toHaveLength(3); + }); + + test('should handle empty customSidebarPanels array', () => { + renderSidebarNav({ + props: { + hasActivity: true, + hasMetadata: true, + customSidebarPanels: [], + }, + }); + + expect(screen.getByTestId('sidebaractivity')).toBeInTheDocument(); + expect(screen.getByTestId('sidebarmetadata')).toBeInTheDocument(); + + expect(screen.queryByTestId('sidebarboxai')).not.toBeInTheDocument(); + + const navButtons = screen.getAllByRole('tab'); + expect(navButtons).toHaveLength(2); + }); + + test('should handle customSidebarPanels with icons', () => { + const MockIcon = () =>
Icon
; + const tabWithIcon = createCustomTab('icontest', { + icon: MockIcon, + title: 'Tab with Icon', + }); + + renderSidebarNav({ + props: { + customSidebarPanels: [tabWithIcon], + }, + }); + + expect(screen.getByTestId('sidebaricontest')).toBeInTheDocument(); + expect(screen.getByTestId('mock-icon')).toBeInTheDocument(); + }); + + test('should call onPanelChange when custom tab is clicked', async () => { + const user = userEvent(); + const onPanelChangeMock = jest.fn(); + const customTab = createCustomTab('testclick'); + + renderSidebarNav({ + props: { + customSidebarPanels: [customTab], + onPanelChange: onPanelChangeMock, + }, + }); + + const button = screen.getByTestId('sidebartestclick'); + await user.click(button); + + expect(onPanelChangeMock).toHaveBeenCalledWith('testclick', false); + }); + }); + + describe('custom panel icon rendering', () => { + const MockIconComponent = () =>
Icon Component
; + const MockIconComponent2 = () =>
Icon Component 2
; + const mockIconElement =
Icon Element
; + + const createCustomTab = (id, overrides = {}) => ({ + id, + path: id, + title: `${id.charAt(0).toUpperCase()}${id.slice(1)} Tab`, + icon: MockIconComponent, + isDisabled: false, + navButtonProps: {}, + ...overrides, + }); + + test('should render custom Box AI tab with provided icon component', () => { + const boxAiPanel = createBoxAIPanel({ icon: MockIconComponent }); + + renderSidebarNav({ + props: { + hasNativeBoxAISidebar: false, + customSidebarPanels: [boxAiPanel], + }, + }); + + expect(screen.getByTestId('sidebarboxai')).toBeInTheDocument(); + expect(screen.getByTestId('mock-icon-component')).toBeInTheDocument(); + }); + + test('should render multiple custom tabs with mixed icon types', () => { + const tabWithComponent = createCustomTab('tab1', { icon: MockIconComponent }); + const tabWithElement = createCustomTab('tab2', { icon: mockIconElement }); + const tabWithComponent2 = createCustomTab('tab3', { icon: MockIconComponent2 }); + + renderSidebarNav({ + props: { + customSidebarPanels: [tabWithComponent, tabWithElement, tabWithComponent2], + }, + }); + + expect(screen.getByTestId('sidebartab1')).toBeInTheDocument(); + expect(screen.getByTestId('sidebartab2')).toBeInTheDocument(); + expect(screen.getByTestId('sidebartab3')).toBeInTheDocument(); + + expect(screen.getByTestId('mock-icon-component')).toBeInTheDocument(); + expect(screen.getByTestId('mock-icon-element')).toBeInTheDocument(); + expect(screen.getByTestId('mock-icon-component-2')).toBeInTheDocument(); + }); + }); }); diff --git a/src/elements/content-sidebar/__tests__/SidebarPanels.test.js b/src/elements/content-sidebar/__tests__/SidebarPanels.test.js index 06d31a82f3..62d9b236f9 100644 --- a/src/elements/content-sidebar/__tests__/SidebarPanels.test.js +++ b/src/elements/content-sidebar/__tests__/SidebarPanels.test.js @@ -6,19 +6,32 @@ import { render, screen } from '@testing-library/react'; import { FEED_ITEM_TYPE_ANNOTATION, FEED_ITEM_TYPE_COMMENT, FEED_ITEM_TYPE_TASK } from '../../../constants'; import { SidebarPanelsComponent as SidebarPanels } from '../SidebarPanels'; -// mock lazy imports jest.mock('../SidebarUtils'); describe('elements/content-sidebar/SidebarPanels', () => { - const getWrapper = ({ path = '/', ...rest } = {}) => - mount( + const createBoxAIPanel = (overrides = {}) => { + const BoxAISidebar = () =>
; + BoxAISidebar.displayName = 'BoxAISidebar'; + + return { + id: 'boxai', + path: 'boxai', + component: BoxAISidebar, + isDisabled: false, + ...overrides, + }; + }; + + const getWrapper = ({ path = '/', ...rest } = {}) => { + return mount( { }, }, ); - - const getSidebarPanels = ({ path = '/', ...props }) => ( - - - , - - ); + }; + + const getSidebarPanels = ({ path = '/', ...props }) => { + return ( + + + + ); + }; describe('render', () => { test.each` @@ -71,28 +87,13 @@ describe('elements/content-sidebar/SidebarPanels', () => { ${'/skills'} | ${'SkillsSidebar'} ${'/boxai'} | ${'BoxAISidebar'} ${'/docgen'} | ${'DocGenSidebar'} - ${'/nonsense'} | ${'DocGenSidebar'} - ${'/'} | ${'DocGenSidebar'} + ${'/nonsense'} | ${'BoxAISidebar'} + ${'/'} | ${'BoxAISidebar'} `('should render $sidebar given the path $path', ({ path, sidebar }) => { const wrapper = getWrapper({ path }); expect(wrapper.exists(sidebar)).toBe(true); }); - test.each` - path | sidebar - ${'/nonsense'} | ${'BoxAISidebar'} - ${'/'} | ${'BoxAISidebar'} - `( - 'should render $sidebar given feature boxai.sidebar.shouldBeDefaultPanel = true and the path $path', - ({ path, sidebar }) => { - const wrapper = getWrapper({ - features: { boxai: { sidebar: { shouldBeDefaultPanel: true } } }, - path, - }); - expect(wrapper.exists(sidebar)).toBe(true); - }, - ); - test.each` defaultPanel | sidebar | expectedPanelName ${'activity'} | ${'activity-sidebar'} | ${'activity'} @@ -101,8 +102,8 @@ describe('elements/content-sidebar/SidebarPanels', () => { ${'metadata'} | ${'metadata-sidebar'} | ${'metadata'} ${'skills'} | ${'skills-sidebar'} | ${'skills'} ${'boxai'} | ${'boxai-sidebar'} | ${'boxai'} - ${'nonsense'} | ${'docgen-sidebar'} | ${'docgen'} - ${undefined} | ${'docgen-sidebar'} | ${'docgen'} + ${'nonsense'} | ${'boxai-sidebar'} | ${'boxai'} + ${undefined} | ${'boxai-sidebar'} | ${'boxai'} `( 'should render $sidebar and call onPanelChange with $expectedPanelName given the path = "/" and defaultPanel = $defaultPanel', ({ defaultPanel, sidebar, expectedPanelName }) => { @@ -119,34 +120,14 @@ describe('elements/content-sidebar/SidebarPanels', () => { ); test.each` - defaultPanel | sidebar | expectedPanelName - ${'nonsense'} | ${'boxai-sidebar'} | ${'boxai'} - ${undefined} | ${'boxai-sidebar'} | ${'boxai'} - `( - 'should render $sidebar and call onPanelChange with $expectedPanelName given feature boxai.sidebar.shouldBeDefaultPanel = true and the path = "/" and defaultPanel = $defaultPanel', - ({ defaultPanel, sidebar, expectedPanelName }) => { - const onPanelChange = jest.fn(); - render( - getSidebarPanels({ - defaultPanel, - features: { boxai: { sidebar: { shouldBeDefaultPanel: true } } }, - onPanelChange, - }), - ); - expect(screen.getByTestId(sidebar)).toBeInTheDocument(); - expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); - }, - ); - - test.each` - defaultPanel | expectedSidebar | hasActivity | hasDetails | hasMetadata | hasSkills | hasDocGen | hasBoxAI | showOnlyBoxAINavButton | expectedPanelName - ${'activity'} | ${'docgen-sidebar'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'details'} | ${'docgen-sidebar'} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'metadata'} | ${'docgen-sidebar'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'skills'} | ${'docgen-sidebar'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${false} | ${'docgen'} - ${'docgen'} | ${'activity-sidebar'} | ${true} | ${true} | ${true} | ${false} | ${false} | ${true} | ${false} | ${'activity'} - ${'boxai'} | ${'docgen-sidebar'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${false} | ${'docgen'} - ${'boxai'} | ${'docgen-sidebar'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${'docgen'} + defaultPanel | expectedSidebar | hasActivity | hasDetails | hasMetadata | hasSkills | hasDocGen | hasNativeBoxAISidebar | showOnlyBoxAINavButton | expectedPanelName + ${'activity'} | ${'boxai-sidebar'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'details'} | ${'boxai-sidebar'} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'metadata'} | ${'boxai-sidebar'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'skills'} | ${'boxai-sidebar'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${false} | ${'boxai'} + ${'docgen'} | ${'boxai-sidebar'} | ${true} | ${true} | ${true} | ${false} | ${false} | ${true} | ${false} | ${'boxai'} + ${'boxai'} | ${'docgen-sidebar'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${false} | ${'docgen'} + ${'boxai'} | ${'docgen-sidebar'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${'docgen'} `( 'should render first available panel and call onPanelChange with $expectedPanelName for users without rights to render default panel, given the path = "/" and defaultPanel = $defaultPanel', ({ @@ -157,7 +138,7 @@ describe('elements/content-sidebar/SidebarPanels', () => { hasMetadata, hasSkills, hasDocGen, - hasBoxAI, + hasNativeBoxAISidebar, showOnlyBoxAINavButton, expectedPanelName, }) => { @@ -171,56 +152,7 @@ describe('elements/content-sidebar/SidebarPanels', () => { hasMetadata, hasSkills, hasDocGen, - hasBoxAI, - onPanelChange, - }), - ); - expect(screen.getByTestId(expectedSidebar)).toBeInTheDocument(); - expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); - }, - ); - - test.each` - defaultPanel | expectedSidebar | hasActivity | hasDetails | hasMetadata | hasSkills | hasDocGen | hasBoxAI | showOnlyBoxAINavButton | expectedPanelName - ${'activity'} | ${'boxai-sidebar'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'details'} | ${'boxai-sidebar'} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'metadata'} | ${'boxai-sidebar'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'skills'} | ${'boxai-sidebar'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${false} | ${'boxai'} - ${'docgen'} | ${'boxai-sidebar'} | ${true} | ${true} | ${true} | ${false} | ${false} | ${true} | ${false} | ${'boxai'} - ${'boxai'} | ${'docgen-sidebar'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${false} | ${'docgen'} - ${'boxai'} | ${'docgen-sidebar'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${'docgen'} - `( - 'should render first available panel and call onPanelChange with $expectedPanelName for users without rights to render default panel, given feature boxai.sidebar.shouldBeDefaultPanel = true and the path = "/" and defaultPanel = $defaultPanel', - ({ - defaultPanel, - expectedSidebar, - hasActivity, - hasDetails, - hasMetadata, - hasSkills, - hasDocGen, - hasBoxAI, - showOnlyBoxAINavButton, - expectedPanelName, - }) => { - const onPanelChange = jest.fn(); - render( - getSidebarPanels({ - features: { - boxai: { - sidebar: { - shouldBeDefaultPanel: true, - showOnlyNavButton: showOnlyBoxAINavButton, - }, - }, - }, - defaultPanel, - hasActivity, - hasDetails, - hasMetadata, - hasSkills, - hasDocGen, - hasBoxAI, + hasNativeBoxAISidebar, onPanelChange, }), ); @@ -285,8 +217,8 @@ describe('elements/content-sidebar/SidebarPanels', () => { ${'/skills'} | ${'skills'} ${'/boxai'} | ${'boxai'} ${'/docgen'} | ${'docgen'} - ${'/nonsense'} | ${'docgen'} - ${'/'} | ${'docgen'} + ${'/nonsense'} | ${'boxai'} + ${'/'} | ${'boxai'} `('should call onPanelChange with $expectedPanelName given the path = $path', ({ path, expectedPanelName }) => { const onPanelChange = jest.fn(); render( @@ -299,44 +231,25 @@ describe('elements/content-sidebar/SidebarPanels', () => { }); test.each` - path | expectedPanelName - ${'/nonsense'} | ${'boxai'} - ${'/'} | ${'boxai'} - `( - 'should call onPanelChange with $expectedPanelName given feature boxai.sidebar.shouldBeDefaultPanel = true and the path = $path', - ({ path, expectedPanelName }) => { - const onPanelChange = jest.fn(); - render( - getSidebarPanels({ - features: { boxai: { sidebar: { shouldBeDefaultPanel: true } } }, - path, - onPanelChange, - }), - ); - expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); - }, - ); - - test.each` - path | hasActivity | hasDetails | hasVersions | hasMetadata | hasSkills | hasDocGen | hasBoxAI | showOnlyBoxAINavButton | expectedPanelName - ${'/activity'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/comments'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/comments/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/tasks'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/tasks/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/annotations/1234/5678'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/annotations/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/versions'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/activity/versions/1234'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/details'} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/details/versions'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/details/versions/1234'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/metadata'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/metadata/filteredTemplates/1,3'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/skills'} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${false} | ${'docgen'} - ${'/docgen'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} | ${false} | ${'skills'} - ${'/boxai'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${false} | ${'docgen'} - ${'/boxai'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${'docgen'} + path | hasActivity | hasDetails | hasVersions | hasMetadata | hasSkills | hasDocGen | hasNativeBoxAISidebar | showOnlyBoxAINavButton | expectedPanelName + ${'/activity'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/comments'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/comments/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/tasks'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/tasks/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/annotations/1234/5678'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/annotations/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/versions'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/activity/versions/1234'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/details'} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/details/versions'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/details/versions/1234'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/metadata'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/metadata/filteredTemplates/1,3'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/skills'} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${false} | ${'boxai'} + ${'/docgen'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} | ${false} | ${'boxai'} + ${'/boxai'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${false} | ${'docgen'} + ${'/boxai'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${'docgen'} `( 'should call onPanelChange with $expectedPanelName given the path = $path for users without rights to render the panel for given path', ({ @@ -347,7 +260,7 @@ describe('elements/content-sidebar/SidebarPanels', () => { hasMetadata, hasSkills, hasDocGen, - hasBoxAI, + hasNativeBoxAISidebar, showOnlyBoxAINavButton, expectedPanelName, }) => { @@ -356,72 +269,12 @@ describe('elements/content-sidebar/SidebarPanels', () => { getSidebarPanels({ features: { boxai: { sidebar: { showOnlyNavButton: showOnlyBoxAINavButton } } }, hasActivity, - hasBoxAI, - hasDetails, - hasDocGen, - hasMetadata, - hasSkills, - hasVersions, - onPanelChange, - path, - }), - ); - expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); - }, - ); - - test.each` - path | hasActivity | hasDetails | hasVersions | hasMetadata | hasSkills | hasDocGen | hasBoxAI | showOnlyBoxAINavButton | expectedPanelName - ${'/activity'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/comments'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/comments/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/tasks'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/tasks/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/annotations/1234/5678'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/annotations/1234'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/versions'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/activity/versions/1234'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/details'} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/details/versions'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/details/versions/1234'} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/metadata'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/metadata/filteredTemplates/1,3'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/skills'} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${false} | ${'boxai'} - ${'/docgen'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} | ${false} | ${'boxai'} - ${'/boxai'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} | ${false} | ${'docgen'} - ${'/boxai'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${'docgen'} - `( - 'should call onPanelChange with $expectedPanelName given feature boxai.sidebar.shouldBeDefaultPanel = true and the path = $path for users without rights to render the panel for given path', - ({ - path, - hasActivity, - hasDetails, - hasVersions, - hasMetadata, - hasSkills, - hasDocGen, - hasBoxAI, - showOnlyBoxAINavButton, - expectedPanelName, - }) => { - const onPanelChange = jest.fn(); - render( - getSidebarPanels({ - features: { - boxai: { - sidebar: { - shouldBeDefaultPanel: true, - showOnlyNavButton: showOnlyBoxAINavButton, - }, - }, - }, - hasActivity, - hasBoxAI, hasDetails, hasDocGen, hasMetadata, hasSkills, hasVersions, + hasNativeBoxAISidebar, onPanelChange, path, }), @@ -457,12 +310,26 @@ describe('elements/content-sidebar/SidebarPanels', () => { test('should render nothing if all sidebars are disabled', () => { const wrapper = getWrapper({ - hasBoxAI: false, hasActivity: false, hasDetails: false, hasMetadata: false, + hasNativeBoxAISidebar: false, hasSkills: false, hasVersions: false, + hasDocGen: false, + }); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + test('should render nothing when showOnlyNavButton is true and no other panels are available', () => { + const wrapper = getWrapper({ + features: { boxai: { sidebar: { showOnlyNavButton: true } } }, + hasActivity: false, + hasDetails: false, + hasMetadata: false, + hasSkills: false, + hasVersions: false, + hasDocGen: false, }); expect(wrapper.isEmptyRender()).toBe(true); }); @@ -535,33 +402,244 @@ describe('elements/content-sidebar/SidebarPanels', () => { }); describe('boxai sidebar', () => { - test('should render, given feature boxai.sidebar.shouldBeDefaultPanel = true and hasBoxAI = true and feature boxai.sidebar.showOnlyNavButton = false', () => { + test('should render native Box AI as default panel', () => { render( getSidebarPanels({ - features: { boxai: { sidebar: { shouldBeDefaultPanel: true, showOnlyNavButton: false } } }, - hasBoxAI: true, + features: { boxai: { sidebar: { showOnlyNavButton: false } } }, }), ); expect(screen.getByTestId('boxai-sidebar')).toBeInTheDocument(); }); test.each` - hasBoxAI | showOnlyNavButton - ${true} | ${true} - ${false} | ${true} - ${false} | ${false} + hasNativeBoxAISidebar | showOnlyNavButton + ${true} | ${true} + ${false} | ${true} + ${false} | ${false} `( - 'should not render, given hasBoxAI = $hasBoxAI and feature boxai.sidebar.showOnlyNavButton = $showOnlyNavButton', - ({ hasBoxAI, showOnlyNavButton }) => { + 'should not render native Box AI, given hasNativeBoxAISidebar = $hasNativeBoxAISidebar and feature boxai.sidebar.showOnlyNavButton = $showOnlyNavButton', + ({ hasNativeBoxAISidebar, showOnlyNavButton }) => { render( getSidebarPanels({ features: { boxai: { sidebar: { showOnlyNavButton } } }, - hasBoxAI, + hasNativeBoxAISidebar, }), ); expect(screen.queryByTestId('boxai-sidebar')).not.toBeInTheDocument(); }, ); + + describe('canShowBoxAISidebarPanel eligibility', () => { + test.each` + hasNativeBoxAISidebar | showOnlyNavButton | expectedEligible | description + ${true} | ${false} | ${true} | ${'native Box AI enabled and showOnlyNavButton is false'} + ${true} | ${true} | ${false} | ${'native Box AI enabled but showOnlyNavButton is true'} + ${false} | ${false} | ${false} | ${'native Box AI disabled'} + ${false} | ${true} | ${false} | ${'native Box AI disabled and showOnlyNavButton is true'} + `( + 'should set Box AI panel eligibility to $expectedEligible when $description', + ({ hasNativeBoxAISidebar, showOnlyNavButton, expectedEligible }) => { + render( + getSidebarPanels({ + path: '/boxai', + features: { boxai: { sidebar: { showOnlyNavButton } } }, + hasNativeBoxAISidebar, + }), + ); + if (expectedEligible) { + expect(screen.getByTestId('boxai-sidebar')).toBeInTheDocument(); + } else { + expect(screen.queryByTestId('boxai-sidebar')).not.toBeInTheDocument(); + } + }, + ); + + test('should redirect to first available panel when Box AI is not eligible due to showOnlyNavButton', () => { + const onPanelChange = jest.fn(); + render( + getSidebarPanels({ + path: '/boxai', + features: { boxai: { sidebar: { showOnlyNavButton: true } } }, + onPanelChange, + }), + ); + // Should redirect to docgen (first in DEFAULT_SIDEBAR_VIEWS) + expect(onPanelChange).toHaveBeenCalledWith('docgen', true); + }); + + test('should render custom Box AI panel when hasNativeBoxAISidebar is false and custom panel is provided', () => { + render( + getSidebarPanels({ + path: '/boxai', + features: { boxai: { sidebar: { showOnlyNavButton: false } } }, + hasNativeBoxAISidebar: false, + customSidebarPanels: [createBoxAIPanel()], + }), + ); + expect(screen.getByTestId('boxai-sidebar')).toBeInTheDocument(); + }); + + test('should render custom Box AI panel when native cannot show (showOnlyNavButton)', () => { + const onPanelChange = jest.fn(); + const CustomBoxAIComponent = () =>
; + render( + getSidebarPanels({ + path: '/boxai', + features: { boxai: { sidebar: { showOnlyNavButton: true } } }, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAIComponent })], + onPanelChange, + }), + ); + // Custom Box AI renders since native route is not in Switch (canShowBoxAISidebarPanel is false) + expect(screen.getByTestId('custom-boxai-component')).toBeInTheDocument(); + expect(onPanelChange).toHaveBeenCalledWith('boxai', true); + }); + }); + + describe('custom boxai sidebar', () => { + const CustomBoxAI = () =>
; + + test('should render custom Box AI sidebar as default panel', () => { + const onPanelChange = jest.fn(); + render( + getSidebarPanels({ + hasNativeBoxAISidebar: false, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI })], + onPanelChange, + }), + ); + expect(screen.getByTestId('custom-boxai-sidebar')).toBeInTheDocument(); + expect(onPanelChange).toHaveBeenCalledWith('boxai', true); + }); + + test('should NOT render custom Box AI sidebar when it is disabled', () => { + render( + getSidebarPanels({ + path: '/boxai', + hasNativeBoxAISidebar: false, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI, isDisabled: true })], + }), + ); + expect(screen.queryByTestId('custom-boxai-sidebar')).not.toBeInTheDocument(); + }); + + test('should redirect to first available panel when custom Box AI sidebar is disabled', () => { + const onPanelChange = jest.fn(); + render( + getSidebarPanels({ + path: '/boxai', + hasNativeBoxAISidebar: false, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI, isDisabled: true })], + onPanelChange, + }), + ); + // Should redirect to docgen (first in DEFAULT_SIDEBAR_VIEWS) + expect(onPanelChange).toHaveBeenCalledWith('docgen', true); + }); + + test('should render custom Box AI sidebar alongside other sidebars', () => { + const wrapper = getWrapper({ + path: '/boxai', + hasNativeBoxAISidebar: false, + hasActivity: true, + hasDetails: true, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI })], + }); + expect(wrapper.find('div[data-testid="custom-boxai-sidebar"]')).toHaveLength(1); + }); + + // Matching test.each patterns for custom Box AI sidebar + test.each` + path | expectedPanelName + ${'/boxai'} | ${'boxai'} + ${'/nonsense'} | ${'boxai'} + ${'/'} | ${'boxai'} + `( + 'should render custom Box AI sidebar and call onPanelChange with $expectedPanelName given path = $path', + ({ path, expectedPanelName }) => { + const onPanelChange = jest.fn(); + render( + getSidebarPanels({ + path, + hasNativeBoxAISidebar: false, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI })], + onPanelChange, + }), + ); + expect(screen.getByTestId('custom-boxai-sidebar')).toBeInTheDocument(); + expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); + }, + ); + + test.each` + defaultPanel | sidebar | expectedPanelName + ${'boxai'} | ${'custom-boxai-sidebar'} | ${'boxai'} + ${'nonsense'} | ${'custom-boxai-sidebar'} | ${'boxai'} + ${undefined} | ${'custom-boxai-sidebar'} | ${'boxai'} + `( + 'should render $sidebar and call onPanelChange with $expectedPanelName given custom Box AI and defaultPanel = $defaultPanel', + ({ defaultPanel, sidebar, expectedPanelName }) => { + const onPanelChange = jest.fn(); + render( + getSidebarPanels({ + defaultPanel, + hasNativeBoxAISidebar: false, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI })], + onPanelChange, + }), + ); + expect(screen.getByTestId(sidebar)).toBeInTheDocument(); + expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); + }, + ); + + test.each` + path | sidebar | defaultPanel | expectedPanelName + ${'/boxai'} | ${'custom-boxai-sidebar'} | ${'details'} | ${'boxai'} + ${'/boxai'} | ${'custom-boxai-sidebar'} | ${'activity'} | ${'boxai'} + ${'/activity'} | ${'activity-sidebar'} | ${'boxai'} | ${'activity'} + ${'/details'} | ${'details-sidebar'} | ${'boxai'} | ${'details'} + `( + 'should render $sidebar given custom Box AI with path = $path and defaultPanel = $defaultPanel (path takes precedence)', + ({ path, sidebar, defaultPanel, expectedPanelName }) => { + const onPanelChange = jest.fn(); + render( + getSidebarPanels({ + path, + defaultPanel, + hasNativeBoxAISidebar: false, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI })], + onPanelChange, + }), + ); + expect(screen.getByTestId(sidebar)).toBeInTheDocument(); + expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); + }, + ); + + test.each` + isDisabled | hasDocGen | expectedSidebar | expectedPanelName + ${false} | ${true} | ${'custom-boxai-sidebar'} | ${'boxai'} + ${true} | ${true} | ${'docgen-sidebar'} | ${'docgen'} + ${true} | ${false} | ${'skills-sidebar'} | ${'skills'} + `( + 'should render $expectedSidebar when custom Box AI isDisabled = $isDisabled and hasDocGen = $hasDocGen', + ({ isDisabled, hasDocGen, expectedSidebar, expectedPanelName }) => { + const onPanelChange = jest.fn(); + render( + getSidebarPanels({ + path: '/boxai', + hasNativeBoxAISidebar: false, + hasDocGen, + customSidebarPanels: [createBoxAIPanel({ component: CustomBoxAI, isDisabled })], + onPanelChange, + }), + ); + expect(screen.getByTestId(expectedSidebar)).toBeInTheDocument(); + expect(onPanelChange).toHaveBeenCalledWith(expectedPanelName, true); + }, + ); + }); }); describe('first loaded behavior', () => { @@ -591,6 +669,26 @@ describe('elements/content-sidebar/SidebarPanels', () => { expect(instance.metadataSidebar.current.refresh).toHaveBeenCalledWith(); expect(instance.versionsSidebar.current.refresh).toHaveBeenCalledWith(); }); + + test('should not throw when custom sidebar does not implement refresh', () => { + const instance = getWrapper({ hasNativeBoxAISidebar: false }).find(SidebarPanels).instance(); + + // Custom sidebar without refresh method + instance.customSidebars.set('customPanel', { current: {} }); + + expect(() => instance.refresh()).not.toThrow(); + }); + + test('should call refresh on custom sidebars that implement it', () => { + const instance = getWrapper({ hasNativeBoxAISidebar: false }).find(SidebarPanels).instance(); + const mockRefresh = jest.fn(); + + instance.customSidebars.set('customPanel', { current: { refresh: mockRefresh } }); + + instance.refresh(); + + expect(mockRefresh).toHaveBeenCalled(); + }); }); describe('componentDidUpdate', () => { @@ -626,4 +724,153 @@ describe('elements/content-sidebar/SidebarPanels', () => { expect(onVersionChange).toBeCalledWith(null); }); }); + + describe('multiple customSidebarPanels rendering', () => { + const createCustomPanel = (id, overrides = {}) => ({ + id, + path: id, + component: () =>
, + isDisabled: false, + ...overrides, + }); + + test('should render multiple custom panels including Box AI', () => { + const customSidebarPanels = [ + createBoxAIPanel(), + createCustomPanel('custom1', { title: 'Custom Panel 1' }), + createCustomPanel('custom2', { title: 'Custom Panel 2' }), + ]; + + const wrapper = getWrapper({ + path: '/boxai', + customSidebarPanels, + }); + + expect(wrapper.exists('BoxAISidebar')).toBe(true); + expect(wrapper.exists('CustomPanel[id="custom1"]')).toBe(false); + expect(wrapper.exists('CustomPanel[id="custom2"]')).toBe(false); + }); + + test('should render custom panels with regular panels', () => { + const customSidebarPanels = [ + createCustomPanel('analytics', { title: 'Analytics Panel' }), + createCustomPanel('reports', { title: 'Reports Panel' }), + ]; + + const analyticsWrapper = getWrapper({ + path: '/analytics', + customSidebarPanels, + hasActivity: true, + hasMetadata: true, + }); + + expect(analyticsWrapper.find('div[data-testid="analytics-sidebar"]')).toHaveLength(1); + + const reportsWrapper = getWrapper({ + path: '/reports', + customSidebarPanels, + hasActivity: true, + hasMetadata: true, + }); + + expect(reportsWrapper.find('div[data-testid="reports-sidebar"]')).toHaveLength(1); + }); + + test('should handle custom panels with different properties', () => { + const disabledPanel = createCustomPanel('disabled', { + isDisabled: true, + title: 'Disabled Panel', + }); + const enabledPanel = createCustomPanel('enabled', { + title: 'Enabled Panel', + }); + + const wrapper = getWrapper({ + path: '/enabled', + customSidebarPanels: [disabledPanel, enabledPanel], + }); + + expect(wrapper.find('div[data-testid="enabled-sidebar"]')).toHaveLength(1); + + const disabledWrapper = getWrapper({ + path: '/disabled', + customSidebarPanels: [disabledPanel, enabledPanel], + }); + + expect(disabledWrapper.find('div[data-testid="disabled-sidebar"]')).toHaveLength(0); + }); + + test('should handle empty custom panels array', () => { + const wrapper = getWrapper({ + path: '/activity', + customSidebarPanels: [], + hasActivity: true, + }); + + expect(wrapper.exists('ActivitySidebar')).toBe(true); + }); + + test('should handle custom panels with Box AI in different positions', () => { + const customSidebarPanels = [ + createCustomPanel('before', { title: 'Before Box AI' }), + createBoxAIPanel(), + createCustomPanel('after', { title: 'After Box AI' }), + ]; + + const boxAIWrapper = getWrapper({ + path: '/boxai', + customSidebarPanels, + }); + expect(boxAIWrapper.exists('BoxAISidebar')).toBe(true); + + const beforeWrapper = getWrapper({ + path: '/before', + customSidebarPanels, + }); + expect(beforeWrapper.find('div[data-testid="before-sidebar"]')).toHaveLength(1); + + const afterWrapper = getWrapper({ + path: '/after', + customSidebarPanels, + }); + expect(afterWrapper.find('div[data-testid="after-sidebar"]')).toHaveLength(1); + }); + + test('should call onPanelChange with correct panel names for custom panels', () => { + const onPanelChange = jest.fn(); + const customSidebarPanels = [createCustomPanel('dashboard', { title: 'Dashboard' }), createBoxAIPanel()]; + + render( + getSidebarPanels({ + path: '/dashboard', + customSidebarPanels, + onPanelChange, + }), + ); + expect(onPanelChange).toHaveBeenCalledWith('dashboard', true); + + onPanelChange.mockClear(); + + render( + getSidebarPanels({ + path: '/boxai', + customSidebarPanels, + onPanelChange, + }), + ); + expect(onPanelChange).toHaveBeenCalledWith('boxai', true); + }); + + test('should render other custom panels alongside native Box AI', () => { + const analyticsPanel = createCustomPanel('analytics', { title: 'Analytics Panel' }); + const boxAiPanel = createBoxAIPanel(); + + const wrapper = getWrapper({ + path: '/analytics', + customSidebarPanels: [boxAiPanel, analyticsPanel], + }); + + expect(wrapper.find('div[data-testid="analytics-sidebar"]')).toHaveLength(1); + }); + }); }); diff --git a/src/elements/content-sidebar/__tests__/SidebarUtils.test.js b/src/elements/content-sidebar/__tests__/SidebarUtils.test.js index 1aba678580..24690a19ed 100644 --- a/src/elements/content-sidebar/__tests__/SidebarUtils.test.js +++ b/src/elements/content-sidebar/__tests__/SidebarUtils.test.js @@ -126,8 +126,8 @@ describe('elements/content-sidebar/SidebarUtil', () => { }); }); describe('canHaveBoxAISidebar()', () => { - test('should return false when hasBoxAI is false', () => { - expect(SidebarUtils.canHaveBoxAISidebar({ hasBoxAI: false })).toBeFalsy(); + test('should return false when boxai.sidebar.enabled feature flag is not set', () => { + expect(SidebarUtils.canHaveBoxAISidebar({ features: {} })).toBeFalsy(); }); test('should return true when isFeatureEnabled returns true', () => { isFeatureEnabled.mockReturnValueOnce(true); diff --git a/src/elements/content-sidebar/flowTypes.js b/src/elements/content-sidebar/flowTypes.js index d4692e6ca3..ebd493dbae 100644 --- a/src/elements/content-sidebar/flowTypes.js +++ b/src/elements/content-sidebar/flowTypes.js @@ -1,6 +1,7 @@ // @flow import * as React from 'react'; import type { UseTargetingApi } from '../../features/targeting/types'; +import type { Props as SidebarNavButtonProps } from './SidebarNavButton'; type ClassificationInfo = { definition?: string, @@ -33,6 +34,23 @@ type AdditionalSidebarTab = { icon?: React.Node, }; +/** + * Optional props to customize the nav button. + * Note: onClick, sidebarView, isDisabled, tooltip, and children are managed + * internally and will be overridden if passed. + */ +type NavButtonOverrideProps = $Shape; + +type CustomSidebarPanel = { + id: string, + path: string, + component: React.ComponentType, + title: string, + isDisabled?: boolean, + icon: React.ComponentType | React.Element, + navButtonProps?: NavButtonOverrideProps, +}; + type Translations = { onTranslate?: Function, translationEnabled?: boolean, @@ -49,6 +67,7 @@ type FileAccessStats = { export type { ClassificationInfo, ContentInsights, + CustomSidebarPanel, NavigateOptions, AdditionalSidebarTab, AdditionalSidebarTabFtuxData, diff --git a/src/elements/content-sidebar/stories/BoxAISideBar.mdx b/src/elements/content-sidebar/stories/BoxAISidebar.mdx similarity index 100% rename from src/elements/content-sidebar/stories/BoxAISideBar.mdx rename to src/elements/content-sidebar/stories/BoxAISidebar.mdx diff --git a/src/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.tsx b/src/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.tsx index 8edd73b9fc..379d20f8c2 100644 --- a/src/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.tsx +++ b/src/elements/content-sidebar/stories/tests/ContentSidebar-visual.stories.tsx @@ -1,6 +1,51 @@ -import { type StoryObj } from '@storybook/react'; +import React from 'react'; +import type { StoryObj } from '@storybook/react'; +import { expect, within, waitFor } from 'storybook/test'; import ContentSidebarComponent from '../../ContentSidebar'; -import BoxAISidebar from '../../BoxAISidebarContent'; +import type { CustomSidebarPanel } from '../../flowTypes'; + +const MockCustomPanel = React.forwardRef(({ title }, ref) => ( +
+

{title}

+

This is a custom sidebar panel content.

+
+)); +MockCustomPanel.displayName = 'MockCustomPanel'; + +const MockCustomBoxAIPanel = React.forwardRef((props, ref) => ( +
+

Custom Box AI Panel

+

This is a custom Box AI implementation provided by the consumer.

+
+)); +MockCustomBoxAIPanel.displayName = 'MockCustomBoxAIPanel'; + +const MockIcon = () => 📋; +const MockBoxAIIcon = () => 🤖; + +const DISABLED_TOOLTIP = 'Box AI is not available for this file type'; + +const customPanelConfig: CustomSidebarPanel = { + id: 'customPanel', + path: 'customPanel', + component: MockCustomPanel, + title: 'Custom Panel', + icon: MockIcon, +}; + +const customBoxAIPanelConfig: CustomSidebarPanel = { + id: 'boxai', + path: 'boxai', + component: MockCustomBoxAIPanel, + title: 'Custom Box AI', + icon: MockBoxAIIcon, +}; + +const defaultFeatures = { + ...global.FEATURE_FLAGS, + metadata: { redesign: { enabled: true } }, + boxai: { sidebar: { enabled: true } }, +}; export default { title: 'Elements/ContentSidebar/tests/visual-regression-tests', @@ -23,35 +68,73 @@ export default { }, }; -export const withModernization = { - args: { - enableModernizedComponents: true, - }, +// Basic +export const ContentSidebar: StoryObj = { + args: { features: defaultFeatures }, +}; + +export const withModernization: StoryObj = { + args: { enableModernizedComponents: true }, +}; + +export const ContentSidebarDetailsTab: StoryObj = { + args: { hasActivityFeed: false, hasMetadata: false }, }; -export const ContentSidebarWithBoxAIDisabled: StoryObj = { +// Custom Panels +export const WithMultipleCustomPanels: StoryObj = { args: { - features: { - ...global.FEATURE_FLAGS, - 'boxai.sidebar.enabled': false, - 'metadata.redesign.enabled': true, - }, + features: defaultFeatures, + customSidebarPanels: [ + customPanelConfig, + { + id: 'anotherCustomPanel', + path: 'anotherCustomPanel', + component: MockCustomPanel, + title: 'Another Panel', + icon: () => 📝, + }, + ], }, }; -export const ContentSidebarWithBoxAIEnabled: StoryObj = { +// Native Box AI +export const NativeBoxAIDisabled: StoryObj = { args: { features: { - ...global.FEATURE_FLAGS, - 'boxai.sidebar.enabled': true, - 'metadata.redesign.enabled': true, + ...defaultFeatures, + boxai: { sidebar: { enabled: true, showOnlyNavButton: true, disabledTooltip: DISABLED_TOOLTIP } }, }, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor( + () => { + const boxAiButton = canvas.getByTestId('sidebarboxai'); + expect(boxAiButton).toHaveAttribute('aria-disabled', 'true'); + expect(boxAiButton).toHaveClass('bdl-is-disabled'); + expect(boxAiButton).toHaveAttribute('aria-selected', 'false'); + }, + { timeout: 5000 }, + ); + }, }; -export const ContentSidebarDetailsTab: StoryObj = { +// Custom Box AI Panel +export const WithCustomBoxAIPanel: StoryObj = { args: { - hasActivityFeed: false, - hasMetadata: false, + features: { ...defaultFeatures, boxai: { sidebar: { enabled: false } } }, + customSidebarPanels: [customBoxAIPanelConfig], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor( + () => { + const boxAiButton = canvas.getByTestId('sidebarboxai'); + expect(boxAiButton).toHaveAttribute('aria-selected', 'true'); + expect(canvas.getByTestId('custom-boxai-panel')).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); }, };