-
Notifications
You must be signed in to change notification settings - Fork 343
feat(ContentPreview): add customPreviewContent prop for extensibility #4439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b4fae4f
b64a8b0
93232a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -32,7 +32,7 @@ import TokenService from '../../utils/TokenService'; | |||||||||||||||||||||||||
| 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 ErrorBoundary, { withErrorBoundary } from '../common/error-boundary'; | ||||||||||||||||||||||||||
| import { withLogger } from '../common/logger'; | ||||||||||||||||||||||||||
| import { PREVIEW_FIELDS_TO_FETCH } from '../../utils/fields'; | ||||||||||||||||||||||||||
| import { mark } from '../../utils/performance'; | ||||||||||||||||||||||||||
|
|
@@ -83,6 +83,16 @@ type StartAt = { | |||||||||||||||||||||||||
| value: number, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Callback types for customPreviewContent | ||||||||||||||||||||||||||
| type CustomPreviewOnError = ( | ||||||||||||||||||||||||||
| error: ErrorType | ElementsXhrError, | ||||||||||||||||||||||||||
| code: string, | ||||||||||||||||||||||||||
| context: Object, | ||||||||||||||||||||||||||
| origin?: string, | ||||||||||||||||||||||||||
| ) => void; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type CustomPreviewOnLoad = (data: { file?: BoxItem, metrics?: Object, ... }) => void; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type Props = { | ||||||||||||||||||||||||||
| advancedContentInsights: { | ||||||||||||||||||||||||||
| isActive: boolean, | ||||||||||||||||||||||||||
|
|
@@ -136,6 +146,43 @@ type Props = { | |||||||||||||||||||||||||
| theme?: Theme, | ||||||||||||||||||||||||||
| token: Token, | ||||||||||||||||||||||||||
| useHotkeys: boolean, | ||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Optional custom component to render instead of Box.Preview. | ||||||||||||||||||||||||||
| * When provided, renders custom preview implementation while preserving | ||||||||||||||||||||||||||
| * ContentPreview layout (sidebar, navigation, header). Custom component | ||||||||||||||||||||||||||
| * receives file metadata and must handle its own rendering and error states. | ||||||||||||||||||||||||||
| * Box.Preview library will not be loaded when this prop is set. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Props passed to custom component: | ||||||||||||||||||||||||||
| * - 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 | ||||||||||||||||||||||||||
| * - 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, code, context) on failures | ||||||||||||||||||||||||||
| * - 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 | ||||||||||||||||||||||||||
| * <ContentPreview | ||||||||||||||||||||||||||
| * customPreviewContent={MarkdownEditor} | ||||||||||||||||||||||||||
| * fileId="123" | ||||||||||||||||||||||||||
| * token={token} | ||||||||||||||||||||||||||
| * /> | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| customPreviewContent?: React.ComponentType<{ | ||||||||||||||||||||||||||
| fileId: string, | ||||||||||||||||||||||||||
| token: Token, | ||||||||||||||||||||||||||
| apiHost: string, | ||||||||||||||||||||||||||
| file: BoxItem, | ||||||||||||||||||||||||||
| onError?: CustomPreviewOnError, | ||||||||||||||||||||||||||
| onLoad?: CustomPreviewOnLoad, | ||||||||||||||||||||||||||
| }>, | ||||||||||||||||||||||||||
| } & ErrorContextProps & | ||||||||||||||||||||||||||
| WithLoggerProps & | ||||||||||||||||||||||||||
| WithAnnotationsProps & | ||||||||||||||||||||||||||
|
|
@@ -366,8 +413,26 @@ class ContentPreview extends React.PureComponent<Props, State> { | |||||||||||||||||||||||||
| * @return {void} | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| componentDidMount(): void { | ||||||||||||||||||||||||||
| this.loadStylesheet(); | ||||||||||||||||||||||||||
| this.loadScript(); | ||||||||||||||||||||||||||
| const { customPreviewContent, logger, onError } = this.props; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Validate customPreviewContent type at runtime | ||||||||||||||||||||||||||
| if (customPreviewContent && typeof customPreviewContent !== 'function') { | ||||||||||||||||||||||||||
| const error = new Error('customPreviewContent must be a React component (function or class)'); | ||||||||||||||||||||||||||
| logger.logError(error, 'INVALID_CUSTOM_PREVIEW_TYPE', { | ||||||||||||||||||||||||||
| receivedType: typeof customPreviewContent, | ||||||||||||||||||||||||||
| receivedValue: String(customPreviewContent), | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| onError(error, 'INVALID_PROP', { error }, ORIGIN_CONTENT_PREVIEW); | ||||||||||||||||||||||||||
| // Don't proceed with custom content | ||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Don't load Box.Preview library when custom content is provided | ||||||||||||||||||||||||||
| // (avoids unnecessary resource loading and potential conflicts with custom renderer) | ||||||||||||||||||||||||||
| if (!customPreviewContent) { | ||||||||||||||||||||||||||
| this.loadStylesheet(); | ||||||||||||||||||||||||||
| this.loadScript(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| this.fetchFile(this.state.currentFileId); | ||||||||||||||||||||||||||
| this.focusPreview(); | ||||||||||||||||||||||||||
|
|
@@ -737,9 +802,26 @@ class ContentPreview extends React.PureComponent<Props, State> { | |||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| onLoad(loadData); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Set loading to false before calling onLoad to ensure UI updates even if callback throws. | ||||||||||||||||||||||||||
| // Common failure scenario: metrics/analytics errors that shouldn't block preview functionality. | ||||||||||||||||||||||||||
| this.setState({ isLoading: false }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||
| onLoad(loadData); | ||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||
| // Catch and log errors from onLoad callback (typically metrics/analytics failures). | ||||||||||||||||||||||||||
| // WARNING: If onLoad contains critical business logic, this may hide important errors. | ||||||||||||||||||||||||||
| // Consider whether errors should propagate to parent components via error boundaries. | ||||||||||||||||||||||||||
| const { logger } = this.props; | ||||||||||||||||||||||||||
| logger.logError(error, 'PREVIEW_ONLOAD_CALLBACK_ERROR', { | ||||||||||||||||||||||||||
| fileId: this.state.currentFileId, | ||||||||||||||||||||||||||
| fileName: this.state.file?.name, | ||||||||||||||||||||||||||
| errorMessage: error.message, | ||||||||||||||||||||||||||
| errorType: error.name, | ||||||||||||||||||||||||||
| hasMetrics: !!loadData.metrics, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| this.focusPreview(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (this.preview && filesToPrefetch.length) { | ||||||||||||||||||||||||||
|
|
@@ -805,6 +887,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 } = {}, | ||||||||||||||||||||||||||
| customPreviewContent, | ||||||||||||||||||||||||||
| enableThumbnailsSidebar, | ||||||||||||||||||||||||||
| features, | ||||||||||||||||||||||||||
| fileOptions, | ||||||||||||||||||||||||||
|
|
@@ -817,6 +900,13 @@ class ContentPreview extends React.PureComponent<Props, State> { | |||||||||||||||||||||||||
| ...rest | ||||||||||||||||||||||||||
| }: Props = this.props; | ||||||||||||||||||||||||||
| const { file, selectedVersion, startAt }: State = this.state; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Early return: Box.Preview initialization not needed when using custom content. | ||||||||||||||||||||||||||
| // Custom component will be rendered directly in the Measure block (see render method) | ||||||||||||||||||||||||||
| if (customPreviewContent) { | ||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| this.previewLibraryLoaded = this.isPreviewLibraryLoaded(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (!this.previewLibraryLoaded || !file || !tokenOrTokenFunction) { | ||||||||||||||||||||||||||
|
|
@@ -1172,8 +1262,12 @@ class ContentPreview extends React.PureComponent<Props, State> { | |||||||||||||||||||||||||
| * @return {void} | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| onKeyDown = (event: SyntheticKeyboardEvent<HTMLElement>) => { | ||||||||||||||||||||||||||
| const { useHotkeys }: Props = this.props; | ||||||||||||||||||||||||||
| if (!useHotkeys) { | ||||||||||||||||||||||||||
| const { useHotkeys, customPreviewContent }: Props = this.props; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 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 || customPreviewContent) { | ||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -1431,9 +1525,71 @@ class ContentPreview extends React.PureComponent<Props, State> { | |||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| {file && ( | ||||||||||||||||||||||||||
| <Measure bounds onResize={this.onResize}> | ||||||||||||||||||||||||||
| {({ measureRef: previewRef }) => ( | ||||||||||||||||||||||||||
| <div ref={previewRef} className="bcpr-content" /> | ||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||
| {({ measureRef: previewRef }) => { | ||||||||||||||||||||||||||
| const { customPreviewContent: CustomPreview, logger } = this.props; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <div ref={previewRef} className="bcpr-content"> | ||||||||||||||||||||||||||
| {CustomPreview && | ||||||||||||||||||||||||||
| (() => { | ||||||||||||||||||||||||||
| // Validate required props before rendering custom content | ||||||||||||||||||||||||||
| if (!currentFileId || !token || !apiHost) { | ||||||||||||||||||||||||||
| const missingProps = []; | ||||||||||||||||||||||||||
| if (!currentFileId) missingProps.push('fileId'); | ||||||||||||||||||||||||||
| if (!token) missingProps.push('token'); | ||||||||||||||||||||||||||
| if (!apiHost) missingProps.push('apiHost'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const validationError = new Error( | ||||||||||||||||||||||||||
| `CustomPreview: Missing required props: ${missingProps.join(', ')}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| logger.logError( | ||||||||||||||||||||||||||
| validationError, | ||||||||||||||||||||||||||
| 'CUSTOM_PREVIEW_INVALID_PROPS', | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| missingProps, | ||||||||||||||||||||||||||
| fileId: currentFileId, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| this.onPreviewError({ | ||||||||||||||||||||||||||
| error: { | ||||||||||||||||||||||||||
| ...validationError, | ||||||||||||||||||||||||||
| code: 'CUSTOM_PREVIEW_INVALID_PROPS', | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
Comment on lines
+1553
to
+1558
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Spreading an
Proposed fix this.onPreviewError({
error: {
- ...validationError,
+ message: validationError.message,
code: 'CUSTOM_PREVIEW_INVALID_PROPS',
},
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Wrap custom preview in error boundary to catch render errors | ||||||||||||||||||||||||||
| // bcpr-content class provides consistent sizing/layout with Box.Preview | ||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||
| <ErrorBoundary | ||||||||||||||||||||||||||
| errorOrigin={ORIGIN_CONTENT_PREVIEW} | ||||||||||||||||||||||||||
| onError={elementsError => { | ||||||||||||||||||||||||||
| logger.logError( | ||||||||||||||||||||||||||
| new Error(elementsError.message), | ||||||||||||||||||||||||||
| 'CUSTOM_PREVIEW_RENDER_ERROR', | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| fileId: currentFileId, | ||||||||||||||||||||||||||
| fileName: file.name, | ||||||||||||||||||||||||||
| errorCode: elementsError.code, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||
|
Comment on lines
+1565
to
+1577
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ErrorBoundary
Suggested fix <ErrorBoundary
errorOrigin={ORIGIN_CONTENT_PREVIEW}
onError={elementsError => {
logger.logError(
- new Error(elementsError.message),
+ elementsError,
'CUSTOM_PREVIEW_RENDER_ERROR',
{
fileId: currentFileId,
fileName: file.name,
- errorCode: elementsError.code,
},
);
}}
>🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| <CustomPreview | ||||||||||||||||||||||||||
| fileId={currentFileId} | ||||||||||||||||||||||||||
| token={token} | ||||||||||||||||||||||||||
| apiHost={apiHost} | ||||||||||||||||||||||||||
| file={file} | ||||||||||||||||||||||||||
| onError={this.onPreviewError} | ||||||||||||||||||||||||||
| onLoad={this.onPreviewLoad} | ||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||
| </ErrorBoundary> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| })()} | ||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||
| </Measure> | ||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||
| <PreviewMask | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Early return on invalid
customPreviewContentskipsfetchFileandfocusPreviewentirely.When validation fails, the component won't fetch file metadata or set focus. The user sees a blank component with no error UI (no
errorstate is set, soPreviewMaskwon't show an error either). Consider setting error state before returning so the mask renders an error, or falling through to the normal Box.Preview path as a degraded experience.Proposed fix — set error state so PreviewMask renders
onError(error, 'INVALID_PROP', { error }, ORIGIN_CONTENT_PREVIEW); - // Don't proceed with custom content - return; + // Set error state so PreviewMask shows the error, then skip custom content loading + this.setState({ error: { code: 'INVALID_PROP', message: error.message }, isLoading: false }); + // Fall through to still fetch file and focus, but custom content won't render due to error state }📝 Committable suggestion
🤖 Prompt for AI Agents