rubyforgood/casa

View on GitHub
app/javascript/src/all_casa_admin/patch_notes.js

Summary

Maintainability
D
2 days
Test Coverage
const { Notifier } = require('../notifier')
const TypeChecker = require('../type_checker')
const patchNotePath = window.location.pathname
const patchNoteFormBeforeEditData = {}
const patchNoteFunctions = {} // A hack to be able to alphabetize functions

let pageNotifier

// Inserts a patch note display after the create patch note form in the patch note list and styles it as new
//  @param    {number} patchNoteGroupId  The id of the group allowed to view the patch note
//  @param    {jQuery} patchNoteList     A jQuery object representing the patch note list
//  @param    {string} patchNoteText     The text of the patch note
//  @param    {number} patchNoteTypeId   The id of the patch note type
//  @throws   {TypeError}  for a parameter of the incorrect type
//  @throws   {RangeError} if an id parameter is negative
patchNoteFunctions.addPatchNoteUI = function (patchNoteGroupId, patchNoteId, patchNoteList, patchNoteText, patchNoteTypeId) {
  TypeChecker.checkPositiveInteger(patchNoteGroupId, 'patchNoteGroupId')
  TypeChecker.checkPositiveInteger(patchNoteId, 'patchNoteId')
  TypeChecker.checkPositiveInteger(patchNoteTypeId, 'patchNoteTypeId')
  TypeChecker.checkNonEmptyJQueryObject(patchNoteList, 'patchNoteList')
  TypeChecker.checkString(patchNoteText, 'patchNoteText')

  const newPatchNoteForm = patchNoteList.children().eq(1)

  if (!(newPatchNoteForm.length)) {
    throw new ReferenceError('Could not find new patch note form')
  }

  const newPatchNoteUI = newPatchNoteForm.clone()
  const newPatchNoteUIFormInputs = patchNoteFunctions.getPatchNoteFormInputs(newPatchNoteUI.children())

  newPatchNoteUI.addClass('new')
  newPatchNoteUI.children().attr('id', `patch-note-${patchNoteId}`)
  newPatchNoteUIFormInputs.noteTextArea.val(patchNoteText)
  newPatchNoteUIFormInputs.dropdownGroup.children().removeAttr('selected')
  newPatchNoteUIFormInputs.dropdownGroup.children(`option[value="${patchNoteGroupId}"]`).attr('selected', true)
  newPatchNoteUIFormInputs.dropdownType.children().removeAttr('selected')
  newPatchNoteUIFormInputs.dropdownType.children(`option[value="${patchNoteTypeId}"]`).attr('selected', true)
  newPatchNoteUIFormInputs.buttonControls.parent().html(`
    <button type="button" class="main-btn primary-btn btn-hovert button-edit">
      <i class="lni lni-pencil-alt mr-10"></i>Edit
    </button>
    <button type="button" class="main-btn danger-btn btn-hover button-delete">
      <i class="lni lni-trash-can mr-10"></i>Delete
    </button>
  `)

  newPatchNoteForm.after(newPatchNoteUI)
  patchNoteFunctions.initPatchNoteForm(newPatchNoteUI)
}

