sanger/limber

View on GitHub
app/frontend/javascript/pool-xp-tube-panel/components/PoolXPTubeSubmitPanel.vue

Summary

Maintainability
Test Coverage
<template>
  <div class="d-flex justify-content-between align-items-center">
    <label
      id="pool_xp_tube_export_status"
      :class="['p-2', 'mt-2', stateStyles.text]"
      :style="{ fontSize: '1.0rem', display: 'flex', alignItems: 'center' }"
    >
      <b-spinner v-show="displaySpinner" id="progress_spinner" small type="border" class="mr-2" />
      <component :is="statusIcon" id="status_icon" class="mr-1" :color="stateStyles.icon" />
      {{ statusText }}
    </label>

    <b-button
      id="pool_xp_tube_export_button"
      name="pool_xp_tube_export_button"
      :disabled="isButtonDisabled"
      :variant="stateStyles.button"
      :class="['w-50']"
      @click="handleSubmit"
    >
      {{ buttonText }}
    </b-button>
  </div>
</template>

<script>
/**
 * Component to export a tube to Traction
 * @module components/PoolXPTubeSubmitPanel
 * @property {String} barcode - The barcode of the tube to be exported
 * @property {String} userId - The id of the user exporting the tube
 * @property {String} sequencescapeApiUrl - The URL of the Sequencescape API
 * @property {String} tractionServiceUrl - The URL of the Traction service
 * @property {String} tractionUIUrl - The URL of the Traction UI
 *
 * This component exports a tube to Traction and polls Traction to check if the tube is exported successfully.
 * This is performed in two steps:
 * - Export the tube to Traction using the Sequencescape API (export_pool_xp_to_traction) endpoint
 * - Poll Traction to check if the tube is exported successfully using the Traction API (GET /pacbio/tubes?filter[barcode]=<barcode>)
 *
 * The component has the following states:
 *
 * State 1: CHECKING_TUBE_STATUS
 *    When the component is first loaded, it is in the CHECKING_TUBE_STATUS state.
 *    The components displays a spinner and a message indicating that it is checking if the tube is in Traction. The button is disabled.
 *    Transition:
 *    - If the tube is not found, the component remains in the State 2 (READY_TO_EXPORT) state.
 *    - If the tube is found, the component transitions to the State 6 (TUBE_ALREADY_EXPORTED).
 *    - If the service is unavailable or returns error, the component transitions to the State 4 (FAILURE_TUBE_CHECK) state.
 *
 * State 2: READY_TO_EXPORT
 *     This state occurs if the tube is found in Traction during the State 1.
 *     The component provides a button to export tube to Traction. Clicking the button transitions the component as follows
 *     Transition:
 *      - If the export using Sequencescape API is successful, the component transitions to the State 3 (EXPORTING_TUBE) state.
 *      - If the export using Sequencescape API fails, the component transitions to the State 8 (FAILURE_EXPORT_TUBE) state.
 *      - If the tube is found in Traction after the export, the component transitions to the State 5 (TUBE_EXPORT_SUCCESS) state.
 *      - If the tube is not found in Traction after the export, the component transitions to the State 7 (FAILURE_TUBE_CHECK_AFTER_EXPORT) state.
 *
 * State 3: EXPORTING_TUBE
 *   This state occurs when the user clicks the export button and the request to export Sequencescape SS API is successful.
 *   At this state, the component polls Traction using Traction API to check if the tube is exported successfully.
 *   The component displays a spinner and a message indicating that the tube is being exported to Traction. The button is disabled.
 *   Transition:
 *      - If the polling is successful, the component transitions to the State 5 (TUBE_EXPORT_SUCCESS) state.
 *      - If the polling fails, the component transitions to the State 7 (FAILURE_TUBE_CHECK_AFTER_EXPORT) state.
 *
 * State 4: FAILURE_TUBE_CHECK
 *   This state occurs if the initial check to see if the tube is in Traction fails.
 *   The component provides a button to retry checking if the tube is in Traction and a message indicating that the export cannot be verified.
 *   On clicking the button, the component perfors same operation as in State 1.
 *   Transition:
 *     - The component transitions to the State 1 (CHECKING_TUBE_STATUS) state.
 *
 * State 5: TUBE_EXPORT_SUCCESS
 * This state occurs if the exporting the tube using Sequencescape API is successful and the polling using Traction API is successful.
 * The component displays a message indicating that the tube has been exported to Traction and an open Traction button to open the tube in Traction is displayed.
 * Transition:
 *    - If the user clicks the open Traction button, the component opens the tube in Traction.
 *
 * State 6: TUBE_ALREADY_EXPORTED
 * This state occurs if the initial check tube to see if the tube is in Traction returns a tube.
 * The component displays a message indicating that the tube is already exported to Traction and an open Traction button to open the tube in Traction is displayed.
 * Transition:
 *   - If the user clicks the open Traction button, the component opens the tube in Traction.
 *
 * State 7: FAILURE_TUBE_CHECK_AFTER_EXPORT
 * This state occurs if the exporting the tube using Sequencescape API is successful but the polling using Traction API fails.
 * The component provides a button to retry polling and a message indicating that the export cannot be verified.
 * Transition:
 *   - When retry button is clicked, the component transitions to the State 3 (EXPORTING_TUBE) state.
 *
 * State 8: FAILURE_EXPORT_TUBE
 * This state occurs if the exporting the tube using Sequencescape API fails.
 * The component provides a button to retry exporting the tube and a message indicating that the tube export to Traction failed.
 * Transition:
 *  - When retry button is clicked, the component transitions to the State 3 (EXPORTING_TUBE) state.
 *
 * State 9: INVALID_PROPS
 * This state occurs if the required props are missing when the component is mounted.
 * The component provides a message indicating that the required props are missing and the button is disabled.
 */

