cspace-ui
Version: 
CollectionSpace user interface for browsers
1,315 lines (1,145 loc) • 36.8 kB
JavaScript
/* global window */
import { defineMessages } from 'react-intl';
import get from 'lodash/get';
import merge from 'lodash/merge';
import Immutable from 'immutable';
import getSession from '../helpers/session';
import getNotificationID from '../helpers/notificationHelpers';
import getErrorDescription from '../helpers/getErrorDescription';
import { hasBlockingError } from '../helpers/validationHelpers';
import HierarchyReparentNotifier from '../components/record/HierarchyReparentNotifier';
import {
  removeNotification,
  removeValidationNotification,
  showNotification,
  showValidationNotification,
} from './notification';
import {
  search,
} from './search';
import {
  getForm,
  getRecordData,
  getRecordSubrecordCsid,
  getRecordValidationErrors,
  getRecordPagePrimaryCsid,
  getSearchResult,
  getStickyFields,
  getSubrecordData,
  getUserRoleNames,
  isRecordReadPending,
} from '../reducers';
import {
  dataPathToFieldDescriptorPath,
} from '../helpers/configHelpers';
import {
  deepGet,
  computeField,
  isExistingRecord,
  prepareForSending,
  validateField,
} from '../helpers/recordDataHelpers';
import {
  getFirstItem,
  getSubrecordSearchName,
} from '../helpers/searchHelpers';
import {
  CLEAR_RECORD,
  CREATE_NEW_RECORD,
  CREATE_NEW_SUBRECORD,
  FIELD_COMPUTE_FULFILLED,
  FIELD_COMPUTE_REJECTED,
  RECORD_CREATED,
  SUBRECORD_CREATED,
  RECORD_DELETE_STARTED,
  RECORD_DELETE_FULFILLED,
  RECORD_DELETE_REJECTED,
  RECORD_READ_STARTED,
  RECORD_READ_FULFILLED,
  RECORD_READ_REJECTED,
  SUBRECORD_READ_FULFILLED,
  RECORD_SAVE_STARTED,
  RECORD_SAVE_FULFILLED,
  RECORD_SAVE_REJECTED,
  RECORD_TRANSITION_STARTED,
  RECORD_TRANSITION_FULFILLED,
  RECORD_TRANSITION_REJECTED,
  ADD_FIELD_INSTANCE,
  SORT_FIELD_INSTANCES,
  DELETE_FIELD_VALUE,
  MOVE_FIELD_VALUE,
  SET_FIELD_VALUE,
  REVERT_RECORD,
  VALIDATION_FAILED,
  VALIDATION_PASSED,
  DETACH_SUBRECORD,
} from '../constants/actionCodes';
import {
  ERR_API,
  ERR_COMPUTE,
} from '../constants/errorCodes';
import {
  STATUS_ERROR,
  STATUS_PENDING,
  STATUS_SUCCESS,
} from '../constants/notificationStatusCodes';
import { setStickyFields } from './prefs';
const deleteMessages = defineMessages({
  deleting: {
    id: 'action.record.deleting',
    description: 'Notification message displayed when a record is being deleted.',
    defaultMessage: `{hasTitle, select,
      yes {Deleting {title}…}
      other {Deleting record…}
    }`,
  },
  errorDeleting: {
    id: 'action.record.errorDeleting',
    description: 'Notification message displayed when a record delete fails.',
    defaultMessage: `{hasTitle, select,
      yes {Error deleting {title}: {error}}
      other {Error deleting record: {error}}
    }`,
  },
  deleted: {
    id: 'action.record.deleted',
    description: 'Notification message displayed when a record is deleted successfully.',
    defaultMessage: `{hasTitle, select,
      yes {Deleted {title}}
      other {Deleted record}
    }`,
  },
});
const saveMessages = defineMessages({
  saving: {
    id: 'action.record.saving',
    description: 'Notification message displayed when a record is being saved.',
    defaultMessage: `{hasTitle, select,
      yes {Saving {title}…}
      other {Saving record…}
    }`,
  },
  errorSaving: {
    id: 'action.record.errorSaving',
    description: 'Notification message displayed when a record save fails and there is no more specific message.',
    defaultMessage: `{hasTitle, select,
      yes {Error saving {title}: {error}}
      other {Error saving record: {error}}
    }`,
  },
  saved: {
    id: 'action.record.saved',
    description: 'Notification message displayed when a record is saved successfully.',
    defaultMessage: `{hasTitle, select,
      yes {Saved {title}}
      other {Saved record}
    }`,
  },
  errorDupRoleName: {
    id: 'action.record.errorDupRoleName',
    description: 'Notification message displayed when a role save fails because of a duplicate name.',
    defaultMessage: 'Error saving {title}: A role already exists with this name. Please choose a different name.',
  },
});
const transitionMessages = {
  delete: defineMessages({
    transitioning: {
      id: 'action.record.transition.delete.transitioning',
      description: 'Notification message displayed when a delete workflow transition (soft-delete) is in progress.',
      defaultMessage: `{hasTitle, select,
        yes {Deleting {title}…}
        other {Deleting record…}
      }`,
    },
    errorTransitioning: {
      id: 'action.record.transition.delete.errorTransitioning',
      description: 'Notification message displayed when a delete workflow transition (soft-delete) fails.',
      defaultMessage: `{hasTitle, select,
        yes {Error deleting {title}: {error}}
        other {Error deleting record: {error}}
      }`,
    },
    transitioned: {
      id: 'action.record.transition.delete.transitioned',
      description: 'Notification message displayed when a delete workflow transition (soft-delete) completes successfully.',
      defaultMessage: `{hasTitle, select,
        yes {Deleted {title}}
        other {Deleted record}
      }`,
    },
  }),
  lock: defineMessages({
    transitioning: {
      id: 'action.record.transition.lock.transitioning',
      description: 'Notification message displayed when a lock workflow transition is in progress.',
      defaultMessage: `{hasTitle, select,
        yes {Locking {title}…}
        other {Locking record…}
      }`,
    },
    errorTransitioning: {
      id: 'action.record.transition.lock.errorTransitioning',
      description: 'Notification message displayed when a lock workflow transition fails.',
      defaultMessage: `{hasTitle, select,
        yes {Error locking {title}: {error}}
        other {Error locking record: {error}}
      }`,
    },
    transitioned: {
      id: 'action.record.transition.lock.transitioned',
      description: 'Notification message displayed when a lock workflow transition completes successfully.',
      defaultMessage: `{hasTitle, select,
        yes {Locked {title}}
        other {Locked record}
      }`,
    },
  }),
};
const getSaveErrorNotificationItem = (error, title) => {
  const data = get(error, ['error', 'response', 'data']);
  if (
    typeof data === 'string'
    && data.includes('unique constraint "roles_rolename_tenant_id_key"')
  ) {
    return {
      message: saveMessages.errorDupRoleName,
      values: {
        title,
      },
    };
  }
  return {
    message: saveMessages.errorSaving,
    values: {
      title,
      hasTitle: title ? 'yes' : '',
      error: getErrorDescription(error),
    },
  };
};
export const computeFieldValue = (recordTypeConfig, csid, path, value) => (dispatch, getState) => {
  const state = getState();
  const computeContext = {
    data: value,
    path: [],
    recordData: getRecordData(state, csid),
    subrecordData: getSubrecordData(state, csid),
    fieldDescriptor: get(recordTypeConfig, ['fields', ...dataPathToFieldDescriptorPath(path)]),
    recordType: recordTypeConfig.name,
    form: getForm(state, recordTypeConfig.name),
    roleNames: getUserRoleNames(state),
  };
  return computeField(computeContext, true)
    .then((computedValue) => {
      if (typeof computedValue !== 'undefined') {
        dispatch({
          type: FIELD_COMPUTE_FULFILLED,
          payload: computedValue,
          meta: {
            csid,
            path,
          },
        });
      }
    })
    .catch((error) => {
      dispatch({
        type: FIELD_COMPUTE_REJECTED,
        payload: {
          code: ERR_COMPUTE,
          error,
        },
        meta: {
          csid,
          path,
        },
      });
      // TODO: Show an error notification?
    });
};
export const computeRecordData = (recordTypeConfig, csid) => (dispatch, getState) => {
  const recordData = getRecordData(getState(), csid);
  return dispatch(computeFieldValue(recordTypeConfig, csid, [], recordData));
};
export const validateFieldValue = (recordTypeConfig, csid, path, value) => (dispatch, getState) => {
  const state = getState();
  const validationContext = {
    csid,
    data: value,
    path: [],
    recordData: getRecordData(state, csid),
    subrecordData: getSubrecordData(state, csid),
    fieldDescriptor: get(recordTypeConfig, ['fields', ...dataPathToFieldDescriptorPath(path)]),
    recordType: recordTypeConfig.name,
    form: getForm(state, recordTypeConfig.name),
    roleNames: getUserRoleNames(state),
  };
  return validateField(validationContext, true)
    .then((errors) => {
      if (errors) {
        dispatch({
          type: VALIDATION_FAILED,
          payload: errors,
          meta: {
            csid,
            path,
          },
        });
        dispatch(showValidationNotification(recordTypeConfig.name, csid));
      } else {
        dispatch({
          type: VALIDATION_PASSED,
          meta: {
            csid,
            path,
          },
        });
        dispatch(removeValidationNotification());
      }
    });
};
export const validateRecordData = (recordTypeConfig, csid) => (dispatch, getState) => {
  const recordData = getRecordData(getState(), csid);
  return dispatch(validateFieldValue(recordTypeConfig, csid, [], recordData));
};
const initializeSubrecords = (config, recordTypeConfig, vocabularyConfig, csid) => (
  (dispatch, getState) => {
    const { subrecords } = recordTypeConfig;
    if (!subrecords) {
      return Promise.resolve();
    }
    const data = getRecordData(getState(), csid);
    return Promise.all(Object.entries(subrecords).map((entry) => {
      const [subrecordName, subrecordConfig] = entry;
      const {
        csidField,
        subresource,
        recordType: subrecordType,
        vocabulary: subrecordVocabulary,
      } = subrecordConfig;
      let subrecordCsidPromise = null;
      if (csidField) {
        subrecordCsidPromise = Promise.resolve(deepGet(data, csidField));
      } else if (subresource) {
        const searchDescriptor = Immutable.fromJS({
          csid,
          subresource,
          recordType: recordTypeConfig.name,
          vocabulary: vocabularyConfig.name,
          searchQuery: {
            // Set page size to 1, and page to 0. This assumes we'll only ever care about the first
            // result.
            p: 0,
            size: 1,
          },
        });
        const searchName = getSubrecordSearchName(csid, subrecordName);
        const listType = subresource
          ? get(config, ['subresources', subresource, 'listType'])
          : null;
        subrecordCsidPromise = dispatch(search(config, searchName, searchDescriptor, listType))
          .then(() => {
            const result = getSearchResult(getState(), searchName, searchDescriptor);
            let subrecordCsid;
            if (result) {
              // Read the csid of the first item.
              const firstItem = getFirstItem(config, result, listType);
              if (firstItem) {
                subrecordCsid = firstItem.get('csid');
              }
            }
            return subrecordCsid;
          });
      }
      if (subrecordCsidPromise) {
        return subrecordCsidPromise.then((subrecordCsid) => {
          const subrecordTypeConfig = get(config, ['recordTypes', subrecordType]);
          const subrecordVocabularyConfig = get(
            subrecordTypeConfig, ['vocabularies', subrecordVocabulary],
          );
          if (subrecordCsid) {
            return (
            // eslint-disable-next-line no-use-before-define
              dispatch(readRecord(
                config, subrecordTypeConfig, subrecordVocabularyConfig, subrecordCsid,
              ))
                .then(() => dispatch({
                  type: SUBRECORD_READ_FULFILLED,
                  meta: {
                    csid,
                    subrecordCsid,
                    subrecordName,
                  },
                }))
            );
          }
          // No existing subrecord. Create one as a default.
          // eslint-disable-next-line no-use-before-define
          return dispatch(createNewSubrecord(
            config, csid, csidField, subrecordName,
            subrecordTypeConfig, subrecordVocabularyConfig, undefined, true,
          ));
        });
      }
      return Promise.resolve();
    }));
  }
);
const doRead = (recordTypeConfig, vocabularyConfig, csid) => {
  const {
    serviceType,
    servicePath: recordServicePath,
  } = recordTypeConfig.serviceConfig;
  const vocabularyServicePath = vocabularyConfig
    ? vocabularyConfig.serviceConfig.servicePath
    : null;
  const pathParts = [recordServicePath];
  if (vocabularyServicePath) {
    pathParts.push(vocabularyServicePath);
    pathParts.push('items');
  }
  pathParts.push(csid);
  const path = pathParts.join('/');
  const requestConfig = {
    params: {
      wf_deleted: false,
    },
  };
  if (serviceType === 'authority' || serviceType === 'object') {
    requestConfig.params.showRelations = true;
    requestConfig.params.pgSz = 0;
  }
  if (recordTypeConfig.requestConfig) {
    merge(requestConfig, recordTypeConfig.requestConfig('read'));
  }
  return getSession().read(path, requestConfig);
};
export const readRecord = (config, recordTypeConfig, vocabularyConfig, csid, options = {}) => (
  (dispatch, getState) => {
    const existingData = getRecordData(getState(), csid);
    if (existingData) {
      return Promise.resolve(existingData);
    }
    if (isRecordReadPending(getState(), csid)) {
      return Promise.resolve();
    }
    dispatch({
      type: RECORD_READ_STARTED,
      meta: {
        recordTypeConfig,
        csid,
      },
    });
    const {
      initSubrecords = true,
    } = options;
    return doRead(recordTypeConfig, vocabularyConfig, csid)
      .then((response) => dispatch({
        type: RECORD_READ_FULFILLED,
        payload: response,
        meta: {
          config,
          recordTypeConfig,
          csid,
        },
      }))
      .then(() => (
        initSubrecords
          ? dispatch(initializeSubrecords(config, recordTypeConfig, vocabularyConfig, csid))
          : Promise.resolve()
      ))
      .then(() => getRecordData(getState(), csid))
      .catch((error) => dispatch({
        type: RECORD_READ_REJECTED,
        payload: {
          code: ERR_API,
          error,
        },
        meta: {
          recordTypeConfig,
          csid,
        },
      }));
  }
);
export const createNewRecord = (config, recordTypeConfig, vocabularyConfig, cloneCsid) => (
  (dispatch, getState) => {
    let readClone;
    if (cloneCsid) {
      const data = getRecordData(getState(), cloneCsid);
      if (!data) {
        // We don't have data for the record to be cloned. Read it first.
        readClone = dispatch(readRecord(config, recordTypeConfig, vocabularyConfig, cloneCsid));
      }
    }
    if (!readClone) {
      // There's nothing to clone, or we already have the record data to be cloned. Perform an
      // async noop, so this function will be consistently async.
      readClone = new Promise((resolve) => {
        window.setTimeout(() => {
          resolve();
        }, 0);
      });
    }
    return (
      readClone.then(() => dispatch({
        type: CREATE_NEW_RECORD,
        meta: {
          config,
          recordTypeConfig,
          cloneCsid,
          stickyFields: getStickyFields(getState()),
        },
      }))
    );
  }
);
export const createNewSubrecord = (
  config, csid, csidField, subrecordName,
  subrecordTypeConfig, subrecordVocabularyConfig, cloneCsid, isDefault,
) => (dispatch, getState) => {
  let readClone;
  if (cloneCsid) {
    const data = getRecordData(getState(), cloneCsid);
    if (!data) {
      // We don't have data for the record to be cloned. Read it first.
      readClone = dispatch(readRecord(
        config, subrecordTypeConfig, subrecordVocabularyConfig, cloneCsid,
      ));
    }
  }
  if (!readClone) {
    // There's nothing to clone, or we already have the record data to be cloned. Perform an
    // async noop, so this function will be consistently async.
    readClone = new Promise((resolve) => {
      window.setTimeout(() => {
        resolve();
      }, 0);
    });
  }
  return (
    readClone.then(() => dispatch({
      type: CREATE_NEW_SUBRECORD,
      meta: {
        config,
        csid,
        csidField,
        subrecordName,
        subrecordTypeConfig,
        cloneCsid,
        isDefault,
        stickyFields: getStickyFields(getState()),
      },
    }))
  );
};
const saveSubrecords = (config, recordTypeConfig, vocabularyConfig, csid, saveStage) => (
  (dispatch, getState) => {
    const { subrecords } = recordTypeConfig;
    if (!subrecords) {
      return Promise.resolve();
    }
    return Promise.all(
      Object.entries(subrecords)
        .filter((entry) => entry[1].saveStage === saveStage)
        .map((entry) => {
          const [subrecordName, subrecordConfig] = entry;
          const subrecordCsid = getRecordSubrecordCsid(getState(), csid, subrecordName);
          if (subrecordCsid) {
            const {
              csidField,
              saveCondition,
              subresource,
            } = subrecordConfig;
            if (saveCondition) {
              const subrecordData = getRecordData(getState(), subrecordCsid);
              if (!saveCondition(subrecordData)) {
                return Promise.resolve();
              }
            }
            if (csidField) {
              const subrecordTypeConfig = get(config, ['recordTypes', subrecordConfig.recordType]);
              const subrecordVocabularyConfig = get(
                subrecordTypeConfig, ['vocabularies', subrecordConfig.vocabulary],
              );
              // eslint-disable-next-line no-use-before-define
              return dispatch(saveRecord(
                config,
                subrecordTypeConfig,
                subrecordVocabularyConfig,
                subrecordCsid,
                undefined,
                undefined,
                undefined,
                (newRecordCsid) => {
                  dispatch({
                    type: SUBRECORD_CREATED,
                    meta: {
                      csid,
                      csidField,
                      subrecordName,
                      subrecordCsid: newRecordCsid,
                    },
                  });
                },
                false,
              ));
            }
            if (subresource) {
              const searchName = getSubrecordSearchName(csid, subrecordName);
              const subrecordSubresourceConfig = get(config, ['subresources', subresource]);
              if (subrecordSubresourceConfig) {
                // eslint-disable-next-line no-use-before-define
                return dispatch(saveRecord(
                  config,
                  recordTypeConfig,
                  vocabularyConfig,
                  csid,
                  subrecordSubresourceConfig,
                  subrecordCsid,
                  undefined,
                  (newRecordCsid) => {
                    dispatch({
                      type: SUBRECORD_CREATED,
                      meta: {
                        csid,
                        searchName,
                        subrecordName,
                        subrecordCsid: newRecordCsid,
                      },
                    });
                  },
                  false,
                ));
              }
            }
          }
          return Promise.resolve();
        }),
    );
  }
);
export const saveRecord = (
  config, recordTypeConfig, vocabularyConfig, csid, subresourceConfig, subresourceCsid,
  relatedSubjectCsid, onRecordCreated, showNotifications = true,
) => (dispatch, getState, intl) => {
  let currentRecordTypeConfig;
  let currentVocabularyConfig;
  let currentCsid;
  if (subresourceConfig) {
    currentRecordTypeConfig = get(config, ['recordTypes', subresourceConfig.recordType]);
    currentVocabularyConfig = get(
      currentRecordTypeConfig, ['vocabularies', subresourceConfig.vocabulary],
    );
    currentCsid = subresourceCsid;
  } else {
    currentRecordTypeConfig = recordTypeConfig;
    currentVocabularyConfig = vocabularyConfig;
    currentCsid = csid;
  }
  return dispatch(computeRecordData(currentRecordTypeConfig, currentCsid))
    .then(() => dispatch(validateRecordData(currentRecordTypeConfig, currentCsid)))
    .then(() => {
      if (hasBlockingError(getRecordValidationErrors(getState(), currentCsid))) {
        return null;
      }
      dispatch({
        type: RECORD_SAVE_STARTED,
        meta: {
          csid: currentCsid,
        },
      });
      const title = currentRecordTypeConfig.title
        ? currentRecordTypeConfig.title(
          getRecordData(getState(), currentCsid), { config, intl },
        )
        : null;
      const notificationID = getNotificationID();
      if (showNotifications) {
        dispatch(showNotification({
          items: [{
            message: saveMessages.saving,
            values: {
              title,
              hasTitle: title ? 'yes' : '',
            },
          }],
          date: new Date(),
          status: STATUS_PENDING,
        }, notificationID));
      }
      return dispatch(saveSubrecords(
        config, currentRecordTypeConfig, currentVocabularyConfig, currentCsid, 'before',
      ))
        .then(() => dispatch(setStickyFields(currentRecordTypeConfig, currentCsid)))
        .then(() => {
          const data = getRecordData(getState(), currentCsid);
          const isExisting = isExistingRecord(data);
          const recordServicePath = get(recordTypeConfig, ['serviceConfig', 'servicePath']);
          const vocabularyServicePath = get(vocabularyConfig, ['serviceConfig', 'servicePath']);
          const pathParts = [recordServicePath];
          if (vocabularyServicePath) {
            pathParts.push(vocabularyServicePath);
            pathParts.push('items');
          }
          if (subresourceConfig) {
            if (csid) {
              pathParts.push(csid);
            }
            const subresourceServicePath = get(subresourceConfig, ['serviceConfig', 'servicePath']);
            if (subresourceServicePath) {
              pathParts.push(subresourceServicePath);
            }
          }
          if (isExisting && currentCsid) {
            pathParts.push(currentCsid);
          }
          const path = pathParts.join('/');
          const requestConfig = {
            data: prepareForSending(data, currentRecordTypeConfig).toJS(),
          };
          if (recordTypeConfig.requestConfig) {
            merge(requestConfig, recordTypeConfig.requestConfig('save', data));
          }
          if (isExisting) {
            return getSession().update(path, requestConfig)
              .then((response) => (
                currentRecordTypeConfig.refetchAfterUpdate
                  ? doRead(currentRecordTypeConfig, currentVocabularyConfig, currentCsid)
                  : response
              ))
              .then((response) => dispatch(saveSubrecords(
                config, currentRecordTypeConfig, currentVocabularyConfig, currentCsid, 'after',
              ))
                .then(() => {
                  if (showNotifications) {
                    dispatch(showNotification({
                      items: [{
                        message: saveMessages.saved,
                        values: {
                          title,
                          hasTitle: title ? 'yes' : '',
                        },
                      }],
                      date: new Date(),
                      status: STATUS_SUCCESS,
                      autoClose: true,
                    }, notificationID));
                  }
                  dispatch({
                    type: RECORD_SAVE_FULFILLED,
                    payload: response,
                    meta: {
                      relatedSubjectCsid,
                      recordTypeConfig: currentRecordTypeConfig,
                      csid: currentCsid,
                      recordPagePrimaryCsid: getRecordPagePrimaryCsid(getState()),
                    },
                  });
                })
                .then(() => dispatch(initializeSubrecords(
                  config, currentRecordTypeConfig, currentVocabularyConfig, currentCsid,
                )))
                .then(() => currentCsid)
                .catch((error) => {
                  throw error;
                }))
              .catch((error) => {
                const wrapper = new Error();
                wrapper.code = ERR_API;
                wrapper.error = error;
                return Promise.reject(wrapper);
              });
          }
          return getSession().create(path, requestConfig)
            .then((response) => {
              if (response.status === 201 && response.headers.location) {
                const { location } = response.headers;
                const newRecordCsid = location.substring(location.lastIndexOf('/') + 1);
                dispatch({
                  type: RECORD_CREATED,
                  meta: {
                    currentCsid,
                    newRecordCsid,
                    recordTypeConfig: currentRecordTypeConfig,
                  },
                });
                return dispatch(saveSubrecords(
                  config, currentRecordTypeConfig, currentVocabularyConfig, newRecordCsid, 'after',
                ))
                  .then(() => doRead(
                    currentRecordTypeConfig, currentVocabularyConfig, newRecordCsid,
                  ))
                  .then((readResponse) => {
                    if (showNotifications) {
                      dispatch(showNotification({
                        items: [{
                          message: saveMessages.saved,
                          values: {
                            title,
                            hasTitle: title ? 'yes' : '',
                          },
                        }],
                        date: new Date(),
                        status: STATUS_SUCCESS,
                        autoClose: true,
                      }, notificationID));
                    }
                    return dispatch({
                      type: RECORD_SAVE_FULFILLED,
                      payload: readResponse,
                      meta: {
                        relatedSubjectCsid,
                        recordTypeConfig: currentRecordTypeConfig,
                        csid: newRecordCsid,
                        recordPagePrimaryCsid: getRecordPagePrimaryCsid(getState()),
                      },
                    });
                  })
                  .then(() => dispatch(initializeSubrecords(
                    config, currentRecordTypeConfig, currentVocabularyConfig, newRecordCsid,
                  )))
                  .then(() => Promise.resolve(
                    onRecordCreated ? onRecordCreated(newRecordCsid) : null,
                  ))
                  .then(() => newRecordCsid);
              }
              const error = new Error('Expected response with status 201 and a location header');
              error.response = response;
              throw error;
            })
            .catch((error) => {
              const wrapper = new Error();
              wrapper.code = ERR_API;
              wrapper.error = error;
              return Promise.reject(wrapper);
            });
        })
        .catch((error) => {
          const notificationItem = getSaveErrorNotificationItem(error, title);
          if (showNotifications) {
            dispatch(showNotification({
              items: [notificationItem],
              date: new Date(),
              status: STATUS_ERROR,
            }, notificationID));
          }
          dispatch({
            type: RECORD_SAVE_REJECTED,
            payload: error,
            meta: {
              csid: currentCsid,
            },
          });
          throw error;
        });
    });
};
export const addFieldInstance = (recordTypeConfig, csid, path, position) => (dispatch) => {
  dispatch({
    type: ADD_FIELD_INSTANCE,
    meta: {
      csid,
      path,
      position,
      recordTypeConfig,
    },
  });
  return dispatch(computeRecordData(recordTypeConfig, csid))
    .then(() => dispatch(validateRecordData(recordTypeConfig, csid)));
};
export const sortFieldInstances = (config, recordTypeConfig, csid, path, byField) => (dispatch) => {
  dispatch({
    type: SORT_FIELD_INSTANCES,
    meta: {
      config,
      csid,
      path,
      byField,
      recordTypeConfig,
    },
  });
  return dispatch(computeRecordData(recordTypeConfig, csid))
    .then(() => dispatch(validateRecordData(recordTypeConfig, csid)));
};
export const deleteFieldValue = (recordTypeConfig, csid, path) => (dispatch) => {
  dispatch({
    type: DELETE_FIELD_VALUE,
    meta: {
      csid,
      path,
    },
  });
  return dispatch(computeRecordData(recordTypeConfig, csid))
    .then(() => dispatch(validateRecordData(recordTypeConfig, csid)));
};
export const moveFieldValue = (recordTypeConfig, csid, path, newPosition) => (dispatch) => {
  dispatch({
    type: MOVE_FIELD_VALUE,
    meta: {
      csid,
      path,
      newPosition,
    },
  });
  return dispatch(computeRecordData(recordTypeConfig, csid))
    .then(() => dispatch(validateRecordData(recordTypeConfig, csid)));
};
export const setFieldValue = (recordTypeConfig, csid, path, value) => (dispatch) => {
  dispatch({
    type: SET_FIELD_VALUE,
    payload: value,
    meta: {
      csid,
      path,
    },
  });
  return dispatch(computeRecordData(recordTypeConfig, csid))
    .then(() => dispatch(validateRecordData(recordTypeConfig, csid)));
};
export const revertRecord = (recordTypeConfig, csid) => (dispatch) => {
  dispatch({
    type: REVERT_RECORD,
    meta: {
      recordTypeConfig,
      csid,
    },
  });
  // Clear validation errors. Could maybe revalidate here, but to be consistent, we should also
  // validate when a record is first loaded.
  dispatch({
    type: VALIDATION_PASSED,
    meta: {
      csid,
      path: [],
    },
  });
  dispatch(removeValidationNotification());
  dispatch(removeNotification(HierarchyReparentNotifier.notificationID));
};
export const deleteRecord = (
  config, recordTypeConfig, vocabularyConfig, csid, relatedSubjectCsid,
) => (dispatch, getState, intl) => {
  const data = getRecordData(getState(), csid);
  const title = recordTypeConfig.title(data, { config, intl });
  const notificationID = getNotificationID();
  dispatch(showNotification({
    items: [{
      message: deleteMessages.deleting,
      values: {
        title,
        hasTitle: title ? 'yes' : '',
      },
    }],
    date: new Date(),
    status: STATUS_PENDING,
  }, notificationID));
  dispatch({
    type: RECORD_DELETE_STARTED,
    meta: {
      recordTypeConfig,
      csid,
    },
  });
  const recordServicePath = recordTypeConfig.serviceConfig.servicePath;
  const vocabularyServicePath = vocabularyConfig
    ? vocabularyConfig.serviceConfig.servicePath
    : null;
  const pathParts = [recordServicePath];
  if (vocabularyServicePath) {
    pathParts.push(vocabularyServicePath);
    pathParts.push('items');
  }
  if (csid) {
    pathParts.push(csid);
  }
  const path = pathParts.join('/');
  return getSession().delete(path)
    .then((response) => {
      dispatch(showNotification({
        items: [{
          message: deleteMessages.deleted,
          values: {
            title,
            hasTitle: title ? 'yes' : '',
          },
        }],
        date: new Date(),
        status: STATUS_SUCCESS,
        autoClose: true,
      }, notificationID));
      return dispatch({
        type: RECORD_DELETE_FULFILLED,
        payload: response,
        meta: {
          recordTypeConfig,
          csid,
          relatedSubjectCsid,
        },
      });
    })
    .catch((error) => {
      dispatch(showNotification({
        items: [{
          message: deleteMessages.errorDeleting,
          values: {
            title,
            hasTitle: title ? 'yes' : '',
            error: getErrorDescription(error),
          },
        }],
        date: new Date(),
        status: STATUS_ERROR,
      }, notificationID));
      return dispatch({
        type: RECORD_DELETE_REJECTED,
        payload: {
          code: ERR_API,
          error,
        },
        meta: {
          recordTypeConfig,
          csid,
        },
      });
    });
};
export const transitionRecord = (
  config, recordTypeConfig, vocabularyConfig, csid, transitionName, relatedSubjectCsid,
) => (dispatch, getState, intl) => {
  const data = getRecordData(getState(), csid);
  const title = recordTypeConfig.title(data, { config, intl });
  const notificationID = getNotificationID();
  const messages = transitionMessages[transitionName];
  if (messages) {
    dispatch(showNotification({
      items: [{
        message: messages.transitioning,
        values: {
          title,
          hasTitle: title ? 'yes' : '',
        },
      }],
      date: new Date(),
      status: STATUS_PENDING,
    }, notificationID));
  }
  dispatch({
    type: RECORD_TRANSITION_STARTED,
    meta: {
      recordTypeConfig,
      csid,
      transitionName,
    },
  });
  const recordServicePath = recordTypeConfig.serviceConfig.servicePath;
  const vocabularyServicePath = vocabularyConfig
    ? vocabularyConfig.serviceConfig.servicePath
    : null;
  const pathParts = [recordServicePath];
  if (vocabularyServicePath) {
    pathParts.push(vocabularyServicePath);
    pathParts.push('items');
  }
  if (csid) {
    pathParts.push(csid);
  }
  pathParts.push('workflow');
  pathParts.push(transitionName);
  const path = pathParts.join('/');
  return getSession().update(path)
    .then((response) => (
      (transitionName === 'delete')
        ? response
      // For all transitions other than delete, re-read the record to obtain the new workflow
      // state.
        : doRead(recordTypeConfig, vocabularyConfig, csid)
    ))
    .then((response) => {
      if (messages) {
        dispatch(showNotification({
          items: [{
            message: messages.transitioned,
            values: {
              transitionName,
              title,
              hasTitle: title ? 'yes' : '',
            },
          }],
          date: new Date(),
          status: STATUS_SUCCESS,
          autoClose: true,
        }, notificationID));
      }
      return dispatch({
        type: RECORD_TRANSITION_FULFILLED,
        payload: response,
        meta: {
          recordTypeConfig,
          csid,
          transitionName,
          relatedSubjectCsid,
          recordPagePrimaryCsid: getRecordPagePrimaryCsid(getState()),
          // We don't get data back from the transition request. Rather than making a separate
          // request to get the actual updated time of the record, just make it the current time.
          updatedTimestamp: (new Date()).toISOString(),
        },
      });
    })
    .catch((error) => {
      if (messages) {
        dispatch(showNotification({
          items: [{
            message: messages.errorTransitioning,
            values: {
              transitionName,
              title,
              hasTitle: title ? 'yes' : '',
              error: getErrorDescription(error),
            },
          }],
          date: new Date(),
          status: STATUS_ERROR,
        }, notificationID));
      }
      return dispatch({
        type: RECORD_TRANSITION_REJECTED,
        payload: {
          code: ERR_API,
          error,
        },
        meta: {
          recordTypeConfig,
          csid,
          transitionName,
        },
      });
    });
};
export const saveRecordWithTransition = (
  config, recordTypeConfig, vocabularyConfig, csid, subresourceConfig, subresourceCsid,
  relatedSubjectCsid, transitionName, onRecordCreated, showNotifications = true,
) => (dispatch) => dispatch(saveRecord(
  config, recordTypeConfig, vocabularyConfig, csid, subresourceConfig, subresourceCsid,
  relatedSubjectCsid, onRecordCreated, showNotifications,
))
  .then((savedCsid) => dispatch(transitionRecord(
    config, recordTypeConfig, vocabularyConfig, savedCsid, transitionName, relatedSubjectCsid,
  )));
export const detachSubrecord = (config, csid, csidField, subrecordName, subrecordTypeConfig) => ({
  type: DETACH_SUBRECORD,
  meta: {
    config,
    csid,
    csidField,
    subrecordName,
    subrecordTypeConfig,
  },
});
export const clearRecord = (csid, clearSubrecords) => ({
  type: CLEAR_RECORD,
  meta: {
    csid,
    clearSubrecords,
  },
});