UNPKG

box-ui-elements-mlh

Version:
591 lines (531 loc) 19.4 kB
/** * @flow * @file Open With Component * @author Box */ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import uniqueid from 'lodash/uniqueId'; import noop from 'lodash/noop'; import { FormattedMessage } from 'react-intl'; import queryString from 'query-string'; import Internationalize from '../common/Internationalize'; import messages from '../common/messages'; import { withErrorBoundary } from '../common/error-boundary'; import API from '../../api'; import IntegrationPortalContainer from './IntegrationPortalContainer'; import OpenWithDropdownMenu from './OpenWithDropdownMenu'; import BoxToolsInstallMessage from './BoxToolsInstallMessage'; import OpenWithButton from './OpenWithButton'; import OpenWithFallbackButton from './OpenWithFallbackButton'; import ExecuteForm from './ExecuteForm'; import '../common/base.scss'; import './ContentOpenWith.scss'; import { BOX_EDIT_INTEGRATION_ID, BOX_EDIT_SFC_INTEGRATION_ID, CLIENT_NAME_OPEN_WITH, DEFAULT_HOSTNAME_API, ERROR_CODE_EXECUTE_INTEGRATION, HTTP_GET, HTTP_POST, ORIGIN_OPEN_WITH, TYPE_FILE, TYPE_FOLDER, } from '../../constants'; import type { Alignment } from '../common/flowTypes'; import type { ExecuteAPI, Integration } from '../../common/types/integrations'; import type { StringMap, Token, BoxItem } from '../../common/types/core'; const UNSUPPORTED_INVOCATION_METHOD_TYPE = 'Integration invocation using this HTTP method type is not supported'; const BLACKLISTED_ERROR_MESSAGE_KEY = 'boxToolsBlacklistedError'; const BOX_TOOLS_INSTALL_ERROR_MESSAGE_KEY = 'boxToolsInstallErrorMessage'; const GENERIC_EXECUTE_MESSAGE_KEY = 'executeIntegrationOpenWithErrorHeader'; const AUTH_CODE = 'auth_code'; type ExternalProps = { show?: boolean, }; type Props = { /** Box API url. */ apiHost: string, /** Class name applied to base component. */ boxToolsInstallUrl?: string, /** Application client name. */ boxToolsName?: string, /** Custom name for Box Tools to display to users */ className: string, /** Custom URL to direct users to install Box Tools */ clientName: string, /** Determines positioning of menu dropdown */ dropdownAlignment: Alignment, /** Box File ID. */ fileId: string, /** Language to use for translations. */ language?: string, /** Messages to be translated. */ messages?: StringMap, /** Callback that executes when an integration attempts to open the given file */ onError: Function, /** Callback that executes when an integration invocation fails. The two most common cases being API failures or blocking of a new window */ onExecute: Function, /** Axios request interceptor that runs before a network request. */ requestInterceptor?: Function, /** Axios response interceptor that runs before a network response is returned. */ responseInterceptor?: Function, /** Access token. */ token: Token, }; type State = { executePostData: ?Object, fetchError: ?Error, integrations: ?Array<Integration>, isDropdownOpen: boolean, isLoading: boolean, shouldRenderErrorIntegrationPortal: boolean, shouldRenderLoadingIntegrationPortal: boolean, }; class ContentOpenWith extends PureComponent<Props, State> { api: API; id: string; props: Props; state: State; window: any; integrationWindow: ?any; static defaultProps = { apiHost: DEFAULT_HOSTNAME_API, className: '', clientName: CLIENT_NAME_OPEN_WITH, onExecute: noop, onError: noop, }; initialState: State = { isDropdownOpen: false, integrations: null, isLoading: true, fetchError: null, executePostData: null, shouldRenderErrorIntegrationPortal: false, shouldRenderLoadingIntegrationPortal: false, }; /** * [constructor] * * @private * @return {ContentOpenWith} */ constructor(props: Props) { super(props); const { token, apiHost, clientName, language, requestInterceptor, responseInterceptor } = props; this.id = uniqueid('bcow_'); this.api = new API({ apiHost, clientName, language, requestInterceptor, responseInterceptor, token, }); // Clone initial state to allow for state reset on new files this.state = { ...this.initialState }; } /** * Destroys api instances with caches * * @private * @return {void} */ clearCache(): void { this.api.destroy(true); } /** * Cleanup * * @private * @inheritdoc * @return {void} */ componentWillUnmount() { // Don't destroy the cache while unmounting this.api.destroy(false); } /** * * @private * @inheritdoc * @return {void} */ componentDidMount() { const { fileId }: Props = this.props; if (!fileId) { return; } this.window = window; this.fetchOpenWithData(); } /** * After component updates, re-fetch Open With data if appropriate. * * @return {void} */ componentDidUpdate(prevProps: Props): void { const { fileId: currentFileId }: Props = this.props; const { fileId: previousFileId }: Props = prevProps; if (!currentFileId) { return; } if (currentFileId !== previousFileId) { this.setState({ ...this.initialState }); this.fetchOpenWithData(); } } /** * Checks if a given integration is a Box Edit integration. * * @param {string} [integrationId] - The integration ID * @return {boolean} */ isBoxEditIntegration(integrationId: ?string): boolean { return integrationId === BOX_EDIT_INTEGRATION_ID || this.isBoxEditSFCIntegration(integrationId); } /** * Checks if a given integration is a Box Edit integration. * * @param {string} [integrationId] - The integration ID * @return {boolean} */ isBoxEditSFCIntegration(integrationId: ?string): boolean { return integrationId === BOX_EDIT_SFC_INTEGRATION_ID; } /** * Fetches Open With data. * * @return {void} */ fetchOpenWithData(): void { const { fileId }: Props = this.props; this.api .getOpenWithAPI(false) .getOpenWithIntegrations(fileId, this.fetchOpenWithSuccessHandler, this.fetchErrorHandler); } /** * Fetch app integrations info needed to render. * * @param {OpenWithIntegrations} integrations - The available Open With integrations * @return {void} */ fetchOpenWithSuccessHandler = async (integrations: Array<Integration>): Promise<any> => { const { boxToolsName, boxToolsInstallUrl } = this.props; const boxEditIntegration = integrations.find(({ appIntegrationId }) => this.isBoxEditIntegration(appIntegrationId), ); if (boxEditIntegration && !boxEditIntegration.isDisabled) { try { const { extension } = await this.getIntegrationFileExtension(); boxEditIntegration.extension = extension; // If Box Edit is present and enabled, we need to set its ability to locally open the given file // No-op if these checks are successful await this.isBoxEditAvailable(); await this.canOpenExtensionWithBoxEdit(boxEditIntegration); } catch (error) { const errorMessageObject = messages[error.message] || messages[GENERIC_EXECUTE_MESSAGE_KEY]; let formattedErrorMessage = <FormattedMessage {...errorMessageObject} />; if (error.message === BOX_TOOLS_INSTALL_ERROR_MESSAGE_KEY) { formattedErrorMessage = ( <BoxToolsInstallMessage boxToolsInstallUrl={boxToolsInstallUrl} boxToolsName={boxToolsName} /> ); } boxEditIntegration.disabledReasons.push(formattedErrorMessage); boxEditIntegration.isDisabled = true; } } this.setState({ integrations, isLoading: false }); }; /** * Fetches the file extension of the current file. * * @return {Promise} */ getIntegrationFileExtension = (): Promise<BoxItem> => { const { fileId }: Props = this.props; return new Promise((resolve, reject) => { this.api .getFileAPI() .getFileExtension(fileId, resolve, () => reject(new Error(GENERIC_EXECUTE_MESSAGE_KEY))); }); }; /** * Uses Box Edit to check if Box Tools is installed and reachable * * @return {Promise} */ isBoxEditAvailable = (): Promise<any> => { return this.api .getBoxEditAPI() .checkBoxEditAvailability() .catch(() => { throw new Error(BOX_TOOLS_INSTALL_ERROR_MESSAGE_KEY); }); }; /** * Uses Box Edit to check if Box Tools can open a given file type * * @param {String} extension - A file extension * @return {Promise} */ canOpenExtensionWithBoxEdit = ({ extension = '' }: Integration): Promise<any> => { return this.api .getBoxEditAPI() .getAppForExtension(extension) .catch(() => { throw new Error(BLACKLISTED_ERROR_MESSAGE_KEY); }); }; /** * Handles a fetch error for the open_with_integrations and app_integrations endpoints * * @param {Error} error - An axios fetch error * @return {void} */ fetchErrorHandler = (error: any, code: string): void => { this.props.onError(error, code, { error }); this.setState({ fetchError: error, isLoading: false }); }; /** * Click handler when an integration is clicked * * @private * @param {string} appIntegrationId - An app integration ID * @param {string} displayName - The integration's display name * @return {void} */ onIntegrationClick = ({ appIntegrationId, displayName }: Integration): void => { const { fileId }: Props = this.props; const isBoxEditIntegration = this.isBoxEditIntegration(appIntegrationId); this.api .getAppIntegrationsAPI(false) .execute( appIntegrationId, fileId, this.executeIntegrationSuccessHandler.bind(this, appIntegrationId), isBoxEditIntegration ? this.executeBoxEditErrorHandler : this.executeIntegrationErrorHandler, ); if (isBoxEditIntegration) { // No window management is required when using Box Edit. return; } // These window features will open the new window directly on top of the current window at the same const windowFeatures = `left=${window.screenX},top=${window.screenY},height=${window.outerHeight},width=${window.innerWidth},toolbar=0`; // window.open() is immediately invoked to avoid popup-blockers // The name is included to be the target of a form if the integration is a POST integration. // A uniqueid is used to force the browser to open a new tab every time, while still allowing // a form to reference a given tab. this.integrationWindow = this.window.open('', `${uniqueid(appIntegrationId)}`, windowFeatures); this.integrationWindow.document.title = displayName; this.integrationWindow.onunload = this.cleanupIntegrationWindow; this.setState({ shouldRenderLoadingIntegrationPortal: true, shouldRenderErrorIntegrationPortal: false, }); }; /** * cleans up the portal UI when a tab is closed so that we can remount the component later. * * @private * @return {void} */ cleanupIntegrationWindow = () => { this.setState({ shouldRenderLoadingIntegrationPortal: false, shouldRenderErrorIntegrationPortal: false, }); }; /** * Opens the integration in a new tab based on the API data * * @private * @param {string} integrationId - The integration that was executed * @param {ExecuteAPI} executeData - API response on how to open an executed integration * @return {void} */ executeIntegrationSuccessHandler = (integrationId: string, executeData: ExecuteAPI): void => { if (this.isBoxEditIntegration(integrationId)) { this.executeBoxEditSuccessHandler(integrationId, executeData); } else { this.executeOnlineIntegrationSuccessHandler(executeData); } this.onExecute(integrationId); }; /** * Opens the file via a Partner Integration * * @private * @param {ExecuteAPI} executeData - API response on how to open an executed integration * @return {void} */ executeOnlineIntegrationSuccessHandler = (executeData: ExecuteAPI): void => { const { method, url } = executeData; switch (method) { case HTTP_POST: this.setState({ executePostData: executeData }); break; case HTTP_GET: if (!this.integrationWindow) { return; } // Prevents abuse of window.opener // see here for more details: https://mathiasbynens.github.io/rel-noopener/ this.integrationWindow.location = url; this.integrationWindow.opener = null; break; default: this.executeIntegrationErrorHandler( Error(UNSUPPORTED_INVOCATION_METHOD_TYPE), ERROR_CODE_EXECUTE_INTEGRATION, ); } this.integrationWindow = null; }; /** * Opens the file via Box Edit * * @private * @param {string} url - Integration execution URL * @return {void} */ executeBoxEditSuccessHandler = (integrationId: string, { url }: ExecuteAPI): void => { const { fileId, token, onError } = this.props; const queryParams = queryString.parse(url); const authCode = queryParams[AUTH_CODE]; const isFileScoped = this.isBoxEditSFCIntegration(integrationId); this.api .getBoxEditAPI() .openFile(fileId, { data: { auth_code: authCode, token, token_scope: isFileScoped ? TYPE_FILE : TYPE_FOLDER, }, }) .catch(error => { onError(error, ERROR_CODE_EXECUTE_INTEGRATION, { error }); }); }; /** * Clears state after a form has been submitted * * @private * @return {void} */ onExecuteFormSubmit = (): void => { this.setState({ executePostData: null }); }; /** * Calls the onExecute prop * * @private * @param {string} integrationID - The integration that was executed * @return {void} */ onExecute(integrationID: string) { this.props.onExecute(integrationID); this.setState({ shouldRenderLoadingIntegrationPortal: false, }); } /** * Handles execution related errors * * @private * @param {Error} error - Error object * @return {void} */ executeIntegrationErrorHandler = (error: any, code: string): void => { this.props.onError(error, code, { error }); // eslint-disable-next-line no-console console.error(error); this.setState({ shouldRenderLoadingIntegrationPortal: false, shouldRenderErrorIntegrationPortal: true, }); }; /** * Handles Box Edit execution related errors * * @private * @param {Error} error - Error object * @return {void} */ executeBoxEditErrorHandler = (error: any): void => { this.props.onError(error); // eslint-disable-next-line no-console console.error(error); }; /** * Gets a display integration, if available, for the Open With button * * @private * @return {?Integration} */ getDisplayIntegration(): ?Integration { const { integrations }: State = this.state; // We only consider an integration a display integration if is the only integration in our state return Array.isArray(integrations) && integrations.length === 1 ? integrations[0] : null; } /** * Render the Open With element * * @private * @inheritdoc * @return {Element} */ render() { const { language, messages: intlMessages, dropdownAlignment }: Props = this.props; const { fetchError, isLoading, integrations, executePostData, shouldRenderLoadingIntegrationPortal, shouldRenderErrorIntegrationPortal, }: State = this.state; const className = classNames('be bcow', this.props.className); const displayIntegration = this.getDisplayIntegration(); const numIntegrations = integrations ? integrations.length : 0; return ( <Internationalize language={language} messages={intlMessages}> <div className={className} data-testid="bcow-content" id={this.id}> {numIntegrations <= 1 ? ( <OpenWithButton displayIntegration={displayIntegration} error={fetchError} isLoading={isLoading} onClick={this.onIntegrationClick} /> ) : ( <OpenWithDropdownMenu dropdownAlignment={dropdownAlignment} integrations={((integrations: any): Array<Integration>)} onClick={this.onIntegrationClick} /> )} {(shouldRenderLoadingIntegrationPortal || shouldRenderErrorIntegrationPortal) && ( <IntegrationPortalContainer hasError={shouldRenderErrorIntegrationPortal} integrationWindow={this.integrationWindow} /> )} {executePostData && ( <ExecuteForm executePostData={executePostData} id={this.id} onSubmit={this.onExecuteFormSubmit} windowName={this.integrationWindow && this.integrationWindow.name} /> )} </div> </Internationalize> ); } } export type ContentOpenWithProps = Props & ExternalProps; export { ContentOpenWith as ContentOpenWithComponent }; export default withErrorBoundary(ORIGIN_OPEN_WITH, OpenWithFallbackButton)(ContentOpenWith);