// Creates a patch note
//  @param    {number} patchNoteGroupId  The id of the group allowed to view the patch note
//  @param    {string} patchNoteText     The text of the patch note
//  @param    {number} patchNoteTypeId   The id of the patch note type
//  @returns  {array} a jQuery jqXHR object. See https://api.jquery.com/jQuery.ajax/#jqXHR
//  @throws   {TypeError}  for a parameter of the incorrect type
//  @throws   {RangeError} if an id parameter is negative
patchNoteFunctions.createPatchNote = function (patchNoteGroupId, patchNoteText, patchNoteTypeId) {
  // Input check
  TypeChecker.checkPositiveInteger(patchNoteGroupId, 'patchNoteGroupId')
  TypeChecker.checkPositiveInteger(patchNoteTypeId, 'patchNoteTypeId')
  TypeChecker.checkString(patchNoteText, 'patchNoteText')

  // Post request
  // return $.post(patchNotePath, {
  //   note: patchNoteText,
  //   patch_note_group_id: patchNoteGroupId,
  //   patch_note_type_id: patchNoteTypeId
  // })
  return $.ajax({
    url: patchNotePath,
    type: 'POST',
    data: {
      note: patchNoteText,
      patch_note_group_id: patchNoteGroupId,
      patch_note_type_id: patchNoteTypeId
    },
    beforeSend: function () {
      pageNotifier.waitForAsyncOperation()
    }
  })
    .then(function (response, textStatus, jqXHR) {
      if (response.errors) {
        return $.Deferred().reject(jqXHR, textStatus, response.error)
      } else if (response.status && response.status === 'created') {
        patchNoteFunctions.resolveAsyncOperation()
      } else {
        patchNoteFunctions.resolveAsyncOperation('Unknown response')
      }

      return response
    })
    .fail(function (jqXHR, textStatus, error) {
      patchNoteFunctions.resolveAsyncOperation(error)
    })
}

// Deletes a patch note
//  @param    {number} .parent().parent()patchNoteId The id of the patch note deleted
//  @returns  {array} a jQuery jqXHR object. See https://api.jquery.com/jQuery.ajax/#jqXHR
//  @throws   {TypeError}  for a parameter of the incorrect type
//  @throws   {RangeError} if optionId is negative
patchNoteFunctions.deletePatchNote = function (patchNoteId) {
  TypeChecker.checkPositiveInteger(patchNoteId, 'patchNoteId')

  return $.ajax({
    url: `${patchNotePath}/${patchNoteId}`,
    type: 'DELETE',
    beforeSend: function () {
      pageNotifier.waitForAsyncOperation()
    }
  })
    .then(function (response, textStatus, jqXHR) {
      if (response.errors) {
        return $.Deferred().reject(jqXHR, textStatus, response.error)
      } else if (response.status && response.status === 'ok') {
        patchNoteFunctions.resolveAsyncOperation()
      } else {
        patchNoteFunctions.resolveAsyncOperation('Unknown response')
      }

      return response
    })
    .fail(function (jqXHR, textStatus, error) {
      patchNoteFunctions.resolveAsyncOperation(error)
    })
}

// Disables all form elements of a patch note form
//  @param    {object} patchNoteFormInputs An object containing the form elements as jQuery objects like the object returned from getPatchNoteFormInputs()
//  @throws   {TypeError} for a parameter of the incorrect type
patchNoteFunctions.disablePatchNoteForm = function (patchNoteFormInputs) {
  for (const formInput of Object.values(patchNoteFormInputs)) {
    formInput.prop('disabled', true)
  }
}

// Enables all form elements of a patch note form
//  @param    {object} patchNoteFormInputs An object containing the form elements as jQuery objects like the object returned from getPatchNoteFormInputs()
//  @throws   {TypeError} for a parameter of the incorrect type
patchNoteFunctions.enablePatchNoteForm = function (patchNoteFormInputs) {
  for (const formInput of Object.values(patchNoteFormInputs)) {
    formInput.removeAttr('disabled')
  }
}

// Change a patch note form into edit mode
//  @param  {object} patchNoteFormInputs An object containing the form elements as jQuery objects like the object returned from getPatchNoteFormInputs()
//  @throws {TypeError} for a parameter of the incorrect type
patchNoteFunctions.enablePatchNoteFormEditMode = function (patchNoteFormInputs) {
  TypeChecker.checkObject(patchNoteFormInputs, 'patchNoteFormInputs')

  patchNoteFunctions.enablePatchNoteForm(patchNoteFormInputs)

  // Change button controls
  //   Clear click listeners
  patchNoteFormInputs.buttonControls.off()

  const buttonLeft = patchNoteFormInputs.buttonControls.siblings('.button-edit')
  const buttonRight = patchNoteFormInputs.buttonControls.siblings('.button-delete')

  buttonLeft.html('<i class="fas fa-save"></i> Save')
  buttonLeft.removeClass('button-edit')
  buttonLeft.addClass('button-save')

  buttonRight.html('<i class="fa-solid fa-xmark"></i> Cancel')
  buttonRight.removeClass('button-delete')
  buttonRight.removeClass('btn-danger')
  buttonRight.addClass('button-cancel')
  buttonRight.addClass('btn-secondary')

  patchNoteFunctions.initPatchNoteForm(patchNoteFormInputs.noteTextArea.parent())
}

