rubyforgood/casa

View on GitHub
app/javascript/controllers/casa_nested_form_controller.js

Summary

Maintainability
A
3 hrs
Test Coverage
import NestedForm from '@stimulus-components/rails-nested-form'

/**
 * Allows nested forms to be used with the autosave controller,
 * creating and destroying records so that the autosave updates do not attempt
 * to create/destroy same nested records repeatedly.
 *
 * Extends stimulus-rails-nested-form.
 * https://www.stimulus-components.com/docs/stimulus-rails-nested-form/
 * add() & remove() are standard, so can be used as stimulus-rails-nested-form.
 * No values are necessary in that case.
 *
 * Created for the the CaseContact form (details), see its usage there.
 *
 * Connects to data-controller="casa-nested-form"
 */
export default class extends NestedForm {
  static values = {
    route: String, // path to create/destroy a record, e.g. "/contact_topic_answers"
    parentName: String, // snake case name of parent model, e.g. "case_contact"
    parentId: Number, // id of record this form is nested within e.g. @case_contact.id
    modelName: String, // name of nested form, e.g. "contact_topic_answer"
    requiredFields: { // fields required (belongs_to etc...) to pass validation (e.g. contact_topic_id)
      type: Array, default: []
    }
  }

  connect () {
    super.connect()

    const headers = new Headers()
    headers.append('Content-Type', 'application/json')
    headers.append('Accept', 'application/json')
    const tokenTag = document.querySelector('meta[name="csrf-token"]')
    if (tokenTag) { // does not exist in test environment
      headers.append('X-CSRF-Token', tokenTag.content)
    }
    this.headers = headers

    document.addEventListener('autosave:success', this.onAutosaveSuccess)
  }

  disconnect () {
    document.removeEventListener('autosave:success', this.onAutosaveSuccess)
  }

  getRecordId (wrapper) {
    const recordInput = wrapper.querySelector('input[name*="id"]')
    if (!recordInput) {
      console.warn('id input not found for nested item:', wrapper)
      return ''
    }
    return recordInput.value
  }

  /* removes any items that have been marked as _destroy: true */
  /* must be marked for destroy elsewhere, see case_contact_form_controller clearExpenses() */
  onAutosaveSuccess = (_e) => {
    const wrappers = this.element.querySelectorAll(this.wrapperSelectorValue)
    wrappers.forEach(wrapper => {
      const destroyInput = wrapper.querySelector("input[name*='_destroy']")
      if (!destroyInput) {
        console.warn('Destroy input not found for nested item:', wrapper)
        return
      }
      if (destroyInput.value === '1') {
        // autosave has already destroyed the record, remove the element from DOM
        wrapper.remove()
      }
    })
  }

  dispatchChangeEvent (action) {
    const detail = { modelName: this.modelNameValue, action }
    const changeEvent = new CustomEvent('casa-nested-form:change', { detail, bubbles: true }) // eslint-disable-line no-undef
    document.dispatchEvent(changeEvent)
  }

  /* Adds item to the form. Item will not be created until form submission. */
  add (e) {
    super.add(e)
    this.dispatchChangeEvent('add')
  }

  /* Creates a new record for the added item (before submission). */
  addAndCreate (e) {
    this.add(e)
    const items = this.element.querySelectorAll(this.wrapperSelectorValue)
    const addedItem = items[items.length - 1]
    // childIndex will be 0,1,... for items at page load, timestamps for items added to form.
    const childIndex = addedItem.dataset.childIndex
    const domIdBase = `${this.parentNameValue}_${this.modelNameValue}s_attributes_${childIndex}`

    const fields = {}
    fields[`${this.parentNameValue}_id`] = this.parentIdValue

    this.requiredFieldsValue.forEach(field => {
      const fieldId = `${domIdBase}_${field}`
      const fieldEl = document.querySelector(`#${fieldId}`)
      if (!fieldEl) {
        console.warn('Aborting: Field not found:', fieldId)
        return
      }
      fields[field] = fieldEl.value
    })

    if (Object.values(fields).some(value => value === '')) {
      console.warn('Aborting: Required field empty:', fields)
      return
    }

    const body = {}
    body[this.modelNameValue] = fields

    fetch(this.routeValue, {
      method: 'POST',
      headers: this.headers,
      body: JSON.stringify(body)
    })
      .then(response => {
        if (response.ok) {
          return response.json()
        } else {
          return Promise.reject(response)
        }
      })
      .then(data => {
        const idAttr = `${domIdBase}_id`
        const idField = document.querySelector(`#${idAttr}`)
        idField.setAttribute('value', data.id)
        addedItem.dataset.newRecord = false
      })
      .catch(error => {
        console.error(error.status, error.statusText)
        error.json().then(errorJson => {
          console.error('errorJson', errorJson)
        })
      })
  }

  /* Removes item from the form. Will not destroy record until form submission. */
  remove (e) {
    super.remove(e)
    this.dispatchChangeEvent('remove')
  }

  /* Destroys a record when removing the item (before submission). */
  destroyAndRemove (e) {
    const wrapper = e.target.closest(this.wrapperSelectorValue)
    const recordId = this.getRecordId(wrapper)
    if (wrapper.dataset.newRecord === 'false' && (recordId.length > 0)) {
      fetch(`${this.routeValue}/${recordId}`, {
        method: 'DELETE',
        headers: this.headers
      })
        .then(response => {
          if (response.ok) {
            // destroy successful; remove as if new record
            wrapper.dataset.newRecord = true
            this.remove(e)
          } else {
            return Promise.reject(response)
          }
        })
        .catch(error => {
          console.error(error.status, error.statusText)
          if (error.status === 404) {
            // NOT FOUND: already deleted -> remove as if new record
            wrapper.dataset.newRecord = true
            this.remove(e)
          } else {
            error.json().then(errorJson => {
              console.error('errorJson', errorJson)
            })
          }
        })
    } else {
      console.warn(
        'Conflicting information while trying to destroy record:', {
          wrapperDatasetNewRecord: wrapper.dataset.newRecord,
          recordId
        }
      )
      this.remove(e) // treat as typical removal
    }
  }

  confirmDestroyAndRemove (e) {
    const text = 'Are you sure you want to remove this item?'
    if (window.confirm(text)) {
      this.destroyAndRemove(e)
    }
  }
}