UNPKG

box-ui-elements-mlh

Version:
1,669 lines (1,501 loc) 56.8 kB
/** * @flow * @file Content Explorer Component * @author Box */ 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 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, FOLDER_FIELDS_TO_FETCH } from '../../utils/fields'; import LocalStore from '../../utils/LocalStore'; import { withFeatureConsumer, withFeatureProvider, type 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, 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, } from '../../constants'; import type { ViewMode } from '../common/flowTypes'; 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?: 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, 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<?string>, rootName: string, searchQuery: string, selected?: BoxItem, sortBy: SortBy, sortDirection: SortDirection, view: View, }; const localStoreViewMode = 'bce.defaultViewMode'; class ContentExplorer extends Component<Props, State> { 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): any): HTMLElement); this.appElement = ((this.rootElement.firstElementChild: any): 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: 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: 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: 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 {Object} item file or folder object * @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); }); }; /** * Chages 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, callback: Function = noop): Object { const { items = [] } = collection; const fileAPI = this.api.getFileAPI(false); const newCollection: Collection = { ...collection }; const selectedId = selectedItem ? selectedItem.id : null; let newSelectedItem: ?BoxItem; 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, }; 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<void>} */ attemptThumbnailGeneration = async (item: BoxItem): Promise<void> => { 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 }; 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: Function = 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: Function = (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(); }); }; /** * 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, (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) { 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, (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 = (newItem: BoxItem) => { const { currentCollection } = this.state; // Update item in collection this.updateCollection(currentCollection, newItem, () => this.setState({ isShareModalOpen: true })); }; /** * Chages the sort by and sort direction * * @private * @return {void} */ shareCallback = (): void => { const { selected }: State = this.state; const { canShare }: Props = this.props; if (!selected || !canShare) { return; } const { permissions } = selected; if (!permissions) { 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}`); } }; /** * Keyboard events * * @private * @return {void} */ onKeyDown = (event: SyntheticKeyboardEvent<HTMLElement>) => { 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': 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, newValue: ?MetadataFieldValue, ): void => { this.metadataQueryAPIHelper.updateMetadata( item, field, oldValue, newValue, () => { this.updateMetadataSuccessCallback(item, field, newValue); }, this.errorCallback, ); }; updateMetadataSuccessCallback = (item: BoxItem, field: string, newValue: ?MetadataFieldValue): 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, 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 ( <Internationalize language={language} messages={messages}> <div id={this.id} className={styleClassName} ref={measureRef} data-testid="content-explorer"> <div className="be-app-element" onKeyDown={this.onKeyDown} tabIndex={0}> {!isDefaultViewMetadata && ( <> <Header view={view} isSmall={isSmall} searchQuery={searchQuery} logoUrl={logoUrl} onSearch={this.search} /> <SubHeader view={view} viewMode={viewMode} rootId={rootFolderId} isSmall={isSmall} rootName={rootName} currentCollection={currentCollection} canUpload={allowUpload} canCreateNewFolder={allowCreate} gridColumnCount={gridColumnCount} gridMaxColumns={GRID_VIEW_MAX_COLUMNS} gridMinColumns={GRID_VIEW_MIN_COLUMNS} maxGridColumnCountForWidth={maxGridColumnCount} onUpload={this.upload} onCreate={this.createFolder} onGridViewSliderChange={this.onGridViewSliderChange} onItemClick={this.fetchFolder} onSortChange={this.sort} onViewModeChange={this.changeViewMode} /> </> )} <Content canDelete={canDelete} canDownload={canDownload} canPreview={canPreview} canRename={canRename} canSetShareAccess={canSetShareAccess} canShare={canShare} currentCollection={currentCollection} focusedRow={focusedRow} gridColumnCount={Math.min(gridColumnCount, maxGridColumnC