// Change a patch note form out of edit mode
//  @param  {object} patchNoteFormInputs An object containing the form elements as jQuery objects like the object returned from getPatchNoteFormInputs()
//  @throws {TypeError} for a parameter of the incorrect type
patchNoteFunctions.exitPatchNoteFormEditMode = function (patchNoteFormInputs) {
  TypeChecker.checkObject(patchNoteFormInputs, 'patchNoteFormInputs')

  patchNoteFormInputs.noteTextArea.prop('disabled', true)
  patchNoteFormInputs.dropdownGroup.prop('disabled', true)
  patchNoteFormInputs.dropdownType.prop('disabled', true)

  // Change button controls
  //   Clear click listeners
  patchNoteFormInputs.buttonControls.off()

  const buttonLeft = patchNoteFormInputs.buttonControls.siblings('.button-save')
  const buttonRight = patchNoteFormInputs.buttonControls.siblings('.button-cancel')

  buttonLeft.html('<i class="fa-solid fa-pen-to-square"></i> Edit')
  buttonLeft.removeClass('button-save')
  buttonLeft.addClass('button-edit')

  buttonRight.html('<i class="fa-solid fa-trash-can"></i> Delete')
  buttonRight.removeClass('btn-secondary')
  buttonRight.removeClass('button-cancel')
  buttonRight.addClass('btn-danger')
  buttonRight.addClass('button-delete')

  patchNoteFunctions.initPatchNoteForm(patchNoteFormInputs.noteTextArea.parent())
}

// Get all form elements of a patch note in edit mode
//  @param    {jQuery} patchNoteElement The direct parent of the form elements
//  @returns  {object} An object containing jQuery objects in this form
//    {
//      dropdownGroup:  The select for the patch note's user visibility group
//      dropdownType:   The select for the patch note's type
//      noteTextArea:   The textarea containing the patch note
//      buttonControls: A list of all the buttons at the bottom of the form
//    }
//  @throws   {TypeError}      for a parameter of the incorrect type
//  @throws   {ReferenceError} if an element could not be found
patchNoteFunctions.getPatchNoteFormInputs = function (patchNoteElement) {
  TypeChecker.checkNonEmptyJQueryObject(patchNoteElement, 'patchNoteElement')

  const selects = patchNoteElement.children('.label-and-select').children('select')

  const fields = {
    dropdownGroup: selects.eq(1),
    dropdownType: selects.eq(0),
    noteTextArea: patchNoteElement.children('textarea'),
    buttonControls: patchNoteElement.children('.patch-note-button-controls').children('button')
  }

  for (const fieldName of Object.keys(fields)) {
    const field = fields[fieldName]

    if (!((field instanceof jQuery) && field.length)) {
      throw new ReferenceError(`Could not find form element ${fieldName}`)
    }
  }

  return fields
}

// Get the id of a patch note from its form
//  @param   {object} patchNoteForm A jQuery object representing the div with the patch note's id
//  @returns {number} The id of the patch note as a number
//  @throws  {TypeError}  for a parameter of the incorrect type
patchNoteFunctions.getPatchNoteId = function (patchNoteForm) {
  TypeChecker.checkNonEmptyJQueryObject(patchNoteForm, 'patchNoteForm')

  return Number.parseInt(patchNoteForm.attr('id').match(/patch-note-(\d+)/)[1])
}

