Spike: [UIOR-156] Leveraging the existing create item/holding forms from orders/receiving

Related Story

https://folio-org.atlassian.net/browse/UIOR-156

Findings:

Current ui-plugin implementations mostly used for displaying/selecting entities from list. I think it's enabled because usually in ui-modules list components implemented as Containers - have its manifest, resources, etc. So ui-plugin-find-vendor just renders a Modal with Vendor's list component inside, passing down corresponding callbacks (selectRow and others).

On the other hand, forms are usually implemented differently - as controlled components, passing to them data loaded from back-end and callbacks with code to call save API.

I believe that one of valuable attribute is to make as less changes to existing ui-modules code as possible, so I see only one way: ui-plugin-create-item should be Container component, to whom user passes required properties (instanceId, locationId for example), it loads everything required to ItemFrom ui-inventory component from back-end and passes it down, as well as callback with POST API call to save new item. In this case no changes required to ui-inventory, but ui-plugin-create-item contains some business logic to fetch and post data (so dependency on okapi interfaces is introduced).

Demo record of PoC: https://drive.google.com/open?id=1lFyN37hkgrDJ9NOpzEEV7DgfU3xTU1BM

PoC code:

ui-orders, consumer, include the plugin:


<Pluggable
  aria-haspopup="true"
  instanceId={instanceId}
  locationId={locationId}
  searchLabel={<FormattedMessage id="ui-orders.checkIn.buttons.addItem" />}
  stripes={stripes}
  type="create-item"
  addItem={this.onAddItem}
>
  <span>[no instance-selection plugin]</span>
</Pluggable>

where onAddItem is a callback, accepting created item:


onAddItem = (item = {}) => {
  const { dispatch, change } = this.props;

  if (item.id) {
    dispatch(change('itemId', item.id));
  }
}

ui-plugin-create-item:

export default class CreateItemWrapper extends React.Component {
  constructor(props) {
    super(props);

    this.connectedCreateItemModal = props.stripes.connect(CreateItemModal);
  }

  render() {
    const { searchButtonStyle, searchLabel } = this.props;
    const props = omitProps(this.props, ['searchButtonStyle', 'searchLabel', 'marginBottom0', 'marginTop0']);

    return (
      <div className={this.getStyle()}>
        <Button
          id="clickable-plugin-create-item"
          key="searchButton"
          buttonStyle={searchButtonStyle}
          onClick={this.openModal}
          tabIndex="-1"
        >
          {searchLabel || <Icon icon="search" color="#fff" />}
        </Button>
        {this.state.openModal && (
          <this.connectedCreateItemModal
            closeCB={this.closeModal}
            {...props}
          />
        )}
      </div>
    );
  }
}

import React from 'react';
import PropTypes from 'prop-types';
import { get, keyBy } from 'lodash';


import ItemForm from '@folio/inventory/src/edit/items/ItemForm';
import {
  Modal,
  omitProps,
} from '@folio/stripes/components';

