box-ui-elements-mlh
Version:
671 lines (605 loc) • 22.7 kB
JavaScript
// @flow
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import isEqual from 'lodash/isEqual';
import cloneDeep from 'lodash/cloneDeep';
import noop from 'lodash/noop';
import Collapsible from '../../components/collapsible/Collapsible';
import Form from '../../components/form-elements/form/Form';
import LoadingIndicatorWrapper from '../../components/loading-indicator/LoadingIndicatorWrapper';
import PlainButton from '../../components/plain-button/PlainButton';
import Tooltip from '../../components/tooltip';
import IconMetadataColored from '../../icons/general/IconMetadataColored';
import IconAlertCircle from '../../icons/general/IconAlertCircle';
import IconEdit from '../../icons/general/IconEdit';
import { bdlWatermelonRed } from '../../styles/variables';
import { scrollIntoView } from '../../utils/dom';
import CascadePolicy from './CascadePolicy';
import TemplatedInstance from './TemplatedInstance';
import CustomInstance from './CustomInstance';
import MetadataInstanceConfirmDialog from './MetadataInstanceConfirmDialog';
import Footer from './Footer';
import messages from './messages';
import { FIELD_TYPE_FLOAT, FIELD_TYPE_INTEGER } from '../metadata-instance-fields/constants';
import TEMPLATE_CUSTOM_PROPERTIES from './constants';
import {
JSON_PATCH_OP_REMOVE,
JSON_PATCH_OP_ADD,
JSON_PATCH_OP_REPLACE,
JSON_PATCH_OP_TEST,
} from '../../common/constants';
import { isValidValue } from '../metadata-instance-fields/validateMetadataField';
import { isHidden } from './metadataUtil';
import { RESIN_TAG_TARGET } from '../../common/variables';
import type {
MetadataFields,
MetadataTemplate,
MetadataCascadePolicy,
MetadataCascadingPolicyData,
MetadataTemplateField,
MetadataFieldValue,
} from '../../common/types/metadata';
import type { JSONPatchOperations } from '../../common/types/api';
import './Instance.scss';
type Props = {
canEdit: boolean,
cascadePolicy?: MetadataCascadePolicy, // eslint-disable-line
data: MetadataFields,
hasError: boolean,
id: string,
isCascadingPolicyApplicable?: boolean,
isDirty: boolean,
isOpen: boolean,
onModification?: (id: string, isDirty: boolean, type?: string) => void,
onRemove?: (id: string) => void,
onSave?: (
id: string,
data: JSONPatchOperations,
cascadingPolicy?: MetadataCascadingPolicyData,
rawData: Object,
) => void,
template: MetadataTemplate,
};
type State = {
data: Object,
errors: { [string]: React.Node },
isBusy: boolean,
isCascadingEnabled: boolean,
isCascadingOverwritten: boolean,
isEditing: boolean,
shouldConfirmRemove: boolean,
shouldShowCascadeOptions: boolean,
};
const createFieldKeyToTypeMap = (fields?: Array<MetadataTemplateField> = []) =>
fields.reduce((prev, { key, type }) => {
prev[key] = type;
return prev;
}, {});
const getValue = (data: Object, key: string, type: string) => {
const value = data[key];
switch (type) {
case FIELD_TYPE_FLOAT:
return parseFloat(value);
case FIELD_TYPE_INTEGER:
return parseInt(value, 10);
default:
return value;
}
};
class Instance extends React.PureComponent<Props, State> {
static defaultProps = {
data: {},
isDirty: false,
};
constructor(props: Props) {
super(props);
this.state = this.getState(props);
this.fieldKeyToTypeMap = createFieldKeyToTypeMap(props.template.fields);
}
componentDidUpdate({ hasError: prevHasError, isDirty: prevIsDirty }: Props, prevState: State): void {
const currentElement = this.collapsibleRef.current;
const { hasError, isDirty }: Props = this.props;
const { isEditing }: State = prevState;
if (currentElement && this.state.shouldConfirmRemove) {
scrollIntoView(currentElement, {
block: 'start',
behavior: 'smooth',
});
}
if (hasError && hasError !== prevHasError) {
// If hasError is true, which means an error occurred while
// doing a network operation and hence hide the busy indicator
// Saving also disables isEditing, so need to enable that back.
// isDirty remains as it was before.
this.setState({ isBusy: false, isEditing: true });
} else if (prevIsDirty && !isDirty) {
// If the form was dirty and now its not dirty
// we know a successful save may have happened.
// We don't modify isEditing here because we maintain the
// prior state for that. If we came here from a save
// success then save already disabled isEditing.
if (isEditing) {
// We are still editing so don't reset it
this.setState({ isBusy: false });
} else {
// For a successfull save we reset cascading overwrite radio
this.setState({ isBusy: false, isCascadingOverwritten: false });
}
}
}
/**
* Undo any changes made
*
* @return {void}
*/
onCancel = (): void => {
const { id, onModification }: Props = this.props;
this.setState(this.getState(this.props));
// Callback to parent to tell that something is dirty
if (onModification) {
onModification(id, false);
}
};
/**
* Allows a user to confirm metadata instance removal
*
* @return {void}
*/
onConfirmRemove = (): void => {
this.setState({ shouldConfirmRemove: true });
};
/**
* Cancel the remove instance attempt
*
* @return {void}
*/
onConfirmCancel = (): void => {
this.setState({ shouldConfirmRemove: false });
};
/**
* Removes an instance
*
* @return {void}
*/
onRemove = (): void => {
if (!this.isEditing()) {
return;
}
const { id, onRemove }: Props = this.props;
if (onRemove) {
onRemove(id);
this.setState({ isBusy: true });
}
};
/**
* Saves instance data
*
* @return {void}
*/
onSave = (): void => {
const {
cascadePolicy,
data: originalData,
id,
isDirty,
isCascadingPolicyApplicable,
onSave,
}: Props = this.props;
const { data: currentData, errors, isCascadingEnabled, isCascadingOverwritten }: State = this.state;
if (!this.isEditing() || !isDirty || !onSave || Object.keys(errors).length) {
return;
}
this.setState({
isBusy: true,
isEditing: false,
shouldShowCascadeOptions: false,
});
onSave(
id,
this.createJSONPatch(currentData, originalData),
isCascadingPolicyApplicable
? {
canEdit: cascadePolicy ? cascadePolicy.canEdit : false,
id: cascadePolicy ? cascadePolicy.id : undefined,
isEnabled: isCascadingEnabled,
overwrite: isCascadingOverwritten,
}
: undefined,
cloneDeep(currentData),
);
};
/**
* Updates a key value in the instance data
*
* @param {string} key - key to update
* @param {FieldValue} value - value to update
* @param {string} type - type of field
* @return {void}
*/
onFieldChange = (key: string, value: MetadataFieldValue, type: string): void => {
const { data, errors }: State = this.state;
// Don't do anything if data is the same or not in edit mode
if (!this.isEditing() || isEqual(data[key], value)) {
return;
}
const isValid = isValidValue(type, value);
const finalErrors = { ...errors };
const finalData = cloneDeep(data);
finalData[key] = value;
if (isValid) {
delete finalErrors[key];
} else {
finalErrors[key] = <FormattedMessage {...messages.invalidInput} />;
}
this.setState({ data: finalData, errors: finalErrors }, () => {
this.setDirty(type);
});
};
/**
* Removes a key from instance data
*
* @param {string} key - key to remove
* @return {void}
*/
onFieldRemove = (key: string): void => {
if (!this.isEditing()) {
return;
}
const { data, errors }: State = this.state;
const finalData = cloneDeep(data);
const finalErrors = { ...errors };
delete finalData[key];
delete finalErrors[key];
this.setState({ data: finalData, errors: finalErrors }, this.setDirty);
};
/**
* Toggle cascading policy
*
* @param {boolean} value - true when turned on
* @return {void}
*/
onCascadeToggle = (value: boolean) => {
const { isCascadingPolicyApplicable }: Props = this.props;
if (!isCascadingPolicyApplicable) {
return;
}
this.setState(
{
isCascadingEnabled: value,
shouldShowCascadeOptions: value,
},
this.setDirty,
);
};
/**
* Changes the cascade mode.
* isCascadingOverwritten is slways false to start off.
*
* @param {boolean} value - true when overwrite policy is chosen
* @return {void}
*/
onCascadeModeChange = (value: boolean): void => {
const { isCascadingPolicyApplicable }: Props = this.props;
if (!isCascadingPolicyApplicable) {
return;
}
this.setState(
{
isCascadingOverwritten: value,
},
this.setDirty,
);
};
/**
* Returns the state from props
*
* @return {Object} - react state
*/
getState(props: Props): State {
return {
data: cloneDeep(props.data),
errors: {},
isBusy: false,
isCascadingEnabled: this.isCascadingEnabled(props),
isCascadingOverwritten: false,
isEditing: false,
shouldConfirmRemove: false,
shouldShowCascadeOptions: false,
};
}
/**
* Returns the card title with possible error mark
*
* @return {Object} - react title element
*/
getTitle(): React.Node {
const { cascadePolicy = {}, hasError, isCascadingPolicyApplicable, template }: Props = this.props;
const isProperties = template.templateKey === TEMPLATE_CUSTOM_PROPERTIES;
const type = isCascadingPolicyApplicable && cascadePolicy.id ? 'cascade' : 'default';
return (
<span className="metadata-instance-editor-instance-title">
<IconMetadataColored type={type} />
<span
className={classNames('metadata-instance-editor-instance-title-text', {
'metadata-instance-editor-instance-has-error': hasError,
})}
>
{isProperties ? <FormattedMessage {...messages.customTitle} /> : template.displayName}
</span>
{hasError && <IconAlertCircle color={bdlWatermelonRed} />}
</span>
);
}
/**
* Render the correct delete message to show based on custom metadata and file/folder metadata
*/
renderDeleteMessage = (isFile: boolean, template: Object) => {
let message;
const isProperties = template.templateKey === TEMPLATE_CUSTOM_PROPERTIES;
if (isProperties) {
message = isFile ? 'fileMetadataRemoveCustomTemplateConfirm' : 'folderMetadataRemoveCustomTemplateConfirm';
} else {
message = isFile ? 'fileMetadataRemoveTemplateConfirm' : 'folderMetadataRemoveTemplateConfirm';
}
return (
<FormattedMessage
{...messages[message]}
values={{
metadataName: template.displayName,
}}
/>
);
};
/**
* Get the delete confirmation message base on the template key
*/
getConfirmationMessage(): React.Node {
const { template, isCascadingPolicyApplicable }: Props = this.props;
const isFile = !isCascadingPolicyApplicable;
return this.renderDeleteMessage(isFile, template);
}
/**
* Evaluates if the metadata was changed or cascading policy
* altered or enabled.
*
* @return {void}
*/
setDirty = (type?: string): void => {
const { id, isCascadingPolicyApplicable, onModification }: Props = this.props;
const { data, isCascadingEnabled, isCascadingOverwritten } = this.state;
const hasDataChanged = !isEqual(data, this.props.data);
let hasCascadingChanged = false;
if (isCascadingPolicyApplicable) {
// isCascadingOverwritten always starts out as false, so true signifies a change
hasCascadingChanged = isCascadingOverwritten || isCascadingEnabled !== this.isCascadingEnabled(this.props);
}
// Callback to parent to tell that something is dirty
if (onModification) {
onModification(id, hasDataChanged || hasCascadingChanged, type);
}
};
collapsibleRef: {
current: null | HTMLDivElement,
} = React.createRef();
fieldKeyToTypeMap: Object;
/**
* Determines if cascading policy is enabled based on
* whether it has an id or not.
*
* @param {Object} props - component props
* @return {boolean} true if cascading policy is enabled
*/
isCascadingEnabled(props: Props) {
if (props.cascadePolicy) {
return !!props.cascadePolicy.id;
}
return false;
}
/**
* Toggles the edit mode
*
* @private
* @return {void}
*/
toggleIsEditing = (): void => {
this.setState(prevState => ({
isEditing: !prevState.isEditing,
}));
};
/**
* Creates JSON Patch operations from the passed in
* data while comparing it to the original data from props.
*
* Only diffs at the root level and primitives.
*
* @param {*} currentData - the latest changes by the user
* @param {*} originalData - the original values
* @return {Array} - JSON patch operations
*/
createJSONPatch(currentData: Object, originalData: Object): JSONPatchOperations {
const ops = [];
const data = cloneDeep(currentData); // clone the data for mutation
// Iterate over the original data and find keys that have changed.
// Also remove them from the data object to only leave new keys.
Object.keys(originalData).forEach(key => {
const type = this.fieldKeyToTypeMap[key];
const originalValue = getValue(originalData, key, type);
const path = `/${key}`;
if (Object.prototype.hasOwnProperty.call(data, key)) {
const value = getValue(data, key, type);
// Only register changed data
if (!isEqual(value, originalValue)) {
// Add a test OP for each replaces
ops.push({
op: JSON_PATCH_OP_TEST,
path,
value: originalValue,
});
ops.push({
op: JSON_PATCH_OP_REPLACE,
path,
value,
});
}
} else {
// Key was removed
// Add a test OP for removes
ops.push({
op: JSON_PATCH_OP_TEST,
path,
value: originalValue,
});
ops.push({ op: JSON_PATCH_OP_REMOVE, path });
}
delete data[key];
});
// Iterate over the remaining keys that are new.
Object.keys(data).forEach(key => {
const type = this.fieldKeyToTypeMap[key];
const value = getValue(data, key, type);
ops.push({
op: JSON_PATCH_OP_ADD,
path: `/${key}`,
value,
});
});
return ops;
}
/**
* Utility function to determine if instance is editable
*
* @return {boolean} true if editable
*/
canEdit(): boolean {
const { canEdit, onModification, onRemove, onSave }: Props = this.props;
return (
canEdit &&
typeof onRemove === 'function' &&
typeof onSave === 'function' &&
typeof onModification === 'function'
);
}
/**
* Utility function to determine if instance is in edit mode
*
* @return {boolean} true if editing
*/
isEditing(): boolean {
const { isEditing }: State = this.state;
return this.canEdit() && isEditing;
}
renderEditButton = () => {
const { isDirty }: Props = this.props;
const { isBusy }: State = this.state;
const canEdit = this.canEdit();
const isEditing = this.isEditing();
const editClassName = classNames('metadata-instance-editor-instance-edit', {
'metadata-instance-editor-instance-is-editing': isEditing,
});
if (canEdit && !isDirty && !isBusy) {
return (
<Tooltip position="top-left" text={<FormattedMessage {...messages.metadataEditTooltip} />}>
<PlainButton
className={editClassName}
data-resin-target="metadata-instanceedit"
onClick={this.toggleIsEditing}
type="button"
>
<IconEdit />
</PlainButton>
</Tooltip>
);
}
return null;
};
render() {
const { cascadePolicy = {}, isDirty, isCascadingPolicyApplicable, isOpen, template }: Props = this.props;
const { fields = [] } = template;
const {
data,
errors,
isBusy,
isCascadingEnabled,
shouldConfirmRemove,
shouldShowCascadeOptions,
isCascadingOverwritten,
}: State = this.state;
const isProperties = template.templateKey === TEMPLATE_CUSTOM_PROPERTIES;
const isEditing = this.isEditing();
if (!template || isHidden(template)) {
return null;
}
// Animate short and tall cards at consistent speeds.
const animationDuration = (fields.length + 1) * 50;
return (
<div ref={this.collapsibleRef}>
<Collapsible
animationDuration={animationDuration}
buttonProps={{
[RESIN_TAG_TARGET]: 'metadata-card',
}}
hasStickyHeader
headerActionItems={this.renderEditButton()}
isBordered
isOpen={isOpen}
title={this.getTitle()}
>
{shouldConfirmRemove && (
<LoadingIndicatorWrapper isLoading={isBusy}>
<MetadataInstanceConfirmDialog
confirmationMessage={this.getConfirmationMessage()}
onCancel={this.onConfirmCancel}
onConfirm={this.onRemove}
/>
</LoadingIndicatorWrapper>
)}
{!shouldConfirmRemove && (
<LoadingIndicatorWrapper isLoading={isBusy}>
<Form onValidSubmit={isDirty ? this.onSave : noop}>
<div className="metadata-instance-editor-instance">
{isCascadingPolicyApplicable && (
<CascadePolicy
canEdit={isEditing && !!cascadePolicy.canEdit}
isCascadingEnabled={isCascadingEnabled}
isCascadingOverwritten={isCascadingOverwritten}
isCustomMetadata={isProperties}
onCascadeModeChange={this.onCascadeModeChange}
onCascadeToggle={this.onCascadeToggle}
shouldShowCascadeOptions={shouldShowCascadeOptions}
/>
)}
{isProperties ? (
<CustomInstance
canEdit={isEditing}
data={data}
onFieldChange={this.onFieldChange}
onFieldRemove={this.onFieldRemove}
/>
) : (
<TemplatedInstance
canEdit={isEditing}
data={data}
errors={errors}
onFieldChange={this.onFieldChange}
onFieldRemove={this.onFieldRemove}
template={template}
/>
)}
</div>
{isEditing && (
<Footer
onCancel={this.onCancel}
onRemove={this.onConfirmRemove}
showSave={isDirty}
/>
)}
</Form>
</LoadingIndicatorWrapper>
)}
</Collapsible>
</div>
);
}
}
export default Instance;