diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index 43221fa39c..35086c9291 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -43,13 +43,23 @@ function ContentSharingV2({ const [avatarURLMap, setAvatarURLMap] = React.useState(null); const [item, setItem] = React.useState(null); const [sharedLink, setSharedLink] = React.useState(null); + const [sharingServiceProps, setSharingServiceProps] = React.useState(null); const [currentUser, setCurrentUser] = React.useState(null); const [collaborationRoles, setCollaborationRoles] = React.useState(null); const [collaborators, setCollaborators] = React.useState(null); const [collaboratorsData, setCollaboratorsData] = React.useState(null); const [owner, setOwner] = React.useState({ id: '', email: '', name: '' }); - const { sharingService } = useSharingService(api, item, itemID, itemType, setItem, setSharedLink); + const { sharingService } = useSharingService({ + api, + item, + itemId: itemID, + itemType, + sharedLink, + sharingServiceProps, + setItem, + setSharedLink, + }); // Handle successful GET requests to /files or /folders const handleGetItemSuccess = React.useCallback(itemData => { @@ -58,10 +68,12 @@ function ContentSharingV2({ item: itemFromApi, ownedBy, sharedLink: sharedLinkFromApi, + sharingService: sharingServicePropsFromApi, } = convertItemResponse(itemData); setItem(itemFromApi); setSharedLink(sharedLinkFromApi); + setSharingServiceProps(sharingServicePropsFromApi); setCollaborationRoles(collaborationRolesFromApi); setOwner({ id: ownedBy.id, email: ownedBy.login, name: ownedBy.name }); }, []); @@ -92,11 +104,12 @@ function ContentSharingV2({ if (!api || isEmpty(api) || !item || currentUser) return; const getUserSuccess = userData => { - const { enterprise, id } = userData; + const { id, enterprise, hostname } = userData; setCurrentUser({ id, enterprise: { name: enterprise ? enterprise.name : '' }, }); + setSharedLink(prevSharedLink => ({ ...prevSharedLink, serverURL: hostname ? `${hostname}v/` : '' })); }; (async () => { diff --git a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx index a1d06a2214..ca87729ce2 100644 --- a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx +++ b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx @@ -158,6 +158,7 @@ describe('elements/content-sharing/ContentSharingV2', () => { test('should render UnifiedShareModal when sharingService is available', async () => { const mockSharingService = { changeSharedLinkPermission: jest.fn(), + updateSharedLink: jest.fn(), }; (useSharingService as jest.Mock).mockReturnValue({ diff --git a/src/elements/content-sharing/__tests__/sharingService.test.ts b/src/elements/content-sharing/__tests__/sharingService.test.ts index 6936f7e976..83b5c973b7 100644 --- a/src/elements/content-sharing/__tests__/sharingService.test.ts +++ b/src/elements/content-sharing/__tests__/sharingService.test.ts @@ -1,39 +1,38 @@ import { PERMISSION_CAN_DOWNLOAD, PERMISSION_CAN_PREVIEW } from '../../../constants'; import { CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS } from '../constants'; -import { convertSharedLinkPermissions, createSharingService } from '../sharingService'; +import { createSharingService } from '../sharingService'; +import { convertSharedLinkPermissions, convertSharedLinkSettings } from '../utils'; + +jest.mock('../utils'); + +const mockItemApiInstance = { + updateSharedLink: jest.fn(), +}; +const options = { id: '123', permissions: { can_set_share_access: true, can_share: true } }; +const mockOnSuccess = jest.fn(); describe('elements/content-sharing/sharingService', () => { - describe('convertSharedLinkPermissions', () => { - test.each([ - [PERMISSION_CAN_DOWNLOAD, { [PERMISSION_CAN_DOWNLOAD]: true, [PERMISSION_CAN_PREVIEW]: false }], - [PERMISSION_CAN_PREVIEW, { [PERMISSION_CAN_DOWNLOAD]: false, [PERMISSION_CAN_PREVIEW]: true }], - ])('should return correct permissions for download permission level', (permissionLevel, expected) => { - const result = convertSharedLinkPermissions(permissionLevel); - expect(result).toEqual(expected); + beforeEach(() => { + (convertSharedLinkPermissions as jest.Mock).mockReturnValue({ + [PERMISSION_CAN_DOWNLOAD]: true, + [PERMISSION_CAN_PREVIEW]: false, }); - - test('should handle empty string permission level', () => { - const result = convertSharedLinkPermissions(''); - expect(result).toEqual({}); + (convertSharedLinkSettings as jest.Mock).mockReturnValue({ + unshared_at: null, + vanity_url: 'https://example.com/vanity-url', }); }); - describe('createSharingService', () => { - const mockItemApiInstance = { - updateSharedLink: jest.fn(), - }; - const mockItemData = { id: '123' }; - const mockOnSuccess = jest.fn(); - - afterEach(() => { - jest.clearAllMocks(); - }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('changeSharedLinkPermission', () => { test('should return an object with changeSharedLinkPermission method', () => { const service = createSharingService({ itemApiInstance: mockItemApiInstance, - itemData: mockItemData, onSuccess: mockOnSuccess, + options, }); expect(service).toHaveProperty('changeSharedLinkPermission'); @@ -43,8 +42,8 @@ describe('elements/content-sharing/sharingService', () => { test('should call updateSharedLink with correct parameters when changeSharedLinkPermission is called', async () => { const service = createSharingService({ itemApiInstance: mockItemApiInstance, - itemData: mockItemData, onSuccess: mockOnSuccess, + options, }); const permissionLevel = PERMISSION_CAN_DOWNLOAD; @@ -56,7 +55,7 @@ describe('elements/content-sharing/sharingService', () => { await service.changeSharedLinkPermission(permissionLevel); expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( - mockItemData, + options, { permissions: expectedPermissions }, mockOnSuccess, {}, @@ -64,4 +63,124 @@ describe('elements/content-sharing/sharingService', () => { ); }); }); + + describe('updateSharedLink', () => { + test('should return an object with updateSharedLink method', () => { + const service = createSharingService({ + itemApiInstance: mockItemApiInstance, + onSuccess: mockOnSuccess, + options, + }); + + expect(service).toHaveProperty('updateSharedLink'); + expect(typeof service.updateSharedLink).toBe('function'); + }); + + test('should call updateSharedLink with basic shared link settings', async () => { + const service = createSharingService({ + itemApiInstance: mockItemApiInstance, + onSuccess: mockOnSuccess, + options, + }); + + const sharedLinkSettings = { + expiration: null, + isDownloadEnabled: true, + isExpirationEnabled: false, + isPasswordEnabled: false, + password: '', + vanityName: 'vanity-name', + }; + + const expectedConvertedSettings = { + unshared_at: null, + vanity_url: 'https://example.com/vanity-url', + }; + + await service.updateSharedLink(sharedLinkSettings); + + expect(convertSharedLinkSettings).toHaveBeenCalledWith( + sharedLinkSettings, + undefined, // access + undefined, // isDownloadAvailable + undefined, // serverURL + ); + expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( + options, + expectedConvertedSettings, + mockOnSuccess, + {}, + CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS, + ); + }); + + test('should call updateSharedLink with options including access, isDownloadAvailable, and serverURL', async () => { + const mockConvertedSharedLinkSettings = { + password: 'test-password', + permissions: { can_download: false, can_preview: true }, + unshared_at: null, + vanity_url: 'https://example.com/vanity-url', + }; + + (convertSharedLinkSettings as jest.Mock).mockReturnValue(mockConvertedSharedLinkSettings); + + const service = createSharingService({ + itemApiInstance: mockItemApiInstance, + onSuccess: mockOnSuccess, + options: { + ...options, + access: 'open', + isDownloadAvailable: true, + serverURL: 'https://example.com/server-url', + }, + }); + + const sharedLinkSettings = { + expiration: null, + isDownloadEnabled: false, + isExpirationEnabled: true, + isPasswordEnabled: true, + password: 'test-password', + vanityName: 'vanity-name', + }; + + await service.updateSharedLink(sharedLinkSettings); + + expect(convertSharedLinkSettings).toHaveBeenCalledWith( + sharedLinkSettings, + 'open', + true, + 'https://example.com/server-url', + ); + expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( + options, + mockConvertedSharedLinkSettings, + mockOnSuccess, + {}, + CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS, + ); + }); + + test('should handle shared link settings correctly', async () => { + const service = createSharingService({ + itemApiInstance: mockItemApiInstance, + onSuccess: mockOnSuccess, + options, + }); + + const expirationDate = new Date('2024-12-31T23:59:59Z'); + const sharedLinkSettings = { + expiration: expirationDate, + isDownloadEnabled: false, + isExpirationEnabled: true, + isPasswordEnabled: false, + password: 'test-password', + vanityName: 'vanity-name', + }; + + await service.updateSharedLink(sharedLinkSettings); + + expect(convertSharedLinkSettings).toHaveBeenCalledWith(sharedLinkSettings, undefined, undefined, undefined); + }); + }); }); diff --git a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts index d0d6eb3567..1e375a9210 100644 --- a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts +++ b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts @@ -26,11 +26,42 @@ const mockItem = { can_download: true, can_preview: false, }, + sharingServiceProps: { + can_set_share_access: true, + can_share: true, + }, +}; +const mockSharedLink = { + access: 'open', + serverURL: 'https://example.com/server-url', + settings: { + isDownloadAvailable: true, + }, +}; +const mockSharingServiceProps = { + can_set_share_access: true, + can_share: true, }; const mockSetItem = jest.fn(); const mockSetSharedLink = jest.fn(); +const renderHookWithProps = (props = {}) => { + return renderHook(() => + useSharingService({ + api: mockApi, + item: mockItem, + itemId: mockItemId, + itemType: TYPE_FILE, + sharedLink: mockSharedLink, + sharingServiceProps: mockSharingServiceProps, + setItem: mockSetItem, + setSharedLink: mockSetSharedLink, + ...props, + }), + ); +}; + describe('elements/content-sharing/hooks/useSharingService', () => { beforeEach(() => { (createSharingService as jest.Mock).mockReturnValue(mockSharingService); @@ -45,9 +76,16 @@ describe('elements/content-sharing/hooks/useSharingService', () => { }); test('should return null itemApiInstance and sharingService when item is null', () => { - const { result } = renderHook(() => - useSharingService(mockApi, null, mockItemId, TYPE_FILE, mockSetItem, mockSetSharedLink), - ); + const { result } = renderHookWithProps({ item: null }); + + expect(result.current.sharingService).toBeNull(); + expect(mockApi.getFileAPI).not.toHaveBeenCalled(); + expect(mockApi.getFolderAPI).not.toHaveBeenCalled(); + expect(createSharingService).not.toHaveBeenCalled(); + }); + + test('should return null itemApiInstance and sharingService when sharedLink is null', () => { + const { result } = renderHookWithProps({ sharedLink: null }); expect(result.current.sharingService).toBeNull(); expect(mockApi.getFileAPI).not.toHaveBeenCalled(); @@ -56,9 +94,7 @@ describe('elements/content-sharing/hooks/useSharingService', () => { }); test('should return null itemApiInstance and sharingService when itemType is neither TYPE_FILE nor TYPE_FOLDER', () => { - const { result } = renderHook(() => - useSharingService(mockApi, mockItem, mockItemId, 'hubs', mockSetItem, mockSetSharedLink), - ); + const { result } = renderHookWithProps({ itemType: 'hubs' }); expect(result.current.sharingService).toBeNull(); expect(mockApi.getFileAPI).not.toHaveBeenCalled(); @@ -72,20 +108,21 @@ describe('elements/content-sharing/hooks/useSharingService', () => { }); test('should create file API instance and sharing service', () => { - const { result } = renderHook(() => - useSharingService(mockApi, mockItem, mockItemId, TYPE_FILE, mockSetItem, mockSetSharedLink), - ); + const { result } = renderHookWithProps(); expect(mockApi.getFileAPI).toHaveBeenCalled(); expect(mockApi.getFolderAPI).not.toHaveBeenCalled(); expect(result.current.sharingService).toBe(mockSharingService); expect(createSharingService).toHaveBeenCalledWith({ itemApiInstance: mockItemApiInstance, - itemData: { + onSuccess: expect.any(Function), + options: { + access: mockSharedLink.access, + isDownloadAvailable: mockSharedLink.settings.isDownloadAvailable, id: mockItemId, - permissions: mockItem.permissions, + permissions: mockItem.sharingServiceProps, + serverURL: mockSharedLink.serverURL, }, - onSuccess: expect.any(Function), }); }); @@ -93,15 +130,13 @@ describe('elements/content-sharing/hooks/useSharingService', () => { const mockConvertedData = { item: { id: mockItemId, - permissions: { can_download: false }, + permissions: { can_download: false, can_preview: true }, }, sharedLink: {}, }; (convertItemResponse as jest.Mock).mockReturnValue(mockConvertedData); - renderHook(() => - useSharingService(mockApi, mockItem, mockItemId, TYPE_FILE, mockSetItem, mockSetSharedLink), - ); + renderHookWithProps(); // Get the onSuccess callback that was passed to mock createSharingService const onSuccessCallback = (createSharingService as jest.Mock).mock.calls[0][0].onSuccess; @@ -118,20 +153,21 @@ describe('elements/content-sharing/hooks/useSharingService', () => { }); test('should create folder API instance and sharing service', () => { - const { result } = renderHook(() => - useSharingService(mockApi, mockItem, mockItemId, TYPE_FOLDER, mockSetItem, mockSetSharedLink), - ); + const { result } = renderHookWithProps({ itemType: TYPE_FOLDER }); expect(mockApi.getFolderAPI).toHaveBeenCalled(); expect(mockApi.getFileAPI).not.toHaveBeenCalled(); expect(result.current.sharingService).toBe(mockSharingService); expect(createSharingService).toHaveBeenCalledWith({ itemApiInstance: mockItemApiInstance, - itemData: { + onSuccess: expect.any(Function), + options: { + access: mockSharedLink.access, + isDownloadAvailable: mockSharedLink.settings.isDownloadAvailable, id: mockItemId, - permissions: mockItem.permissions, + permissions: mockItem.sharingServiceProps, + serverURL: mockSharedLink.serverURL, }, - onSuccess: expect.any(Function), }); }); }); diff --git a/src/elements/content-sharing/hooks/useSharingService.ts b/src/elements/content-sharing/hooks/useSharingService.ts index 571cc098a3..606b58da2d 100644 --- a/src/elements/content-sharing/hooks/useSharingService.ts +++ b/src/elements/content-sharing/hooks/useSharingService.ts @@ -4,9 +4,18 @@ import { TYPE_FILE, TYPE_FOLDER } from '../../../constants'; import { convertItemResponse } from '../utils/convertItemResponse'; import { createSharingService } from '../sharingService'; -export const useSharingService = (api, item, itemId, itemType, setItem, setSharedLink) => { +export const useSharingService = ({ + api, + item, + itemId, + itemType, + sharedLink, + sharingServiceProps, + setItem, + setSharedLink, +}) => { const itemApiInstance = React.useMemo(() => { - if (!item) { + if (!item || !sharedLink) { return null; } @@ -19,16 +28,19 @@ export const useSharingService = (api, item, itemId, itemType, setItem, setShare } return null; - }, [api, item, itemType]); + }, [api, item, itemType, sharedLink]); const sharingService = React.useMemo(() => { if (!itemApiInstance) { return null; } - const itemData = { + const options = { id: itemId, - permissions: item.permissions, + access: sharedLink.access, + permissions: sharingServiceProps, + serverURL: sharedLink.serverURL, + isDownloadAvailable: sharedLink.settings?.isDownloadAvailable ?? false, }; const handleSuccess = updatedItemData => { @@ -37,8 +49,8 @@ export const useSharingService = (api, item, itemId, itemType, setItem, setShare setSharedLink(prevSharedLink => ({ ...prevSharedLink, ...updatedSharedLink })); }; - return createSharingService({ itemApiInstance, itemData, onSuccess: handleSuccess }); - }, [itemApiInstance, item, itemId, setItem, setSharedLink]); + return createSharingService({ itemApiInstance, onSuccess: handleSuccess, options }); + }, [itemApiInstance, itemId, sharedLink, sharingServiceProps, setItem, setSharedLink]); return { sharingService }; }; diff --git a/src/elements/content-sharing/sharingService.ts b/src/elements/content-sharing/sharingService.ts index 8cf61123c7..18f1cdb569 100644 --- a/src/elements/content-sharing/sharingService.ts +++ b/src/elements/content-sharing/sharingService.ts @@ -1,21 +1,35 @@ -import { PERMISSION_CAN_DOWNLOAD, PERMISSION_CAN_PREVIEW } from '../../constants'; +import type { API } from '../../api'; import { CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS } from './constants'; +import { convertSharedLinkPermissions, convertSharedLinkSettings } from './utils'; -export const convertSharedLinkPermissions = (permissionLevel: string) => { - if (!permissionLevel) { - return {}; - } +import type { SharedLinkSettings } from './types'; - return { - [PERMISSION_CAN_DOWNLOAD]: permissionLevel === PERMISSION_CAN_DOWNLOAD, - [PERMISSION_CAN_PREVIEW]: permissionLevel === PERMISSION_CAN_PREVIEW, +export interface ItemData { + id: string; + permissions: { + can_set_share_access: boolean; + can_share: boolean; }; -}; +} + +export interface Options extends ItemData { + access?: string; + isDownloadAvailable?: boolean; + serverURL?: string; +} + +export interface CreateSharingServiceProps { + itemApiInstance: API; + onSuccess: (itemData: ItemData) => void; + options: Options; +} + +export const createSharingService = ({ itemApiInstance, onSuccess, options }: CreateSharingServiceProps) => { + const { id, permissions } = options; -export const createSharingService = ({ itemApiInstance, itemData, onSuccess }) => { const changeSharedLinkPermission = async (permissionLevel: string) => { return itemApiInstance.updateSharedLink( - itemData, + { id, permissions }, { permissions: convertSharedLinkPermissions(permissionLevel) }, onSuccess, {}, @@ -23,7 +37,20 @@ export const createSharingService = ({ itemApiInstance, itemData, onSuccess }) = ); }; + const updateSharedLink = async (sharedLinkSettings: SharedLinkSettings) => { + const { access, isDownloadAvailable, serverURL } = options; + + return itemApiInstance.updateSharedLink( + { id, permissions }, + convertSharedLinkSettings(sharedLinkSettings, access, isDownloadAvailable, serverURL), + onSuccess, + {}, + CONTENT_SHARING_SHARED_LINK_UPDATE_PARAMS, + ); + }; + return { changeSharedLinkPermission, + updateSharedLink, }; }; diff --git a/src/elements/content-sharing/types.js b/src/elements/content-sharing/types.js index 36ccb04004..3ccf79861c 100644 --- a/src/elements/content-sharing/types.js +++ b/src/elements/content-sharing/types.js @@ -1,5 +1,5 @@ // @flow -import type { CollaborationRole, Item, SharedLink } from '@box/unified-share-modal'; +import type { CollaborationRole, DateValue, Item, SharedLink } from '@box/unified-share-modal'; import API from '../../api'; import type { @@ -174,3 +174,12 @@ export interface FetchItemProps extends BaseFetchProps { export interface FetchCollaboratorsProps extends BaseFetchProps { collaborators: Collaboration[]; } + +export interface SharedLinkSettings { + expiration: ?DateValue; + isDownloadEnabled: boolean; + isExpirationEnabled: boolean; + isPasswordEnabled: boolean; + password: string; + vanityName: string; +} diff --git a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts new file mode 100644 index 0000000000..90dd47a845 --- /dev/null +++ b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts @@ -0,0 +1,206 @@ +import { + ACCESS_COLLAB, + ACCESS_COMPANY, + ACCESS_OPEN, + PERMISSION_CAN_DOWNLOAD, + PERMISSION_CAN_PREVIEW, +} from '../../../../constants'; +import { convertISOStringToUTCDate } from '../../../../utils/datetime'; +import { convertSharedLinkPermissions, convertSharedLinkSettings } from '../convertSharingServiceData'; + +jest.mock('../../../../utils/datetime'); + +describe('elements/content-sharing/utils/convertSharingServiceData', () => { + beforeEach(() => { + // Mock convertISOStringToUTCDate to return a the same date as the expiration date to simplify the test logic + (convertISOStringToUTCDate as jest.Mock).mockReturnValue(new Date('2024-12-31T23:59:59Z')); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('convertSharedLinkPermissions', () => { + test.each([ + [PERMISSION_CAN_DOWNLOAD, { [PERMISSION_CAN_DOWNLOAD]: true, [PERMISSION_CAN_PREVIEW]: false }], + [PERMISSION_CAN_PREVIEW, { [PERMISSION_CAN_DOWNLOAD]: false, [PERMISSION_CAN_PREVIEW]: true }], + ])('should return correct permissions for download permission level', (permissionLevel, expected) => { + const result = convertSharedLinkPermissions(permissionLevel); + expect(result).toEqual(expected); + }); + + test('should handle empty string permission level', () => { + const result = convertSharedLinkPermissions(''); + expect(result).toEqual({}); + }); + }); + + describe('convertSharedLinkSettings', () => { + const mockServerURL = 'https://example.com/server-url/'; + const mockSettings = { + expiration: new Date('2024-12-31T23:59:59Z'), + isDownloadEnabled: true, + isExpirationEnabled: true, + isPasswordEnabled: true, + password: 'test-password', + vanityName: 'vanity-name', + }; + + describe('basic conversion', () => { + test('should return settings with correct values', () => { + const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, true, mockServerURL); + + expect(result).toEqual({ + password: 'test-password', + permissions: { + can_preview: false, + can_download: true, + }, + unshared_at: '2024-12-31T23:59:59.000Z', + vanity_url: 'https://example.com/server-url/vanity-name', + }); + }); + + test('should handle minimal settings', () => { + const minimalSettings = { + expiration: null, + isDownloadEnabled: false, + isExpirationEnabled: false, + isPasswordEnabled: false, + password: '', + vanityName: '', + }; + + const result = convertSharedLinkSettings(minimalSettings, ACCESS_OPEN, false, ''); + + expect(result).toEqual({ + password: null, + permissions: { + can_preview: true, + }, + unshared_at: null, + vanity_url: '', + }); + }); + }); + + describe('expiration handling', () => { + test('should set unshared_at to null when expiration is disabled', () => { + const settingsWithoutExpiration = { + ...mockSettings, + isExpirationEnabled: false, + expiration: new Date('2024-12-31T23:59:59Z'), + }; + + const result = convertSharedLinkSettings(settingsWithoutExpiration, ACCESS_OPEN, true, mockServerURL); + + expect(result.unshared_at).toBeNull(); + }); + + test.each([null, undefined])('should set unshared_at to null when expiration is %s', expiration => { + const settingsWithNullExpiration = { + ...mockSettings, + isExpirationEnabled: true, + expiration, + }; + + const result = convertSharedLinkSettings(settingsWithNullExpiration, ACCESS_OPEN, true, mockServerURL); + + expect(result.unshared_at).toBeNull(); + }); + }); + + describe('vanity URL', () => { + test.each([ + ['', ''], + ['', 'vanity-name'], + ['https://example.com/server-url/', ''], + ])( + 'should return empty string when at least one of serverURL or vanityName is empty', + (serverURL, vanityName) => { + const result = convertSharedLinkSettings( + { ...mockSettings, vanityName }, + ACCESS_OPEN, + true, + serverURL, + ); + + expect(result.vanity_url).toBe(''); + }, + ); + }); + + describe('permissions handling', () => { + test('should not set permissions for ACCESS_COLLAB access level', () => { + const result = convertSharedLinkSettings(mockSettings, ACCESS_COLLAB, true, mockServerURL); + + expect(result.permissions).toBeUndefined(); + }); + + test('should set permissions with download disabled when isDownloadEnabled is false', () => { + const settingsWithDownloadDisabled = { + ...mockSettings, + isDownloadEnabled: false, + }; + + const result = convertSharedLinkSettings( + settingsWithDownloadDisabled, + ACCESS_OPEN, + true, + mockServerURL, + ); + + expect(result.permissions).toEqual({ + can_preview: true, + can_download: false, + }); + }); + + test('should not set can_download when isDownloadAvailable is false', () => { + const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, false, mockServerURL); + + expect(result.permissions).toEqual({ + can_preview: false, + }); + expect(result.permissions.can_download).toBeUndefined(); + }); + }); + + describe('password handling', () => { + test('should set password to null when isPasswordEnabled is false', () => { + const settingsWithPasswordDisabled = { + ...mockSettings, + isPasswordEnabled: false, + password: 'existingpassword', + }; + + const result = convertSharedLinkSettings( + settingsWithPasswordDisabled, + ACCESS_OPEN, + true, + mockServerURL, + ); + + expect(result.password).toBeNull(); + }); + + test('should not set password when isPasswordEnabled is true but password is empty', () => { + const settingsWithEmptyPassword = { + ...mockSettings, + isPasswordEnabled: true, + password: '', + }; + + const result = convertSharedLinkSettings(settingsWithEmptyPassword, ACCESS_OPEN, true, mockServerURL); + + expect(result.password).toBeUndefined(); + }); + + test.each([ACCESS_COLLAB, ACCESS_COMPANY])('should not set password for non open access level', access => { + const result = convertSharedLinkSettings(mockSettings, access, true, mockServerURL); + + expect(result.password).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/elements/content-sharing/utils/convertItemResponse.ts b/src/elements/content-sharing/utils/convertItemResponse.ts index 51d1be9060..7c72e86ecb 100644 --- a/src/elements/content-sharing/utils/convertItemResponse.ts +++ b/src/elements/content-sharing/utils/convertItemResponse.ts @@ -65,14 +65,14 @@ export const convertItemResponse = (itemAPIData: ContentSharingItemAPIResponse): allowed_shared_link_access_levels, allowed_shared_link_access_levels_disabled_reasons, ), - expiresAt: expirationTimestamp, + expiresAt: expirationTimestamp ? new Date(expirationTimestamp).getTime() : undefined, // convert to milliseconds permission, permissionLevels: getAllowedPermissionLevels(canChangeAccessLevel, isDownloadSettingAvailable, permission), settings: { canChangeDownload, canChangeExpiration, canChangePassword, - canChangeVanityName: false, // vanity URLs cannot be set via the API, + canChangeVanityName: false, // vanity URLs cannot be set via the API isDownloadAvailable: isDownloadSettingAvailable, isDownloadEnabled: isDownloadAllowed, isPasswordAvailable: isPasswordAvailable ?? false, diff --git a/src/elements/content-sharing/utils/convertSharingServiceData.ts b/src/elements/content-sharing/utils/convertSharingServiceData.ts new file mode 100644 index 0000000000..8d6a3ebeea --- /dev/null +++ b/src/elements/content-sharing/utils/convertSharingServiceData.ts @@ -0,0 +1,83 @@ +import { ACCESS_COLLAB, ACCESS_OPEN, PERMISSION_CAN_DOWNLOAD, PERMISSION_CAN_PREVIEW } from '../../../constants'; +import { convertISOStringToUTCDate } from '../../../utils/datetime'; + +import type { SharedLinkSettings } from '../types'; + +export interface ConvertSharedLinkSettingsReturnType { + password?: string | null; + permissions?: { + can_download?: boolean; + can_preview: boolean; + }; + unshared_at: string | null; + vanity_url: string; +} + +export const convertSharedLinkPermissions = (permissionLevel: string) => { + if (!permissionLevel) { + return {}; + } + + return { + [PERMISSION_CAN_DOWNLOAD]: permissionLevel === PERMISSION_CAN_DOWNLOAD, + [PERMISSION_CAN_PREVIEW]: permissionLevel === PERMISSION_CAN_PREVIEW, + }; +}; + +/** + * Convert a shared link settings object from the USM into the format that the API expects. + * This function compares the provided access level to both API and internal USM access level constants, to accommodate two potential flows: + * - Changing the settings for a shared link right after the shared link has been created. The access level is saved directly from the data + * returned by the API, so it is in API format. + * - Changing the settings for a shared link in any other scenario. The access level is saved from the initial calls to the Item API and + * convertItemResponse, so it is in internal USM format. + */ +export const convertSharedLinkSettings = ( + newSettings: SharedLinkSettings, + accessLevel: string, + isDownloadAvailable: boolean, + serverURL: string, +): ConvertSharedLinkSettingsReturnType => { + const { expiration, isDownloadEnabled, isExpirationEnabled, isPasswordEnabled, password, vanityName } = newSettings; + + const convertedSettings: ConvertSharedLinkSettingsReturnType = { + unshared_at: + expiration && isExpirationEnabled + ? convertISOStringToUTCDate(new Date(expiration).toISOString()).toISOString() + : null, + vanity_url: serverURL && vanityName ? `${serverURL}${vanityName}` : '', + }; + + // Download permissions can only be set on "company" or "open" shared links. + if (accessLevel !== ACCESS_COLLAB) { + const permissions: ConvertSharedLinkSettingsReturnType['permissions'] = { can_preview: !isDownloadEnabled }; + if (isDownloadAvailable) { + permissions.can_download = isDownloadEnabled; + } + + (convertedSettings as ConvertSharedLinkSettingsReturnType).permissions = permissions; + } + + /** + * This block covers the following cases: + * - Setting a new password: "isPasswordEnabled" is true, and "password" is a non-empty string. + * - Removing a password: "isPasswordEnabled" is false, and "password" is an empty string. + * The API only accepts non-empty strings and null values, so the empty string must be converted to null. + * + * Other notes: + * - Passwords can only be set on "open" shared links. + * - Attempting to set the password field on any other type of shared link will throw a 400 error. + * - When other settings are updated, and a password has already been set, the SharedLinkSettingsModal + * returns password = '' and isPasswordEnabled = true. In these cases, the password should *not* + * be converted to null, because that would remove the existing password. + */ + if (accessLevel === ACCESS_OPEN) { + if (isPasswordEnabled && !!password) { + convertedSettings.password = password; + } else if (!isPasswordEnabled) { + convertedSettings.password = null; + } + } + + return convertedSettings; +}; diff --git a/src/elements/content-sharing/utils/index.ts b/src/elements/content-sharing/utils/index.ts index 0e191c227a..287b01c3e8 100644 --- a/src/elements/content-sharing/utils/index.ts +++ b/src/elements/content-sharing/utils/index.ts @@ -1,4 +1,5 @@ export { convertItemResponse } from './convertItemResponse'; +export { convertSharedLinkPermissions, convertSharedLinkSettings } from './convertSharingServiceData'; export { convertCollabsResponse } from './convertCollaborators'; export { getAllowedAccessLevels } from './getAllowedAccessLevels'; export { getAllowedPermissionLevels } from './getAllowedPermissionLevels';