// Add event listeners to a patch note form
//  @param {object} patchNoteForm A jQuery object representing the patch note form
//  @throws   {TypeError}  for a parameter of the incorrect type
patchNoteFunctions.initPatchNoteForm = function (patchNoteForm) {
  TypeChecker.checkNonEmptyJQueryObject(patchNoteForm, 'patchNoteForm')

  patchNoteForm.find('.button-cancel').click(patchNoteFunctions.onCancelEdit)
  patchNoteForm.find('.button-delete').click(patchNoteFunctions.onDeletePatchNote)
  patchNoteForm.find('.button-edit').click(patchNoteFunctions.onEditPatchNote)
  patchNoteForm.find('.button-save').click(patchNoteFunctions.onSavePatchNote)
}

// Called when the cancel button is pressed on a patch note form
patchNoteFunctions.onCancelEdit = function () {
  const patchNoteFormContainer = $(this).parent().parent()
  const formInputs = patchNoteFunctions.getPatchNoteFormInputs(patchNoteFormContainer)

  patchNoteFunctions.patchNoteFormDataResetBeforeEdit(formInputs)
  patchNoteFunctions.exitPatchNoteFormEditMode(formInputs)
}

// Called when the create button is pressed on the new patch note form
patchNoteFunctions.onCreate = function () {
  try {
    const patchNoteList = $('#patch-note-list')
    const newPatchNoteFormInputs = patchNoteFunctions.getPatchNoteFormInputs($('#new-patch-note'))

    if (!(newPatchNoteFormInputs.noteTextArea.val())) {
      pageNotifier.notify('Cannot save an empty patch note', 'warn')
      return
    }

    patchNoteFunctions.disablePatchNoteForm(newPatchNoteFormInputs)

    const patchNoteGroupId = Number.parseInt(newPatchNoteFormInputs.dropdownGroup.val())
    const patchNoteTypeId = Number.parseInt(newPatchNoteFormInputs.dropdownType.val())
    const patchNoteText = newPatchNoteFormInputs.noteTextArea.val()

    patchNoteFunctions.createPatchNote(
      patchNoteGroupId,
      patchNoteText,
      patchNoteTypeId
    ).then(function (response) {
      newPatchNoteFormInputs.noteTextArea.val('')
      patchNoteFunctions.addPatchNoteUI(patchNoteGroupId, response.id, patchNoteList, patchNoteText, patchNoteTypeId)
    }).fail(function (err) {
      pageNotifier.notify('Failed to update UI', 'error')
      pageNotifier.notify(err.message, 'error')
      console.error(err)
    }).always(function () {
      patchNoteFunctions.enablePatchNoteForm(newPatchNoteFormInputs)
    })
  } catch (err) {
    pageNotifier.notify('Failed to save patch note', 'error')
    pageNotifier.notify(err.message, 'error')
    console.error(err)
  }
}

// Called when the delete button is pressed on a patch note form
patchNoteFunctions.onDeletePatchNote = function () {
  const deleteButton = $(this)
  const patchNoteFormContainer = deleteButton.parent().parent()
  const formInputs = patchNoteFunctions.getPatchNoteFormInputs(patchNoteFormContainer)

  switch (deleteButton.text().trim()) {
    case 'Delete':
      pageNotifier.notify('Click 2 more times to delete', 'warn')
      deleteButton.text('2')
      break
    case '2':
      deleteButton.text('1')
      break
    case '1':
      patchNoteFunctions.disablePatchNoteForm(formInputs)

      patchNoteFunctions.deletePatchNote(
        patchNoteFunctions.getPatchNoteId(patchNoteFormContainer)
      ).then(function () {
        patchNoteFormContainer.parent().remove()
      }).fail(function () {
        patchNoteFunctions.enablePatchNoteForm(formInputs)
        deleteButton.html('<i class="fa-solid fa-trash-can"></i> Delete')
      })

      break
  }
}

// Called when the delete button is pressed on a patch note form
patchNoteFunctions.onEditPatchNote = function () {
  const patchNoteFormInputs = patchNoteFunctions.getPatchNoteFormInputs($(this).parent().parent())

  patchNoteFunctions.patchNoteFormDataSaveTemp(patchNoteFormInputs)
  patchNoteFunctions.enablePatchNoteFormEditMode(patchNoteFormInputs)
}