import ReadyIcon from '../../icons/ReadyIcon.vue'
import SuccessIcon from '../../icons/SuccessIcon.vue'
import ErrorIcon from '../../icons/ErrorIcon.vue'
import TubeSearchIcon from '../../icons/TubeSearchIcon.vue'
import TubeIcon from '../../icons/TubeIcon.vue'

const DEFAULT_MAX_TUBE_CHECK_RETRIES = 3
const DEFAULT_MAX_TUBE_CHECK_RETRY_DELAY = 1000

/**
 * Enum for the possible states of the component
 */
const StateEnum = {
  READY_TO_EXPORT: 'ready_to_export', // The state when the component is ready to export the tube
  CHECKING_TUBE_STATUS: 'checking_tube_status', // The state when the component is checking if the tube is in Traction and this is the initial state as well
  TUBE_ALREADY_EXPORTED: 'tube_exists', // The state when the tube is already exported to Traction
  EXPORTING_TUBE: 'exporting', // The state when the component is exporting the tube to Traction
  TUBE_EXPORT_SUCESS: 'tube_export_success', // The state when the tube is successfully exported to Traction
  FAILURE_TUBE_CHECK: 'failure_tube_check', // The state when the component fails to check if the tube is in Traction using the Traction API
  FAILURE_TUBE_CHECK_AFTER_EXPORT: 'failure_tube_check_export', // The state when the component fails to check if the tube is in Traction after exporting using the Traction API
  FAILURE_EXPORT_TUBE: 'failure_export_tube', // The state when the component fails to export the tube when the export (SS API) fails
  INVALID_PROPS: 'invalid_props', // The state when the component receives invalid props
}

/**
 * Data for the different states of the component
 */
const StateData = {
  [StateEnum.CHECKING_TUBE_STATUS]: {
    statusText: 'Checking tube is in Traction',
    buttonText: 'Please wait',
    styles: { button: 'success', text: 'text-black', icon: 'black' },
    icon: TubeSearchIcon,
  },
  [StateEnum.READY_TO_EXPORT]: {
    statusText: 'Ready to export',
    buttonText: 'Export',
    styles: { button: 'success', text: 'text-success', icon: 'green' },
    icon: ReadyIcon,
  },
  [StateEnum.TUBE_ALREADY_EXPORTED]: {
    statusText: 'Tube already exported to Traction',
    buttonText: 'Open Traction',
    styles: { button: 'primary', text: 'text-success', icon: 'green' },
    icon: SuccessIcon,
  },

  [StateEnum.EXPORTING_TUBE]: {
    statusText: 'Tube is being exported to Traction',
    buttonText: 'Please wait',
    styles: { button: 'success', text: 'text-black', icon: 'black' },
    icon: TubeIcon,
  },
  [StateEnum.TUBE_EXPORT_SUCESS]: {
    statusText: 'Tube has been exported to Traction',
    buttonText: 'Open Traction',
    styles: { button: 'primary', text: 'text-success', icon: 'green' },
    icon: SuccessIcon,
  },
  [StateEnum.FAILURE_TUBE_CHECK]: {
    statusText: 'The export cannot be verified. Refresh to try again',
    buttonText: 'Refresh',
    styles: { button: 'danger', text: 'text-danger', icon: 'red' },
    icon: ErrorIcon,
  },
  [StateEnum.FAILURE_TUBE_CHECK_AFTER_EXPORT]: {
    statusText: 'The export cannot be verified. Try again',
    buttonText: 'Try again',
    styles: { button: 'danger', text: 'text-danger', icon: 'red' },
    icon: ErrorIcon,
  },
  [StateEnum.FAILURE_EXPORT_TUBE]: {
    statusText: 'The tube export to Traction failed. Try again',
    buttonText: 'Try again',
    styles: { button: 'danger', text: 'text-danger', icon: 'red' },
    icon: ErrorIcon,
  },
  [StateEnum.INVALID_PROPS]: {
    statusText: 'Required props are missing',
    buttonText: 'Export',
    styles: { button: 'danger', text: 'text-danger', icon: 'red' },
    icon: ErrorIcon,
  },
}

