From ecbd64ecdf294b6b68b9fbae70477f8b234fc990 Mon Sep 17 00:00:00 2001 From: Greg Wong Date: Mon, 27 Jan 2025 01:04:49 -0500 Subject: [PATCH] chore(content-explorer): Migrate ContentExplorer --- .../content-explorer/ContentExplorer.js.flow | 1980 +++++++++++++++++ ...ContentExplorer.js => ContentExplorer.tsx} | 255 +-- .../content-explorer/PreviewDialog.tsx | 7 +- src/elements/content-explorer/ShareDialog.tsx | 4 +- .../__tests__/ContentExplorer.test.js | 718 ------ .../__tests__/ContentExplorer.test.tsx | 970 ++++++++ .../stories/__mocks__/mockRootFolder.ts | 361 ++- .../content-uploader/ContentUploader.tsx | 5 +- 8 files changed, 3437 insertions(+), 863 deletions(-) create mode 100644 src/elements/content-explorer/ContentExplorer.js.flow rename src/elements/content-explorer/{ContentExplorer.js => ContentExplorer.tsx} (90%) delete mode 100644 src/elements/content-explorer/__tests__/ContentExplorer.test.js create mode 100644 src/elements/content-explorer/__tests__/ContentExplorer.test.tsx diff --git a/src/elements/content-explorer/ContentExplorer.js.flow b/src/elements/content-explorer/ContentExplorer.js.flow new file mode 100644 index 0000000000..cd375c907e --- /dev/null +++ b/src/elements/content-explorer/ContentExplorer.js.flow @@ -0,0 +1,1980 @@ +import 'regenerator-runtime/runtime'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import cloneDeep from 'lodash/cloneDeep'; +import debounce from 'lodash/debounce'; +import flow from 'lodash/flow'; +import getProp from 'lodash/get'; +import noop from 'lodash/noop'; +import uniqueid from 'lodash/uniqueId'; +import CreateFolderDialog from '../common/create-folder-dialog'; +import UploadDialog from '../common/upload-dialog'; +import Header from '../common/header'; +import Pagination from '../../features/pagination'; +import SubHeader from '../common/sub-header/SubHeader'; +import makeResponsive from '../common/makeResponsive'; +import openUrlInsideIframe from '../../utils/iframe'; +import Internationalize from '../common/Internationalize'; +import ThemingStyles from '../common/theming'; +import API from '../../api'; +import MetadataQueryAPIHelper from '../../features/metadata-based-view/MetadataQueryAPIHelper'; +import Footer from './Footer'; +import PreviewDialog from './PreviewDialog'; +import ShareDialog from './ShareDialog'; +import RenameDialog from './RenameDialog'; +import DeleteConfirmationDialog from './DeleteConfirmationDialog'; +import Content from './Content'; +import isThumbnailReady from './utils'; +import { isFocusableElement, isInputElement, focus } from '../../utils/dom'; +import { FILE_SHARED_LINK_FIELDS_TO_FETCH } from '../../utils/fields'; +import CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH from './constants'; +import LocalStore from '../../utils/LocalStore'; +import { withFeatureConsumer, withFeatureProvider, FeatureConfig } from '../common/feature-checking'; +import { + DEFAULT_HOSTNAME_UPLOAD, + DEFAULT_HOSTNAME_API, + DEFAULT_HOSTNAME_APP, + DEFAULT_HOSTNAME_STATIC, + DEFAULT_SEARCH_DEBOUNCE, + SORT_ASC, + FIELD_NAME, + FIELD_PERMISSIONS_CAN_SHARE, + FIELD_SHARED_LINK, + DEFAULT_ROOT, + VIEW_SEARCH, + VIEW_FOLDER, + VIEW_ERROR, + VIEW_RECENTS, + VIEW_METADATA, + VIEW_MODE_LIST, + TYPE_FILE, + TYPE_WEBLINK, + TYPE_FOLDER, + CLIENT_NAME_CONTENT_EXPLORER, + DEFAULT_PAGE_NUMBER, + DEFAULT_PAGE_SIZE, + DEFAULT_VIEW_FILES, + DEFAULT_VIEW_RECENTS, + DEFAULT_VIEW_METADATA, + ERROR_CODE_ITEM_NAME_INVALID, + ERROR_CODE_ITEM_NAME_TOO_LONG, + TYPED_ID_FOLDER_PREFIX, + VIEW_MODE_GRID, +} from '../../constants'; +import type { ViewMode } from '../common/flowTypes'; +import type {Theme} from '../common/theming'; +import type { MetadataQuery, FieldsToShow } from '../../common/types/metadataQueries'; +import type { MetadataFieldValue } from '../../common/types/metadata'; +import type { + View, + DefaultView, + StringMap, + SortBy, + SortDirection, + Token, + Access, + Collection, + BoxItemPermission, + BoxItem, +} from '../../common/types/core'; + +import '../common/fonts.scss'; +import '../common/base.scss'; +import '../common/modal.scss'; +import './ContentExplorer.scss'; + +const GRID_VIEW_MAX_COLUMNS = 7; +const GRID_VIEW_MIN_COLUMNS = 1; + +type Props = { + apiHost: string, + appHost: string, + autoFocus: boolean, + canCreateNewFolder: boolean, + canDelete: boolean, + canDownload: boolean, + canPreview: boolean, + canRename: boolean, + canSetShareAccess: boolean, + canShare: boolean, + canUpload: boolean, + className: string, + contentPreviewProps: ContentPreviewProps, + contentUploaderProps: ContentUploaderProps, + currentFolderId?: string, + defaultView: DefaultView, + features: FeatureConfig, + fieldsToShow?: FieldsToShow, + initialPage: number, + initialPageSize: number, + isLarge: boolean, + isMedium: boolean, + isSmall: boolean, + isTouch: boolean, + isVeryLarge: boolean, + language?: string, + logoUrl?: string, + measureRef?: any, + messages?: StringMap, + metadataQuery?: MetadataQuery, + onCreate: any, + onDelete: any, + onDownload: any, + onNavigate: any, + onPreview: any, + onRename: any, + onSelect: any, + onUpload: any, + previewLibraryVersion: string, + requestInterceptor?: any, + responseInterceptor?: any, + rootFolderId: string, + sharedLink?: string, + sharedLinkPassword?: string, + sortBy: SortBy, + sortDirection: SortDirection, + staticHost: string, + staticPath: string, + theme?: Theme, + token: Token, + uploadHost: string +}; + +type State = { + currentCollection: Collection, + currentOffset: number, + currentPageNumber: number, + currentPageSize: number, + errorCode: string, + focusedRow: number, + gridColumnCount: number, + isCreateFolderModalOpen: boolean, + isDeleteModalOpen: boolean, + isLoading: boolean, + isPreviewModalOpen: boolean, + isRenameModalOpen: boolean, + isShareModalOpen: boolean, + isUploadModalOpen: boolean, + markers: Array, + rootName: string, + searchQuery: string, + selected?: BoxItem, + sortBy: SortBy, + sortDirection: SortDirection, + view: View +}; + +const localStoreViewMode = 'bce.defaultViewMode'; + +class ContentExplorer extends Component { + id: string; + + api: API; + + state: State; + + props: Props; + + table: any; + + rootElement: HTMLElement; + + appElement: HTMLElement; + + globalModifier: boolean; + + firstLoad: boolean = true; // Keeps track of very 1st load + + store: LocalStore = new LocalStore(); + + metadataQueryAPIHelper: MetadataQueryAPIHelper; + + static defaultProps = { + rootFolderId: DEFAULT_ROOT, + sortBy: FIELD_NAME, + sortDirection: SORT_ASC, + canDownload: true, + canDelete: true, + canUpload: true, + canRename: true, + canShare: true, + canPreview: true, + canSetShareAccess: true, + canCreateNewFolder: true, + autoFocus: false, + apiHost: DEFAULT_HOSTNAME_API, + appHost: DEFAULT_HOSTNAME_APP, + staticHost: DEFAULT_HOSTNAME_STATIC, + uploadHost: DEFAULT_HOSTNAME_UPLOAD, + className: '', + onDelete: noop, + onDownload: noop, + onPreview: noop, + onRename: noop, + onCreate: noop, + onSelect: noop, + onUpload: noop, + onNavigate: noop, + defaultView: DEFAULT_VIEW_FILES, + initialPage: DEFAULT_PAGE_NUMBER, + initialPageSize: DEFAULT_PAGE_SIZE, + contentPreviewProps: { + contentSidebarProps: {}, + }, + contentUploaderProps: {}, + }; + + /** + * [constructor] + * + * @private + * @return {ContentExplorer} + */ + constructor(props: Props) { + super(props); + + const { + apiHost, + initialPage, + initialPageSize, + language, + requestInterceptor, + responseInterceptor, + rootFolderId, + sharedLink, + sharedLinkPassword, + sortBy, + sortDirection, + token, + uploadHost, + }: Props = props; + + this.api = new API({ + apiHost, + clientName: CLIENT_NAME_CONTENT_EXPLORER, + id: `${TYPED_ID_FOLDER_PREFIX}${rootFolderId}`, + language, + requestInterceptor, + responseInterceptor, + sharedLink, + sharedLinkPassword, + token, + uploadHost, + }); + + this.id = uniqueid('bce_'); + + this.state = { + currentCollection: {}, + currentOffset: initialPageSize * (initialPage - 1), + currentPageSize: initialPageSize, + currentPageNumber: 0, + errorCode: '', + focusedRow: 0, + gridColumnCount: 4, + isCreateFolderModalOpen: false, + isDeleteModalOpen: false, + isLoading: false, + isPreviewModalOpen: false, + isRenameModalOpen: false, + isShareModalOpen: false, + isUploadModalOpen: false, + markers: [], + rootName: '', + searchQuery: '', + sortBy, + sortDirection, + view: VIEW_FOLDER, + }; + } + + /** + * Destroys api instances + * + * @private + * @return {void} + */ + clearCache(): void { + this.api.destroy(true); + } + + /** + * Cleanup + * + * @private + * @inheritdoc + * @return {void} + */ + componentWillUnmount() { + this.clearCache(); + } + + /** + * Fetches the root folder on load + * + * @private + * @inheritdoc + * @return {void} + */ + componentDidMount() { + const { + currentFolderId, + defaultView, + }: Props = this.props; + this.rootElement = (document.getElementById(this.id) as HTMLElement); + this.appElement = (this.rootElement.firstElementChild as HTMLElement); + + switch (defaultView) { + case DEFAULT_VIEW_RECENTS: + this.showRecents(); + break; + case DEFAULT_VIEW_METADATA: + this.showMetadataQueryResults(); + break; + default: + this.fetchFolder(currentFolderId); + } + } + + /** + * Fetches the current folder if different + * from what was already fetched before. + * + * @private + * @inheritdoc + * @return {void} + */ + componentDidUpdate( + { + currentFolderId: prevFolderId, + }: Props, + prevState: State, + ): void { + const { + currentFolderId, + }: Props = this.props; + const { + currentCollection: { id }, + }: State = prevState; + + if (prevFolderId === currentFolderId) { + return; + } + + if (typeof currentFolderId === 'string' && id !== currentFolderId) { + this.fetchFolder(currentFolderId); + } + } + + /** + * Metadata queries success callback + * + * @private + * @param {Object} metadataQueryCollection - Metadata query response collection + * @return {void} + */ + showMetadataQueryResultsSuccessCallback = (metadataQueryCollection: Collection): void => { + const { nextMarker } = metadataQueryCollection; + const { + currentCollection, + currentPageNumber, + markers, + }: State = this.state; + const cloneMarkers = [...markers]; + if (nextMarker) { + cloneMarkers[currentPageNumber + 1] = nextMarker; + } + this.setState({ + currentCollection: { + ...currentCollection, + ...metadataQueryCollection, + percentLoaded: 100, + }, + markers: cloneMarkers, + }); + }; + + /** + * Queries metadata_queries/execute API and fetches the result + * + * @private + * @return {void} + */ + showMetadataQueryResults() { + const { + metadataQuery = {}, + }: Props = this.props; + const { + currentPageNumber, + markers, + }: State = this.state; + const metadataQueryClone = cloneDeep(metadataQuery); + + if (currentPageNumber === 0) { + // Preserve the marker as part of the original query + markers[currentPageNumber] = metadataQueryClone.marker; + } + + if (typeof markers[currentPageNumber] === 'string') { + // Set marker to the query to get next set of results + metadataQueryClone.marker = markers[currentPageNumber]; + } + + if (typeof metadataQueryClone.limit !== 'number') { + // Set limit to the query for pagination support + metadataQueryClone.limit = DEFAULT_PAGE_SIZE; + } + // Reset search state, the view and show busy indicator + this.setState({ + searchQuery: '', + currentCollection: this.currentUnloadedCollection(), + view: VIEW_METADATA, + }); + this.metadataQueryAPIHelper = new MetadataQueryAPIHelper(this.api); + this.metadataQueryAPIHelper.fetchMetadataQueryResults( + metadataQueryClone, + this.showMetadataQueryResultsSuccessCallback, + this.errorCallback, + ); + } + + /** + * Resets the collection so that the loading bar starts showing + * + * @private + * @return {Collection} + */ + currentUnloadedCollection(): Collection { + const { + currentCollection, + }: State = this.state; + return Object.assign(currentCollection, { + percentLoaded: 0, + }); + } + + /** + * Network error callback + * + * @private + * @param {Error} error error object + * @return {void} + */ + errorCallback = (error: any) => { + this.setState({ + view: VIEW_ERROR, + }); + /* eslint-disable no-console */ + console.error(error); + /* eslint-enable no-console */ + }; + + /** + * Focuses the grid and fires navigate event + * + * @private + * @return {void} + */ + finishNavigation() { + const { + autoFocus, + }: Props = this.props; + const { + currentCollection: { percentLoaded }, + }: State = this.state; + + // If loading for the very first time, only allow focus if autoFocus is true + if (this.firstLoad && !autoFocus) { + this.firstLoad = false; + return; + } + + // Don't focus the grid until its loaded and user is not already on an interactable element + if (percentLoaded === 100 && !isFocusableElement(document.activeElement)) { + focus(this.rootElement, '.bce-item-row'); + this.setState({ focusedRow: 0 }); + } + + this.firstLoad = false; + } + + /** + * Refreshing the item collection depending upon the view. + * Navigation event is prevented. + * + * @private + * @return {void} + */ + refreshCollection = () => { + const { + currentCollection: { id }, + view, + searchQuery, + }: State = this.state; + if (view === VIEW_FOLDER && id) { + this.fetchFolder(id, false); + } else if (view === VIEW_RECENTS) { + this.showRecents(false); + } else if (view === VIEW_SEARCH && searchQuery) { + this.search(searchQuery); + } else if (view === VIEW_METADATA) { + this.showMetadataQueryResults(); + } else { + throw new Error('Cannot refresh incompatible view!'); + } + }; + + /** + * Folder fetch success callback + * + * @private + * @param {Object} collection - item collection object + * @param {Boolean|void} triggerNavigationEvent - To trigger navigate event and focus grid + * @return {void} + */ + fetchFolderSuccessCallback(collection: Collection, triggerNavigationEvent: boolean): void { + const { + onNavigate, + rootFolderId, + }: Props = this.props; + const { + boxItem, + id, + name, + }: Collection = collection; + const { + selected, + }: State = this.state; + const rootName = id === rootFolderId ? name : ''; + + // Close any open modals + this.closeModals(); + + this.updateCollection(collection, selected, () => { + if (triggerNavigationEvent) { + // Fire folder navigation event + this.setState({ rootName }, this.finishNavigation); + if (boxItem) { + onNavigate(cloneDeep(boxItem)); + } + } else { + this.setState({ rootName }); + } + }); + } + + /** + * Fetches a folder, defaults to fetching root folder + * + * @private + * @param {string|void} [id] folder id + * @param {Boolean|void} [triggerNavigationEvent] To trigger navigate event + * @return {void} + */ + fetchFolder = (id?: string, triggerNavigationEvent: boolean = true) => { + const { + rootFolderId, + }: Props = this.props; + const { + currentCollection: { id: currentId }, + currentOffset, + currentPageSize: limit, + searchQuery = '', + sortBy, + sortDirection, + }: State = this.state; + const folderId: string = typeof id === 'string' ? id : rootFolderId; + const hasFolderChanged = currentId && currentId !== folderId; + const hasSearchQuery = !!searchQuery.trim().length; + const offset = hasFolderChanged || hasSearchQuery ? 0 : currentOffset; // Reset offset on folder or mode change + + // If we are navigating around, aka not first load + // then reset the focus to the root so that after + // the collection loads the activeElement is not the + // button that was clicked to fetch the folder + if (!this.firstLoad) { + this.rootElement.focus(); + } + + // Reset search state, the view and show busy indicator + this.setState({ + searchQuery: '', + view: VIEW_FOLDER, + currentCollection: this.currentUnloadedCollection(), + currentOffset: offset, + }); + + // Fetch the folder using folder API + this.api.getFolderAPI().getFolder( + folderId, + limit, + offset, + sortBy, + sortDirection, + (collection: Collection) => { + this.fetchFolderSuccessCallback(collection, triggerNavigationEvent); + }, + this.errorCallback, + { fields: CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH, forceFetch: true }, + ); + }; + + /** + * Action performed when clicking on an item + * + * @private + * @param {Object|string} item - the clicked box item + * @return {void} + */ + onItemClick = (item: BoxItem | string) => { + // If the id was passed in, just use that + if (typeof item === 'string') { + this.fetchFolder(item); + return; + } + + const { + id, + type, + }: BoxItem = item; + const { + isTouch, + }: Props = this.props; + + if (type === TYPE_FOLDER) { + this.fetchFolder(id); + return; + } + + if (isTouch) { + return; + } + + this.preview(item); + }; + + /** + * Search success callback + * + * @private + * @param {Object} collection item collection object + * @return {void} + */ + searchSuccessCallback = (collection: Collection) => { + const { + selected, + }: State = this.state; + + // Close any open modals + this.closeModals(); + + this.updateCollection(collection, selected); + }; + + /** + * Debounced searching + * + * @private + * @param {string} id folder id + * @param {string} query search string + * @return {void} + */ + debouncedSearch = debounce((id: string, query: string) => { + const { + currentOffset, + currentPageSize, + }: State = this.state; + + this.api + .getSearchAPI() + .search(id, query, currentPageSize, currentOffset, this.searchSuccessCallback, this.errorCallback, { + fields: CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH, + forceFetch: true, + }); + }, DEFAULT_SEARCH_DEBOUNCE); + + /** + * Searches + * + * @private + * @param {string} query search string + * @return {void} + */ + search = (query: string) => { + const { + rootFolderId, + }: Props = this.props; + const { + currentCollection: { id }, + currentOffset, + searchQuery, + }: State = this.state; + const folderId = typeof id === 'string' ? id : rootFolderId; + const trimmedQuery: string = query.trim(); + + if (!query) { + // Cancel the debounce so we don't search on a previous query + this.debouncedSearch.cancel(); + + // Query was cleared out, load the prior folder + // The prior folder is always the parent folder for search + this.setState({ currentOffset: 0 }, () => { + this.fetchFolder(folderId, false); + }); + + return; + } + + if (!trimmedQuery) { + // Query now only has bunch of spaces + // do nothing and but update prior state + this.setState({ + searchQuery: query, + }); + return; + } + + this.setState({ + currentCollection: this.currentUnloadedCollection(), + currentOffset: trimmedQuery === searchQuery ? currentOffset : 0, + searchQuery: query, + selected: undefined, + view: VIEW_SEARCH, + }); + + this.debouncedSearch(folderId, query); + }; + + /** + * Recents fetch success callback + * + * @private + * @param {Object} collection item collection object + * @param {Boolean} triggerNavigationEvent - To trigger navigate event + * @return {void} + */ + recentsSuccessCallback(collection: Collection, triggerNavigationEvent: boolean) { + if (triggerNavigationEvent) { + this.updateCollection(collection, undefined, this.finishNavigation); + } else { + this.updateCollection(collection); + } + } + + /** + * Shows recents. + * + * @private + * @param {Boolean|void} [triggerNavigationEvent] To trigger navigate event + * @return {void} + */ + showRecents(triggerNavigationEvent: boolean = true): void { + const { + rootFolderId, + }: Props = this.props; + + // Reset search state, the view and show busy indicator + this.setState({ + searchQuery: '', + view: VIEW_RECENTS, + currentCollection: this.currentUnloadedCollection(), + currentOffset: 0, + }); + + // Fetch the folder using folder API + this.api.getRecentsAPI().recents( + rootFolderId, + (collection: Collection) => { + this.recentsSuccessCallback(collection, triggerNavigationEvent); + }, + this.errorCallback, + { fields: CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH, forceFetch: true }, + ); + } + + /** + * Uploads + * + * @private + * @param {File} file dom file object + * @return {void} + */ + upload = () => { + const { + currentCollection: { id, permissions }, + }: State = this.state; + const { + canUpload, + }: Props = this.props; + if (!canUpload || !id || !permissions) { + return; + } + + const { + can_upload, + }: BoxItemPermission = permissions; + if (!can_upload) { + return; + } + + this.setState({ + isUploadModalOpen: true, + }); + }; + + /** + * Upload success handler + * + * @private + * @param {File} file dom file object + * @return {void} + */ + uploadSuccessHandler = () => { + const { + currentCollection: { id }, + }: State = this.state; + this.fetchFolder(id, false); + }; + + /** + * Changes the share access of an item + * + * @private + * @param {string} access share access + * @return {void} + */ + changeShareAccess = (access: Access) => { + const { + selected, + }: State = this.state; + const { + canSetShareAccess, + }: Props = this.props; + if (!selected || !canSetShareAccess) { + return; + } + + const { + permissions, + type, + }: BoxItem = selected; + if (!permissions || !type) { + return; + } + + const { + can_set_share_access, + }: BoxItemPermission = permissions; + if (!can_set_share_access) { + return; + } + + this.setState({ isLoading: true }); + this.api.getAPI(type).share(selected, access, (updatedItem: BoxItem) => { + this.setState({ isLoading: false }); + this.select(updatedItem); + }); + }; + + /** + * Changes the sort by and sort direction + * + * @private + * @param {string} sortBy - field to sort by + * @param {string} sortDirection - sort direction + * @return {void} + */ + sort = (sortBy: SortBy, sortDirection: SortDirection) => { + const { + currentCollection: { id }, + }: State = this.state; + if (id) { + this.setState({ sortBy, sortDirection }, this.refreshCollection); + } + }; + + /** + * Sets state with currentCollection updated to have items.selected properties + * set according to the given selected param. Also updates the selected item in the + * currentCollection. selectedItem will be set to the selected state + * item if it is in currentCollection, otherwise it will be set to undefined. + * + * @private + * @param {Collection} collection - collection that needs to be updated + * @param {Object} [selectedItem] - The item that should be selected in that collection (if present) + * @param {Function} [callback] - callback function that should be called after setState occurs + * @return {void} + */ + async updateCollection( + collection: Collection, + selectedItem?: BoxItem | null, + callback: any = noop, + ): Promise { + const newCollection: Collection = cloneDeep(collection); + const { items = [] } = newCollection; + const fileAPI = this.api.getFileAPI(false); + const selectedId = selectedItem ? selectedItem.id : null; + let newSelectedItem: BoxItem | null | undefined; + + const itemThumbnails = await Promise.all( + items.map(item => { + return item.type === TYPE_FILE ? fileAPI.getThumbnailUrl(item) : null; + }), + ); + + newCollection.items = items.map((item, index) => { + const isSelected = item.id === selectedId; + const currentItem = isSelected ? selectedItem : item; + const thumbnailUrl = itemThumbnails[index]; + + const newItem = { + ...currentItem, + selected: isSelected, + thumbnailUrl, + } as const; + + if (item.type === TYPE_FILE && thumbnailUrl && !isThumbnailReady(newItem)) { + this.attemptThumbnailGeneration(newItem); + } + + // Only if selectedItem is in the current collection do we want to set selected state + if (isSelected) { + newSelectedItem = newItem; + } + + return newItem; + }); + this.setState({ currentCollection: newCollection, selected: newSelectedItem }, callback); + } + + /** + * Attempts to generate a thumbnail for the given item and assigns the + * item its thumbnail url if successful + * + * @param {BoxItem} item - item to generate thumbnail for + * @return {Promise} + */ + attemptThumbnailGeneration = async (item: BoxItem): Promise => { + const entries = getProp(item, 'representations.entries'); + const representation = getProp(entries, '[0]'); + + if (representation) { + const updatedRepresentation = await this.api.getFileAPI(false).generateRepresentation(representation); + if (updatedRepresentation !== representation) { + this.updateItemInCollection({ + ...cloneDeep(item), + representations: { + entries: [updatedRepresentation, ...entries.slice(1)], + }, + }); + } + } + }; + + /** + * Update item in this.state.currentCollection + * + * @param {BoxItem} newItem - item with updated properties + * @return {void} + */ + updateItemInCollection = (newItem: BoxItem): void => { + const { currentCollection } = this.state; + const { items = [] } = currentCollection; + const newCollection = { ...currentCollection } as const; + + newCollection.items = items.map(item => (item.id === newItem.id ? newItem : item)); + this.setState({ currentCollection: newCollection }); + }; + + /** + * Selects or unselects an item + * + * @private + * @param {Object} item - file or folder object + * @param {Function|void} [onSelect] - optional on select callback + * @return {void} + */ + select = (item: BoxItem, callback: any = noop): void => { + const { + selected, + currentCollection, + }: State = this.state; + const { items = [] } = currentCollection; + const { + onSelect, + }: Props = this.props; + + if (item === selected) { + callback(item); + return; + } + + const selectedItem: BoxItem = { ...item, selected: true }; + + this.updateCollection(currentCollection, selectedItem, () => { + onSelect(cloneDeep([selectedItem])); + callback(selectedItem); + }); + + const focusedRow: number = items.findIndex((i: BoxItem) => i.id === item.id); + + this.setState({ focusedRow }); + }; + + /** + * Selects the clicked file and then previews it + * or opens it, if it was a web link + * + * @private + * @param {Object} item - file or folder object + * @return {void} + */ + preview = (item: BoxItem): void => { + const { + type, + url, + }: BoxItem = item; + if (type === TYPE_WEBLINK) { + window.open(url); + return; + } + + this.select(item, this.previewCallback); + }; + + /** + * Previews a file + * + * @private + * @param {Object} item - file or folder object + * @return {void} + */ + previewCallback = (): void => { + const { + selected, + }: State = this.state; + const { + canPreview, + }: Props = this.props; + if (!selected || !canPreview) { + return; + } + + const { permissions } = selected; + if (!permissions) { + return; + } + + const { + can_preview, + }: BoxItemPermission = permissions; + if (!can_preview) { + return; + } + + this.setState({ isPreviewModalOpen: true }); + }; + + /** + * Selects the clicked file and then downloads it + * + * @private + * @param {Object} item - file or folder object + * @return {void} + */ + download = (item: BoxItem): void => { + this.select(item, this.downloadCallback); + }; + + /** + * Downloads a file + * + * @private + * @return {void} + */ + downloadCallback = (): void => { + const { + selected, + }: State = this.state; + const { + canDownload, + onDownload, + }: Props = this.props; + if (!selected || !canDownload) { + return; + } + + const { id, permissions } = selected; + if (!id || !permissions) { + return; + } + + const { + can_download, + }: BoxItemPermission = permissions; + if (!can_download) { + return; + } + + const openUrl: any = (url: string) => { + openUrlInsideIframe(url); + onDownload(cloneDeep([selected])); + }; + + const { + type, + }: BoxItem = selected; + if (type === TYPE_FILE) { + this.api.getFileAPI().getDownloadUrl(id, selected, openUrl, noop); + } + }; + + /** + * Selects the clicked file and then deletes it + * + * @private + * @param {Object} item - file or folder object + * @return {void} + */ + delete = (item: BoxItem): void => { + this.select(item, this.deleteCallback); + }; + + /** + * Deletes a file + * + * @private + * @return {void} + */ + deleteCallback = (): void => { + const { + selected, + isDeleteModalOpen, + }: State = this.state; + const { + canDelete, + onDelete, + }: Props = this.props; + if (!selected || !canDelete) { + return; + } + + const { + id, + permissions, + parent, + type, + }: BoxItem = selected; + if (!id || !permissions || !parent || !type) { + return; + } + + const { id: parentId } = parent; + const { + can_delete, + }: BoxItemPermission = permissions; + if (!can_delete || !parentId) { + return; + } + + if (!isDeleteModalOpen) { + this.setState({ isDeleteModalOpen: true }); + return; + } + + this.setState({ isLoading: true }); + this.api.getAPI(type).deleteItem( + selected, + () => { + onDelete(cloneDeep([selected])); + this.refreshCollection(); + }, + () => { + this.refreshCollection(); + }, + ); + }; + + /** + * Selects the clicked file and then renames it + * + * @private + * @param {Object} item - file or folder object + * @return {void} + */ + rename = (item: BoxItem): void => { + this.select(item, this.renameCallback); + }; + + /** + * Callback for renaming an item + * + * @private + * @param {string} value new item name + * @return {void} + */ + renameCallback = (nameWithoutExt: string, extension: string): void => { + const { + selected, + isRenameModalOpen, + }: State = this.state; + const { + canRename, + onRename, + }: Props = this.props; + if (!selected || !canRename) { + return; + } + + const { + id, + permissions, + type, + }: BoxItem = selected; + if (!id || !permissions || !type) { + return; + } + + const { + can_rename, + }: BoxItemPermission = permissions; + if (!can_rename) { + return; + } + + if (!isRenameModalOpen || !nameWithoutExt) { + this.setState({ isRenameModalOpen: true, errorCode: '' }); + return; + } + + const name = `${nameWithoutExt}${extension}`; + if (!nameWithoutExt.trim()) { + this.setState({ + errorCode: ERROR_CODE_ITEM_NAME_INVALID, + isLoading: false, + }); + return; + } + + this.setState({ isLoading: true }); + this.api.getAPI(type).rename( + selected, + name.trim(), + (updatedItem: BoxItem) => { + this.setState({ isRenameModalOpen: false }); + this.refreshCollection(); + this.select(updatedItem); + onRename(cloneDeep(selected)); + }, + ({ code }) => { + this.setState({ errorCode: code, isLoading: false }); + }, + ); + }; + + /** + * Creates a new folder + * + * @private + * @return {void} + */ + createFolder = (): void => { + this.createFolderCallback(); + }; + + /** + * New folder callback + * + * @private + * @param {string} name - folder name + * @return {void} + */ + createFolderCallback = (name?: string): void => { + const { + isCreateFolderModalOpen, + currentCollection, + }: State = this.state; + const { + canCreateNewFolder, + onCreate, + }: Props = this.props; + if (!canCreateNewFolder) { + return; + } + + const { + id, + permissions, + }: Collection = currentCollection; + if (!id || !permissions) { + return; + } + + const { + can_upload, + }: BoxItemPermission = permissions; + if (!can_upload) { + return; + } + + if (!isCreateFolderModalOpen || !name) { + this.setState({ isCreateFolderModalOpen: true, errorCode: '' }); + return; + } + + if (!name.trim()) { + this.setState({ + errorCode: ERROR_CODE_ITEM_NAME_INVALID, + isLoading: false, + }); + return; + } + + if (name.length > 255) { + this.setState({ + errorCode: ERROR_CODE_ITEM_NAME_TOO_LONG, + isLoading: false, + }); + return; + } + + this.setState({ isLoading: true }); + this.api.getFolderAPI().create( + id, + name.trim(), + (item: BoxItem) => { + this.refreshCollection(); + this.select(item); + onCreate(cloneDeep(item)); + }, + ({ code }) => { + this.setState({ + errorCode: code, + isLoading: false, + }); + }, + ); + }; + + /** + * Selects the clicked file and then shares it + * + * @private + * @param {Object} item - file or folder object + * @return {void} + */ + share = (item: BoxItem): void => { + this.select(item, this.shareCallback); + }; + + /** + * Fetch the shared link info + * @param {BoxItem} item - The item (folder, file, weblink) + * @returns {void} + */ + fetchSharedLinkInfo = (item: BoxItem): void => { + const { + id, + type, + }: BoxItem = item; + + switch (type) { + case TYPE_FOLDER: + this.api.getFolderAPI().getFolderFields(id, this.handleSharedLinkSuccess, noop, { + fields: FILE_SHARED_LINK_FIELDS_TO_FETCH, + }); + break; + case TYPE_FILE: + this.api + .getFileAPI() + .getFile(id, this.handleSharedLinkSuccess, noop, { fields: FILE_SHARED_LINK_FIELDS_TO_FETCH }); + break; + case TYPE_WEBLINK: + this.api + .getWebLinkAPI() + .getWeblink(id, this.handleSharedLinkSuccess, noop, { fields: FILE_SHARED_LINK_FIELDS_TO_FETCH }); + break; + default: + throw new Error('Unknown Type'); + } + }; + + /** + * Handles the shared link info by either creating a share link using enterprise defaults if + * it does not already exist, otherwise update the item in the state currentCollection. + * + * @param {Object} item file or folder + * @returns {void} + */ + handleSharedLinkSuccess = async (item: BoxItem) => { + const { currentCollection } = this.state; + let updatedItem = item; + + // if there is no shared link, create one with enterprise default access + if (!item[FIELD_SHARED_LINK] && getProp(item, FIELD_PERMISSIONS_CAN_SHARE, false)) { + await this.api.getAPI(item.type).share(item, undefined, (sharedItem: BoxItem) => { + updatedItem = sharedItem; + }); + } + + this.updateCollection(currentCollection, updatedItem, () => this.setState({ isShareModalOpen: true })); + }; + + /** + * Callback for sharing an item + * + * @private + * @return {void} + */ + shareCallback = (): void => { + const { + selected, + }: State = this.state; + const { + canShare, + }: Props = this.props; + + if (!selected || !canShare) { + return; + } + + const { permissions, type } = selected; + if (!permissions || !type) { + return; + } + + const { + can_share, + }: BoxItemPermission = permissions; + if (!can_share) { + return; + } + + this.fetchSharedLinkInfo(selected); + }; + + /** + * Saves reference to table component + * + * @private + * @param {Component} react component + * @return {void} + */ + tableRef = (table: React.Component): void => { + this.table = table; + }; + + /** + * Closes the modal dialogs that may be open + * + * @private + * @return {void} + */ + closeModals = (): void => { + const { + focusedRow, + }: State = this.state; + + this.setState({ + isLoading: false, + isDeleteModalOpen: false, + isRenameModalOpen: false, + isCreateFolderModalOpen: false, + isShareModalOpen: false, + isUploadModalOpen: false, + isPreviewModalOpen: false, + }); + + const { + selected, + currentCollection: { items = [] }, + }: State = this.state; + if (selected && items.length > 0) { + focus(this.rootElement, `.bce-item-row-${focusedRow}`); + } + }; + + /** + * Returns whether the currently focused element is an item + * + * @returns {bool} + */ + isFocusOnItem = () => { + const focusedElementClassList = document.activeElement?.classList; + return focusedElementClassList && focusedElementClassList.contains('be-item-label'); + }; + + /** + * Keyboard events + * + * @private + * @return {void} + */ + onKeyDown = (event: React.KeyboardEvent) => { + if (isInputElement(event.target)) { + return; + } + + const { + rootFolderId, + }: Props = this.props; + const key = event.key.toLowerCase(); + + switch (key) { + case '/': + focus(this.rootElement, '.be-search input[type="search"]', false); + event.preventDefault(); + break; + case 'arrowdown': + if (this.getViewMode() === VIEW_MODE_GRID) { + if (!this.isFocusOnItem()) { + focus(this.rootElement, '.be-item-name .be-item-label', false); + event.preventDefault(); + } + } else { + focus(this.rootElement, '.bce-item-row', false); + this.setState({ focusedRow: 0 }); + event.preventDefault(); + } + break; + case 'g': + break; + case 'b': + if (this.globalModifier) { + focus(this.rootElement, '.be-breadcrumb button', false); + event.preventDefault(); + } + + break; + case 'f': + if (this.globalModifier) { + this.fetchFolder(rootFolderId); + event.preventDefault(); + } + + break; + case 'u': + if (this.globalModifier) { + this.upload(); + event.preventDefault(); + } + + break; + case 'r': + if (this.globalModifier) { + this.showRecents(); + event.preventDefault(); + } + + break; + case 'n': + if (this.globalModifier) { + this.createFolder(); + event.preventDefault(); + } + + break; + default: + this.globalModifier = false; + return; + } + + this.globalModifier = key === 'g'; + }; + + /** + * Handle pagination changes for offset based pagination + * + * @param {number} newOffset - the new page offset value + */ + paginate = (newOffset: number) => { + this.setState({ currentOffset: newOffset }, this.refreshCollection); + }; + + /** + * Handle pagination changes for marker based pagination + * @param {number} newOffset - the new page offset value + */ + markerBasedPaginate = (newOffset: number) => { + const { currentPageNumber } = this.state; + this.setState( + { + currentPageNumber: currentPageNumber + newOffset, // newOffset could be negative + }, + this.refreshCollection, + ); + }; + + /** + * Get the current viewMode, checking local store if applicable + * + * @return {ViewMode} + */ + getViewMode = (): ViewMode => this.store.getItem(localStoreViewMode) || VIEW_MODE_LIST; + + /** + * Get the maximum number of grid view columns based on the current width of the + * content explorer. + * + * @return {number} + */ + getMaxNumberOfGridViewColumnsForWidth = (): number => { + const { isSmall, isMedium, isLarge } = this.props; + let maxWidthColumns = GRID_VIEW_MAX_COLUMNS; + if (isSmall) { + maxWidthColumns = 1; + } else if (isMedium) { + maxWidthColumns = 3; + } else if (isLarge) { + maxWidthColumns = 5; + } + return maxWidthColumns; + }; + + /** + * Change the current view mode + * + * @param {ViewMode} viewMode - the new view mode + * @return {void} + */ + changeViewMode = (viewMode: ViewMode): void => { + this.store.setItem(localStoreViewMode, viewMode); + this.forceUpdate(); + }; + + /** + * Callback for when value of GridViewSlider changes + * + * @param {number} sliderValue - value of slider + * @return {void} + */ + onGridViewSliderChange = (sliderValue: number): void => { + // need to do this calculation since lowest value of grid view slider + // means highest number of columns + const gridColumnCount = GRID_VIEW_MAX_COLUMNS - sliderValue + 1; + this.setState({ gridColumnCount }); + }; + + /** + * Function to update metadata field value in metadata based view + * @param {BoxItem} item - file item whose metadata is being changed + * @param {string} field - metadata template field name + * @param {MetadataFieldValue} oldValue - current value + * @param {MetadataFieldValue} newVlaue - new value the field to be updated to + */ + + updateMetadata = ( + item: BoxItem, + field: string, + oldValue?: MetadataFieldValue | null, + newValue?: MetadataFieldValue | null, + ): void => { + this.metadataQueryAPIHelper.updateMetadata( + item, + field, + oldValue, + newValue, + () => { + this.updateMetadataSuccessCallback(item, field, newValue); + }, + this.errorCallback, + ); + }; + + updateMetadataSuccessCallback = (item: BoxItem, field: string, newValue?: MetadataFieldValue | null): void => { + const { + currentCollection, + }: State = this.state; + const { items = [], nextMarker } = currentCollection; + const updatedItems = items.map(collectionItem => { + const clonedItem = cloneDeep(collectionItem); + if (item.id === clonedItem.id) { + const fields = getProp(clonedItem, 'metadata.enterprise.fields', []); + fields.forEach(itemField => { + if (itemField.key.split('.').pop() === field) { + itemField.value = newValue; // set updated metadata value to correct item in currentCollection + } + }); + } + return clonedItem; + }); + + this.setState({ + currentCollection: { + items: updatedItems, + nextMarker, + percentLoaded: 100, + }, + }); + }; + + /** + * Renders the file picker + * + * @private + * @inheritdoc + * @return {Element} + */ + render() { + const { + apiHost, + appHost, + canCreateNewFolder, + canDelete, + canDownload, + canPreview, + canRename, + canSetShareAccess, + canShare, + canUpload, + className, + contentPreviewProps, + contentUploaderProps, + defaultView, + isMedium, + isSmall, + isTouch, + language, + logoUrl, + measureRef, + messages, + fieldsToShow, + onDownload, + onPreview, + onUpload, + requestInterceptor, + responseInterceptor, + rootFolderId, + sharedLink, + sharedLinkPassword, + staticHost, + staticPath, + previewLibraryVersion, + theme, + token, + uploadHost, + }: Props = this.props; + + const { + currentCollection, + currentPageNumber, + currentPageSize, + errorCode, + focusedRow, + gridColumnCount, + isCreateFolderModalOpen, + isDeleteModalOpen, + isLoading, + isPreviewModalOpen, + isRenameModalOpen, + isShareModalOpen, + isUploadModalOpen, + markers, + rootName, + searchQuery, + selected, + view, + }: State = this.state; + + const { + id, + offset, + permissions, + totalCount, + }: Collection = currentCollection; + const { + can_upload, + }: BoxItemPermission = permissions || {}; + const styleClassName = classNames('be bce', className); + const allowUpload: boolean = canUpload && !!can_upload; + const allowCreate: boolean = canCreateNewFolder && !!can_upload; + const isDefaultViewMetadata: boolean = defaultView === DEFAULT_VIEW_METADATA; + const isErrorView: boolean = view === VIEW_ERROR; + + const viewMode = this.getViewMode(); + const maxGridColumnCount = this.getMaxNumberOfGridViewColumnsForWidth(); + + const hasNextMarker: boolean = !!markers[currentPageNumber + 1]; + const hasPreviousMarker: boolean = currentPageNumber === 1 || !!markers[currentPageNumber - 1]; + + /* eslint-disable jsx-a11y/no-static-element-interactions */ + /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ + return ( + +
+ +
+ {!isDefaultViewMetadata && ( + <> +
+ + + )} + + {!isErrorView && ( +
+ +
+ )} +
+ {allowUpload && !!this.appElement ? ( + + ) : null} + {allowCreate && !!this.appElement ? ( + + ) : null} + {canDelete && selected && !!this.appElement ? ( + + ) : null} + {canRename && selected && !!this.appElement ? ( + + ) : null} + {canShare && selected && !!this.appElement ? ( + + ) : null} + {canPreview && selected && !!this.appElement ? ( + + ) : null} +
+
+ ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + /* eslint-enable jsx-a11y/no-noninteractive-tabindex */ + } +} + +export { ContentExplorer as ContentExplorerComponent }; +export default flow([makeResponsive, withFeatureConsumer, withFeatureProvider])(ContentExplorer); diff --git a/src/elements/content-explorer/ContentExplorer.js b/src/elements/content-explorer/ContentExplorer.tsx similarity index 90% rename from src/elements/content-explorer/ContentExplorer.js rename to src/elements/content-explorer/ContentExplorer.tsx index 645eabbf5d..8a206208f7 100644 --- a/src/elements/content-explorer/ContentExplorer.js +++ b/src/elements/content-explorer/ContentExplorer.tsx @@ -1,9 +1,3 @@ -/** - * @flow - * @file Content Explorer Component - * @author Box - */ - import 'regenerator-runtime/runtime'; import React, { Component } from 'react'; import classNames from 'classnames'; @@ -13,6 +7,7 @@ import flow from 'lodash/flow'; import getProp from 'lodash/get'; import noop from 'lodash/noop'; import uniqueid from 'lodash/uniqueId'; +import { AxiosRequestConfig, AxiosResponse } from 'axios'; import CreateFolderDialog from '../common/create-folder-dialog'; import UploadDialog from '../common/upload-dialog'; import Header from '../common/header'; @@ -21,7 +16,6 @@ import SubHeader from '../common/sub-header/SubHeader'; import makeResponsive from '../common/makeResponsive'; import openUrlInsideIframe from '../../utils/iframe'; import Internationalize from '../common/Internationalize'; -// $FlowFixMe TypeScript file import ThemingStyles from '../common/theming'; import API from '../../api'; import MetadataQueryAPIHelper from '../../features/metadata-based-view/MetadataQueryAPIHelper'; @@ -36,7 +30,7 @@ import { isFocusableElement, isInputElement, focus } from '../../utils/dom'; import { FILE_SHARED_LINK_FIELDS_TO_FETCH } from '../../utils/fields'; import CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH from './constants'; import LocalStore from '../../utils/LocalStore'; -import { withFeatureConsumer, withFeatureProvider, type FeatureConfig } from '../common/feature-checking'; +import { withFeatureConsumer, withFeatureProvider, FeatureConfig } from '../common/feature-checking'; import { DEFAULT_HOSTNAME_UPLOAD, DEFAULT_HOSTNAME_API, @@ -69,7 +63,6 @@ import { VIEW_MODE_GRID, } from '../../constants'; import type { ViewMode } from '../common/flowTypes'; -// $FlowFixMe TypeScript file import type { Theme } from '../common/theming'; import type { MetadataQuery, FieldsToShow } from '../../common/types/metadataQueries'; import type { MetadataFieldValue } from '../../common/types/metadata'; @@ -85,6 +78,8 @@ import type { BoxItemPermission, BoxItem, } from '../../common/types/core'; +import type { ContentPreviewProps } from '../content-preview'; +import type { ContentUploaderProps } from '../content-uploader'; import '../common/fonts.scss'; import '../common/base.scss'; @@ -94,96 +89,96 @@ import './ContentExplorer.scss'; const GRID_VIEW_MAX_COLUMNS = 7; const GRID_VIEW_MIN_COLUMNS = 1; -type Props = { - apiHost: string, - appHost: string, - autoFocus: boolean, - canCreateNewFolder: boolean, - canDelete: boolean, - canDownload: boolean, - canPreview: boolean, - canRename: boolean, - canSetShareAccess: boolean, - canShare: boolean, - canUpload: boolean, - className: string, - contentPreviewProps: ContentPreviewProps, - contentUploaderProps: ContentUploaderProps, - currentFolderId?: string, - defaultView: DefaultView, - features: FeatureConfig, - fieldsToShow?: FieldsToShow, - initialPage: number, - initialPageSize: number, - isLarge: boolean, - isMedium: boolean, - isSmall: boolean, - isTouch: boolean, - isVeryLarge: boolean, - language?: string, - logoUrl?: string, - measureRef?: Function, - messages?: StringMap, - metadataQuery?: MetadataQuery, - onCreate: Function, - onDelete: Function, - onDownload: Function, - onNavigate: Function, - onPreview: Function, - onRename: Function, - onSelect: Function, - onUpload: Function, - previewLibraryVersion: string, - requestInterceptor?: Function, - responseInterceptor?: Function, - rootFolderId: string, - sharedLink?: string, - sharedLinkPassword?: string, - sortBy: SortBy, - sortDirection: SortDirection, - staticHost: string, - staticPath: string, - theme?: Theme, - token: Token, - uploadHost: string, -}; +export interface ContentExplorerProps { + apiHost?: string; + appHost?: string; + autoFocus?: boolean; + canCreateNewFolder?: boolean; + canDelete?: boolean; + canDownload?: boolean; + canPreview?: boolean; + canRename?: boolean; + canSetShareAccess?: boolean; + canShare?: boolean; + canUpload?: boolean; + className?: string; + contentPreviewProps?: ContentPreviewProps; + contentUploaderProps?: ContentUploaderProps; + currentFolderId?: string; + defaultView?: DefaultView; + features?: FeatureConfig; + fieldsToShow?: FieldsToShow; + initialPage?: number; + initialPageSize?: number; + isLarge?: boolean; + isMedium?: boolean; + isSmall?: boolean; + isTouch?: boolean; + isVeryLarge?: boolean; + language?: string; + logoUrl?: string; + measureRef?: (ref: Element | null) => void; + messages?: StringMap; + metadataQuery?: MetadataQuery; + onCreate?: (item: BoxItem) => void; + onDelete?: (item: BoxItem) => void; + onDownload?: (item: BoxItem) => void; + onNavigate?: (item: BoxItem) => void; + onPreview?: (data: unknown) => void; + onRename?: (item: BoxItem) => void; + onSelect?: (item: BoxItem) => void; + onUpload?: (item: BoxItem) => void; + previewLibraryVersion?: string; + requestInterceptor?: (response: AxiosResponse) => void; + responseInterceptor?: (config: AxiosRequestConfig) => void; + rootFolderId: string; + sharedLink?: string; + sharedLinkPassword?: string; + sortBy?: SortBy; + sortDirection?: SortDirection; + staticHost?: string; + staticPath?: string; + theme?: Theme; + token: Token; + uploadHost?: string; +} type State = { - currentCollection: Collection, - currentOffset: number, - currentPageNumber: number, - currentPageSize: number, - errorCode: string, - focusedRow: number, - gridColumnCount: number, - isCreateFolderModalOpen: boolean, - isDeleteModalOpen: boolean, - isLoading: boolean, - isPreviewModalOpen: boolean, - isRenameModalOpen: boolean, - isShareModalOpen: boolean, - isUploadModalOpen: boolean, - markers: Array, - rootName: string, - searchQuery: string, - selected?: BoxItem, - sortBy: SortBy, - sortDirection: SortDirection, - view: View, + currentCollection: Collection; + currentOffset: number; + currentPageNumber: number; + currentPageSize: number; + errorCode: string; + focusedRow: number; + gridColumnCount: number; + isCreateFolderModalOpen: boolean; + isDeleteModalOpen: boolean; + isLoading: boolean; + isPreviewModalOpen: boolean; + isRenameModalOpen: boolean; + isShareModalOpen: boolean; + isUploadModalOpen: boolean; + markers: Array; + rootName: string; + searchQuery: string; + selected?: BoxItem; + sortBy: SortBy; + sortDirection: SortDirection; + view: View; }; const localStoreViewMode = 'bce.defaultViewMode'; -class ContentExplorer extends Component { +class ContentExplorer extends Component { id: string; api: API; state: State; - props: Props; + props: ContentExplorerProps; - table: any; + table: React.Component; rootElement: HTMLElement; @@ -238,7 +233,7 @@ class ContentExplorer extends Component { * @private * @return {ContentExplorer} */ - constructor(props: Props) { + constructor(props: ContentExplorerProps) { super(props); const { @@ -255,7 +250,7 @@ class ContentExplorer extends Component { sortDirection, token, uploadHost, - }: Props = props; + }: ContentExplorerProps = props; this.api = new API({ apiHost, @@ -325,9 +320,9 @@ class ContentExplorer extends Component { * @return {void} */ componentDidMount() { - const { currentFolderId, defaultView }: Props = this.props; - this.rootElement = ((document.getElementById(this.id): any): HTMLElement); - this.appElement = ((this.rootElement.firstElementChild: any): HTMLElement); + const { currentFolderId, defaultView }: ContentExplorerProps = this.props; + this.rootElement = document.getElementById(this.id) as HTMLElement; + this.appElement = this.rootElement.firstElementChild as HTMLElement; switch (defaultView) { case DEFAULT_VIEW_RECENTS: @@ -349,8 +344,8 @@ class ContentExplorer extends Component { * @inheritdoc * @return {void} */ - componentDidUpdate({ currentFolderId: prevFolderId }: Props, prevState: State): void { - const { currentFolderId }: Props = this.props; + componentDidUpdate({ currentFolderId: prevFolderId }: ContentExplorerProps, prevState: State): void { + const { currentFolderId }: ContentExplorerProps = this.props; const { currentCollection: { id }, }: State = prevState; @@ -395,7 +390,7 @@ class ContentExplorer extends Component { * @return {void} */ showMetadataQueryResults() { - const { metadataQuery = {} }: Props = this.props; + const { metadataQuery = {} }: ContentExplorerProps = this.props; const { currentPageNumber, markers }: State = this.state; const metadataQueryClone = cloneDeep(metadataQuery); @@ -447,7 +442,7 @@ class ContentExplorer extends Component { * @param {Error} error error object * @return {void} */ - errorCallback = (error: any) => { + errorCallback = (error: unknown) => { this.setState({ view: VIEW_ERROR, }); @@ -463,7 +458,7 @@ class ContentExplorer extends Component { * @return {void} */ finishNavigation() { - const { autoFocus }: Props = this.props; + const { autoFocus }: ContentExplorerProps = this.props; const { currentCollection: { percentLoaded }, }: State = this.state; @@ -518,7 +513,7 @@ class ContentExplorer extends Component { * @return {void} */ fetchFolderSuccessCallback(collection: Collection, triggerNavigationEvent: boolean): void { - const { onNavigate, rootFolderId }: Props = this.props; + const { onNavigate, rootFolderId }: ContentExplorerProps = this.props; const { boxItem, id, name }: Collection = collection; const { selected }: State = this.state; const rootName = id === rootFolderId ? name : ''; @@ -547,8 +542,8 @@ class ContentExplorer extends Component { * @param {Boolean|void} [triggerNavigationEvent] To trigger navigate event * @return {void} */ - fetchFolder = (id?: string, triggerNavigationEvent?: boolean = true) => { - const { rootFolderId }: Props = this.props; + fetchFolder = (id?: string, triggerNavigationEvent: boolean = true) => { + const { rootFolderId }: ContentExplorerProps = this.props; const { currentCollection: { id: currentId }, currentOffset, @@ -608,7 +603,7 @@ class ContentExplorer extends Component { } const { id, type }: BoxItem = item; - const { isTouch }: Props = this.props; + const { isTouch }: ContentExplorerProps = this.props; if (type === TYPE_FOLDER) { this.fetchFolder(id); @@ -665,7 +660,7 @@ class ContentExplorer extends Component { * @return {void} */ search = (query: string) => { - const { rootFolderId }: Props = this.props; + const { rootFolderId }: ContentExplorerProps = this.props; const { currentCollection: { id }, currentOffset, @@ -731,7 +726,7 @@ class ContentExplorer extends Component { * @return {void} */ showRecents(triggerNavigationEvent: boolean = true): void { - const { rootFolderId }: Props = this.props; + const { rootFolderId }: ContentExplorerProps = this.props; // Reset search state, the view and show busy indicator this.setState({ @@ -763,7 +758,7 @@ class ContentExplorer extends Component { const { currentCollection: { id, permissions }, }: State = this.state; - const { canUpload }: Props = this.props; + const { canUpload }: ContentExplorerProps = this.props; if (!canUpload || !id || !permissions) { return; } @@ -801,7 +796,7 @@ class ContentExplorer extends Component { */ changeShareAccess = (access: Access) => { const { selected }: State = this.state; - const { canSetShareAccess }: Props = this.props; + const { canSetShareAccess }: ContentExplorerProps = this.props; if (!selected || !canSetShareAccess) { return; } @@ -841,7 +836,7 @@ class ContentExplorer extends Component { }; /** - * Sets state with currentCollection updated to have items.selected properties + * Sets state with currentCollection updated to have `items.selected` properties * set according to the given selected param. Also updates the selected item in the * currentCollection. selectedItem will be set to the selected state * item if it is in currentCollection, otherwise it will be set to undefined. @@ -852,12 +847,17 @@ class ContentExplorer extends Component { * @param {Function} [callback] - callback function that should be called after setState occurs * @return {void} */ - async updateCollection(collection: Collection, selectedItem: ?BoxItem, callback: Function = noop): Object { + async updateCollection( + collection: Collection, + selectedItem?: BoxItem | null, + callback: () => void = noop, + ): Promise { const newCollection: Collection = cloneDeep(collection); const { items = [] } = newCollection; + const fileAPI = this.api.getFileAPI(false); const selectedId = selectedItem ? selectedItem.id : null; - let newSelectedItem: ?BoxItem; + let newSelectedItem: BoxItem | null | undefined; const itemThumbnails = await Promise.all( items.map(item => { @@ -874,7 +874,7 @@ class ContentExplorer extends Component { ...currentItem, selected: isSelected, thumbnailUrl, - }; + } as const; if (item.type === TYPE_FILE && thumbnailUrl && !isThumbnailReady(newItem)) { this.attemptThumbnailGeneration(newItem); @@ -923,7 +923,7 @@ class ContentExplorer extends Component { updateItemInCollection = (newItem: BoxItem): void => { const { currentCollection } = this.state; const { items = [] } = currentCollection; - const newCollection = { ...currentCollection }; + const newCollection = { ...currentCollection } as const; newCollection.items = items.map(item => (item.id === newItem.id ? newItem : item)); this.setState({ currentCollection: newCollection }); @@ -937,10 +937,10 @@ class ContentExplorer extends Component { * @param {Function|void} [onSelect] - optional on select callback * @return {void} */ - select = (item: BoxItem, callback: Function = noop): void => { + select = (item: BoxItem, callback: (item: BoxItem) => void = noop): void => { const { selected, currentCollection }: State = this.state; const { items = [] } = currentCollection; - const { onSelect }: Props = this.props; + const { onSelect }: ContentExplorerProps = this.props; if (item === selected) { callback(item); @@ -986,7 +986,7 @@ class ContentExplorer extends Component { */ previewCallback = (): void => { const { selected }: State = this.state; - const { canPreview }: Props = this.props; + const { canPreview }: ContentExplorerProps = this.props; if (!selected || !canPreview) { return; } @@ -1023,7 +1023,7 @@ class ContentExplorer extends Component { */ downloadCallback = (): void => { const { selected }: State = this.state; - const { canDownload, onDownload }: Props = this.props; + const { canDownload, onDownload }: ContentExplorerProps = this.props; if (!selected || !canDownload) { return; } @@ -1038,7 +1038,7 @@ class ContentExplorer extends Component { return; } - const openUrl: Function = (url: string) => { + const openUrl = (url: string) => { openUrlInsideIframe(url); onDownload(cloneDeep([selected])); }; @@ -1068,7 +1068,7 @@ class ContentExplorer extends Component { */ deleteCallback = (): void => { const { selected, isDeleteModalOpen }: State = this.state; - const { canDelete, onDelete }: Props = this.props; + const { canDelete, onDelete }: ContentExplorerProps = this.props; if (!selected || !canDelete) { return; } @@ -1120,9 +1120,10 @@ class ContentExplorer extends Component { * @param {string} value new item name * @return {void} */ - renameCallback = (nameWithoutExt: string, extension: string): void => { + renameCallback = (nameWithoutExt?: string, extension?: string): void => { const { selected, isRenameModalOpen }: State = this.state; - const { canRename, onRename }: Props = this.props; + const { canRename, onRename }: ContentExplorerProps = this.props; + if (!selected || !canRename) { return; } @@ -1133,6 +1134,7 @@ class ContentExplorer extends Component { } const { can_rename }: BoxItemPermission = permissions; + if (!can_rename) { return; } @@ -1186,7 +1188,7 @@ class ContentExplorer extends Component { */ createFolderCallback = (name?: string): void => { const { isCreateFolderModalOpen, currentCollection }: State = this.state; - const { canCreateNewFolder, onCreate }: Props = this.props; + const { canCreateNewFolder, onCreate }: ContentExplorerProps = this.props; if (!canCreateNewFolder) { return; } @@ -1293,7 +1295,6 @@ class ContentExplorer extends Component { // if there is no shared link, create one with enterprise default access if (!item[FIELD_SHARED_LINK] && getProp(item, FIELD_PERMISSIONS_CAN_SHARE, false)) { - // $FlowFixMe await this.api.getAPI(item.type).share(item, undefined, (sharedItem: BoxItem) => { updatedItem = sharedItem; }); @@ -1310,7 +1311,7 @@ class ContentExplorer extends Component { */ shareCallback = (): void => { const { selected }: State = this.state; - const { canShare }: Props = this.props; + const { canShare }: ContentExplorerProps = this.props; if (!selected || !canShare) { return; @@ -1336,7 +1337,7 @@ class ContentExplorer extends Component { * @param {Component} react component * @return {void} */ - tableRef = (table: React$Component<*, *>): void => { + tableRef = (table: React.Component): void => { this.table = table; }; @@ -1384,12 +1385,12 @@ class ContentExplorer extends Component { * @private * @return {void} */ - onKeyDown = (event: SyntheticKeyboardEvent) => { + onKeyDown = (event: React.KeyboardEvent) => { if (isInputElement(event.target)) { return; } - const { rootFolderId }: Props = this.props; + const { rootFolderId }: ContentExplorerProps = this.props; const key = event.key.toLowerCase(); switch (key) { @@ -1538,8 +1539,8 @@ class ContentExplorer extends Component { updateMetadata = ( item: BoxItem, field: string, - oldValue: ?MetadataFieldValue, - newValue: ?MetadataFieldValue, + oldValue?: MetadataFieldValue | null, + newValue?: MetadataFieldValue | null, ): void => { this.metadataQueryAPIHelper.updateMetadata( item, @@ -1553,7 +1554,7 @@ class ContentExplorer extends Component { ); }; - updateMetadataSuccessCallback = (item: BoxItem, field: string, newValue: ?MetadataFieldValue): void => { + updateMetadataSuccessCallback = (item: BoxItem, field: string, newValue?: MetadataFieldValue | null): void => { const { currentCollection }: State = this.state; const { items = [], nextMarker } = currentCollection; const updatedItems = items.map(collectionItem => { @@ -1623,7 +1624,7 @@ class ContentExplorer extends Component { theme, token, uploadHost, - }: Props = this.props; + }: ContentExplorerProps = this.props; const { currentCollection, diff --git a/src/elements/content-explorer/PreviewDialog.tsx b/src/elements/content-explorer/PreviewDialog.tsx index f1db79bfb9..0e2132de29 100644 --- a/src/elements/content-explorer/PreviewDialog.tsx +++ b/src/elements/content-explorer/PreviewDialog.tsx @@ -3,6 +3,7 @@ import { useIntl } from 'react-intl'; import Modal from 'react-modal'; import cloneDeep from 'lodash/cloneDeep'; +import { AxiosRequestConfig, AxiosResponse } from 'axios'; import ContentPreview, { ContentPreviewProps } from '../content-preview'; import { TYPE_FILE, CLASS_MODAL_CONTENT_FULL_BLEED, CLASS_MODAL_OVERLAY, CLASS_MODAL } from '../../constants'; import type { Token, BoxItem, Collection } from '../../common/types/core'; @@ -22,12 +23,12 @@ export interface PreviewDialogProps { isTouch: boolean; item: BoxItem; onCancel: () => void; - onDownload: () => void; + onDownload: (item: BoxItem) => void; onPreview: (data: unknown) => void; parentElement: HTMLElement; previewLibraryVersion: string; - requestInterceptor?: () => void; - responseInterceptor?: () => void; + requestInterceptor?: (response: AxiosResponse) => void; + responseInterceptor?: (config: AxiosRequestConfig) => void; sharedLink?: string; sharedLinkPassword?: string; staticHost: string; diff --git a/src/elements/content-explorer/ShareDialog.tsx b/src/elements/content-explorer/ShareDialog.tsx index edb254095d..5b1934cb8d 100644 --- a/src/elements/content-explorer/ShareDialog.tsx +++ b/src/elements/content-explorer/ShareDialog.tsx @@ -6,7 +6,7 @@ import { Button, Modal as BlueprintModal, Text } from '@box/blueprint-web'; import ShareAccessSelect from '../common/share-access-select'; import { CLASS_MODAL_CONTENT, CLASS_MODAL_OVERLAY, CLASS_MODAL } from '../../constants'; -import type { BoxItem } from '../../common/types/core'; +import type { Access, BoxItem } from '../../common/types/core'; import messages from '../common/messages'; @@ -19,7 +19,7 @@ export interface ShareDialogProps { isOpen: boolean; item: BoxItem; onCancel: () => void; - onShareAccessChange: () => void; + onShareAccessChange: (access: Access) => void; parentElement: HTMLElement; } diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.js b/src/elements/content-explorer/__tests__/ContentExplorer.test.js deleted file mode 100644 index e37351fa56..0000000000 --- a/src/elements/content-explorer/__tests__/ContentExplorer.test.js +++ /dev/null @@ -1,718 +0,0 @@ -import React, { act } from 'react'; -import cloneDeep from 'lodash/cloneDeep'; -import { mount } from 'enzyme'; -import noop from 'lodash/noop'; -import * as utils from '../utils'; -import { ContentExplorerComponent as ContentExplorer } from '../ContentExplorer'; -import UploadDialog from '../../common/upload-dialog'; -import CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH from '../constants'; -import { VIEW_MODE_GRID } from '../../../constants'; - -jest.mock('../../common/header/Header', () => 'mock-header'); -jest.mock('../../common/sub-header/SubHeader', () => 'mock-subheader'); -jest.mock('../Content', () => 'mock-content'); -jest.mock('../../common/upload-dialog/UploadDialog', () => 'mock-uploaddialog'); -jest.mock('../../common/create-folder-dialog/CreateFolderDialog', () => 'mock-createfolderdialog'); -jest.mock('../DeleteConfirmationDialog', () => 'mock-deletedialog'); -jest.mock('../RenameDialog', () => 'mock-renamedialog'); -jest.mock('../ShareDialog', () => 'mock-sharedialog'); -jest.mock('../PreviewDialog', () => 'mock-previewdialog'); - -describe('elements/content-explorer/ContentExplorer', () => { - let rootElement; - const getWrapper = (props = {}) => mount(, { attachTo: rootElement }); - - beforeEach(() => { - rootElement = document.createElement('div'); - rootElement.appendChild(document.createElement('div')); - document.body.appendChild(rootElement); - }); - - afterEach(() => { - document.body.removeChild(rootElement); - }); - - describe('uploadSuccessHandler()', () => { - test('should force reload the files list', () => { - const wrapper = getWrapper(); - const instance = wrapper.instance(); - - act(() => { - instance.setState({ - currentCollection: { - id: '123', - }, - }); - }); - - instance.fetchFolder = jest.fn(); - - act(() => { - instance.uploadSuccessHandler(); - }); - - expect(instance.fetchFolder).toHaveBeenCalledWith('123', false); - }); - }); - - describe('changeViewMode()', () => { - const localStoreViewMode = 'bce.defaultViewMode'; - - test('should change to grid view', () => { - const wrapper = getWrapper(); - const instance = wrapper.instance(); - instance.store.setItem = jest.fn(); - instance.changeViewMode(VIEW_MODE_GRID); - expect(instance.store.setItem).toHaveBeenCalledWith(localStoreViewMode, VIEW_MODE_GRID); - }); - }); - - describe('fetchFolder()', () => { - const getFolder = jest.fn(); - const getFolderAPI = jest.fn().mockReturnValue({ - getFolder, - }); - - let wrapper; - let instance; - - test('should fetch folder without representations field if grid view is not enabled', () => { - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.api = { getFolderAPI }; - instance.setState = jest.fn(); - instance.fetchFolder(); - expect(instance.setState).toHaveBeenCalled(); - expect(getFolder).toHaveBeenCalledWith( - '0', - 50, - 0, - 'name', - 'ASC', - expect.any(Function), - expect.any(Function), - { forceFetch: true, fields: CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH }, - ); - }); - }); - - describe('fetchFolderSuccessCallback()', () => { - const collection = { name: 'collection ' }; - - test('updateCollection should be called with a callback', () => { - const wrapper = getWrapper(); - const instance = wrapper.instance(); - instance.closeModals = jest.fn(); - instance.updateCollection = jest.fn(); - - instance.fetchFolderSuccessCallback(collection, false); - expect(instance.closeModals).toHaveBeenCalled(); - expect(instance.updateCollection).toHaveBeenCalledWith(collection, undefined, expect.any(Function)); - }); - }); - - describe('recentsSuccessCallback()', () => { - const collection = { name: 'collection ' }; - - test('navigation event should not be triggered if argument set to false', () => { - const wrapper = getWrapper(); - const instance = wrapper.instance(); - instance.updateCollection = jest.fn(); - - instance.recentsSuccessCallback(collection, false); - expect(instance.updateCollection).toHaveBeenCalledWith(collection); - }); - - test('navigation event should be triggered if argument set to true ', () => { - const wrapper = getWrapper(); - const instance = wrapper.instance(); - instance.updateCollection = jest.fn(); - - instance.recentsSuccessCallback(collection, true); - expect(instance.updateCollection).toHaveBeenCalledWith(collection, undefined, instance.finishNavigation); - }); - }); - - describe('updateCollection()', () => { - describe('selection', () => { - const item1 = { id: 1 }; - const item2 = { id: 2 }; - const collection = { boxItem: {}, id: '0', items: [item1, item2], name: 'name' }; - - let wrapper; - let instance; - - beforeEach(() => { - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.setState({ currentCollection: collection, selected: undefined }); - instance.setState = jest.fn(); - }); - - test('should set same collection and no selected item to state if no items present in collection', () => { - const noItemsCollection = { ...collection, items: undefined }; - const expectedCollection = { ...collection, items: [] }; - - instance.updateCollection(noItemsCollection, { id: 3 }).then(() => { - expect(instance.setState).toHaveBeenCalledWith( - { currentCollection: expectedCollection, selected: undefined }, - noop, - ); - }); - }); - - test('should update the collection items selected to false even if selected item is not in the collection', () => { - const expectedItem1 = { id: 1, selected: false, thumbnailUrl: null }; - const expectedItem2 = { id: 2, selected: false, thumbnailUrl: null }; - const expectedCollection = { - boxItem: {}, - id: '0', - items: [expectedItem1, expectedItem2], - name: 'name', - }; - - instance.updateCollection(collection, { id: 3 }).then(() => { - expect(instance.setState).toHaveBeenCalledWith( - { currentCollection: expectedCollection, selected: undefined }, - noop, - ); - }); - }); - - test('should update the collection items selected to false except for the selected item in the collection', () => { - const expectedItem1 = { id: 1, selected: false, thumbnailUrl: null }; - const expectedItem2 = { id: 2, selected: true, thumbnailUrl: null }; - const expectedCollection = { - boxItem: {}, - id: '0', - items: [expectedItem1, expectedItem2], - name: 'name', - }; - - instance.updateCollection(collection, { id: 2 }).then(() => { - expect(instance.setState).toHaveBeenCalledWith( - { currentCollection: expectedCollection, selected: expectedItem2 }, - noop, - ); - }); - }); - - test('should update the selected item in the collection', () => { - const expectedItem1 = { id: 1, selected: false, thumbnailUrl: null }; - const expectedItem2 = { id: 2, selected: true, newProperty: 'newProperty', thumbnailUrl: null }; - const expectedCollection = { - boxItem: {}, - id: '0', - items: [expectedItem1, expectedItem2], - name: 'name', - }; - - instance.updateCollection(collection, { id: 2, newProperty: 'newProperty' }).then(() => { - expect(instance.setState).toHaveBeenCalledWith( - { - currentCollection: expectedCollection, - selected: { ...expectedItem2, newProperty: 'newProperty' }, - }, - noop, - ); - }); - }); - }); - - describe('thumbnails', () => { - const baseItem = { id: '1', selected: true, type: 'file' }; - const baseCollection = { - boxItem: {}, - id: '0', - items: [baseItem], - name: 'collectionName', - selected: baseItem, - }; - const thumbnailUrl = 'thumbnailUrl'; - const callback = jest.fn(); - - let wrapper; - let instance; - let collection; - let item; - - beforeEach(() => { - collection = cloneDeep(baseCollection); - item = cloneDeep(baseItem); - }); - - test('should add thumbnailUrl', () => { - const getThumbnailUrl = jest.fn().mockReturnValue(thumbnailUrl); - const getFileAPI = jest.fn().mockReturnValue({ - getThumbnailUrl, - }); - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.api = { getFileAPI }; - instance.setState = jest.fn(); - - return instance.updateCollection(collection, item, callback).then(() => { - const newSelected = { ...item, thumbnailUrl }; - const newCollection = { ...collection, items: [newSelected] }; - - expect(instance.setState).toHaveBeenCalledWith( - { currentCollection: newCollection, selected: newSelected }, - callback, - ); - }); - }); - test('should not call attemptThumbnailGeneration if thumbnail is null', () => { - const getThumbnailUrl = jest.fn().mockReturnValue(null); - const getFileAPI = jest.fn().mockReturnValue({ - getThumbnailUrl, - }); - - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.api = { getFileAPI }; - instance.setState = jest.fn(); - instance.attemptThumbnailGeneration = jest.fn(); - - return instance.updateCollection(collection, item, callback).then(() => { - expect(instance.attemptThumbnailGeneration).not.toHaveBeenCalled(); - }); - }); - - test('should not call attemptThumbnailGeneration if isThumbnailReady is true', () => { - const getThumbnailUrl = jest.fn().mockReturnValue(null); - const getFileAPI = jest.fn().mockReturnValue({ - getThumbnailUrl, - }); - - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.api = { getFileAPI }; - instance.setState = jest.fn(); - instance.attemptThumbnailGeneration = jest.fn(); - utils.isThumbnailReady = jest.fn().mockReturnValue(true); - - return instance.updateCollection(collection, item, callback).then(() => { - expect(instance.attemptThumbnailGeneration).not.toHaveBeenCalled(); - }); - }); - - test('should call attemptThumbnailGeneration if isThumbnailReady is false', () => { - const getThumbnailUrl = jest.fn().mockReturnValue(thumbnailUrl); - const getFileAPI = jest.fn().mockReturnValue({ - getThumbnailUrl, - }); - - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.api = { getFileAPI }; - instance.setState = jest.fn(); - instance.attemptThumbnailGeneration = jest.fn(); - utils.isThumbnailReady = jest.fn().mockReturnValue(false); - - return instance.updateCollection(collection, item, callback).then(() => { - expect(instance.attemptThumbnailGeneration).toHaveBeenCalled(); - }); - }); - - test('should not call attemptThumbnailGeneration or getThumbnailUrl if item is not file', () => { - const getThumbnailUrl = jest.fn().mockReturnValue(thumbnailUrl); - const getFileAPI = jest.fn().mockReturnValue({ - getThumbnailUrl, - }); - - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.api = { getFileAPI }; - instance.setState = jest.fn(); - instance.attemptThumbnailGeneration = jest.fn(); - utils.isThumbnailReady = jest.fn().mockReturnValue(false); - - collection.items[0].type = 'folder'; - return instance.updateCollection(collection, item, callback).then(() => { - expect(instance.attemptThumbnailGeneration).not.toHaveBeenCalled(); - expect(getThumbnailUrl).not.toHaveBeenCalled(); - }); - }); - }); - - describe('attemptThumbnailGeneration()', () => { - const entry1 = { name: 'entry1', updated: false }; - const entry2 = { name: 'entry2', updated: false }; - const itemWithRepresentation = { representations: { entries: [entry1, entry2] } }; - const itemWithoutRepresentation = { name: 'item' }; - - let wrapper; - let instance; - - test('should not update item in collection if grid view is not enabled', () => { - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.updateItemInCollection = jest.fn(); - return instance.attemptThumbnailGeneration(itemWithRepresentation).then(() => { - expect(instance.updateItemInCollection).not.toHaveBeenCalled(); - }); - }); - - test('should not update item in collection if item does not have representation', () => { - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.updateItemInCollection = jest.fn(); - return instance.attemptThumbnailGeneration(itemWithoutRepresentation).then(() => { - expect(instance.updateItemInCollection).not.toHaveBeenCalled(); - }); - }); - - test('should not update item in collection if updated representation matches given representation', () => { - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.updateItemInCollection = jest.fn(); - instance.api = { - getFileAPI: jest - .fn() - .mockReturnValue({ generateRepresentation: jest.fn().mockReturnValue(entry1) }), - }; - return instance.attemptThumbnailGeneration(itemWithRepresentation).then(() => { - expect(instance.updateItemInCollection).not.toHaveBeenCalled(); - }); - }); - - test('should update item in collection if representation is updated', () => { - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.updateItemInCollection = jest.fn(); - instance.api = { - getFileAPI: jest.fn().mockReturnValue({ - generateRepresentation: jest.fn().mockReturnValue({ ...entry1, updated: true }), - }), - }; - return instance.attemptThumbnailGeneration(itemWithRepresentation).then(() => { - expect(instance.updateItemInCollection).toHaveBeenCalledWith({ - ...itemWithRepresentation, - representations: { entries: [{ ...entry1, updated: true }, entry2] }, - }); - }); - }); - }); - - describe('updateItemInCollection()', () => { - const item1 = { id: '1', updated: false }; - const item2 = { id: '2', updated: false }; - const baseCollection = { items: [item1, item2] }; - - let wrapper; - let instance; - - beforeEach(() => { - wrapper = getWrapper(); - instance = wrapper.instance(); - act(() => { - instance.setState({ currentCollection: baseCollection }); - }); - instance.setState = jest.fn(); - }); - - test('should not update collection if matching id is not present in collection', () => { - const item3 = { id: '3', updated: true }; - act(() => { - instance.updateItemInCollection(item3); - }); - expect(instance.setState).toHaveBeenCalledWith({ currentCollection: baseCollection }); - }); - - test('should update collection if matching id is present in collection', () => { - const newItem2 = { id: '2', updated: true }; - act(() => { - instance.updateItemInCollection(newItem2); - }); - expect(instance.setState).toHaveBeenCalledWith({ - currentCollection: { ...baseCollection, items: [item1, newItem2] }, - }); - }); - }); - }); - - describe('lifecycle methods', () => { - test('componentDidUpdate', () => { - const props = { - currentFolderId: '123', - }; - - const wrapper = getWrapper(props); - const instance = wrapper.instance(); - instance.fetchFolder = jest.fn(); - - wrapper.setProps({ currentFolderId: '345' }); - - expect(instance.fetchFolder).toBeCalledWith('345'); - }); - }); - - describe('getMaxNumberOfGridViewColumnsForWidth()', () => { - test('should be able to display 7 columns if isVeryLarge', () => { - const wrapper = getWrapper({ isVeryLarge: true }); - const instance = wrapper.instance(); - expect(instance.getMaxNumberOfGridViewColumnsForWidth()).toBe(7); - }); - - test('should only be able to display 5 columns if isLarge', () => { - const wrapper = getWrapper({ isLarge: true }); - const instance = wrapper.instance(); - expect(instance.getMaxNumberOfGridViewColumnsForWidth()).toBe(5); - }); - - test('should only be able to display 3 columns if isMedium', () => { - const wrapper = getWrapper({ isMedium: true }); - const instance = wrapper.instance(); - expect(instance.getMaxNumberOfGridViewColumnsForWidth()).toBe(3); - }); - - test('should only be able to display 1 column if isSmall', () => { - const wrapper = getWrapper({ isSmall: true }); - const instance = wrapper.instance(); - expect(instance.getMaxNumberOfGridViewColumnsForWidth()).toBe(1); - }); - }); - - describe('updateMetadata()', () => { - test('should update metadata for given Box item, field, old and new values', () => { - const item = {}; - const field = 'amount'; - const oldValue = 'abc'; - const newValue = 'pqr'; - - const wrapper = getWrapper(); - const instance = wrapper.instance(); - instance.metadataQueryAPIHelper = { - updateMetadata: jest.fn(), - }; - - instance.updateMetadata(item, field, oldValue, newValue); - expect(instance.metadataQueryAPIHelper.updateMetadata).toHaveBeenCalledWith( - item, - field, - oldValue, - newValue, - expect.any(Function), - instance.errorCallback, - ); - }); - }); - - describe('updateMetadataSuccessCallback()', () => { - test('should correctly update the current collection and set the state', () => { - const boxItem = { id: 2 }; - const field = 'amount'; - const newValue = 111.22; - const collectionItem1 = { - id: 1, - metadata: { - enterprise: { - fields: [ - { - name: 'name', - key: 'name', - value: 'abc', - type: 'string', - }, - { - name: 'amount', - key: 'amount', - value: 100.34, - type: 'float', - }, - ], - }, - }, - }; - const collectionItem2 = { - id: 2, - metadata: { - enterprise: { - fields: [ - { - name: 'name', - key: 'name', - value: 'pqr', - type: 'string', - }, - { - name: 'amount', - key: 'amount', - value: 354.23, - type: 'float', - }, - ], - }, - }, - }; - const clonedCollectionItem2 = cloneDeep(collectionItem2); - const nextMarker = 'markermarkermarkermarkermarkermarker'; - const currentCollection = { - items: [collectionItem1, collectionItem2], - nextMarker, - }; - const wrapper = getWrapper(); - - // update the metadata - clonedCollectionItem2.metadata.enterprise.fields.find(item => item.key === field).value = newValue; - - const updatedItems = [collectionItem1, clonedCollectionItem2]; - - act(() => { - wrapper.setState({ currentCollection }); - }); - - const instance = wrapper.instance(); - instance.setState = jest.fn(); - act(() => { - instance.updateMetadataSuccessCallback(boxItem, field, newValue); - }); - expect(instance.setState).toHaveBeenCalledWith({ - currentCollection: { - items: updatedItems, - nextMarker, - percentLoaded: 100, - }, - }); - }); - }); - - describe('handleSharedLinkSuccess()', () => { - const getApiShareMock = jest.fn().mockImplementation((item, access, callback) => callback()); - const getApiMock = jest.fn().mockReturnValue({ share: getApiShareMock }); - const updateCollectionMock = jest.fn(); - - const boxItem = { - shared_link: 'not null', - permissions: { - can_share: true, - can_set_share_access: false, - }, - type: 'file', - }; - - let wrapper; - let instance; - - beforeEach(() => { - wrapper = getWrapper(); - instance = wrapper.instance(); - instance.api = { getAPI: getApiMock }; - instance.updateCollection = updateCollectionMock; - }); - - afterEach(() => { - getApiMock.mockClear(); - getApiShareMock.mockClear(); - updateCollectionMock.mockClear(); - }); - - test('should create shared link if it does not exist', async () => { - await instance.handleSharedLinkSuccess({ ...boxItem, shared_link: null }); - - expect(getApiMock).toBeCalledTimes(1); - expect(getApiShareMock).toBeCalledTimes(1); - expect(updateCollectionMock).toBeCalledTimes(1); - }); - - test('should not create shared link if it already exists', async () => { - await instance.handleSharedLinkSuccess(boxItem); - - expect(getApiMock).not.toBeCalled(); - expect(getApiShareMock).not.toBeCalled(); - expect(updateCollectionMock).toBeCalledTimes(1); - }); - }); - - describe('render()', () => { - test('should render UploadDialog with contentUploaderProps', () => { - const contentUploaderProps = { - apiHost: 'https://api.box.com', - chunked: false, - }; - const wrapper = getWrapper({ canUpload: true, contentUploaderProps }); - act(() => { - wrapper.setState({ - currentCollection: { - permissions: { - can_upload: true, - }, - }, - }); - }); - const uploadDialogElement = wrapper.find(UploadDialog); - expect(uploadDialogElement.length).toBe(1); - expect(uploadDialogElement.prop('contentUploaderProps')).toEqual(contentUploaderProps); - }); - - test('should render test id for e2e testing', () => { - const wrapper = getWrapper(); - expect(wrapper.find('[data-testid="content-explorer"]')).toHaveLength(1); - }); - }); - - describe('deleteCallback', () => { - const getApiDeleteMock = jest.fn(); - const getApiMock = jest.fn().mockReturnValue({ deleteItem: getApiDeleteMock }); - const refreshCollectionMock = jest.fn(); - const onDeleteMock = jest.fn(); - const boxItem = { - id: '123', - parent: { - id: '122', - }, - permissions: { - can_delete: true, - }, - type: 'file', - }; - - let wrapper; - let instance; - - beforeEach(() => { - wrapper = getWrapper({ - canDelete: true, - onDelete: onDeleteMock, - }); - instance = wrapper.instance(); - instance.api = { getAPI: getApiMock, getCache: jest.fn() }; - instance.refreshCollection = refreshCollectionMock; - act(() => { - instance.setState({ - selected: boxItem, - isDeleteModalOpen: true, - }); - }); - }); - - afterEach(() => { - getApiMock.mockClear(); - getApiDeleteMock.mockClear(); - refreshCollectionMock.mockClear(); - }); - - test('should call refreshCollection and onDelete callback on success', async () => { - getApiDeleteMock.mockImplementation((item, successCallback) => successCallback()); - act(() => { - instance.deleteCallback(); - }); - expect(getApiMock).toBeCalledTimes(1); - expect(getApiDeleteMock).toBeCalledTimes(1); - expect(onDeleteMock).toBeCalledTimes(1); - expect(refreshCollectionMock).toBeCalledTimes(1); - }); - - test('should call refreshCollection on error', async () => { - getApiDeleteMock.mockImplementation((item, successCallback, errorCallback) => errorCallback()); - act(() => { - instance.deleteCallback(); - }); - - expect(getApiMock).toBeCalledTimes(1); - expect(getApiDeleteMock).toBeCalledTimes(1); - expect(onDeleteMock).not.toBeCalled(); - expect(refreshCollectionMock).toBeCalledTimes(1); - }); - }); -}); diff --git a/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx new file mode 100644 index 0000000000..a25f325141 --- /dev/null +++ b/src/elements/content-explorer/__tests__/ContentExplorer.test.tsx @@ -0,0 +1,970 @@ +import React, { act } from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import { mount } from 'enzyme'; +import noop from 'lodash/noop'; +import userEvent from '@testing-library/user-event'; +import * as utils from '../utils'; +import { render, screen, waitFor, within } from '../../../test-utils/testing-library'; +import { ContentExplorerComponent as ContentExplorer, ContentExplorerProps } from '../ContentExplorer'; +import UploadDialog from '../../common/upload-dialog'; +import CONTENT_EXPLORER_FOLDER_FIELDS_TO_FETCH from '../constants'; +import { VIEW_MODE_GRID } from '../../../constants'; +import { mockRootFolder, mockRootFolderSharedLink } from '../stories/__mocks__/mockRootFolder'; + +jest.mock('../../../utils/Xhr', () => { + return jest.fn().mockImplementation(() => { + return { + get: jest.fn(({ url }) => { + switch (url) { + case 'https://api.box.com/2.0/folders/69083462919': + return Promise.resolve({ data: mockRootFolder }); + case 'https://api.box.com/2.0/folders/73426618530': + return Promise.resolve({ data: mockRootFolderSharedLink }); + default: + return Promise.reject(new Error('Not Found')); + } + }), + put: jest.fn(({ url, data }) => { + switch (url) { + case 'https://api.box.com/2.0/folders/73426618530': + return Promise.resolve({ data }); + default: + return Promise.reject(new Error('Not Found')); + } + }), + delete: jest.fn(({ url }) => { + switch (url) { + case 'https://api.box.com/2.0/folders/73426618530?recursive=true': + return Promise.resolve({ data: {} }); + default: + return Promise.reject(new Error('Not Found')); + } + }), + abort: jest.fn(), + }; + }); +}); + +jest.mock( + '@box/react-virtualized/dist/es/AutoSizer', + () => + ({ children }) => + children({ height: 600, width: 600 }), +); + +describe('elements/content-explorer/ContentExplorer', () => { + let rootElement: any; + const getWrapper = (props = {}) => + mount(, { attachTo: rootElement }); + + const renderComponent = (props: Partial = {}) => { + return render(); + }; + + beforeEach(() => { + rootElement = document.createElement('div'); + rootElement.appendChild(document.createElement('div')); + document.body.appendChild(rootElement); + }); + + afterEach(() => { + document.body.removeChild(rootElement); + }); + + describe('render', () => { + test('should render the component', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: 'Preview Test Folder' })).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Modified')).toBeInTheDocument(); + expect(screen.getByText('Size')).toBeInTheDocument(); + expect(screen.getByText('An Ordered Folder')).toBeInTheDocument(); + expect(screen.getByText('Modified Tue Apr 16 2019 by Preview')).toBeInTheDocument(); + }); + + test('shoulder render grid view mode', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: 'Preview Test Folder' })).toBeInTheDocument(); + + const gridButton = screen.getByRole('button', { name: 'Switch to Grid View' }); + await userEvent.click(gridButton); + + expect(screen.queryByText('Name')).not.toBeInTheDocument(); + expect(screen.queryByText('Modified')).not.toBeInTheDocument(); + expect(screen.queryByText('Size')).not.toBeInTheDocument(); + + expect(screen.getByText('An Ordered Folder')).toBeInTheDocument(); + expect(screen.getByText('Modified Tue Apr 16 2019 by Preview')).toBeInTheDocument(); + expect(screen.getByText('193.24 MB')).toBeInTheDocument(); + }); + }); + + describe('Upload', () => { + test('should upload a new item', async () => { + renderComponent({ canUpload: true }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const addButton = screen.getByRole('button', { name: 'Add' }); + await userEvent.click(addButton); + + const uploadButton = screen.getByText('Upload'); + await userEvent.click(uploadButton); + + expect(screen.getByText('Drag and drop files')).toBeInTheDocument(); + expect(screen.getByText('Browse your device')).toBeInTheDocument(); + }); + + test('should not render upload button when canUpload is false', async () => { + renderComponent({ canUpload: false }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const addButton = screen.getByRole('button', { name: 'Add' }); + await userEvent.click(addButton); + + expect(screen.queryByText('Upload')).not.toBeInTheDocument(); + }); + }); + + describe('New Folder', () => { + test('should open new folder dialog', async () => { + const onCreate = jest.fn(); + renderComponent({ canCreateNewFolder: true, onCreate }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const addButton = screen.getByRole('button', { name: 'Add' }); + await userEvent.click(addButton); + + const uploadButton = screen.getByText('New Folder'); + await userEvent.click(uploadButton); + + expect(screen.getByText('Please enter a name.')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + test('should not render new folder button when canCreateNewFolder is false', async () => { + renderComponent({ canCreateNewFolder: false }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const addButton = screen.getByRole('button', { name: 'Add' }); + await userEvent.click(addButton); + + expect(screen.queryByText('New Folder')).not.toBeInTheDocument(); + }); + }); + + describe('Rename item', () => { + test('should open rename dialog', async () => { + const onRename = jest.fn(); + renderComponent({ onRename }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[0]; + await userEvent.click(moreOptionsButton); + let renameButton = screen.getByText('Rename'); + expect(renameButton).toBeInTheDocument(); + await userEvent.click(renameButton); + + const input = screen.getByRole('textbox', { name: 'Please enter a new name for An Ordered Folder:' }); + expect(input).toBeInTheDocument(); + + renameButton = screen.getByRole('button', { name: 'Rename' }); + expect(renameButton).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + await userEvent.clear(input); + await userEvent.type(input, 'New Ordered Folder'); + await userEvent.click(renameButton); + + expect(onRename).toHaveBeenCalledWith({ + ...mockRootFolder.item_collection.entries[0], + selected: true, + thumbnailUrl: null, + }); + }); + + test('should not render rename button when canRename is false', async () => { + renderComponent({ canRename: false }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[0]; + await userEvent.click(moreOptionsButton); + + expect(screen.queryByText('Rename')).not.toBeInTheDocument(); + }); + }); + + describe('Share', () => { + test('should create share link', async () => { + renderComponent({ isSmall: true }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[0]; + await userEvent.click(moreOptionsButton); + + const shareButton = within(screen.getByRole('menu')).getByText('Share'); + expect(shareButton).toBeInTheDocument(); + await userEvent.click(shareButton); + // const shareButton = screen.getByRole('button', { name: 'Share' }); + // expect(shareButton).toBeInTheDocument(); + // await userEvent.click(shareButton, { pointerEventsCheck: 0 }); + screen.debug(undefined, 30000); + + // expect(await screen.findByText('Please enter a new name for An Ordered Folder:')).toBeInTheDocument(); + // expect(screen.getByPlaceholderText('https://exmaple.com/share-link')).toBeInTheDocument(); + + // + // expect(screen.getByRole('button', { name: 'COPY' })).toBeInTheDocument(); + // expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + test('should not render share button when canShare is false', async () => { + renderComponent({ canShare: false }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[0]; + await userEvent.click(moreOptionsButton); + + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + }); + }); + + describe('Delete', () => { + test('should delete item', async () => { + const onDelete = jest.fn(); + renderComponent({ canCreateNewFolder: true, onDelete }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[0]; + await userEvent.click(moreOptionsButton); + const deleteButton = screen.getByText('Delete'); + expect(deleteButton).toBeInTheDocument(); + await userEvent.click(deleteButton); + + screen.debug(undefined, 30000); + + expect( + screen.getByText('Are you sure you want to delete An Ordered Folder and all its contents?'), + ).toBeInTheDocument(); + + const deleteButtonConfirm = screen.getByRole('button', { name: 'Delete' }); + expect(deleteButtonConfirm).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + + await userEvent.click(deleteButtonConfirm); + + expect(onDelete).toHaveBeenCalledWith([ + { ...mockRootFolder.item_collection.entries[0], selected: true, thumbnailUrl: null }, + ]); + }); + + test('should not render delete button when canDelete is false', async () => { + renderComponent({ canDelete: false }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[0]; + await userEvent.click(moreOptionsButton); + + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + }); + + describe('Download', () => { + test('should download item', async () => { + const onDownload = jest.fn(); + renderComponent({ onDownload }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[3]; + await userEvent.click(moreOptionsButton); + + const downloadButton = screen.getByText('Download'); + expect(downloadButton).toBeInTheDocument(); + await userEvent.click(downloadButton); + + expect(onDownload).toHaveBeenCalledWith([ + { + ...mockRootFolder.item_collection.entries[3], + selected: true, + thumbnailUrl: + 'https://dl.boxcloud.com/api/2.0/internal_files/416044542013/versions/439751948413/representations/jpg_1024x1024/content/?access_token=token', + }, + ]); + }); + + test('should not render download button when canDownload is false', async () => { + renderComponent({ canDownload: false }); + + await waitFor(() => { + expect(screen.getByTestId('content-explorer')).toBeInTheDocument(); + expect(screen.getByText('Please wait while the items load...')).toBeInTheDocument(); + }); + + const moreOptionsButton = screen.getAllByRole('button', { name: 'More options' })[3]; + await userEvent.click(moreOptionsButton); + + expect(screen.queryByText('Download')).not.toBeInTheDocument(); + }); + }); + + describe('metadata', () => { + test('should render metadata view', async () => {}); + }); + + xdescribe('old', () => { + describe('uploadSuccessHandler()', () => { + test('should force reload the files list', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + + act(() => { + instance.setState({ + currentCollection: { + id: '123', + }, + }); + }); + + instance.fetchFolder = jest.fn(); + + act(() => { + instance.uploadSuccessHandler(); + }); + + expect(instance.fetchFolder).toHaveBeenCalledWith('123', false); + }); + }); + + describe('recentsSuccessCallback()', () => { + const collection = { name: 'collection ' } as const; + + test('navigation event should not be triggered if argument set to false', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + instance.updateCollection = jest.fn(); + + instance.recentsSuccessCallback(collection, false); + expect(instance.updateCollection).toHaveBeenCalledWith(collection); + }); + + test('navigation event should be triggered if argument set to true ', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + instance.updateCollection = jest.fn(); + + instance.recentsSuccessCallback(collection, true); + expect(instance.updateCollection).toHaveBeenCalledWith( + collection, + undefined, + instance.finishNavigation, + ); + }); + }); + + describe('updateCollection()', () => { + describe('selection', () => { + const item1 = { id: 1 } as const; + const item2 = { id: 2 } as const; + const collection = { boxItem: {}, id: '0', items: [item1, item2], name: 'name' } as const; + + let wrapper: any; + let instance: any; + + beforeEach(() => { + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.setState({ currentCollection: collection, selected: undefined }); + instance.setState = jest.fn(); + }); + + test('should set same collection and no selected item to state if no items present in collection', () => { + const noItemsCollection = { ...collection, items: undefined } as const; + const expectedCollection = { ...collection, items: [] } as const; + + instance.updateCollection(noItemsCollection, { id: 3 }).then(() => { + expect(instance.setState).toHaveBeenCalledWith( + { currentCollection: expectedCollection, selected: undefined }, + noop, + ); + }); + }); + + test('should update the collection items selected to false even if selected item is not in the collection', () => { + const expectedItem1 = { id: 1, selected: false, thumbnailUrl: null } as const; + const expectedItem2 = { id: 2, selected: false, thumbnailUrl: null } as const; + const expectedCollection = { + boxItem: {}, + id: '0', + items: [expectedItem1, expectedItem2], + name: 'name', + } as const; + + instance.updateCollection(collection, { id: 3 }).then(() => { + expect(instance.setState).toHaveBeenCalledWith( + { currentCollection: expectedCollection, selected: undefined }, + noop, + ); + }); + }); + + test('should update the collection items selected to false except for the selected item in the collection', () => { + const expectedItem1 = { id: 1, selected: false, thumbnailUrl: null } as const; + const expectedItem2 = { id: 2, selected: true, thumbnailUrl: null } as const; + const expectedCollection = { + boxItem: {}, + id: '0', + items: [expectedItem1, expectedItem2], + name: 'name', + } as const; + + instance.updateCollection(collection, { id: 2 }).then(() => { + expect(instance.setState).toHaveBeenCalledWith( + { currentCollection: expectedCollection, selected: expectedItem2 }, + noop, + ); + }); + }); + + test('should update the selected item in the collection', () => { + const expectedItem1 = { id: 1, selected: false, thumbnailUrl: null } as const; + const expectedItem2 = { + id: 2, + selected: true, + newProperty: 'newProperty', + thumbnailUrl: null, + } as const; + const expectedCollection = { + boxItem: {}, + id: '0', + items: [expectedItem1, expectedItem2], + name: 'name', + } as const; + + instance.updateCollection(collection, { id: 2, newProperty: 'newProperty' }).then(() => { + expect(instance.setState).toHaveBeenCalledWith( + { + currentCollection: expectedCollection, + selected: { ...expectedItem2, newProperty: 'newProperty' }, + }, + noop, + ); + }); + }); + }); + + describe('thumbnails', () => { + const baseItem = { id: '1', selected: true, type: 'file' } as const; + const baseCollection = { + boxItem: {}, + id: '0', + items: [baseItem], + name: 'collectionName', + selected: baseItem, + } as const; + const thumbnailUrl = 'thumbnailUrl'; + const callback = jest.fn(); + + let wrapper: any; + let instance: any; + let collection: any; + let item: any; + + beforeEach(() => { + collection = cloneDeep(baseCollection); + item = cloneDeep(baseItem); + }); + + test('should add thumbnailUrl', () => { + const getThumbnailUrl = jest.fn().mockReturnValue(thumbnailUrl); + const getFileAPI = jest.fn().mockReturnValue({ + getThumbnailUrl, + }); + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.api = { getFileAPI }; + instance.setState = jest.fn(); + + return instance.updateCollection(collection, item, callback).then(() => { + const newSelected = { ...item, thumbnailUrl } as const; + const newCollection = { ...collection, items: [newSelected] } as const; + + expect(instance.setState).toHaveBeenCalledWith( + { currentCollection: newCollection, selected: newSelected }, + callback, + ); + }); + }); + test('should not call attemptThumbnailGeneration if thumbnail is null', () => { + const getThumbnailUrl = jest.fn().mockReturnValue(null); + const getFileAPI = jest.fn().mockReturnValue({ + getThumbnailUrl, + }); + + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.api = { getFileAPI }; + instance.setState = jest.fn(); + instance.attemptThumbnailGeneration = jest.fn(); + + return instance.updateCollection(collection, item, callback).then(() => { + expect(instance.attemptThumbnailGeneration).not.toHaveBeenCalled(); + }); + }); + + test('should not call attemptThumbnailGeneration if isThumbnailReady is true', () => { + const getThumbnailUrl = jest.fn().mockReturnValue(null); + const getFileAPI = jest.fn().mockReturnValue({ + getThumbnailUrl, + }); + + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.api = { getFileAPI }; + instance.setState = jest.fn(); + instance.attemptThumbnailGeneration = jest.fn(); + utils.isThumbnailReady = jest.fn().mockReturnValue(true); + + return instance.updateCollection(collection, item, callback).then(() => { + expect(instance.attemptThumbnailGeneration).not.toHaveBeenCalled(); + }); + }); + + test('should call attemptThumbnailGeneration if isThumbnailReady is false', () => { + const getThumbnailUrl = jest.fn().mockReturnValue(thumbnailUrl); + const getFileAPI = jest.fn().mockReturnValue({ + getThumbnailUrl, + }); + + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.api = { getFileAPI }; + instance.setState = jest.fn(); + instance.attemptThumbnailGeneration = jest.fn(); + utils.isThumbnailReady = jest.fn().mockReturnValue(false); + + return instance.updateCollection(collection, item, callback).then(() => { + expect(instance.attemptThumbnailGeneration).toHaveBeenCalled(); + }); + }); + + test('should not call attemptThumbnailGeneration or getThumbnailUrl if item is not file', () => { + const getThumbnailUrl = jest.fn().mockReturnValue(thumbnailUrl); + const getFileAPI = jest.fn().mockReturnValue({ + getThumbnailUrl, + }); + + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.api = { getFileAPI }; + instance.setState = jest.fn(); + instance.attemptThumbnailGeneration = jest.fn(); + utils.isThumbnailReady = jest.fn().mockReturnValue(false); + + collection.items[0].type = 'folder'; + return instance.updateCollection(collection, item, callback).then(() => { + expect(instance.attemptThumbnailGeneration).not.toHaveBeenCalled(); + expect(getThumbnailUrl).not.toHaveBeenCalled(); + }); + }); + }); + + describe('attemptThumbnailGeneration()', () => { + const entry1 = { name: 'entry1', updated: false } as const; + const entry2 = { name: 'entry2', updated: false } as const; + const itemWithRepresentation = { representations: { entries: [entry1, entry2] } } as const; + const itemWithoutRepresentation = { name: 'item' } as const; + + let wrapper: any; + let instance: any; + + test('should not update item in collection if grid view is not enabled', () => { + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.updateItemInCollection = jest.fn(); + return instance.attemptThumbnailGeneration(itemWithRepresentation).then(() => { + expect(instance.updateItemInCollection).not.toHaveBeenCalled(); + }); + }); + + test('should not update item in collection if item does not have representation', () => { + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.updateItemInCollection = jest.fn(); + return instance.attemptThumbnailGeneration(itemWithoutRepresentation).then(() => { + expect(instance.updateItemInCollection).not.toHaveBeenCalled(); + }); + }); + + test('should not update item in collection if updated representation matches given representation', () => { + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.updateItemInCollection = jest.fn(); + instance.api = { + getFileAPI: jest + .fn() + .mockReturnValue({ generateRepresentation: jest.fn().mockReturnValue(entry1) }), + }; + return instance.attemptThumbnailGeneration(itemWithRepresentation).then(() => { + expect(instance.updateItemInCollection).not.toHaveBeenCalled(); + }); + }); + + test('should update item in collection if representation is updated', () => { + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.updateItemInCollection = jest.fn(); + instance.api = { + getFileAPI: jest.fn().mockReturnValue({ + generateRepresentation: jest.fn().mockReturnValue({ ...entry1, updated: true }), + }), + }; + return instance.attemptThumbnailGeneration(itemWithRepresentation).then(() => { + expect(instance.updateItemInCollection).toHaveBeenCalledWith({ + ...itemWithRepresentation, + representations: { entries: [{ ...entry1, updated: true }, entry2] }, + }); + }); + }); + }); + + describe('updateItemInCollection()', () => { + const item1 = { id: '1', updated: false } as const; + const item2 = { id: '2', updated: false } as const; + const baseCollection = { items: [item1, item2] } as const; + + let wrapper: any; + let instance: any; + + beforeEach(() => { + wrapper = getWrapper(); + instance = wrapper.instance(); + act(() => { + instance.setState({ currentCollection: baseCollection }); + }); + instance.setState = jest.fn(); + }); + + test('should not update collection if matching id is not present in collection', () => { + const item3 = { id: '3', updated: true } as const; + act(() => { + instance.updateItemInCollection(item3); + }); + expect(instance.setState).toHaveBeenCalledWith({ currentCollection: baseCollection }); + }); + + test('should update collection if matching id is present in collection', () => { + const newItem2 = { id: '2', updated: true } as const; + act(() => { + instance.updateItemInCollection(newItem2); + }); + expect(instance.setState).toHaveBeenCalledWith({ + currentCollection: { ...baseCollection, items: [item1, newItem2] }, + }); + }); + }); + }); + + describe('updateMetadata()', () => { + test('should update metadata for given Box item, field, old and new values', () => { + const item: Record = {}; + const field = 'amount'; + const oldValue = 'abc'; + const newValue = 'pqr'; + + const wrapper = getWrapper(); + const instance = wrapper.instance(); + instance.metadataQueryAPIHelper = { + updateMetadata: jest.fn(), + }; + + instance.updateMetadata(item, field, oldValue, newValue); + expect(instance.metadataQueryAPIHelper.updateMetadata).toHaveBeenCalledWith( + item, + field, + oldValue, + newValue, + expect.any(Function), + instance.errorCallback, + ); + }); + }); + + describe('updateMetadataSuccessCallback()', () => { + test('should correctly update the current collection and set the state', () => { + const boxItem = { id: 2 } as const; + const field = 'amount'; + const newValue = 111.22; + const collectionItem1 = { + id: 1, + metadata: { + enterprise: { + fields: [ + { + name: 'name', + key: 'name', + value: 'abc', + type: 'string', + }, + { + name: 'amount', + key: 'amount', + value: 100.34, + type: 'float', + }, + ], + }, + }, + }; + const collectionItem2 = { + id: 2, + metadata: { + enterprise: { + fields: [ + { + name: 'name', + key: 'name', + value: 'pqr', + type: 'string', + }, + { + name: 'amount', + key: 'amount', + value: 354.23, + type: 'float', + }, + ], + }, + }, + }; + const clonedCollectionItem2 = cloneDeep(collectionItem2); + const nextMarker = 'markermarkermarkermarkermarkermarker'; + const currentCollection = { + items: [collectionItem1, collectionItem2], + nextMarker, + } as const; + const wrapper = getWrapper(); + + // update the metadata + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + clonedCollectionItem2.metadata.enterprise.fields.find(item => item.key === field).value = newValue; + + const updatedItems = [collectionItem1, clonedCollectionItem2]; + + act(() => { + wrapper.setState({ currentCollection }); + }); + + const instance = wrapper.instance(); + instance.setState = jest.fn(); + act(() => { + instance.updateMetadataSuccessCallback(boxItem, field, newValue); + }); + expect(instance.setState).toHaveBeenCalledWith({ + currentCollection: { + items: updatedItems, + nextMarker, + percentLoaded: 100, + }, + }); + }); + }); + + describe('handleSharedLinkSuccess()', () => { + const getApiShareMock = jest.fn().mockImplementation((item: any, access: any, callback: any) => callback()); + const getApiMock = jest.fn().mockReturnValue({ share: getApiShareMock }); + const updateCollectionMock = jest.fn(); + + const boxItem = { + shared_link: 'not null', + permissions: { + can_share: true, + can_set_share_access: false, + }, + type: 'file', + } as const; + + let wrapper: any; + let instance: any; + + beforeEach(() => { + wrapper = getWrapper(); + instance = wrapper.instance(); + instance.api = { getAPI: getApiMock }; + instance.updateCollection = updateCollectionMock; + }); + + afterEach(() => { + getApiMock.mockClear(); + getApiShareMock.mockClear(); + updateCollectionMock.mockClear(); + }); + + test('should create shared link if it does not exist', async () => { + await instance.handleSharedLinkSuccess({ ...boxItem, shared_link: null }); + + expect(getApiMock).toBeCalledTimes(1); + expect(getApiShareMock).toBeCalledTimes(1); + expect(updateCollectionMock).toBeCalledTimes(1); + }); + + test('should not create shared link if it already exists', async () => { + await instance.handleSharedLinkSuccess(boxItem); + + expect(getApiMock).not.toBeCalled(); + expect(getApiShareMock).not.toBeCalled(); + expect(updateCollectionMock).toBeCalledTimes(1); + }); + }); + + describe('render()', () => { + test('should render UploadDialog with contentUploaderProps', () => { + const contentUploaderProps = { + apiHost: 'https://api.box.com', + chunked: false, + } as const; + const wrapper = getWrapper({ canUpload: true, contentUploaderProps }); + act(() => { + wrapper.setState({ + currentCollection: { + permissions: { + can_upload: true, + }, + }, + }); + }); + const uploadDialogElement = wrapper.find(UploadDialog); + expect(uploadDialogElement.length).toBe(1); + expect(uploadDialogElement.prop('contentUploaderProps')).toEqual(contentUploaderProps); + }); + + test('should render test id for e2e testing', () => { + const wrapper = getWrapper(); + expect(wrapper.find('[data-testid="content-explorer"]')).toHaveLength(1); + }); + }); + + describe('deleteCallback', () => { + const getApiDeleteMock = jest.fn(); + const getApiMock = jest.fn().mockReturnValue({ deleteItem: getApiDeleteMock }); + const refreshCollectionMock = jest.fn(); + const onDeleteMock = jest.fn(); + const boxItem = { + id: '123', + parent: { + id: '122', + }, + permissions: { + can_delete: true, + }, + type: 'file', + } as const; + + let wrapper: any; + let instance: any; + + beforeEach(() => { + wrapper = getWrapper({ + canDelete: true, + onDelete: onDeleteMock, + }); + instance = wrapper.instance(); + instance.api = { getAPI: getApiMock, getCache: jest.fn() }; + instance.refreshCollection = refreshCollectionMock; + act(() => { + instance.setState({ + selected: boxItem, + isDeleteModalOpen: true, + }); + }); + }); + + afterEach(() => { + getApiMock.mockClear(); + getApiDeleteMock.mockClear(); + refreshCollectionMock.mockClear(); + }); + + test('should call refreshCollection and onDelete callback on success', async () => { + getApiDeleteMock.mockImplementation((item: any, successCallback: any) => successCallback()); + act(() => { + instance.deleteCallback(); + }); + expect(getApiMock).toBeCalledTimes(1); + expect(getApiDeleteMock).toBeCalledTimes(1); + expect(onDeleteMock).toBeCalledTimes(1); + expect(refreshCollectionMock).toBeCalledTimes(1); + }); + + test('should call refreshCollection on error', async () => { + getApiDeleteMock.mockImplementation((item: any, successCallback: any, errorCallback: any) => + errorCallback(), + ); + act(() => { + instance.deleteCallback(); + }); + + expect(getApiMock).toBeCalledTimes(1); + expect(getApiDeleteMock).toBeCalledTimes(1); + expect(onDeleteMock).not.toBeCalled(); + expect(refreshCollectionMock).toBeCalledTimes(1); + }); + }); + }); +}); diff --git a/src/elements/content-explorer/stories/__mocks__/mockRootFolder.ts b/src/elements/content-explorer/stories/__mocks__/mockRootFolder.ts index 1ff6d9b26d..7f04fdfd3b 100644 --- a/src/elements/content-explorer/stories/__mocks__/mockRootFolder.ts +++ b/src/elements/content-explorer/stories/__mocks__/mockRootFolder.ts @@ -73,11 +73,11 @@ const mockRootFolder = { parent: null, permissions: { can_download: true, - can_upload: false, - can_rename: false, - can_delete: false, + can_upload: true, + can_rename: true, + can_delete: true, can_share: false, - can_invite_collaborator: false, + can_invite_collaborator: true, can_set_share_access: false, }, path_collection: { @@ -124,11 +124,11 @@ const mockRootFolder = { }, permissions: { can_download: true, - can_upload: false, - can_rename: false, - can_delete: false, - can_share: false, - can_invite_collaborator: false, + can_upload: true, + can_rename: true, + can_delete: true, + can_share: true, + can_invite_collaborator: true, can_set_share_access: false, }, path_collection: { @@ -178,6 +178,63 @@ const mockRootFolder = { etag: '2', name: 'Preview Test Folder', }, + permissions: { + can_download: true, + can_upload: true, + can_rename: true, + can_delete: true, + can_share: false, + can_invite_collaborator: true, + can_set_share_access: false, + }, + path_collection: { + total_count: 2, + entries: [ + { + type: 'folder', + id: '0', + sequence_id: null, + etag: null, + name: 'All Files', + }, + { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, + ], + }, + modified_at: '2020-12-16T03:21:44-07:00', + created_at: '2020-11-12T09:33:22-07:00', + modified_by: { + type: 'user', + id: '7503712462', + name: 'Preview', + login: 'preview@boxdemo.com', + }, + has_collaborations: true, + is_externally_owned: false, + shared_link: null, + watermark_info: { + is_watermarked: false, + }, + archive_type: 'archive', + }, + { + type: 'folder', + id: '73426618532', + etag: '3', + name: 'Archived Folder', + size: 1231231, + parent: { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, permissions: { can_download: true, can_upload: false, @@ -206,6 +263,288 @@ const mockRootFolder = { }, ], }, + modified_at: '2020-12-17T05:21:44-07:00', + created_at: '2020-11-12T12:33:22-07:00', + modified_by: { + type: 'user', + id: '7503712462', + name: 'Preview', + login: 'preview@boxdemo.com', + }, + has_collaborations: true, + is_externally_owned: false, + shared_link: null, + watermark_info: { + is_watermarked: false, + }, + archive_type: 'folder_archive', + }, + { + type: 'file', + id: '416044542013', + etag: '1', + name: 'Book Sample.pdf', + size: 144481, + parent: { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, + extension: 'pdf', + permissions: { + can_download: true, + can_preview: true, + can_upload: false, + can_comment: true, + can_rename: true, + can_delete: true, + can_share: false, + can_set_share_access: false, + can_invite_collaborator: false, + can_annotate: false, + can_view_annotations_all: true, + can_view_annotations_self: true, + can_create_annotations: true, + can_view_annotations: true, + }, + path_collection: { + total_count: 2, + entries: [ + { + type: 'folder', + id: '0', + sequence_id: null, + etag: null, + name: 'All Files', + }, + { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, + ], + }, + modified_at: '2022-12-07T22:13:30-08:00', + created_at: '2019-03-05T12:47:51-08:00', + modified_by: { + type: 'user', + id: '7503712462', + name: 'Preview', + login: 'preview@boxdemo.com', + }, + has_collaborations: true, + is_externally_owned: false, + authenticated_download_url: 'https://dl.boxcloud.com/api/2.0/files/416044542013/content', + is_download_available: true, + representations: { + entries: [ + { + representation: 'jpg', + properties: { + dimensions: '1024x1024', + paged: 'false', + thumb: 'false', + }, + info: { + url: 'https://api.box.com/2.0/internal_files/416044542013/versions/439751948413/representations/jpg_1024x1024', + }, + status: { + state: 'success', + }, + content: { + url_template: + 'https://dl.boxcloud.com/api/2.0/internal_files/416044542013/versions/439751948413/representations/jpg_1024x1024/content/{+asset_path}', + }, + }, + ], + }, + file_version: { + type: 'file_version', + id: '439751948413', + sha1: '81fa3796742c6d194ddc54e9424f855f78009cf1', + }, + sha1: '81fa3796742c6d194ddc54e9424f855f78009cf1', + shared_link: { + url: 'https://example.com/share-link', + permissions: { + can_preview: true, + can_download: true, + can_edit: false, + }, + }, + watermark_info: { + is_watermarked: false, + }, + }, + ], + offset: 0, + limit: 50, + order: [ + { + by: 'type', + direction: 'ASC', + }, + { + by: 'name', + direction: 'ASC', + }, + ], + }, +}; + +const mockRootFolderSharedLink = { + type: 'folder', + id: '69083462919', + etag: '2', + name: 'Preview Test Folder', + size: 1301485279, + parent: null, + permissions: { + can_download: true, + can_upload: true, + can_rename: true, + can_delete: true, + can_share: false, + can_invite_collaborator: true, + can_set_share_access: false, + }, + path_collection: { + total_count: 1, + entries: [ + { + type: 'folder', + id: '0', + sequence_id: null, + etag: null, + name: 'All Files', + }, + ], + }, + modified_at: '2024-01-16T09:50:27-08:00', + created_at: '2019-03-04T11:23:26-08:00', + modified_by: { + type: 'user', + id: '7505500060', + name: 'Preview', + login: 'preview@boxdemo.com', + }, + has_collaborations: true, + is_externally_owned: false, + shared_link: null, + watermark_info: { + is_watermarked: false, + }, + item_collection: { + total_count: 2, + entries: [ + { + type: 'folder', + id: '73426618530', + etag: '3', + name: 'An Ordered Folder', + size: 202621773, + parent: { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, + permissions: { + can_download: true, + can_upload: true, + can_rename: true, + can_delete: true, + can_share: true, + can_invite_collaborator: true, + can_set_share_access: false, + }, + path_collection: { + total_count: 2, + entries: [ + { + type: 'folder', + id: '0', + sequence_id: null, + etag: null, + name: 'All Files', + }, + { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, + ], + }, + modified_at: '2019-04-16T15:44:44-07:00', + created_at: '2019-04-16T15:44:14-07:00', + modified_by: { + type: 'user', + id: '7503712462', + name: 'Preview', + login: 'preview@boxdemo.com', + }, + has_collaborations: true, + is_externally_owned: false, + shared_link: { + url: 'https://example.com/share-link', + permissions: { + can_preview: true, + can_download: true, + can_edit: false, + }, + }, + watermark_info: { + is_watermarked: false, + }, + }, + { + type: 'folder', + id: '73426618531', + etag: '3', + name: 'Archive', + size: 1231231, + parent: { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, + permissions: { + can_download: true, + can_upload: true, + can_rename: true, + can_delete: true, + can_share: false, + can_invite_collaborator: true, + can_set_share_access: false, + }, + path_collection: { + total_count: 2, + entries: [ + { + type: 'folder', + id: '0', + sequence_id: null, + etag: null, + name: 'All Files', + }, + { + type: 'folder', + id: '69083462919', + sequence_id: '2', + etag: '2', + name: 'Preview Test Folder', + }, + ], + }, modified_at: '2020-12-16T03:21:44-07:00', created_at: '2020-11-12T09:33:22-07:00', modified_by: { @@ -300,7 +639,7 @@ const mockRootFolder = { can_comment: true, can_rename: true, can_delete: true, - can_share: true, + can_share: false, can_set_share_access: false, can_invite_collaborator: false, can_annotate: false, @@ -396,4 +735,4 @@ const mockRootFolder = { }, }; -export { mockEmptyRootFolder, mockRootFolder }; +export { mockEmptyRootFolder, mockRootFolder, mockRootFolderSharedLink }; diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index e510957067..e4a5ae80f5 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -6,6 +6,7 @@ import getProp from 'lodash/get'; import noop from 'lodash/noop'; import uniqueid from 'lodash/uniqueId'; import { TooltipProvider } from '@box/blueprint-web'; +import { AxiosRequestConfig, AxiosResponse } from 'axios'; import DroppableContent from './DroppableContent'; import Footer from './Footer'; import UploadsManager from './UploadsManager'; @@ -90,8 +91,8 @@ export interface ContentUploaderProps { onUpgradeCTAClick?: () => void; onUpload: (item?: UploadItem | BoxItem) => void; overwrite: boolean; - requestInterceptor?: Function; - responseInterceptor?: Function; + requestInterceptor?: (response: AxiosResponse) => void; + responseInterceptor?: (config: AxiosRequestConfig) => void; rootFolderId: string; sharedLink?: string; sharedLinkPassword?: string;