// Called when the save button is pressed on a patch note form in edit mode
patchNoteFunctions.onSavePatchNote = function () {
  const patchNoteForm = $(this).parents('.card-body')
  const patchNoteFormInputs = patchNoteFunctions.getPatchNoteFormInputs(patchNoteForm)

  if ($(this).parent().siblings('textarea').val() === '') {
    pageNotifier.notify('Cannot save a blank patch note', 'warn')
    return
  }

  const patchNoteGroupId = Number.parseInt(patchNoteFormInputs.dropdownGroup.val())
  const patchNoteId = patchNoteFunctions.getPatchNoteId(patchNoteForm)
  const patchNoteTypeId = Number.parseInt(patchNoteFormInputs.dropdownType.val())
  const patchNoteText = patchNoteFormInputs.noteTextArea.val()

  patchNoteFunctions.disablePatchNoteForm(patchNoteFormInputs)

  patchNoteFunctions.savePatchNote(
    patchNoteGroupId,
    patchNoteId,
    patchNoteText,
    patchNoteTypeId
  ).then(function (response) {
    patchNoteFormInputs.noteTextArea.prop('disabled', true)
    patchNoteFormInputs.dropdownGroup.prop('disabled', true)
    patchNoteFormInputs.dropdownType.prop('disabled', true)

    // Change button controls
    //   Clear click listeners
    patchNoteFormInputs.buttonControls.off()

    const buttonLeft = patchNoteFormInputs.buttonControls.siblings('.button-save')
    const buttonRight = patchNoteFormInputs.buttonControls.siblings('.button-cancel')

    buttonLeft.html('<i class="fa-solid fa-pen-to-square"></i> Edit')
    buttonLeft.removeClass('button-save')
    buttonLeft.addClass('button-edit')

    buttonRight.html('<i class="fa-solid fa-trash-can"></i> Delete')
    buttonRight.removeClass('btn-secondary')
    buttonRight.removeClass('button-cancel')
    buttonRight.addClass('btn-danger')
    buttonRight.addClass('button-delete')

    patchNoteFunctions.initPatchNoteForm(patchNoteFormInputs.noteTextArea.parent())
  }).fail(function (err) {
    pageNotifier.notify('Failed to update patch note', 'error')
    pageNotifier.notify(err.message, 'error')
    console.error(err)

    patchNoteFunctions.enablePatchNoteForm(patchNoteFormInputs)
  }).always(function () {
    patchNoteFormInputs.buttonControls.prop('disabled', false)
  })
}

// Set the value of a patch note form's inputs to before the form was put in edit mode
//  @param  {object} patchNoteFormInputs An object containing the form elements as jQuery objects like the object returned from getPatchNoteFormInputs()
//  @throws {TypeError} for a parameter of the incorrect type
patchNoteFunctions.patchNoteFormDataResetBeforeEdit = function (patchNoteFormInputs) {
  TypeChecker.checkObject(patchNoteFormInputs, 'patchNoteFormInputs')

  let patchNoteDataBeforeEditing

  try {
    patchNoteDataBeforeEditing = patchNoteFormBeforeEditData[patchNoteFunctions.getPatchNoteId(patchNoteFormInputs.noteTextArea.parent())]

    patchNoteFormInputs.noteTextArea.val(patchNoteDataBeforeEditing.note)
    patchNoteFormInputs.dropdownGroup.val(patchNoteDataBeforeEditing.groupId)
    patchNoteFormInputs.dropdownType.val(patchNoteDataBeforeEditing.typeId)
  } catch (e) {
    pageNotifier.notify('Failed to load patch note data from before editing', 'error')
    throw e
  }
}

