UNPKG

box-ui-elements-mlh

Version:
444 lines (403 loc) 14.4 kB
/** * @flow * @file Metadata sidebar component * @author Box */ import * as React from 'react'; import flow from 'lodash/flow'; import getProp from 'lodash/get'; import noop from 'lodash/noop'; import { FormattedMessage } from 'react-intl'; import type { MessageDescriptor } from 'react-intl'; import API from '../../api'; import EmptyContent from '../../features/metadata-instance-editor/EmptyContent'; import InlineError from '../../components/inline-error/InlineError'; import Instances from '../../features/metadata-instance-editor/Instances'; import LoadingIndicator from '../../components/loading-indicator/LoadingIndicator'; import LoadingIndicatorWrapper from '../../components/loading-indicator/LoadingIndicatorWrapper'; import messages from '../common/messages'; import SidebarContent from './SidebarContent'; import TemplateDropdown from '../../features/metadata-instance-editor/TemplateDropdown'; import { normalizeTemplates } from '../../features/metadata-instance-editor/metadataUtil'; import { EVENT_JS_READY } from '../common/logger/constants'; import { isUserCorrectableError } from '../../utils/error'; import { mark } from '../../utils/performance'; import { withAPIContext } from '../common/api-context'; import { withErrorBoundary } from '../common/error-boundary'; import { withLogger } from '../common/logger'; import { FIELD_IS_EXTERNALLY_OWNED, FIELD_PERMISSIONS, FIELD_PERMISSIONS_CAN_UPLOAD, IS_ERROR_DISPLAYED, ORIGIN_METADATA_SIDEBAR, SIDEBAR_VIEW_METADATA, } from '../../constants'; import type { WithLoggerProps } from '../../common/types/logging'; import type { ElementsXhrError, ErrorContextProps, JSONPatchOperations } from '../../common/types/api'; import type { MetadataEditor, MetadataTemplate } from '../../common/types/metadata'; import type { BoxItem } from '../../common/types/core'; import './MetadataSidebar.scss'; type ExternalProps = { isFeatureEnabled: boolean, selectedTemplateKey?: string, templateFilters?: Array<string> | string, }; type PropsWithoutContext = { elementId: string, fileId: string, hasSidebarInitialized?: boolean, } & ExternalProps; type Props = { api: API, } & PropsWithoutContext & ErrorContextProps & WithLoggerProps; type State = { editors?: Array<MetadataEditor>, error?: MessageDescriptor, file?: BoxItem, isLoading: boolean, templates?: Array<MetadataTemplate>, }; const MARK_NAME_JS_READY = `${ORIGIN_METADATA_SIDEBAR}_${EVENT_JS_READY}`; mark(MARK_NAME_JS_READY); class MetadataSidebar extends React.PureComponent<Props, State> { state = { isLoading: false }; static defaultProps = { isFeatureEnabled: true, }; constructor(props: Props) { super(props); const { logger } = this.props; logger.onReadyMetric({ endMarkName: MARK_NAME_JS_READY, }); } componentDidMount() { this.fetchFile(); } /** * Common error callback * * @param {Error} error - API error * @param {string} code - error code * @param {Object} [newState] - optional state to set * @return {void} */ onApiError = (error: ElementsXhrError, code: string, newState: Object = {}) => { const { onError }: Props = this.props; const { status } = error; const isValidError = isUserCorrectableError(status); this.setState({ error: messages.sidebarMetadataEditingErrorContent, isLoading: false, ...newState, }); onError(error, code, { error, [IS_ERROR_DISPLAYED]: isValidError, }); }; /** * Checks upload permission * * @return {boolean} - true if metadata can be edited */ canEdit(): boolean { const { file }: State = this.state; return getProp(file, FIELD_PERMISSIONS_CAN_UPLOAD, false); } /** * Finds the editor we are editing * * @param {number} id - instance id * @return {Object} editor instance */ getEditor(id: string): ?MetadataEditor { const { editors = [] }: State = this.state; return editors.find(({ instance }) => instance.id === id); } /** * Instance remove success handler * * @param {Object} editor - the editor to remove * @return {void} */ onRemoveSuccessHandler(editor: MetadataEditor): void { const { editors = [] }: State = this.state; const clone = editors.slice(0); clone.splice(editors.indexOf(editor), 1); this.setState({ editors: clone }); } /** * Instance remove handler * * @param {string} id - instance id * @return {void} */ onRemove = (id: string): void => { const { api }: Props = this.props; const { file }: State = this.state; const editor = this.getEditor(id); if (!editor || !file) { return; } api.getMetadataAPI(false).deleteMetadata( file, editor.template, () => this.onRemoveSuccessHandler(editor), this.onApiError, ); }; /** * Instance add success handler * * @param {Object} editor - instance editor * @return {void} */ onAddSuccessHandler = (editor: MetadataEditor): void => { const { editors = [] }: State = this.state; const clone = editors.slice(0); clone.push(editor); this.setState({ editors: clone, isLoading: false }); }; /** * Instance add handler * * @param {Object} template - instance template * @return {void} */ onAdd = (template: MetadataTemplate) => { const { api }: Props = this.props; const { file }: State = this.state; if (!file) { return; } this.setState({ isLoading: true }); api.getMetadataAPI(false).createMetadata(file, template, this.onAddSuccessHandler, this.onApiError); }; /** * Instance save success handler * * @param {Object} oldEditor - prior editor * @param {Object} newEditor - updated editor * @return {void} */ replaceEditor(oldEditor: MetadataEditor, newEditor: MetadataEditor): void { const { editors = [] }: State = this.state; const clone = editors.slice(0); clone.splice(editors.indexOf(oldEditor), 1, newEditor); this.setState({ editors: clone }); } /** * Instance save error handler * * @param {Object} oldEditor - prior editor * @param {Object} error - api error * @param {string} code - error code * @return {void} */ onSaveErrorHandler(oldEditor: MetadataEditor, error: ElementsXhrError, code: string): void { const clone: MetadataEditor = { ...oldEditor, hasError: true }; // shallow clone suffices for hasError setting this.replaceEditor(oldEditor, clone); this.onApiError(error, code); } /** * Instance save handler * * @param {string} id - instance id * @param {Array} ops - json patch ops * @return {void} */ onSave = (id: string, ops: JSONPatchOperations): void => { const { api }: Props = this.props; const { file }: State = this.state; const oldEditor = this.getEditor(id); if (!oldEditor || !file) { return; } api.getMetadataAPI(false).updateMetadata( file, oldEditor.template, ops, (newEditor: MetadataEditor) => { this.replaceEditor(oldEditor, newEditor); }, (error: ElementsXhrError, code: string) => { this.onSaveErrorHandler(oldEditor, error, code); }, ); }; /** * Instance dirty handler * * @param {string} id - instance id * @param {boolean} isDirty - instance dirty state * @return {void} */ onModification = (id: string, isDirty: boolean) => { const oldEditor = this.getEditor(id); if (!oldEditor) { return; } const newEditor = { ...oldEditor, isDirty }; // shallow clone suffices for isDirty setting this.replaceEditor(oldEditor, newEditor); }; /** * Handles a failed metadata fetch * * @private * @param {Error} e - API error * @param {string} code - error code * @return {void} */ fetchMetadataErrorCallback = (e: ElementsXhrError, code: string) => { this.onApiError(e, code, { editors: undefined, error: messages.sidebarMetadataFetchingErrorContent, templates: undefined, }); }; /** * Handles a successful metadata fetch * * @param {Object} metadata - instances and templates * @return {void} */ fetchMetadataSuccessCallback = ({ editors, templates, }: { editors: Array<MetadataEditor>, templates: Array<MetadataTemplate>, }) => { const { selectedTemplateKey, templateFilters } = this.props; this.setState({ editors: editors.slice(0), // cloned for potential editing error: undefined, isLoading: false, templates: normalizeTemplates(templates, selectedTemplateKey, templateFilters), }); }; /** * Fetches the metadata editors * * @return {void} */ fetchMetadata(): void { const { api, isFeatureEnabled }: Props = this.props; const { file }: State = this.state; if (!file) { return; } api.getMetadataAPI(false).getMetadata( file, this.fetchMetadataSuccessCallback, this.fetchMetadataErrorCallback, isFeatureEnabled, { refreshCache: true }, ); } /** * Handles a failed file fetch * * @private * @param {Error} e - API error * @param {string} code - error code * @return {void} */ fetchFileErrorCallback = (e: ElementsXhrError, code: string) => { this.onApiError(e, code, { error: messages.sidebarFileFetchingErrorContent, file: undefined }); }; /** * Handles a successful file fetch. * Can be called multiple times when refreshing caches. * On file load we should fetch metadata, but we shouldn't need to fetch * if the file permissions haven't changed from a prior file fetch. * Metadata editors mostly care about upload permission. * * @param {Object} file - the Box file * @return {void} */ fetchFileSuccessCallback = (file: BoxItem) => { const { file: currentFile }: State = this.state; const currentCanUpload = getProp(currentFile, FIELD_PERMISSIONS_CAN_UPLOAD, false); const newCanUpload = getProp(file, FIELD_PERMISSIONS_CAN_UPLOAD, false); const shouldFetchMetadata = !currentFile || currentCanUpload !== newCanUpload; const callback = shouldFetchMetadata ? this.fetchMetadata : noop; this.setState({ file }, callback); }; /** * Fetches a file with the fields needed for metadata sidebar * * @return {void} */ fetchFile(): void { const { api, fileId }: Props = this.props; api.getFileAPI().getFile(fileId, this.fetchFileSuccessCallback, this.fetchFileErrorCallback, { fields: [FIELD_IS_EXTERNALLY_OWNED, FIELD_PERMISSIONS], refreshCache: true, // see implications in file success callback }); } refresh(): void { this.fetchMetadata(); } render() { const { editors, file, error, isLoading, templates }: State = this.state; const { elementId, selectedTemplateKey }: Props = this.props; const showEditor = !!file && !!templates && !!editors; const showLoadingIndicator = !error && !showEditor; const canEdit = this.canEdit(); const showTemplateDropdown = showEditor && canEdit; const showEmptyContent = showEditor && ((editors: any): Array<MetadataEditor>).length === 0; return ( <SidebarContent actions={ showTemplateDropdown ? ( <TemplateDropdown hasTemplates={templates && templates.length !== 0} isDropdownBusy={false} onAdd={this.onAdd} // $FlowFixMe checked via showTemplateDropdown & showEditor templates={templates} // $FlowFixMe checked via showTemplateDropdown & showEditor usedTemplates={editors.map(editor => editor.template)} /> ) : null } className="bcs-metadata" elementId={elementId} sidebarView={SIDEBAR_VIEW_METADATA} title={<FormattedMessage {...messages.sidebarMetadataTitle} />} > {error && ( <InlineError title={<FormattedMessage {...messages.error} />}> <FormattedMessage {...error} /> </InlineError> )} {showLoadingIndicator && <LoadingIndicator />} {showEditor && ( <LoadingIndicatorWrapper className="metadata-instance-editor" isLoading={isLoading}> {showEmptyContent ? ( <EmptyContent canAdd={canEdit} /> ) : ( <Instances editors={editors} onModification={this.onModification} onRemove={this.onRemove} onSave={this.onSave} selectedTemplateKey={selectedTemplateKey} /> )} </LoadingIndicatorWrapper> )} </SidebarContent> ); } } export type MetadataSidebarProps = ExternalProps; export { MetadataSidebar as MetadataSidebarComponent }; export default flow([withLogger(ORIGIN_METADATA_SIDEBAR), withErrorBoundary(ORIGIN_METADATA_SIDEBAR), withAPIContext])( MetadataSidebar, );