export default class CreateItemModal extends React.Component {
  static manifest = {
    identifierTypes: {
      type: 'okapi',
      records: 'identifierTypes',
      path: 'identifier-types?limit=1000&query=cql.allRecords=1 sortby name',
    },
    contributorTypes: {
      type: 'okapi',
      records: 'contributorTypes',
      path: 'contributor-types?limit=400&query=cql.allRecords=1 sortby name',
    },
    contributorNameTypes: {
      type: 'okapi',
      records: 'contributorNameTypes',
      path: 'contributor-name-types?limit=1000&query=cql.allRecords=1 sortby ordering',
    },
    instanceFormats: {
      type: 'okapi',
      records: 'instanceFormats',
      path: 'instance-formats?limit=1000&query=cql.allRecords=1 sortby name',
    },
    instanceTypes: {
      type: 'okapi',
      records: 'instanceTypes',
      path: 'instance-types?limit=1000&query=cql.allRecords=1 sortby name',
    },
    classificationTypes: {
      type: 'okapi',
      records: 'classificationTypes',
      path: 'classification-types?limit=1000&query=cql.allRecords=1 sortby name',
    },
    alternativeTitleTypes: {
      type: 'okapi',
      records: 'alternativeTitleTypes',
      path: 'alternative-title-types?limit=1000&query=cql.allRecords=1 sortby name',
    },
    locations: {
      type: 'okapi',
      records: 'locations',
      path: 'locations?limit=1000&query=cql.allRecords=1 sortby name',
    },
    instanceRelationshipTypes: {
      type: 'okapi',
      records: 'instanceRelationshipTypes',
      path: 'instance-relationship-types?limit=1000&query=cql.allRecords=1 sortby name',
    },
    instanceStatuses: {
      type: 'okapi',
      records: 'instanceStatuses',
      path: 'instance-statuses?limit=1000&query=cql.allRecords=1 sortby name',
    },
    modesOfIssuance: {
      type: 'okapi',
      records: 'issuanceModes',
      path: 'modes-of-issuance?limit=1000&query=cql.allRecords=1 sortby name',
    },
    electronicAccessRelationships: {
      type: 'okapi',
      records: 'electronicAccessRelationships',
      path: 'electronic-access-relationships?limit=1000&query=cql.allRecords=1 sortby name',
    },
    statisticalCodeTypes: {
      type: 'okapi',
      records: 'statisticalCodeTypes',
      path: 'statistical-code-types?limit=1000&query=cql.allRecords=1 sortby name',
    },
    statisticalCodes: {
      type: 'okapi',
      records: 'statisticalCodes',
      path: 'statistical-codes?limit=1000&query=cql.allRecords=1 sortby statisticalCodeTypeId',
    },
    illPolicies: {
      type: 'okapi',
      path: 'ill-policies?limit=1000&query=cql.allRecords=1 sortby name',
      records: 'illPolicies',
    },
    holdingsTypes: {
      type: 'okapi',
      path: 'holdings-types?limit=1000&query=cql.allRecords=1 sortby name',
      records: 'holdingsTypes',
    },
    callNumberTypes: {
      type: 'okapi',
      path: 'call-number-types?limit=1000&query=cql.allRecords=1 sortby name',
      records: 'callNumberTypes',
    },
    holdingsNoteTypes: {
      type: 'okapi',
      path: 'holdings-note-types?limit=1000&query=cql.allRecords=1 sortby name',
      records: 'holdingsNoteTypes',
    },
    materialTypes: {
      type: 'okapi',
      path: 'material-types',
      records: 'mtypes',
    },
    loanTypes: {
      type: 'okapi',
      path: 'loan-types',
      params: {
        query: 'cql.allRecords=1 sortby name',
        limit: '40',
      },
      records: 'loantypes',
    },
    instance: {
      type: 'okapi',
      path: 'inventory/instances/!{instanceId}',
    },
    items: {
      type: 'okapi',
      records: 'items',
      path: 'inventory/items',
      fetch: false,
    },
    holdings: {
      type: 'okapi',
      records: 'holdingsRecords',
      path: 'holdings-storage/holdings?query=instanceId==!{instanceId} and permanentLocationId==!{locationId}',
    },
  };

  static propTypes = {
    stripes: PropTypes.shape({
      connect: PropTypes.func.isRequired,
    }).isRequired,
    closeCB: PropTypes.func.isRequired,
    locationId: PropTypes.string.isRequired,
    addItem: PropTypes.func.isRequired,
    mutator: PropTypes.object,
    onCloseModal: PropTypes.func,
    resources: PropTypes.object,
  }

  constructor(props) {
    super(props);
    this.connectedApp = props.stripes.connect(ItemForm);
    this.state = {
      error: null,
    };
  }

  closeModal = () => {
    this.props.closeCB();
    this.setState({
      error: null,
    });
  }

