Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 19 additions & 18 deletions src/elements/content-preview/ContentPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,12 @@ type Props = {
token: Token,
useHotkeys: boolean,
/**
* Optional React element to render instead of Box.Preview.
* Optional render function for custom preview content.
* When provided, renders custom preview implementation while preserving
* ContentPreview layout (sidebar, navigation, header).
* Box.Preview library will not be loaded when children are provided.
* Box.Preview library will not be loaded when renderCustomPreview is provided.
*
* The child element will be cloned with injected props:
* The render function receives a props object with:
* - fileId: ID of the file being previewed
* - token: Auth token for API calls
* - apiHost: Box API endpoint
Expand All @@ -163,11 +163,13 @@ type Props = {
* - Component should be memoized/pure for performance
*
* @example
* <ContentPreview fileId="123" token={token}>
* <MarkdownEditor />
* </ContentPreview>
* <ContentPreview
* fileId="123"
* token={token}
* renderCustomPreview={(props) => <MarkdownEditor {...props} />}
* />
*/
children?: React.Node,
renderCustomPreview?: (props: ContentPreviewChildProps) => React.Node,
} & ErrorContextProps &
WithLoggerProps &
WithAnnotationsProps &
Expand Down Expand Up @@ -889,7 +891,7 @@ class ContentPreview extends React.PureComponent<Props, State> {
const {
advancedContentInsights, // will be removed once preview package will be updated to utilize feature flip for ACI
annotatorState: { activeAnnotationId } = {},
children,
renderCustomPreview,
enableThumbnailsSidebar,
features,
fileOptions,
Expand All @@ -903,9 +905,9 @@ class ContentPreview extends React.PureComponent<Props, State> {
}: Props = this.props;
const { file, selectedVersion, startAt }: State = this.state;

// Early return: Box.Preview initialization not needed when using custom content children.
// Early return: Box.Preview initialization not needed when using custom render function.
// Custom content will be rendered directly in the Measure block (see render method)
if (children) {
if (renderCustomPreview) {
return;
}

Expand Down Expand Up @@ -1271,12 +1273,12 @@ class ContentPreview extends React.PureComponent<Props, State> {
* @return {void}
*/
onKeyDown = (event: SyntheticKeyboardEvent<HTMLElement>) => {
const { useHotkeys, children }: Props = this.props;
const { useHotkeys, renderCustomPreview }: Props = this.props;

// Skip ContentPreview hotkeys when custom content children are provided to prevent conflicts.
// Skip ContentPreview hotkeys when custom content is 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) {
if (!useHotkeys || renderCustomPreview) {
return;
}

Expand Down Expand Up @@ -1536,22 +1538,21 @@ class ContentPreview extends React.PureComponent<Props, State> {
{file && (
<Measure bounds onResize={this.onResize}>
{({ measureRef: previewRef }) => {
const { children, logger } = this.props;
const { renderCustomPreview, logger } = this.props;

return (
<div ref={previewRef} className="bcpr-content">
{children ? (
{renderCustomPreview ? (
<CustomPreviewWrapper
renderCustomPreview={renderCustomPreview}
fileId={currentFileId}
token={token}
apiHost={apiHost}
file={file}
logger={logger}
onPreviewError={this.onPreviewError}
onPreviewLoad={this.onPreviewLoad}
>
{children}
</CustomPreviewWrapper>
/>
) : null}
</div>
);
Expand Down
29 changes: 20 additions & 9 deletions src/elements/content-preview/CustomPreviewWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,23 @@ type CustomPreviewOnError = (error: Error | ErrorType | ElementsXhrError) => voi
type CustomPreviewOnLoad = (data: Object) => void;

/**
* Props that are automatically injected into ContentPreview children.
* Props passed to the renderCustomPreview function.
* Import this type to ensure your custom preview component accepts the required props.
*
* @example
* import type { ContentPreviewChildProps } from 'box-ui-elements';
*
* // Define your custom preview component
* const MyCustomPreview = ({ fileId, token, apiHost, file, onError, onLoad }: ContentPreviewChildProps) => {
* // Your implementation
* };
*
* // Use with ContentPreview
* <ContentPreview
* fileId="123"
* token={token}
* renderCustomPreview={(props) => <MyCustomPreview {...props} />}
* />
*/
export type ContentPreviewChildProps = {
fileId: string,
Expand All @@ -31,7 +39,7 @@ export type ContentPreviewChildProps = {
};

type Props = {
children: React.Node,
renderCustomPreview: (props: ContentPreviewChildProps) => React.Node,
apiHost: string,
file: BoxItem,
fileId: string,
Expand All @@ -43,11 +51,11 @@ type Props = {

/**
* 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.
* Calls the render function with props (fileId, token, apiHost, file, onError, onLoad).
* Wraps rendered content in ErrorBoundary and transforms errors to ContentPreview error format.
*/
function CustomPreviewWrapper({
children,
renderCustomPreview,
apiHost,
file,
fileId,
Expand Down Expand Up @@ -93,19 +101,22 @@ function CustomPreviewWrapper({
}
};

// Clone child element and inject props
const childWithProps = React.cloneElement((children: any), {
// Build props object for render function
const childProps: ContentPreviewChildProps = {
fileId,
token,
apiHost,
file,
onError: handleCustomError,
onLoad: onPreviewLoad,
});
};

// Call render function with props and wrap in fragment to ensure it's a valid React.Element
const customContent = <>{renderCustomPreview(childProps)}</>;

return (
<ErrorBoundary errorOrigin={ORIGIN_CONTENT_PREVIEW} onError={handleRenderError}>
{childWithProps}
{customContent}
</ErrorBoundary>
);
}
Expand Down
54 changes: 28 additions & 26 deletions src/elements/content-preview/__tests__/ContentPreview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1687,7 +1687,7 @@ describe('elements/content-preview/ContentPreview', () => {
});
});

describe('children (custom preview content)', () => {
describe('renderCustomPreview (custom preview content)', () => {
const CustomPreview = () => <div className="custom-preview">Custom Content</div>;
let onError;
let onLoad;
Expand All @@ -1703,7 +1703,7 @@ describe('elements/content-preview/ContentPreview', () => {
token: 'token',
fileId: file.id,
apiHost: 'https://api.box.com',
children: <CustomPreview />,
renderCustomPreview: childProps => <CustomPreview {...childProps} />,
onError,
onLoad,
};
Expand All @@ -1722,7 +1722,7 @@ describe('elements/content-preview/ContentPreview', () => {
});

describe('loadPreview()', () => {
test('should return early without loading Box.Preview when children is provided', async () => {
test('should return early without loading Box.Preview when renderCustomPreview is provided', async () => {
const wrapper = getWrapper(props);
wrapper.setState({ file });
const instance = wrapper.instance();
Expand All @@ -1735,9 +1735,9 @@ describe('elements/content-preview/ContentPreview', () => {
expect(instance.preview).toBeUndefined();
});

test('should load Box.Preview normally when children is not provided', async () => {
test('should load Box.Preview normally when renderCustomPreview is not provided', async () => {
const propsWithoutCustom = { ...props };
delete propsWithoutCustom.children;
delete propsWithoutCustom.renderCustomPreview;
const wrapper = getWrapper(propsWithoutCustom);
wrapper.setState({ file });
const instance = wrapper.instance();
Expand All @@ -1751,7 +1751,7 @@ describe('elements/content-preview/ContentPreview', () => {
});

describe('onKeyDown()', () => {
test('should return early when children is provided', () => {
test('should return early when renderCustomPreview is provided', () => {
const wrapper = getWrapper({ ...props, useHotkeys: true });
const instance = wrapper.instance();
const event = {
Expand All @@ -1766,13 +1766,13 @@ describe('elements/content-preview/ContentPreview', () => {
expect(event.stopPropagation).not.toHaveBeenCalled();
});

test('should not return early due to children when it is not provided', () => {
test('should not return early due to renderCustomPreview when it is not provided', () => {
const propsWithoutCustom = { ...props, useHotkeys: true };
delete propsWithoutCustom.children;
delete propsWithoutCustom.renderCustomPreview;
const wrapper = getWrapper(propsWithoutCustom);
const instance = wrapper.instance();

// Spy on getViewer to verify we get past the children check
// Spy on getViewer to verify we get past the renderCustomPreview check
const getViewerSpy = jest.spyOn(instance, 'getViewer');

const event = {
Expand All @@ -1786,15 +1786,15 @@ describe('elements/content-preview/ContentPreview', () => {

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
// If we got past the renderCustomPreview check, getViewer should have been called
// This proves the early return for renderCustomPreview didn't trigger
expect(getViewerSpy).toHaveBeenCalled();
getViewerSpy.mockRestore();
});
});

describe('render()', () => {
test('should render custom preview content inside .bcpr-content when children is provided', () => {
test('should render custom preview content inside .bcpr-content when renderCustomPreview is provided', () => {
const wrapper = getWrapper(props);
wrapper.setState({ file });

Expand All @@ -1809,9 +1809,9 @@ describe('elements/content-preview/ContentPreview', () => {
// Now verify CustomPreviewWrapper is rendered
expect(measureContent.find('CustomPreviewWrapper').exists()).toBe(true);

// Verify children are passed to the wrapper
// Verify renderCustomPreview is passed to the wrapper
const wrapperInstance = measureContent.find('CustomPreviewWrapper');
expect(wrapperInstance.prop('children')).toEqual(props.children);
expect(wrapperInstance.prop('renderCustomPreview')).toEqual(props.renderCustomPreview);
});

test('should pass correct props to custom preview content', () => {
Expand All @@ -1832,23 +1832,25 @@ describe('elements/content-preview/ContentPreview', () => {
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('renderCustomPreview')).toEqual(props.renderCustomPreview);
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
// Shallow dive into CustomPreviewWrapper to verify render function is called with 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');
// The rendered content is wrapped in a fragment inside ErrorBoundary
const fragment = errorBoundary.prop('children');
// The actual custom preview content is inside the fragment
const renderedContent = fragment.props.children;
expect(renderedContent.props.fileId).toBe(file.id);
expect(renderedContent.props.token).toBe(props.token);
expect(renderedContent.props.apiHost).toBe(props.apiHost);
expect(renderedContent.props.file).toBe(file);
expect(typeof renderedContent.props.onError).toBe('function');
expect(typeof renderedContent.props.onLoad).toBe('function');
});

test('should not render custom preview content when file is not loaded', () => {
Expand All @@ -1868,9 +1870,9 @@ describe('elements/content-preview/ContentPreview', () => {
}
});

test('should render normal preview when children is not provided', () => {
test('should render normal preview when renderCustomPreview is not provided', () => {
const propsWithoutCustom = { ...props };
delete propsWithoutCustom.children;
delete propsWithoutCustom.renderCustomPreview;
const wrapper = getWrapper(propsWithoutCustom);
wrapper.setState({ file });

Expand Down