/**
 * Enum for the possible results of the tube search using the Traction API
 */
const TubeSearchResult = {
  FOUND: 'found',
  NOT_FOUND: 'not_found',
  SERVICE_ERROR: 'service_error',
}

export default {
  name: 'PoolXPTubeSubmitPanel',
  components: {
    ReadyIcon,
  },
  props: {
    barcode: {
      type: String,
      required: true,
    },
    userId: {
      type: String,
      required: true,
    },
    sequencescapeApiUrl: {
      type: String,
      required: true,
    },
    tractionServiceUrl: {
      type: String,
      required: true,
    },
    tractionUIUrl: {
      type: String,
      required: true,
    },
  },
  data: function () {
    return {
      state: StateEnum.CHECKING_TUBE_STATUS,
    }
  },
  computed: {
    statusText() {
      return StateData[this.state].statusText
    },
    buttonText() {
      return StateData[this.state].buttonText
    },
    stateStyles() {
      return StateData[this.state]?.styles || { button: 'danger', text: 'text-danger', icon: 'red' }
    },
    statusIcon() {
      return StateData[this.state].icon
    },

    isButtonDisabled() {
      return (
        this.state === StateEnum.CHECKING_TUBE_STATUS ||
        this.state === StateEnum.EXPORTING_TUBE ||
        this.state === StateEnum.INVALID_PROPS
      )
    },
    displaySpinner() {
      return this.state === StateEnum.EXPORTING_TUBE || this.state === StateEnum.CHECKING_TUBE_STATUS
    },
    sequencescapeApiExportUrl() {
      return `${this.sequencescapeApiUrl}/bioscan/export_pool_xp_to_traction`
    },
    tractionTubeCheckUrl() {
      if (!this.barcode || !this.tractionServiceUrl) return ''
      return `${this.tractionServiceUrl}/pacbio/tubes?filter[barcode]=${this.barcode}`
    },
    tractionTubeOpenUrl() {
      if (!this.barcode || !this.tractionUIUrl) return ''
      return `${this.tractionUIUrl}/#/pacbio/libraries?filter_value=source_identifier&filter_input=${this.barcode}`
    },
    submitPayload() {
      return {
        data: {
          attributes: {
            barcode: this.barcode,
          },
        },
      }
    },
  },

  /**
   * On mount, check if the tube is already exported to Traction
   * If the tube is found, transition to the TUBE_ALREADY_EXPORTED state
   * If the tube is not found, transition to the INITIAL state
   * If the service is unavailable or returns error, transition to the FAILURE_TUBE_CHECK state
   */
  async mounted() {
    // Validate the props
    this.validateProps()
    if (this.state === StateEnum.INVALID_PROPS) return
    this.initialiseStartState()
  },
  methods: {
    /**
     * Validate the props
     */
    validateProps() {
      if (!(this.barcode && this.userId && this.sequencescapeApiUrl && this.tractionServiceUrl && this.tractionUIUrl)) {
        this.state = StateEnum.INVALID_PROPS
        return
      }
    },

    /**
     * Check if the tube is in Traction
     * @returns {TubeSearchResult} - The result of the tube search
     * - FOUND: If the tube is found in Traction
     * - NOT_FOUND: If the tube is not found in Traction
     * - SERVICE_ERROR: If the service is unavailable or returns error
     */
    async checkTubeInTraction() {
      try {
        const response = await fetch(this.tractionTubeCheckUrl)
        if (response.ok) {
          const data = await response.json()
          if (data.data && data.data.length > 0) {
            return TubeSearchResult.FOUND
          }
          return TubeSearchResult.NOT_FOUND
        } else {
          console.log('Fetch response not ok:', response.statusText)
          return TubeSearchResult.SERVICE_ERROR
        }
      } catch (error) {
        console.log('Error during fetch:', error)
        return TubeSearchResult.SERVICE_ERROR
      }
    },

    /**
     * Initialise the start state of the component
     * - Check if the tube is already exported to Traction and transition to the appropriate state based on the result
     * - If the tube is found in Traction, transition to the TUBE_ALREADY_EXPORTED state
     * - If the service is unavailable or returns error, transition to the FAILURE_TUBE_CHECK state
     * - If the tube is not found in Traction, transition to the INITIAL state which allows the user to export the tube
     */
    async initialiseStartState() {
      this.state = StateEnum.CHECKING_TUBE_STATUS
      const result = await this.checkTubeStatusWithRetries()

      // If the tube is found in Traction, transition to the TUBE_ALREADY_EXPORTED state
      if (result === TubeSearchResult.FOUND) {
        this.state = StateEnum.TUBE_ALREADY_EXPORTED
        return
      }
      // If the service is unavailable or returns error, transition to the FAILURE_TUBE_CHECK state
      if (result === TubeSearchResult.SERVICE_ERROR) {
        this.state = StateEnum.FAILURE_TUBE_CHECK
        return
      }
      // If the tube is not found in Traction, transition to the INITIAL state which allows the user to export the tube
      this.state = StateEnum.READY_TO_EXPORT
    },

    /**
     * Handle the submit button click
     * The action taken depends on the current state of the component
     *
     * - If the state is FAILURE_TUBE_CHECK, it means the initial check failed, therefore repeat the initial check
     * - If the state is TUBE_EXPORT_SUCESS or TUBE_ALREADY_EXPORTED, open the tube in Traction
     * - Otherwise, export the tube to Traction
     * */
    async handleSubmit() {
      switch (this.state) {
        case StateEnum.FAILURE_TUBE_CHECK: {
          this.initialiseStartState()
          break
        }
        case StateEnum.TUBE_EXPORT_SUCESS:
        case StateEnum.TUBE_ALREADY_EXPORTED: {
          // Open Traction in a new tab
          window.open(this.tractionTubeOpenUrl, '_blank')
          break
        }

        default: {
          await this.exportTubeToTraction()
          break
        }
      }
    },
    /**
     * Export the tube to Traction and poll Traction for the tube status if the export is successful
     *
     * - If the export api is successful, transition to the EXPORTING_TUBE state and poll Traction for the tube status
     * - If the export api fails, transition to the FAILURE_EXPORT_TUBE state which allows the user to retry exporting the tube
     * - If the tube is found in Traction after the export, transition to the TUBE_EXPORT_SUCCESS state which allows the user to open the tube in Traction
     * - If the tube is not found in Traction after the export, transition to the FAILURE_TUBE_CHECK_AFTER_EXPORT state which allows the user to retry exporting the tube
     */
    async exportTubeToTraction() {
      try {
        const response = await fetch(this.sequencescapeApiExportUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify(this.submitPayload),
        })

        if (response.ok) {
          this.state = StateEnum.EXPORTING_TUBE
          const retStatus = await this.checkTubeStatusWithRetries(DEFAULT_MAX_TUBE_CHECK_RETRIES + 2)
          this.state =
            retStatus === TubeSearchResult.FOUND
              ? StateEnum.TUBE_EXPORT_SUCESS
              : StateEnum.FAILURE_TUBE_CHECK_AFTER_EXPORT
          return
        } else {
          this.state = StateEnum.FAILURE_EXPORT_TUBE
        }
      } catch (error) {
        console.log('Error exporting tube to Traction:', error)
        this.state = StateEnum.FAILURE_EXPORT_TUBE
      }
    },

    /**
     * Check the tube status with retries
     * @param {number} retries - Number of retries
     * @param {number} delay - Delay between retries in milliseconds
     */
    async checkTubeStatusWithRetries(
      retries = DEFAULT_MAX_TUBE_CHECK_RETRIES,
      delay = DEFAULT_MAX_TUBE_CHECK_RETRY_DELAY,
    ) {
      let result = TubeSearchResult.NOT_FOUND
      for (let i = 0; i < retries; i++) {
        result = await this.checkTubeInTraction()
        if (result === TubeSearchResult.FOUND) {
          return result
        }
        if (i < retries - 1) {
          await this.sleep(delay)
        }
      }

      return result
    },

    /**
     * Sleep for a specified duration
     * @param {number} ms - Duration in milliseconds
     * @returns {Promise}
     */
    sleep(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms))
    },
  },
}
</script>