  onSubmit = (values) => {
    const { addItem, mutator } = this.props;
    mutator.items.POST(values)
      .then((item) => {
        addItem(item);
        this.closeModal();
      })
      .catch(() => this.setState({ error: 'Error on item creation' }));
  }

  render() {
    const { resources } = this.props;
    const referenceTables = {
      contributorTypes: get(resources, 'contributorTypes.records', []),
      contributorNameTypes: get(resources, 'contributorNameTypes.records', []),
      instanceRelationshipTypes: get(resources, 'instanceRelationshipTypes.records', []),
      identifierTypes: get(resources, 'identifierTypes.records', []),
      classificationTypes: get(resources, 'classificationTypes.records', []),
      instanceTypes: get(resources, 'instanceTypes.records', []),
      instanceFormats: get(resources, 'instanceFormats.records', []),
      alternativeTitleTypes: get(resources, 'alternativeTitleTypes.records', []),
      instanceStatuses: get(resources, 'instanceStatuses.records', []),
      modesOfIssuance: get(resources, 'modesOfIssuance.records', []),
      electronicAccessRelationships: get(resources, 'electronicAccessRelationships.records', []),
      statisticalCodeTypes: get(resources, 'statisticalCodeTypes.records', []),
      statisticalCodes: get(resources, 'statisticalCodes.records', []),
      illPolicies: get(resources, 'illPolicies.records', []),
      holdingsTypes: get(resources, 'holdingsTypes.records', []),
      callNumberTypes: get(resources, 'callNumberTypes.records', []),
      holdingsNoteTypes: get(resources, 'holdingsNoteTypes.records', []),
      locationsById: keyBy(get(resources, 'locations.records', []), 'id'),
      materialTypes: get(resources, 'materialTypes.records', []),
      loanTypes: get(resources, 'loanTypes.records', []),
    };

    const holdings = get(resources, 'holdings.records', [{}]);
    const holdingsRecord = {
      ...holdings[0],
      permanentLocationId: this.props.locationId,
    };
    const instance = get(resources, 'instance.records.0', {});
    const props = omitProps(this.props, ['resources', 'mutator', 'closeCB', 'locationId', 'instanceId']);
    const initialValues = {
      status: { name: 'Available' },
      holdingsRecordId: holdingsRecord.id,
    };

    return (
      <Modal
        size="large"
        open
        showHeader={false}
        dismissible
      >
        <div>
          {this.state.error ? <div>{this.state.error}</div> : null}
          <this.connectedApp
            {...props}
            onSubmit={this.onSubmit}
            initialValues={initialValues}
            referenceTables={referenceTables}
            holdingsRecord={holdingsRecord}
            instance={instance}
            onCancel={this.closeModal}
          />
        </div>
      </Modal>
    );
  }
}



Summary:

According to slack thread there are plans to leverage edit of various FOLIO entities in a more common way across the whole platform. But since they are mostly long-term I think we can come up with current solution of using plug-in stripes system to reuse ItemForm of ui-inventory module. However, in comments Zak suggested to change my solution - to leverage ItemForm component from ui-inventory. I think we need to choose from these two ways. I'll attach diagrams with both ways as well as Pros and Cons of each.

My way, Pros:

  • no changes required in ui-inventory;
  • lest time required for implementation, since code is ready.

Cons:

  • Tightly coupled with ui-inventory code, so it depends on changes inside it. I think this could be partially solved with tests coverage in ui-plugin repo, so we can see if it's OK to work with future ui-inventory versions on the build (release) stage.

Zak's suggestion Pros:

  • ui-plugin-create-item is independent from ui-inventory and contains all things required to create item (no tight coupling on other ui-module);

Cons:

  • changes required to ui-invenotry (to use newly created plugin);
  • more time consuming in this case: to implement solution and check that no duplicated API calls to fetch data in Invenotry forms, etc. (may be important to Acquisitions PO);