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 isEqual from 'lodash/isEqual'; import noop from 'lodash/noop'; import throttle from 'lodash/throttle'; import uniqueid from 'lodash/uniqueId'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { Key, Selection } from 'react-aria-components'; import type { MetadataTemplateField } from '@box/metadata-editor'; import CreateFolderDialog from '../common/create-folder-dialog'; import UploadDialog from '../common/upload-dialog'; import Header from '../common/header'; import Pagination from '../common/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 MetadataQueryAPIHelperV2 from './MetadataQueryAPIHelper'; import MetadataQueryAPIHelper from '../../features/metadata-based-view/MetadataQueryAPIHelper'; import MetadataSidePanel from './MetadataSidePanel'; import Footer from './Footer'; import PreviewDialog from '../common/preview-dialog/PreviewDialog'; import ShareDialog from './ShareDialog'; import RenameDialog from './RenameDialog'; import DeleteConfirmationDialog from './DeleteConfirmationDialog'; import Content from './Content'; import { isThumbnailAvailable } from '../common/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, isFeatureEnabled, 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_ITEM_NAME, 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, CLIENT_VERSION, 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 { ItemAction } from '../common/item'; import type { Theme } from '../common/theming'; import type { JSONPatchOperations } from '../../common/types/api'; import type { MetadataQuery, FieldsToShow } from '../../common/types/metadataQueries'; import type { MetadataFieldValue, MetadataTemplate } from '../../common/types/metadata'; import type { View, DefaultView, StringMap, SortBy, SortDirection, Token, Access, Collection, BoxItemPermission, BoxItem, } from '../../common/types/core'; import type { BulkItemAction } from '../common/sub-header/BulkItemActionMenu'; import type { ContentPreviewProps } from '../content-preview'; import type { ContentUploaderProps } from '../content-uploader'; import type { ExternalFilterValues, MetadataViewContainerProps } from './MetadataViewContainer'; import '../common/fonts.scss'; import '../common/base.scss'; import '../common/modal.scss'; import './ContentExplorer.scss'; import { withBlueprintModernization } from '../common/withBlueprintModernization'; import Providers from '../common/Providers'; const GRID_VIEW_MAX_COLUMNS = 7; const GRID_VIEW_MIN_COLUMNS = 1; export interface ContentExplorerProps { apiHost?: string; appHost?: string; autoFocus?: boolean; bulkItemActions?: BulkItemAction[]; 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; hasProviders?: boolean; initialPage?: number; initialPageSize?: number; isLarge?: boolean; isMedium?: boolean; isSmall?: boolean; isTouch?: boolean; isVeryLarge?: boolean; itemActions?: ItemAction[]; language?: string; logoUrl?: string; measureRef?: (ref: Element | null) => void; messages?: StringMap; metadataQuery?: MetadataQuery; metadataViewProps?: Omit< MetadataViewContainerProps, 'hasError' | 'currentCollection' | 'metadataTemplate' | 'selectedKeys' >; 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 | Key; sortDirection?: SortDirection; staticHost?: string; staticPath?: string; theme?: Theme; title?: string; token: Token; uploadHost?: string; } enum SidePanelState { CLOSED = 'CLOSED', OPEN = 'OPEN', EDITING = 'EDITING', } 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; metadataTemplate: MetadataTemplate; metadataFilters: ExternalFilterValues; rootName: string; searchQuery: string; selected?: BoxItem; selectedItemIds: Selection; sidePanelState: SidePanelState; sortBy: SortBy | string; sortDirection: SortDirection; view: View; }; const localStoreViewMode = 'bce.defaultViewMode'; class ContentExplorer extends Component { id: string; api: API; state: State; props: ContentExplorerProps; rootElement: HTMLElement; appElement: HTMLElement; globalModifier: boolean; firstLoad: boolean = true; // Keeps track of very 1st load store: LocalStore = new LocalStore(); metadataQueryAPIHelper: MetadataQueryAPIHelper | MetadataQueryAPIHelperV2; 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: {}, metadataViewProps: {}, }; /** * [constructor] * * @private * @return {ContentExplorer} */ constructor(props: ContentExplorerProps) { super(props); const { apiHost, initialPage, initialPageSize, language, requestInterceptor, responseInterceptor, rootFolderId, sharedLink, sharedLinkPassword, sortBy, sortDirection, token, uploadHost, }: ContentExplorerProps = props; this.api = new API({ apiHost, clientName: CLIENT_NAME_CONTENT_EXPLORER, id: `${TYPED_ID_FOLDER_PREFIX}${rootFolderId}`, language, requestInterceptor, responseInterceptor, sharedLink, sharedLinkPassword, token, uploadHost, version: CLIENT_VERSION, }); 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, sidePanelState: SidePanelState.CLOSED, isPreviewModalOpen: false, isRenameModalOpen: false, isShareModalOpen: false, isUploadModalOpen: false, markers: [], metadataFilters: {}, metadataTemplate: {}, rootName: '', selectedItemIds: new Set(), 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 }: ContentExplorerProps = 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 }: ContentExplorerProps, prevState: State): void { const { currentFolderId }: ContentExplorerProps = 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 * @param {Object} metadataTemplate - Metadata template object * @return {void} */ showMetadataQueryResultsSuccessCallback = ( metadataQueryCollection: Collection, metadataTemplate: MetadataTemplate, ): void => { const { nextMarker } = metadataQueryCollection; const { metadataQuery, features } = this.props; const { currentCollection, currentPageNumber, markers }: State = this.state; const cloneMarkers = [...markers]; if (nextMarker) { cloneMarkers[currentPageNumber + 1] = nextMarker; } const nextState = { currentCollection: { ...currentCollection, ...metadataQueryCollection, percentLoaded: 100, }, markers: cloneMarkers, metadataTemplate, }; this.validateSelectedItemIds(metadataQueryCollection.items || []); // if v2, fetch folder name and add to state if (metadataQuery?.ancestor_folder_id && isFeatureEnabled(features, 'contentExplorer.metadataViewV2')) { this.api.getFolderAPI().getFolderFields( metadataQuery.ancestor_folder_id, ({ name }) => { this.setState({ ...nextState, rootName: name || '', }); }, this.errorCallback, { fields: [FIELD_NAME] }, ); } else { // No folder name to fetch, update state immediately with just metadata this.setState(nextState); } }; /** * Queries metadata_queries/execute API and fetches the result * * @private * @return {void} */ showMetadataQueryResults() { const { features, metadataQuery = {} }: ContentExplorerProps = this.props; const { currentPageNumber, markers, metadataFilters, sortBy, sortDirection }: 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, }); if (isFeatureEnabled(features, 'contentExplorer.metadataViewV2')) { metadataQueryClone.order_by = [ { // Default to the prefixed name field for metadata view v2 only, while not touching the default sortBy for other views. field_key: sortBy === FIELD_NAME ? FIELD_ITEM_NAME : sortBy, direction: sortDirection, }, ]; this.metadataQueryAPIHelper = new MetadataQueryAPIHelperV2(this.api); this.metadataQueryAPIHelper.fetchMetadataQueryResults( metadataQueryClone, this.showMetadataQueryResultsSuccessCallback, this.errorCallback, metadataFilters, ); } else { metadataQueryClone.order_by = [ { field_key: sortBy, direction: sortDirection, }, ]; this.metadataQueryAPIHelper = new MetadataQueryAPIHelper(this.api); this.metadataQueryAPIHelper.fetchMetadataQueryResults( metadataQueryClone, this.showMetadataQueryResultsSuccessCallback, this.errorCallback, ); } } /** * Update selected items' metadata instances based on original and new field values in the metadata instance form * * @private * @return {void} */ updateMetadataV2 = async ( items: BoxItem[], operations: JSONPatchOperations, templateOldFields: MetadataTemplateField[], templateNewFields: MetadataTemplateField[], successCallback: () => void, errorCallback: ErrorCallback, ) => { if (items.length === 1) { await this.metadataQueryAPIHelper.updateMetadataWithOperations( items[0], operations, successCallback, errorCallback, ); } else { await this.metadataQueryAPIHelper.bulkUpdateMetadata( items, templateOldFields, templateNewFields, successCallback, 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: unknown) => { 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 | Key, sortDirection: SortDirection) => { const { currentCollection: { id }, view, }: State = this.state; if (id || view === VIEW_METADATA) { 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: () => 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 | 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 && !isThumbnailAvailable(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); } /** * Validates selectedItemIds to ensure all selected IDs exist in current items * This should be called whenever currentCollection changes * * @private * @param {BoxItem[]} items - current items in the collection * @return {void} */ validateSelectedItemIds = (items: BoxItem[]): void => { const { selectedItemIds } = this.state; let validSelectedIds = new Set(); if (selectedItemIds === 'all') { validSelectedIds = new Set(items.map(item => item.id)); } else if (selectedItemIds.size > 0) { validSelectedIds = new Set(items.filter(item => selectedItemIds.has(item.id)).map(item => item.id)); } if (!isEqual(validSelectedIds, selectedItemIds)) { this.handleSelectedIdsChange(validSelectedIds, true); this.closeSidePanel(); } }; /** * 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.validateSelectedItemIds(newCollection.items); 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: (item: BoxItem) => void = noop): void => { const { selected, currentCollection }: State = this.state; const { items = [] } = currentCollection; const { onSelect }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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 = (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 }: ContentExplorerProps = 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 }: ContentExplorerProps = 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.throttledCreateFolderCallback(); }; /** * New folder callback * * @private * @param {string} name - folder name * @return {void} */ createFolderCallback = (name?: string): void => { const { isCreateFolderModalOpen, currentCollection }: State = this.state; const { canCreateNewFolder, onCreate }: ContentExplorerProps = 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, }); }, ); }; /** * Throttled version of createFolderCallback to prevent errors from rapid clicking. * * @private * @param {string} [name] - folder name * @return {void} */ throttledCreateFolderCallback = throttle(this.createFolderCallback, 500, { trailing: 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 }: ContentExplorerProps = 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); }; /** * 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, sidePanelState: SidePanelState.CLOSED, }); 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: React.KeyboardEvent) => { if (isInputElement(event.target)) { return; } const { rootFolderId }: ContentExplorerProps = 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 (document.activeElement && !document.activeElement.closest('.be-ItemGrid')) { focus(this.rootElement, '.be-ItemGrid-item', false); event.preventDefault(); } } else if (document.activeElement && !document.activeElement.closest('.be-ItemList')) { focus(this.rootElement, '.be-ItemList-item', 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; }; getMetadataViewProps = (): Omit< MetadataViewContainerProps, 'hasError' | 'currentCollection' | 'metadataTemplate' > => { const { metadataViewProps } = this.props; const { currentPageNumber, markers, selectedItemIds } = this.state; const hasNextMarker: boolean = !!markers[currentPageNumber + 1]; const hasPrevMarker: boolean = currentPageNumber === 1 || !!markers[currentPageNumber - 1]; return { ...metadataViewProps, selectedKeys: selectedItemIds, onSelectionChange: this.handleSelectedIdsChange, paginationProps: { onMarkerBasedPageChange: this.markerBasedPaginate, hasNextMarker, hasPrevMarker, type: 'marker', }, }; }; /** * 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.validateSelectedItemIds(updatedItems); this.setState({ currentCollection: { items: updatedItems, nextMarker, percentLoaded: 100, }, }); }; handleSelectedIdsChange = (ids: Selection, allowDuringEditing: boolean = false) => { const { metadataViewProps } = this.props; const { onSelectionChange: onSelectionChangeExternal } = metadataViewProps; if (!allowDuringEditing && this.state.sidePanelState === SidePanelState.EDITING) { return; } onSelectionChangeExternal?.(ids); this.setState({ selectedItemIds: ids, }); const isSelectionEmpty = ids !== 'all' && ids.size === 0; if (isSelectionEmpty) { this.closeSidePanel(); } }; clearSelectedItemIds = () => { this.handleSelectedIdsChange(new Set(), true); }; /** * Toggle metadata side panel visibility * * @private * @return {void} */ onSidePanelToggle = () => { this.setState(prevState => ({ sidePanelState: prevState.sidePanelState === SidePanelState.CLOSED ? SidePanelState.OPEN : SidePanelState.CLOSED, })); }; closeSidePanel = () => { this.setState({ sidePanelState: SidePanelState.CLOSED, }); }; onSidePanelEditingChange = (isEditing: boolean) => { const { sidePanelState } = this.state; if (sidePanelState !== SidePanelState.CLOSED) { this.setState({ sidePanelState: isEditing ? SidePanelState.EDITING : SidePanelState.OPEN, }); } }; filterMetadata = (fields: ExternalFilterValues) => { this.setState({ metadataFilters: fields }, this.refreshCollection); }; /** * Renders the file picker * * @private * @inheritdoc * @return {Element} */ render() { const { apiHost, appHost, bulkItemActions, canCreateNewFolder, canDelete, canDownload, canPreview, canRename, canSetShareAccess, canShare, canUpload, className, contentPreviewProps, contentUploaderProps, defaultView, features, hasProviders, isMedium, isSmall, isTouch, itemActions, language, logoUrl, measureRef, messages, fieldsToShow, onDownload, onPreview, onUpload, requestInterceptor, responseInterceptor, rootFolderId, sharedLink, sharedLinkPassword, staticHost, staticPath, previewLibraryVersion, theme, title, token, uploadHost, }: ContentExplorerProps = this.props; const { currentCollection, currentPageNumber, currentPageSize, errorCode, gridColumnCount, isCreateFolderModalOpen, isDeleteModalOpen, isLoading, sidePanelState, isPreviewModalOpen, isRenameModalOpen, isShareModalOpen, isUploadModalOpen, markers, metadataTemplate, rootName, selected, selectedItemIds, 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 isMetadataViewV2Feature = isFeatureEnabled(features, 'contentExplorer.metadataViewV2'); 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]; const metadataViewProps = this.getMetadataViewProps(); const isSidePanelOpen = sidePanelState !== SidePanelState.CLOSED; const isEditing = sidePanelState === SidePanelState.EDITING; /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ return (
{!isDefaultViewMetadata && (
)} {!isErrorView && !isMetadataViewV2Feature && (
)}
{isDefaultViewMetadata && isMetadataViewV2Feature && isSidePanelOpen && ( )}
{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, withBlueprintModernization])( ContentExplorer, );