// Save the values of the patch note form inputs
//  @param  {object} patchNoteFormInputs An object containing the form elements as jQuery objects like the object returned from getPatchNoteFormInputs()
//  @throws {TypeError} for a parameter of the incorrect type
patchNoteFunctions.patchNoteFormDataSaveTemp = function (patchNoteFormInputs) {
  TypeChecker.checkObject(patchNoteFormInputs, 'patchNoteFormInputs')

  try {
    patchNoteFormBeforeEditData[patchNoteFunctions.getPatchNoteId(patchNoteFormInputs.noteTextArea.parent())] = {
      note: patchNoteFormInputs.noteTextArea.val(),
      groupId: Number.parseInt(patchNoteFormInputs.dropdownGroup.val()),
      typeId: Number.parseInt(patchNoteFormInputs.dropdownType.val())
    }
  } catch (e) {
    pageNotifier.notify('Failed to save patch note form data before editing', 'error')
    throw e
  }
}

// Called when an async operation completes. May show notifications describing how the operation completed
//  @param    {string | Error=}  error The error to be displayed(optional)
//  @throws   {TypeError}  for a parameter of the incorrect type
//  @throws   {Error}      for trying to resolve more async operations than the amount currently awaiting
patchNoteFunctions.resolveAsyncOperation = function (error) {
  if (error instanceof Error) {
    error = error.message
  }

  pageNotifier.resolveAsyncOperation(error)
}

// Saves an edited patch note
//  @param    {number} patchNoteGroupId  The id of the group allowed to view the patch note
//  @param    {number} patchNoteId  The id of the patch note
//  @param    {string} patchNoteText     The text of the patch note
//  @param    {number} patchNoteTypeId   The id of the patch note type
//  @returns  {array} a jQuery jqXHR object. See https://api.jquery.com/jQuery.ajax/#jqXHR
//  @throws   {TypeError}  for a parameter of the incorrect type
//  @throws   {RangeError} if an id parameter is negative
patchNoteFunctions.savePatchNote = function (patchNoteGroupId, patchNoteId, patchNoteText, patchNoteTypeId) {
  // Input check
  TypeChecker.checkPositiveInteger(patchNoteGroupId, 'patchNoteGroupId')
  TypeChecker.checkPositiveInteger(patchNoteId, 'patchNoteGroupId')
  TypeChecker.checkPositiveInteger(patchNoteTypeId, 'patchNoteTypeId')
  TypeChecker.checkString(patchNoteText, 'patchNoteText')

  // Post request
  return $.ajax({
    url: `${patchNotePath}/${patchNoteId}`,
    type: 'PUT',
    data: {
      note: patchNoteText,
      patch_note_group_id: patchNoteGroupId,
      patch_note_type_id: patchNoteTypeId
    },
    beforeSend: function () {
      pageNotifier.waitForAsyncOperation()
    }
  })
    .then(function (response, textStatus, jqXHR) {
      if (response.errors) {
        return $.Deferred().reject(jqXHR, textStatus, response.error)
      } else if (response.status && response.status === 'ok') {
        patchNoteFunctions.resolveAsyncOperation()
      } else {
        patchNoteFunctions.resolveAsyncOperation('Unknown response')
        console.error('Unexpected repsonse')
        console.error(response)
      }

      return response
    })
    .fail(function (jqXHR, textStatus, error) {
      patchNoteFunctions.resolveAsyncOperation(error)
    })
}

$(() => { // JQuery's callback for the DOM loading
  if (!(window.location.pathname.includes('patch_notes'))) {
    return
  }

  try {
    const asyncNotificationsElement = $('#notifications')
    pageNotifier = new Notifier(asyncNotificationsElement)

    $('#new-patch-note button').on('click', patchNoteFunctions.onCreate)
    $('#patch-note-list .button-delete').on('click', patchNoteFunctions.onDeletePatchNote)
    $('#patch-note-list .button-edit').on('click', patchNoteFunctions.onEditPatchNote)
  } catch (err) {
    pageNotifier.notify('Could not intialize app', 'error')
    pageNotifier.notify(err.message, 'error')
    console.error(err)
  }
})