diff --git a/src/elements/content-preview/ContentPreview.js b/src/elements/content-preview/ContentPreview.js index 392ba85354..36ec268b4d 100644 --- a/src/elements/content-preview/ContentPreview.js +++ b/src/elements/content-preview/ContentPreview.js @@ -72,7 +72,13 @@ import type { ErrorType, AdditionalVersionInfo } from '../common/flowTypes'; import type { WithLoggerProps } from '../../common/types/logging'; import type { RequestOptions, ErrorContextProps, ElementsXhrError } from '../../common/types/api'; import type { StringMap, Token, BoxItem, BoxItemVersion } from '../../common/types/core'; -import type { VersionChangeCallback } from '../content-sidebar/versions'; +import type { + ComparisonBannerProps, + ComparisonVersionActions, + RegisterVersionActionsCallback, + VersionChangeCallback, +} from '../content-sidebar/versions'; +import { selectComparisonBannerActions } from '../content-sidebar/versions/versionItemActionVisibility'; import type { FeatureConfig } from '../common/feature-checking'; import type { WithAnnotationsProps, WithAnnotatorContextProps } from '../common/annotator-context'; // $FlowFixMe TypeScript file @@ -141,6 +147,7 @@ type Props = { isComparedPreview?: boolean, comparedSlotRef?: (?HTMLDivElement) => mixed, comparedVersion?: BoxItemVersion, + comparisonBanner?: ComparisonBannerProps, isLarge: boolean, isVeryLarge?: boolean, language: string, @@ -175,6 +182,7 @@ type Props = { }, requestInterceptor?: Function, responseInterceptor?: Function, + registerVersionActions?: RegisterVersionActionsCallback, sharedLink?: string, sharedLinkAuth?: 'logged_in' | 'logged_out' | 'na', sharedLinkPassword?: string, @@ -630,28 +638,6 @@ class ContentPreview extends React.PureComponent { this.updatePreviewToken(); } - // The click that starts comparison writes selectedVersion while isComparing is still - // false. Drop it so the left pane stays on current when comparison ends. - if (!prevProps.isComparing && this.props.isComparing && this.state.selectedVersion) { - this.setState({ selectedVersion: undefined }); - } - - // Comparison can start on an already-open current pane; stamp BCP without reloading. - const comparisonFlagsChanged = - !!prevProps.isComparing !== !!this.props.isComparing || - !!prevProps.isComparedPreview !== !!this.props.isComparedPreview; - if ( - comparisonFlagsChanged && - this.preview && - this.preview.setComparisonMode && - !this.shouldLoadPreview(prevProps, prevState) - ) { - this.preview.setComparisonMode({ - isComparing: !!(this.props.isComparing || this.props.isComparedPreview), - isComparedPreview: !!this.props.isComparedPreview, - }); - } - if (haveExperiencesChanged && this.preview && this.preview.updateExperiences) { this.preview.updateExperiences(previewExperiences); } @@ -663,6 +649,9 @@ class ContentPreview extends React.PureComponent { ) { this.preview.updateContentInsightsOptions(features?.advancedContentInsights); } + + this.updatePreviewComparisonMode(prevProps, prevState); + this.updatePreviewComparisonBanner(prevProps, prevState); } /** @@ -676,6 +665,58 @@ class ContentPreview extends React.PureComponent { } } + /** + * Handles comparison starting or ending on an already-open pane without reloading. + * + * @param {Props} prevProps - Previous props + * @param {State} prevState - Previous state + */ + updatePreviewComparisonMode(prevProps: Props, prevState: State) { + const { isComparing, isComparedPreview } = this.props; + + // The click that starts comparison writes selectedVersion while isComparing is still + // false. Drop it so the left pane stays on current when comparison ends. + if (!prevProps.isComparing && isComparing && this.state.selectedVersion) { + this.setState({ selectedVersion: undefined }); + } + + const haveFlagsChanged = + !!prevProps.isComparing !== !!isComparing || !!prevProps.isComparedPreview !== !!isComparedPreview; + + if ( + haveFlagsChanged && + this.preview && + this.preview.setComparisonMode && + !this.shouldLoadPreview(prevProps, prevState) + ) { + this.preview.setComparisonMode({ + isComparing: !!(isComparing || isComparedPreview), + isComparedPreview: !!isComparedPreview, + }); + } + } + + /** + * Pushes a changed comparisonBanner into an already-shown preview without reloading. + * + * @param {Props} prevProps - Previous props + * @param {State} prevState - Previous state + */ + updatePreviewComparisonBanner(prevProps: Props, prevState: State) { + const { comparisonBanner } = this.props; + + if ( + prevProps.comparisonBanner !== comparisonBanner && + this.preview && + this.preview.options && + this.preview.updateComparisonBanner && + !this.shouldLoadPreview(prevProps, prevState) + ) { + this.preview.options.comparisonBanner = comparisonBanner || {}; + this.preview.updateComparisonBanner(); + } + } + /** * Returns the version this instance previews. * @@ -1080,6 +1121,7 @@ class ContentPreview extends React.PureComponent { previewExperiences, previewMode, previewVersion, + registerVersionActions, // sidebar-only, not a Preview option sharedLinkAuth, showAnnotationsControls, token: tokenOrTokenFunction, @@ -1772,6 +1814,7 @@ class ContentPreview extends React.PureComponent { logoUrl, onClose, measureRef, + registerVersionActions, sharedLink, sharedLinkPassword, requestInterceptor, @@ -1792,8 +1835,12 @@ class ContentPreview extends React.PureComponent { }: State = this.state; const hostOnEditingStateChange = contentSidebarProps.metadataSidebarProps?.onEditingStateChange; + const { versionsSidebarProps } = contentSidebarProps; const mergedContentSidebarProps = { ...contentSidebarProps, + versionsSidebarProps: registerVersionActions + ? { ...versionsSidebarProps, registerVersionActions } + : versionsSidebarProps, metadataSidebarProps: { ...contentSidebarProps.metadataSidebarProps, onEditingStateChange: (isEditing: boolean) => { @@ -1969,11 +2016,26 @@ const ConnectedContentPreview = flow([ const MemoConnectedContentPreview = React.memo(ConnectedContentPreview); function ContentPreviewWithComparison(props: ContentPreviewProps) { - const { comparedVersion, ...rest } = props; + const { comparedVersion, comparisonBanner: hostComparisonBanner, ...rest } = props; const [comparedSlot, setComparedSlot] = React.useState(null); + const [versionActions, setVersionActions] = React.useState(null); const comparedPreviewRef = React.useRef(null); const comparedVersionId = comparedVersion && comparedVersion.id; const isComparing = comparedVersionId != null && comparedVersionId !== ''; + const registerVersionActions = React.useCallback((actions: ComparisonVersionActions | null) => { + setVersionActions(actions); + }, []); + const comparisonBanner = React.useMemo(() => { + if (!versionActions || comparedVersionId == null || comparedVersionId === '') { + return hostComparisonBanner; + } + + // Sidebar handlers override the host's; a handler the sidebar withholds keeps the host's. + return { + ...hostComparisonBanner, + ...selectComparisonBannerActions(versionActions, comparedVersionId), + }; + }, [hostComparisonBanner, versionActions, comparedVersionId]); const handleComparedAnnotationSelect = React.useCallback((annotation, deferScrollToOnload) => { const comparedPreview = comparedPreviewRef.current; @@ -1987,9 +2049,11 @@ function ContentPreviewWithComparison(props: ContentPreviewProps) { {comparedSlot && isComparing ? createPortal( @@ -2000,6 +2064,7 @@ function ContentPreviewWithComparison(props: ContentPreviewProps) { advancedContentInsights={undefined} autoFocus={false} collection={EMPTY_COLLECTION} + comparisonBanner={comparisonBanner} componentRef={comparedPreviewRef} comparedSlotRef={undefined} contentAnswersProps={undefined} diff --git a/src/elements/content-preview/__tests__/ContentPreview.test.js b/src/elements/content-preview/__tests__/ContentPreview.test.js index 5b6a66d11e..dda56ad4db 100644 --- a/src/elements/content-preview/__tests__/ContentPreview.test.js +++ b/src/elements/content-preview/__tests__/ContentPreview.test.js @@ -1407,6 +1407,35 @@ describe('elements/content-preview/ContentPreview', () => { expect(wrapper.childAt(1).props().children.key).toBe('789'); expect(instance.loadPreview).not.toHaveBeenCalled(); }); + + describe('registerVersionActions', () => { + const getSidebar = wrapper => wrapper.find('.bcpr-body').children().last(); + const getSidebarWrapper = (extraProps = {}) => { + const wrapper = getWrapper({ fileId: '123', ...extraProps }); + wrapper.setState({ currentFileId: '123', file: { id: '123' } }); + return wrapper; + }; + + test('should add registerVersionActions to the versions sidebar props', () => { + const registerVersionActions = jest.fn(); + const wrapper = getSidebarWrapper({ + contentSidebarProps: { versionsSidebarProps: { foo: 'bar' } }, + registerVersionActions, + }); + + expect(getSidebar(wrapper).prop('versionsSidebarProps')).toEqual({ + foo: 'bar', + registerVersionActions, + }); + }); + + test('should leave the versions sidebar props alone without registerVersionActions', () => { + const versionsSidebarProps = { foo: 'bar' }; + const wrapper = getSidebarWrapper({ contentSidebarProps: { versionsSidebarProps } }); + + expect(getSidebar(wrapper).prop('versionsSidebarProps')).toBe(versionsSidebarProps); + }); + }); }); describe('loadFileFromStage()', () => { @@ -1596,6 +1625,63 @@ describe('elements/content-preview/ContentPreview', () => { expect(livePreview.setComparisonMode).not.toHaveBeenCalled(); }); + + describe('comparison banner', () => { + const comparisonBanner = { onDownload: jest.fn() }; + + beforeEach(() => { + instance.shouldLoadPreview = jest.fn().mockReturnValue(false); + instance.preview = { + options: {}, + updateComparisonBanner: jest.fn(), + }; + }); + + test('should push a changed banner to the live preview', () => { + wrapper.setProps({ comparisonBanner }); + + expect(instance.preview.options.comparisonBanner).toBe(comparisonBanner); + expect(instance.preview.updateComparisonBanner).toHaveBeenCalledTimes(1); + }); + + test('should push an empty banner when the banner is cleared', () => { + wrapper.setProps({ comparisonBanner }); + wrapper.setProps({ comparisonBanner: undefined }); + + expect(instance.preview.options.comparisonBanner).toEqual({}); + expect(instance.preview.updateComparisonBanner).toHaveBeenCalledTimes(2); + }); + + test('should not push the banner when it did not change', () => { + wrapper.setProps({ foo: 'bar' }); + + expect(instance.preview.options.comparisonBanner).toBeUndefined(); + expect(instance.preview.updateComparisonBanner).not.toHaveBeenCalled(); + }); + + test('should not push the banner when the preview is reloading anyway', () => { + instance.shouldLoadPreview = jest.fn().mockReturnValue(true); + + wrapper.setProps({ comparisonBanner }); + + expect(instance.preview.options.comparisonBanner).toBeUndefined(); + expect(instance.preview.updateComparisonBanner).not.toHaveBeenCalled(); + }); + + test('should not push the banner when the preview has no updateComparisonBanner', () => { + instance.preview = { options: {} }; + + wrapper.setProps({ comparisonBanner }); + + expect(instance.preview.options.comparisonBanner).toBeUndefined(); + }); + + test('should not push the banner when there is no live preview', () => { + instance.preview = undefined; + + expect(() => wrapper.setProps({ comparisonBanner })).not.toThrow(); + }); + }); }); describe('getDerivedStateFromProps()', () => { @@ -3225,6 +3311,104 @@ describe('elements/content-preview/ContentPreview', () => { expect(wrapper.childAt(1).props().children.props.isComparedPreview).toBe(true); }); + describe('comparison banner', () => { + const permissions = { can_delete: true, can_download: true, can_upload: true }; + const versions = [ + { extension: 'docx', id: '123', is_download_available: true, permissions, version_number: '3' }, + { extension: 'docx', id: '456', is_download_available: true, permissions, version_number: '2' }, + ]; + const versionActions = { + delete: jest.fn(), + download: jest.fn(), + promote: jest.fn(), + isArchived: false, + isWatermarked: false, + versionCount: 2, + versionLimit: Infinity, + versions, + }; + + const getComparingWrapper = (extraProps = {}) => { + const wrapper = shallow( + , + ); + wrapper.childAt(0).props().comparedSlotRef(document.createElement('div')); + wrapper.update(); + return wrapper; + }; + const getBanners = wrapper => ({ + main: wrapper.childAt(0).props().comparisonBanner, + compared: wrapper.childAt(1).props().children.props.comparisonBanner, + }); + + test('should pass registerVersionActions to the main pane only', () => { + const wrapper = getComparingWrapper(); + + expect(wrapper.childAt(0).props().registerVersionActions).toEqual(expect.any(Function)); + expect(wrapper.childAt(1).props().children.props.registerVersionActions).toBeUndefined(); + }); + + test('should pass the host banner through until version actions register', () => { + const hostBanner = { currentFileVersion: { id: '123' }, onDownload: jest.fn() }; + const wrapper = getComparingWrapper({ comparisonBanner: hostBanner }); + + const { main, compared } = getBanners(wrapper); + expect(main).toBe(hostBanner); + expect(compared).toBe(hostBanner); + }); + + test('should add the selected version actions to the host banner', () => { + const hostBanner = { + currentFileVersion: { id: '123' }, + onOpenFullPreview: jest.fn(), + previousFileVersion: { id: '456' }, + }; + const wrapper = getComparingWrapper({ comparisonBanner: hostBanner }); + + wrapper.childAt(0).props().registerVersionActions(versionActions); + wrapper.update(); + + const { main, compared } = getBanners(wrapper); + expect(compared).toBe(main); + expect(compared.currentFileVersion).toBe(hostBanner.currentFileVersion); + expect(compared.previousFileVersion).toBe(hostBanner.previousFileVersion); + expect(compared.onOpenFullPreview).toBe(hostBanner.onOpenFullPreview); + + compared.onDownload(); + expect(versionActions.download).toHaveBeenCalledWith('456'); + }); + + test('should keep only the host fields when the selected version has no actions', () => { + const hostBanner = { previousFileVersion: { id: '456' } }; + const wrapper = getComparingWrapper({ comparisonBanner: hostBanner }); + + wrapper + .childAt(0) + .props() + .registerVersionActions({ ...versionActions, versions: [] }); + wrapper.update(); + + expect(getBanners(wrapper).compared).toEqual(hostBanner); + }); + + test('should fall back to the host banner once version actions unregister', () => { + const hostBanner = { previousFileVersion: { id: '456' } }; + const wrapper = getComparingWrapper({ comparisonBanner: hostBanner }); + + wrapper.childAt(0).props().registerVersionActions(versionActions); + wrapper.update(); + wrapper.childAt(0).props().registerVersionActions(null); + wrapper.update(); + + expect(getBanners(wrapper).compared).toBe(hostBanner); + }); + }); + test('should not navigate when isComparing', () => { const wrapper = getWrapper({ fileId: '456', diff --git a/src/elements/content-sidebar/versions/VersionsItem.js b/src/elements/content-sidebar/versions/VersionsItem.js index 4569a0e133..2a72b45d24 100644 --- a/src/elements/content-sidebar/versions/VersionsItem.js +++ b/src/elements/content-sidebar/versions/VersionsItem.js @@ -13,6 +13,7 @@ import VersionsItemActions from './VersionsItemActions'; import VersionsItemButton from './VersionsItemButton'; import VersionsItemBadge from './VersionsItemBadge'; import VersionsItemRetention from './VersionsItemRetention'; +import { getVersionItemActionVisibility } from './versionItemActionVisibility'; import { ReadableTime } from '../../../components/time'; import { FILE_REQUEST_NAME, @@ -47,7 +48,6 @@ const ACTION_MAP = { [VERSION_PROMOTE_ACTION]: messages.versionPromotedBy, [VERSION_UPLOAD_ACTION]: messages.versionUploadedBy, }; -const FILE_EXTENSIONS_OFFICE = ['xlsb', 'xlsm', 'xlsx']; const FIVE_MINUTES_MS = 5 * 60 * 1000; const VersionsItem = ({ @@ -67,10 +67,7 @@ const VersionsItem = ({ }: Props) => { const { created_at: createdAt, - extension, id: versionId, - is_download_available, - permissions = {}, restored_at: restoredAt, retention, size, @@ -78,13 +75,9 @@ const VersionsItem = ({ version_number: versionNumber, version_promoted: versionPromoted, } = version; - const { can_delete, can_download, can_preview, can_upload } = permissions; - const { applied_at: retentionAppliedAt, disposition_at: retentionDispositionAt } = retention || {}; - const retentionDispositionAtDate = retentionDispositionAt && new Date(retentionDispositionAt); // Version info helpers const versionAction = selectors.getVersionAction(version); - const versionInteger = versionNumber ? parseInt(versionNumber, 10) : 1; const versionTime = restoredAt || trashedAt || createdAt; const versionTimestamp = versionTime && new Date(versionTime).getTime(); const versionUserName = selectors.getVersionUser(version).name || ( @@ -96,22 +89,26 @@ const VersionsItem = ({ ) : ( ); - // Version state helpers - const isDeleted = versionAction === VERSION_DELETE_ACTION; - const isDownloadable = !!is_download_available; - const isLimited = versionCount - versionInteger >= versionLimit; - const isOffice = FILE_EXTENSIONS_OFFICE.includes(extension); - const isRestricted = (isOffice || isWatermarked) && !isCurrent; - const isRetained = !!retentionAppliedAt && (!retentionDispositionAtDate || retentionDispositionAtDate > new Date()); - - // Version action helpers - const canPreview = can_preview && !isDeleted && !isLimited && !isRestricted; - const showDelete = can_delete && !isDeleted && !isArchived && !isCurrent; - const showDownload = can_download && !isDeleted && isDownloadable; - const showPromote = can_upload && !isDeleted && !isArchived && !isCurrent; - const showRestore = can_delete && isDeleted && !isArchived; - const showPreview = canPreview && !isSelected; - const hasActions = showDelete || showDownload || showPreview || showPromote || showRestore; + const { + canPreview, + hasActions, + isLimited, + isRetained, + showDelete, + showDownload, + showPreview, + showPromote, + showRestore, + } = getVersionItemActionVisibility({ + isArchived, + isCurrent, + isSelected, + isWatermarked, + version, + versionAction, + versionCount, + versionLimit, + }); // Version action callback helper const handleAction = (handler?: VersionActionCallback) => (): void => { diff --git a/src/elements/content-sidebar/versions/VersionsSidebarContainer.js b/src/elements/content-sidebar/versions/VersionsSidebarContainer.js index 2db8a9626a..395fc3c62f 100644 --- a/src/elements/content-sidebar/versions/VersionsSidebarContainer.js +++ b/src/elements/content-sidebar/versions/VersionsSidebarContainer.js @@ -23,7 +23,12 @@ import VersionsSidebarAPI from './VersionsSidebarAPI'; import { withAPIContext } from '../../common/api-context'; import { withRouterIfEnabled } from '../../common/routing'; import type { FeatureConfig } from '../../common/feature-checking'; -import type { VersionActionCallback, VersionChangeCallback, SidebarLoadCallback } from './flowTypes'; +import type { + RegisterVersionActionsCallback, + VersionActionCallback, + VersionChangeCallback, + SidebarLoadCallback, +} from './flowTypes'; import type { BoxItemVersion, BoxItem, FileVersions } from '../../../common/types/core'; import { ViewType, @@ -50,6 +55,7 @@ type Props = { onVersionPromote: VersionActionCallback, onVersionRestore: VersionActionCallback, parentName: ViewTypeValues, + registerVersionActions?: RegisterVersionActionsCallback, routerDisabled?: boolean, versionId?: string, }; @@ -91,17 +97,57 @@ class VersionsSidebarContainer extends React.Component { window: any = window; + publishVersionActions = (prevState?: State) => { + const { registerVersionActions } = this.props; + const { isArchived, isWatermarked, versionCount, versionLimit, versions } = this.state; + + if (!registerVersionActions) { + return; + } + + // With prevState, only publish when something the actions depend on changed. + if ( + prevState && + prevState.versions === versions && + prevState.isArchived === isArchived && + prevState.isWatermarked === isWatermarked && + prevState.versionCount === versionCount && + prevState.versionLimit === versionLimit + ) { + return; + } + + registerVersionActions({ + delete: this.handleActionDelete, + download: this.handleActionDownload, + promote: this.handleActionPromote, + isArchived, + isWatermarked, + versionCount, + versionLimit, + versions, + }); + }; + componentDidMount() { const { onLoad } = this.props; + this.publishVersionActions(); this.initialize(); this.fetchData().then(() => { onLoad({ component: 'preview', feature: 'versions' }); }); } - componentDidUpdate({ fileId: prevFileId, versionId: prevVersionId }: Props) { + componentWillUnmount() { + const { registerVersionActions } = this.props; + registerVersionActions?.(null); + } + + componentDidUpdate({ fileId: prevFileId, versionId: prevVersionId }: Props, prevState: State) { const { fileId, versionId } = this.props; + this.publishVersionActions(prevState); + if (fileId !== prevFileId) { this.refresh(); } diff --git a/src/elements/content-sidebar/versions/__tests__/VersionsSidebarContainer.test.js b/src/elements/content-sidebar/versions/__tests__/VersionsSidebarContainer.test.js index 9bc1b1ca59..5c7c8a52ce 100644 --- a/src/elements/content-sidebar/versions/__tests__/VersionsSidebarContainer.test.js +++ b/src/elements/content-sidebar/versions/__tests__/VersionsSidebarContainer.test.js @@ -98,6 +98,61 @@ describe('elements/content-sidebar/versions/VersionsSidebarContainer', () => { expect(onLoad).toHaveBeenCalledTimes(1); }); }); + + test('should register version actions and clear them on unmount', () => { + const registerVersionActions = jest.fn(); + const { unmount } = renderComponent({ registerVersionActions }); + + expect(registerVersionActions).toHaveBeenCalledWith( + expect.objectContaining({ + delete: expect.any(Function), + download: expect.any(Function), + promote: expect.any(Function), + isArchived: false, + isWatermarked: false, + versionCount: Infinity, + versionLimit: Infinity, + versions: [], + }), + ); + + unmount(); + + expect(registerVersionActions).toHaveBeenLastCalledWith(null); + }); + + test('should republish version actions when the version list loads', async () => { + const loadedVersions = [ + { + id: '123', + created_at: '2023-01-02T00:00:00Z', + permissions: { can_download: true }, + }, + { + id: '456', + created_at: '2023-01-01T00:00:00Z', + permissions: { can_delete: true, can_download: true, can_upload: true }, + }, + ]; + versionsAPI.addPermissions.mockReturnValueOnce({ + entries: loadedVersions, + total_count: 2, + }); + const registerVersionActions = jest.fn(); + + renderComponent({ registerVersionActions }); + + await waitFor(() => { + expect(registerVersionActions).toHaveBeenLastCalledWith( + expect.objectContaining({ + isArchived: false, + isWatermarked: false, + versionCount: 2, + versions: loadedVersions, + }), + ); + }); + }); }); describe('handleActionDelete', () => { diff --git a/src/elements/content-sidebar/versions/__tests__/versionItemActionVisibility.test.js b/src/elements/content-sidebar/versions/__tests__/versionItemActionVisibility.test.js new file mode 100644 index 0000000000..a7e7351704 --- /dev/null +++ b/src/elements/content-sidebar/versions/__tests__/versionItemActionVisibility.test.js @@ -0,0 +1,123 @@ +import { selectComparisonBannerActions } from '../versionItemActionVisibility'; + +describe('elements/content-sidebar/versions/versionItemActionVisibility', () => { + const handlers = { + delete: jest.fn(() => Promise.resolve()), + download: jest.fn(() => Promise.resolve()), + promote: jest.fn(() => Promise.resolve()), + }; + const permissions = { + can_delete: true, + can_download: true, + can_preview: true, + can_upload: true, + }; + const currentVersion = { + extension: 'docx', + id: '123', + is_download_available: true, + permissions, + version_number: '3', + }; + const comparedVersion = { + extension: 'docx', + id: '456', + is_download_available: true, + permissions, + version_number: '2', + }; + + const getActions = (overrides = {}) => ({ + ...handlers, + isArchived: false, + isWatermarked: false, + versionCount: 2, + versionLimit: Infinity, + versions: [currentVersion, comparedVersion], + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should omit every action until the version is in the list', () => { + expect(selectComparisonBannerActions(getActions(), 'missing')).toEqual({}); + }); + + test('should offer download, make current, and delete for an older version the row can act on', async () => { + const selected = selectComparisonBannerActions(getActions(), '456'); + + expect(selected.onDownload).toEqual(expect.any(Function)); + expect(selected.onMakeCurrent).toEqual(expect.any(Function)); + expect(selected.onDelete).toEqual(expect.any(Function)); + + await selected.onDownload(); + await selected.onMakeCurrent(); + await selected.onDelete(); + + expect(handlers.download).toHaveBeenCalledWith('456'); + expect(handlers.promote).toHaveBeenCalledWith('456'); + expect(handlers.delete).toHaveBeenCalledWith('456'); + }); + + test('should offer only download for the current version', () => { + const selected = selectComparisonBannerActions(getActions(), '123'); + + expect(selected.onDownload).toEqual(expect.any(Function)); + expect(selected.onMakeCurrent).toBeUndefined(); + expect(selected.onDelete).toBeUndefined(); + }); + + test('should omit an action the version permissions do not allow', () => { + const version = { + ...comparedVersion, + is_download_available: false, + permissions: { can_delete: false, can_download: true, can_upload: false }, + }; + const selected = selectComparisonBannerActions(getActions({ versions: [currentVersion, version] }), '456'); + + expect(selected).toEqual({}); + }); + + test('should omit download, make current, and delete for a deleted version', () => { + const version = { ...comparedVersion, trashed_at: '2019-04-01T00:00:00Z' }; + const selected = selectComparisonBannerActions(getActions({ versions: [currentVersion, version] }), '456'); + + expect(selected).toEqual({}); + }); + + test('should omit make current and delete when the file is archived', () => { + const selected = selectComparisonBannerActions(getActions({ isArchived: true }), '456'); + + expect(selected.onDownload).toEqual(expect.any(Function)); + expect(selected.onMakeCurrent).toBeUndefined(); + expect(selected.onDelete).toBeUndefined(); + }); + + test('should omit delete when the version is retained', () => { + const version = { + ...comparedVersion, + retention: { applied_at: '2019-01-01T00:00:00Z' }, + }; + const selected = selectComparisonBannerActions(getActions({ versions: [currentVersion, version] }), '456'); + + expect(selected.onDownload).toEqual(expect.any(Function)); + expect(selected.onMakeCurrent).toEqual(expect.any(Function)); + expect(selected.onDelete).toBeUndefined(); + }); + + test('should omit every action when the version is past the version limit', () => { + const version = { ...comparedVersion, version_number: '1' }; + const selected = selectComparisonBannerActions( + getActions({ + versionCount: 10, + versionLimit: 5, + versions: [currentVersion, version], + }), + '456', + ); + + expect(selected).toEqual({}); + }); +}); diff --git a/src/elements/content-sidebar/versions/flowTypes.js b/src/elements/content-sidebar/versions/flowTypes.js index ec68d90fb7..6d641c3e6c 100644 --- a/src/elements/content-sidebar/versions/flowTypes.js +++ b/src/elements/content-sidebar/versions/flowTypes.js @@ -3,5 +3,27 @@ import type { AdditionalVersionInfo } from '../../common/flowTypes'; import type { BoxItemVersion } from '../../../common/types/core'; export type VersionActionCallback = (versionId: string) => void; +export type ComparisonVersionActions = { + delete: (versionId: string) => Promise, + download: (versionId: string) => Promise, + promote: (versionId: string) => Promise, + isArchived: boolean, + isWatermarked: boolean, + versionCount: number, + versionLimit: number, + versions: Array, +}; +export type RegisterVersionActionsCallback = (actions: ComparisonVersionActions | null) => void; +export type ComparisonBannerVersionActions = { + onDelete?: () => Promise, + onDownload?: () => Promise, + onMakeCurrent?: () => Promise, + ... +}; +export type ComparisonBannerProps = { + ...ComparisonBannerVersionActions, + onOpenFullPreview?: () => mixed, + ... +}; export type VersionChangeCallback = (version: ?BoxItemVersion, additionalVersionInfo: ?AdditionalVersionInfo) => void; export type SidebarLoadCallback = (data: Object) => void; diff --git a/src/elements/content-sidebar/versions/versionItemActionVisibility.js b/src/elements/content-sidebar/versions/versionItemActionVisibility.js new file mode 100644 index 0000000000..ea83b9efbf --- /dev/null +++ b/src/elements/content-sidebar/versions/versionItemActionVisibility.js @@ -0,0 +1,124 @@ +/** + * @flow + * @file Which version-row actions are available. The comparison banner derives its handlers from the same result. + * @author Box + */ + +import selectors from '../../common/selectors/version'; +import { VERSION_DELETE_ACTION } from '../../../constants'; +import type { BoxItemVersion } from '../../../common/types/core'; +import type { ComparisonBannerVersionActions, ComparisonVersionActions } from './flowTypes'; + +const FILE_EXTENSIONS_OFFICE = ['xlsb', 'xlsm', 'xlsx']; + +export type VersionItemActionVisibility = {| + canPreview: boolean, + hasActions: boolean, + isLimited: boolean, + isRetained: boolean, + showDelete: boolean, + showDownload: boolean, + showPreview: boolean, + showPromote: boolean, + showRestore: boolean, +|}; + +type VisibilityArgs = {| + isArchived?: boolean, + isCurrent?: boolean, + isSelected?: boolean, + isWatermarked?: boolean, + version: BoxItemVersion, + versionAction: string, + versionCount: number, + versionLimit: number, +|}; + +export function getVersionItemActionVisibility({ + isArchived = false, + isCurrent = false, + isSelected = false, + isWatermarked = false, + version, + versionAction, + versionCount, + versionLimit, +}: VisibilityArgs): VersionItemActionVisibility { + const { + extension, + is_download_available: isDownloadAvailable, + permissions = {}, + retention, + version_number: versionNumber, + } = version; + const { + can_delete: canDelete, + can_download: canDownload, + can_preview: canPreviewPermission, + can_upload: canUpload, + } = permissions; + const { applied_at: retentionAppliedAt, disposition_at: retentionDispositionAt } = retention || {}; + const retentionDispositionAtDate = retentionDispositionAt && new Date(retentionDispositionAt); + const versionInteger = versionNumber ? parseInt(versionNumber, 10) : 1; + const isDeleted = versionAction === VERSION_DELETE_ACTION; + const isLimited = versionCount - versionInteger >= versionLimit; + const isOffice = FILE_EXTENSIONS_OFFICE.includes(extension); + const isRestricted = (isOffice || isWatermarked) && !isCurrent; + const isRetained = !!retentionAppliedAt && (!retentionDispositionAtDate || retentionDispositionAtDate > new Date()); + const canPreview = canPreviewPermission && !isDeleted && !isLimited && !isRestricted; + const showDelete = !!(canDelete && !isDeleted && !isArchived && !isCurrent); + const showDownload = !!(canDownload && !isDeleted && isDownloadAvailable); + const showPromote = !!(canUpload && !isDeleted && !isArchived && !isCurrent); + const showRestore = !!(canDelete && isDeleted && !isArchived); + const showPreview = !!(canPreview && !isSelected); + const hasActions = showDelete || showDownload || showPreview || showPromote || showRestore; + + return { + canPreview: !!canPreview, + hasActions, + isLimited, + isRetained, + showDelete, + showDownload, + showPreview, + showPromote, + showRestore, + }; +} + +/** + * Handlers for the comparison banner. An action is omitted when the row would not offer it, and Delete is + * also omitted for retained versions (the row disables it). + */ +export function selectComparisonBannerActions( + versionActions: ComparisonVersionActions, + versionId: string, +): ComparisonBannerVersionActions { + const { isArchived, isWatermarked, versionCount, versionLimit, versions } = versionActions; + const version = versions.find(item => item.id === versionId); + + if (!version) { + return {}; + } + + const currentId = versions[0] ? versions[0].id : undefined; + const { isLimited, isRetained, showDelete, showDownload, showPromote } = getVersionItemActionVisibility({ + isArchived, + isCurrent: currentId === version.id, + isWatermarked, + version, + versionAction: selectors.getVersionAction(version), + versionCount, + versionLimit, + }); + + if (isLimited) { + return {}; + } + + return { + ...(showDelete && !isRetained ? { onDelete: () => versionActions.delete(versionId) } : {}), + ...(showDownload ? { onDownload: () => versionActions.download(versionId) } : {}), + ...(showPromote ? { onMakeCurrent: () => versionActions.promote(versionId) } : {}), + }; +}