diff --git a/src/common/types/logging.js b/src/common/types/logging.js index 501febdff2..030ba9a34c 100644 --- a/src/common/types/logging.js +++ b/src/common/types/logging.js @@ -18,6 +18,7 @@ type ElementsLoadMetricData = { }; type LoggerProps = { + logError?: (error: Error, errorCode: string, context?: Object) => void, onPreviewMetric: (data: Object) => void, onReadyMetric: (data: ElementsLoadMetricData) => void, }; diff --git a/src/elements/content-preview/ContentPreview.js b/src/elements/content-preview/ContentPreview.js index cd3954ca68..148ef64157 100644 --- a/src/elements/content-preview/ContentPreview.js +++ b/src/elements/content-preview/ContentPreview.js @@ -33,6 +33,7 @@ import { isInputElement, focus } from '../../utils/dom'; import { getTypedFileId } from '../../utils/file'; import { withAnnotations, withAnnotatorContext } from '../common/annotator-context'; import { withErrorBoundary } from '../common/error-boundary'; +import CustomPreviewWrapper, { type ContentPreviewChildProps } from './CustomPreviewWrapper'; import { withLogger } from '../common/logger'; import { PREVIEW_FIELDS_TO_FETCH } from '../../utils/fields'; import { mark } from '../../utils/performance'; @@ -137,6 +138,36 @@ type Props = { theme?: Theme, token: Token, useHotkeys: boolean, + /** + * Optional React element to render instead of Box.Preview. + * When provided, renders custom preview implementation while preserving + * ContentPreview layout (sidebar, navigation, header). + * Box.Preview library will not be loaded when children are provided. + * + * The child element will be cloned with injected props: + * - fileId: ID of the file being previewed + * - token: Auth token for API calls + * - apiHost: Box API endpoint + * - file: Current file object with full metadata + * - onError: Optional callback for preview failures - call when content fails to load + * Pass error object with optional 'code' property for error categorization + * - onLoad: Optional callback for successful load - call when content is ready + * + * Expected behavior: + * - Component should call onLoad() when content is successfully rendered + * - Component should call onError(error) on failures, where error can be: + * - Error instance with optional 'code' property + * - Object with 'code' and 'message' properties + * - Component should handle its own loading states and error display + * - Component should handle its own keyboard shortcuts (ContentPreview hotkeys are disabled) + * - Component should be memoized/pure for performance + * + * @example + * + * + * + */ + children?: React.Node, } & ErrorContextProps & WithLoggerProps & WithAnnotationsProps & @@ -395,6 +426,8 @@ class ContentPreview extends React.PureComponent { * @return {void} */ componentDidMount(): void { + // Always load Box.Preview library assets + // Even when children are provided, we need assets ready for transitions this.loadStylesheet(); this.loadScript(); @@ -856,6 +889,7 @@ class ContentPreview extends React.PureComponent { const { advancedContentInsights, // will be removed once preview package will be updated to utilize feature flip for ACI annotatorState: { activeAnnotationId } = {}, + children, enableThumbnailsSidebar, features, fileOptions, @@ -868,6 +902,13 @@ class ContentPreview extends React.PureComponent { ...rest }: Props = this.props; const { file, selectedVersion, startAt }: State = this.state; + + // Early return: Box.Preview initialization not needed when using custom content children. + // Custom content will be rendered directly in the Measure block (see render method) + if (children) { + return; + } + this.previewLibraryLoaded = this.isPreviewLibraryLoaded(); if (!this.previewLibraryLoaded || !file || !tokenOrTokenFunction) { @@ -1230,8 +1271,12 @@ class ContentPreview extends React.PureComponent { * @return {void} */ onKeyDown = (event: SyntheticKeyboardEvent) => { - const { useHotkeys }: Props = this.props; - if (!useHotkeys) { + const { useHotkeys, children }: Props = this.props; + + // Skip ContentPreview hotkeys when custom content children are provided to prevent conflicts. + // Custom components must implement their own keyboard shortcuts (arrow navigation, etc) + // as ContentPreview's default handlers only work with Box.Preview viewer. + if (!useHotkeys || children) { return; } @@ -1490,9 +1535,27 @@ class ContentPreview extends React.PureComponent { > {file && ( - {({ measureRef: previewRef }) => ( -
- )} + {({ measureRef: previewRef }) => { + const { children, logger } = this.props; + + return ( +
+ {children ? ( + + {children} + + ) : null} +
+ ); + }} )} { } export type ContentPreviewProps = Props; +export type { ContentPreviewChildProps }; export { ContentPreview as ContentPreviewComponent }; export default flow([ makeResponsive, diff --git a/src/elements/content-preview/CustomPreviewWrapper.js b/src/elements/content-preview/CustomPreviewWrapper.js new file mode 100644 index 0000000000..f7b7769160 --- /dev/null +++ b/src/elements/content-preview/CustomPreviewWrapper.js @@ -0,0 +1,113 @@ +// @flow +import * as React from 'react'; +import ErrorBoundary from '../common/error-boundary'; +import { ORIGIN_CONTENT_PREVIEW } from '../../constants'; +import type { Token, BoxItem } from '../../common/types/core'; +import type { ErrorType } from '../common/flowTypes'; +import type { ElementsXhrError } from '../../common/types/api'; +import type { LoggerProps } from '../../common/types/logging'; + +type CustomPreviewOnError = (error: Error | ErrorType | ElementsXhrError) => void; +type CustomPreviewOnLoad = (data: Object) => void; + +/** + * Props that are automatically injected into ContentPreview children. + * Import this type to ensure your custom preview component accepts the required props. + * + * @example + * import type { ContentPreviewChildProps } from 'box-ui-elements'; + * + * const MyCustomPreview = ({ fileId, token, apiHost, file, onError, onLoad }: ContentPreviewChildProps) => { + * // Your implementation + * }; + */ +export type ContentPreviewChildProps = { + fileId: string, + token: Token, + apiHost: string, + file: BoxItem, + onError: CustomPreviewOnError, + onLoad: CustomPreviewOnLoad, +}; + +type Props = { + children: React.Node, + apiHost: string, + file: BoxItem, + fileId: string, + logger?: LoggerProps, + onPreviewError: (errorData: { error: ErrorType }) => void, + onPreviewLoad: CustomPreviewOnLoad, + token: Token, +}; + +/** + * Wrapper component for custom preview content. + * Clones the child element and injects props (fileId, token, apiHost, file, onError, onLoad). + * Wraps children in ErrorBoundary and transforms errors to ContentPreview error format. + */ +function CustomPreviewWrapper({ + children, + apiHost, + file, + fileId, + logger, + onPreviewError, + onPreviewLoad, + token, +}: Props): React.Node { + // Create wrapper for onError to transform to PreviewLibraryError signature + const handleCustomError: CustomPreviewOnError = (customError: ErrorType | ElementsXhrError) => { + // Extract error code + const errorCodeValue = + customError && typeof customError === 'object' && 'code' in customError + ? customError.code + : 'error_custom_preview'; + + // Extract error message + let errorMessageValue: string; + if (customError instanceof Error) { + errorMessageValue = customError.message; + } else if (customError && typeof customError === 'object' && 'message' in customError) { + errorMessageValue = customError.message || 'Unknown error'; + } else { + errorMessageValue = String(customError); + } + + const errorObj: ErrorType = { + code: errorCodeValue, + message: errorMessageValue, + }; + onPreviewError({ error: errorObj }); + }; + + // Error boundary handler for render errors + const handleRenderError = (elementsError: { code: string, message: string }) => { + const logError = logger?.logError; + if (logError) { + logError(new Error(elementsError.message), 'CUSTOM_PREVIEW_RENDER_ERROR', { + fileId, + fileName: file.name, + errorCode: elementsError.code, + }); + } + }; + + // Clone child element and inject props + const childWithProps = React.cloneElement((children: any), { + fileId, + token, + apiHost, + file, + onError: handleCustomError, + onLoad: onPreviewLoad, + }); + + return ( + + {childWithProps} + + ); +} + +export default CustomPreviewWrapper; diff --git a/src/elements/content-preview/__tests__/ContentPreview.test.js b/src/elements/content-preview/__tests__/ContentPreview.test.js index a6661b16e5..75e9c1c8ab 100644 --- a/src/elements/content-preview/__tests__/ContentPreview.test.js +++ b/src/elements/content-preview/__tests__/ContentPreview.test.js @@ -1686,4 +1686,280 @@ describe('elements/content-preview/ContentPreview', () => { expect(addEventListener).toBeCalledWith('loadeddata', expect.any(Function)); }); }); + + describe('children (custom preview content)', () => { + const CustomPreview = () =>
Custom Content
; + let onError; + let onLoad; + + beforeEach(() => { + onError = jest.fn(); + onLoad = jest.fn(); + file = { + id: '123', + name: 'test.md', + }; + props = { + token: 'token', + fileId: file.id, + apiHost: 'https://api.box.com', + children: , + onError, + onLoad, + }; + }); + + describe('componentDidMount()', () => { + test('should always load preview library assets', () => { + const loadStylesheetSpy = jest.spyOn(ContentPreview.prototype, 'loadStylesheet'); + const loadScriptSpy = jest.spyOn(ContentPreview.prototype, 'loadScript'); + getWrapper(props); + expect(loadStylesheetSpy).toHaveBeenCalled(); + expect(loadScriptSpy).toHaveBeenCalled(); + loadStylesheetSpy.mockRestore(); + loadScriptSpy.mockRestore(); + }); + }); + + describe('loadPreview()', () => { + test('should return early without loading Box.Preview when children is provided', async () => { + const wrapper = getWrapper(props); + wrapper.setState({ file }); + const instance = wrapper.instance(); + instance.isPreviewLibraryLoaded = jest.fn().mockReturnValue(true); + const getFileIdSpy = jest.spyOn(instance, 'getFileId'); + + await instance.loadPreview(); + + expect(getFileIdSpy).not.toHaveBeenCalled(); + expect(instance.preview).toBeUndefined(); + }); + + test('should load Box.Preview normally when children is not provided', async () => { + const propsWithoutCustom = { ...props }; + delete propsWithoutCustom.children; + const wrapper = getWrapper(propsWithoutCustom); + wrapper.setState({ file }); + const instance = wrapper.instance(); + instance.isPreviewLibraryLoaded = jest.fn().mockReturnValue(true); + + await instance.loadPreview(); + + expect(instance.preview).toBeDefined(); + expect(instance.preview.show).toHaveBeenCalled(); + }); + }); + + describe('onKeyDown()', () => { + test('should return early when children is provided', () => { + const wrapper = getWrapper({ ...props, useHotkeys: true }); + const instance = wrapper.instance(); + const event = { + key: 'ArrowRight', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + instance.onKeyDown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + }); + + test('should not return early due to children when it is not provided', () => { + const propsWithoutCustom = { ...props, useHotkeys: true }; + delete propsWithoutCustom.children; + const wrapper = getWrapper(propsWithoutCustom); + const instance = wrapper.instance(); + + // Spy on getViewer to verify we get past the children check + const getViewerSpy = jest.spyOn(instance, 'getViewer'); + + const event = { + key: 'ArrowRight', + which: 39, + keyCode: 39, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + target: document.createElement('div'), + }; + + instance.onKeyDown(event); + + // If we got past the children check, getViewer should have been called + // This proves the early return for children didn't trigger + expect(getViewerSpy).toHaveBeenCalled(); + getViewerSpy.mockRestore(); + }); + }); + + describe('render()', () => { + test('should render custom preview content inside .bcpr-content when children is provided', () => { + const wrapper = getWrapper(props); + wrapper.setState({ file }); + + // Find the Measure component and extract its render prop + const measureComponent = wrapper.find('Measure'); + expect(measureComponent.exists()).toBe(true); + + // Get the render function (children prop) and call it with mock measureRef + const renderProp = measureComponent.prop('children'); + const measureContent = shallow(
{renderProp({ measureRef: jest.fn() })}
); + + // Now verify CustomPreviewWrapper is rendered + expect(measureContent.find('CustomPreviewWrapper').exists()).toBe(true); + + // Verify children are passed to the wrapper + const wrapperInstance = measureContent.find('CustomPreviewWrapper'); + expect(wrapperInstance.prop('children')).toEqual(props.children); + }); + + test('should pass correct props to custom preview content', () => { + const wrapper = getWrapper(props); + wrapper.setState({ file }); + const instance = wrapper.instance(); + + // Find the Measure component and extract its render prop + const measureComponent = wrapper.find('Measure'); + const renderProp = measureComponent.prop('children'); + const measureContent = shallow(
{renderProp({ measureRef: jest.fn() })}
); + + // Find the CustomPreviewWrapper + const wrapperInstance = measureContent.find('CustomPreviewWrapper'); + + // Verify props passed to wrapper + expect(wrapperInstance.prop('fileId')).toBe(file.id); + expect(wrapperInstance.prop('token')).toBe(props.token); + expect(wrapperInstance.prop('apiHost')).toBe(props.apiHost); + expect(wrapperInstance.prop('file')).toBe(file); + expect(wrapperInstance.prop('children')).toEqual(props.children); + expect(wrapperInstance.prop('onPreviewError')).toBe(instance.onPreviewError); + expect(wrapperInstance.prop('onPreviewLoad')).toBe(instance.onPreviewLoad); + + // Shallow dive into CustomPreviewWrapper to verify children are cloned with injected props + const wrapperChildren = wrapperInstance.dive(); + const errorBoundary = wrapperChildren.find('ErrorBoundary'); + expect(errorBoundary.exists()).toBe(true); + + // The cloned child is inside ErrorBoundary + const clonedChild = errorBoundary.prop('children'); + expect(clonedChild.props.fileId).toBe(file.id); + expect(clonedChild.props.token).toBe(props.token); + expect(clonedChild.props.apiHost).toBe(props.apiHost); + expect(clonedChild.props.file).toBe(file); + expect(typeof clonedChild.props.onError).toBe('function'); + expect(typeof clonedChild.props.onLoad).toBe('function'); + }); + + test('should not render custom preview content when file is not loaded', () => { + const wrapper = getWrapper(props); + // Don't set file state - file should be undefined + + // Check if Measure component exists (it may not render without file) + const measureComponent = wrapper.find('Measure'); + if (measureComponent.exists()) { + const renderProp = measureComponent.prop('children'); + const measureContent = shallow(
{renderProp({ measureRef: jest.fn() })}
); + + expect(measureContent.find('CustomPreviewWrapper').exists()).toBe(false); + } else { + // If Measure doesn't exist, custom preview definitely isn't rendered + expect(measureComponent.exists()).toBe(false); + } + }); + + test('should render normal preview when children is not provided', () => { + const propsWithoutCustom = { ...props }; + delete propsWithoutCustom.children; + const wrapper = getWrapper(propsWithoutCustom); + wrapper.setState({ file }); + + // Find the Measure component and extract its render prop + const measureComponent = wrapper.find('Measure'); + const renderProp = measureComponent.prop('children'); + const measureContent = shallow(
{renderProp({ measureRef: jest.fn() })}
); + + const bcprContent = measureContent.find('.bcpr-content'); + expect(bcprContent.exists()).toBe(true); + // Should only have the empty div, no CustomPreviewWrapper + expect(bcprContent.find('CustomPreviewWrapper').exists()).toBe(false); + }); + }); + + describe('onPreviewLoad()', () => { + test('should set isLoading to false before calling onLoad callback', () => { + const wrapper = getWrapper(props); + const instance = wrapper.instance(); + wrapper.setState({ isLoading: true }); + instance.focusPreview = jest.fn(); + instance.addFetchFileTimeToPreviewMetrics = jest.fn().mockReturnValue({ + conversion: 0, + rendering: 100, + total: 100, + }); + + const data = { + file: { id: '123' }, + metrics: { + time: { + conversion: 0, + rendering: 100, + total: 100, + }, + }, + }; + + instance.onPreviewLoad(data); + + expect(wrapper.state('isLoading')).toBe(false); + expect(onLoad).toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + test('should wrap CustomPreview in ErrorBoundary', () => { + const wrapper = getWrapper(props); + wrapper.setState({ file }); + + // Find the Measure component and extract its render prop + const measureComponent = wrapper.find('Measure'); + const renderProp = measureComponent.prop('children'); + const measureContent = shallow(
{renderProp({ measureRef: jest.fn() })}
); + + // Find CustomPreviewWrapper + const wrapperInstance = measureContent.find('CustomPreviewWrapper'); + expect(wrapperInstance.exists()).toBe(true); + + // Shallow dive into CustomPreviewWrapper to verify ErrorBoundary + const wrapperChildren = wrapperInstance.dive(); + const errorBoundary = wrapperChildren.find('ErrorBoundary'); + expect(errorBoundary.exists()).toBe(true); + expect(errorBoundary.prop('errorOrigin')).toBe('content_preview'); + }); + + test('should call onPreviewError not onError in CustomPreview', () => { + const wrapper = getWrapper(props); + wrapper.setState({ file }); + const instance = wrapper.instance(); + + // Verify the instance has onPreviewError method + expect(typeof instance.onPreviewError).toBe('function'); + + // Verify onError doesn't exist as an instance method + expect(instance.onError).toBeUndefined(); + + // Find CustomPreviewWrapper in render output + const measureComponent = wrapper.find('Measure'); + const renderProp = measureComponent.prop('children'); + const measureContent = shallow(
{renderProp({ measureRef: jest.fn() })}
); + + const wrapperInstance = measureContent.find('CustomPreviewWrapper'); + + if (wrapperInstance.exists()) { + // Verify CustomPreviewWrapper receives onPreviewError + expect(wrapperInstance.prop('onPreviewError')).toBe(instance.onPreviewError); + } + }); + }); + }); });