Source: AjaxSubmit.js

import BaseCustomElement from "./BaseCustomElement"
import ConstraintViolationMessages from "./ConstraintViolationMessages"
import ConstraintViolationMessage from "./ConstraintViolationMessage"

/** Wraps a `<BUTTON>` assumed to be inside a form to indicate that, when clicked, it should submit
 * the form it's a part of via AJAX. It accounts for network failures and timeouts.
 *
 * The general flow is as follows:
 *
 * 1. When the button is clicked, the form's validity is checked. If it's not valid, nothing happens.
 * 2. If the form is valid, this element will be given the `requesting` attribute.
 * 3. The request will be initiated, set to abort after `request-timeout` ms (see below).
 * 4. If the request returns OK:
 *    - `requesting` will be removed and `submitted` will be added.
 *    - `submitted` will be removed after `submitted-lifetime` ms.
 * 5. If the request returned a 422, error messages are parsed. See below.
 * 6. If the request returns not OK and not 422:
 *    - if it has been `request-timeout` ms or more since the button was first clicked, the operation is aborted (see below).
 *    - if it has been less than `request-timeout` ms and the HTTP status code was 5xx, the operation is retried.
 *    - otherwise, the operation is aborted.
 * 7. If fetch throws an error, the operation is aborted.
 *
 * Aborting the operation will submit the form in the normal way, allowing the browser to deal with whatever the issue is. You can set
 * `log-request-errors` to introspect this process.
 *
 * For a 422 response, this element assumes the response is `text/html` and contains one or more `<brut-cv>`
 * elements.  These elements will be inserted into the proper `<brut-cv-messages>` element, as follows:
 *
 * 1. The `input-name` is examined.
 * 2. A `<brut-cv-messages input-name="«input-name»">` is located
 * 3. The containing form is located
 * 4. The input element(s) are located inside that form, based on `input-name`.
 * 5. The `<brut-cv-messages>` are cleared of any element with attribute `server-side`
 * 6. The messages from the server are inserted, with the attribute `server-side` added if it's not there.
 * 7. The input is set as having a custom validity
 * 8. validity is reported
 * 9. The first input located is scrolled into view
 * 10. If the input is modified after this all happens, custom validity is cleared
 *
 * @property {number} request-timeout - number of ms that the entire operation is expected to complete within. Default is 5000
 * @property {number} submitted-lifetime - number of ms that "submitted" should remain on the element after the form has completed. Default is 2000
 * @property {boolean} requesting - boolean attribute that indicates the request has been made, but not yet returned. Don't set this yourself outside of development. It will be set and removed by this element.
 * @property {boolean} submitted - boolean attribute that indicates the form has been successfully submitted. Don't set this yourselr outside of develoment. It will be set and removed by this element.
 * @property {boolean} log-request-errors - if set, logging related to request error handling will appear in the console. It will also
 * cause any form submission to be delayed by 2s to allow you to read the console.
 *
 * @fires brut:submitok Fired when the AJAX request initated by this returns OK and all processing has completed
 * @fires brut:submitinvalid Fired when the AJAX request initated by this returns a 422 and all logic around managing the reponse has completed
 *
 * @example
 * <form action="/widgets" method="post">
 *   <input type=text name=name>
 *
 *   <brut-ajax-submit>
 *     <button>Save</button>
*    </brut-ajax-submit>
 * </form>
 *
 * @customelement brut-ajax-submit
 */
class AjaxSubmit extends BaseCustomElement {
  static tagName = "brut-ajax-submit"
  static observedAttributes = [
    "show-warnings",
    "requesting",
    "submitted",
    "submitted-lifetime",
    "request-timeout",
    "max-retry-attempts",
    "log-request-errors",
  ]

  #requestErrorLogger = () => {}
  #formSubmitDelay    = 0
  #submittedLifetime  = 2000
  #requestTimeout     = 5000
  #maxRetryAttempts   = 25

  submittedLifetimeChangedCallback({newValue}) {
    const newValueAsInt = parseInt(newValue)
    if (isNaN(newValueAsInt)) {
      throw `submitted-lifetime must be a number, not '${newValue}'`
    }
    this.#submittedLifetime = newValueAsInt
  }

  maxRetryAttemptsChangedCallback({newValue}) {
    const num = parseInt(newValue)
    if (isNaN(num)) {
      this.logger.warn(`max-retry-attempts '${newValue}' is not a number. Using 1 as a fallback`)
      this.#maxRetryAttempts = 1
    }
    else {
      this.#maxRetryAttempts = num
    }
  }

  requestTimeoutChangedCallback({newValue}) {
    const newValueAsInt = parseInt(newValue)
    if (isNaN(newValueAsInt)) {
      throw `request-timeout must be a number, not '${newValue}'`
    }
    this.#requestTimeout = newValueAsInt
  }

  submittedChangedCallback({newValueAsBoolean}) {
    // no op
  }

  requestingChangedCallback({newValueAsBoolean}) {
    if (this.#button()) {
      if (newValueAsBoolean) {
        this.#button().setAttribute("disabled",true)
      }
      else {
        this.#button().removeAttribute("disabled",true)
      }
    }
  }

  logRequestErrorsChangedCallback({newValueAsBoolean}) {
    if (newValueAsBoolean) {
      this.#requestErrorLogger = console.warn
      this.#formSubmitDelay = 2000
    }
    else {
      this.#requestErrorLogger = () => {}
      this.#formSubmitDelay = 0
    }
  }

  update() {
    const button = this.#button()
    if (!button)
    {
      this.logger.info("Could not find a <button> to attach behavior to")
      return
    }
    const form = button.form
    if (!form) {
      this.logger.info("%o did not have a form associated with it - cannot attach behavior",button)
      return
    }
    button.form.addEventListener("submit",this.#formSubmitted)
  }


  #formSubmitted = (event) => {
    const submitter = event.submitter
    if (submitter == this.#button()) {
      event.preventDefault()
      const now = Date.now()
      this.#submitForm(event.target, now, 0)
    }
  }

  #submitForm(form, firstSubmittedAt, numAttempts) {

    const headers = new Headers()
    headers.append("X-Requested-With","XMLHttpRequest")
    headers.append("Content-Type","application/x-www-form-urlencoded")

    const formData = new FormData(form)
    const urlSearchParams = new URLSearchParams(formData)

    const timeoutSignal = AbortSignal.timeout(this.#requestTimeout)

    const request = new Request(
      form.action,
      {
        headers: headers,
        method: form.method,
        body: urlSearchParams,
        signal: timeoutSignal,
      }
    )

    if (numAttempts > this.#maxRetryAttempts) {
      this.#requestErrorLogger("%d attempts. Giving up",numAttempts)
      this.#submitFormThroughBrowser(form)
      return
    }
    this.setAttribute("requesting", true)
    fetch(request).then( (response) => {
      if (response.ok) {
        this.removeAttribute("requesting")
        this.setAttribute("submitted",true)

        setTimeout( () => this.removeAttribute("submitted"), this.#submittedLifetime )
        this.dispatchEvent(new CustomEvent("brut:submitok"))
      }
      else {

        let retry    = false // if true, we retry the request via ajax
        let resubmit = false // if true, and we aren't retrying, we submit the
                             // form the old fashioned way

        if ( (Date.now() - firstSubmittedAt) > this.#requestTimeout) {
          this.#requestErrorLogger("Since initial button press %d, it's taken more than %d ms to get a response.",firstSubmittedAt,this.#requestTimeout)
          retry = false
          resubmit = true
        }
        else {
          const status = parseInt(response.status)
          if (isNaN(status)) {
            this.#requestErrorLogger("Got unparseable status: %d",response.status)
            retry = false
          }
          else if (status >= 500) {
            this.#requestErrorLogger("Got a %d, maybe retry will fix", status)
            retry = true
          }
          else {
            retry = false
            if (status == 422) {
              this.#handleConstraintViolations(response)
            }
          }
        }
        if (retry) {
          this.#requestErrorLogger("Trying again (attempt %d)",numAttempts +1)
          setTimeout( () => this.#submitForm(form, firstSubmittedAt, numAttempts + 1), numAttempts * 10)
        }
        else if (resubmit) {
          this.#requestErrorLogger("'retry' was marked false, but resubmit is 'true', so submitting through browser")
          this.#submitFormThroughBrowser(form)
          this.removeAttribute("requesting")
        }
      }
    }).catch( (error) => {
      this.#requestErrorLogger("Got %o, which cannot be retried",error)
      this.#submitFormThroughBrowser(form)
    })
  }

  #button = () => { return this.querySelector("button") }

  #submitFormThroughBrowser(form) {
    form.removeEventListener("submit",this.#formSubmitted)
    if (this.#formSubmitDelay > 0) {
      console.log("Form submission has been delayed by %d ms in order to allow examining the log",this.#formSubmitDelay)
      setTimeout( () => form.requestSubmit(this.#button()), this.#formSubmitDelay)
    }
    else {
      form.requestSubmit(this.#button())
    }

  }

  #handleConstraintViolations(response) {
    let resubmit = false
    response.text().then( (text) => {
      try {
        const inputsToMessages = ErrorMessagesForInput.mapInputsToErrorMessages(
          this.#errorMessagesFromServer(text),
          this.#requestErrorLogger
        )

        let inputToScrollToAfterReportingValidity
        for (const [inputName, {input, messagesElement, errorMessages}] of Object.entries(inputsToMessages)) {
          if (!inputToScrollToAfterReportingValidity) {
            inputToScrollToAfterReportingValidity = input
          }
          messagesElement.clearServerSideMessages()
          errorMessages.forEach( (element) => {
            ConstraintViolationMessage.markServerSide(element)
            messagesElement.appendChild(element) 
          })
          this.#setCustomValidityThatClearsOnChange(input,errorMessages)
        }

        if (inputToScrollToAfterReportingValidity) {
          inputToScrollToAfterReportingValidity.scrollIntoView()
        }
        resubmit = false
        this.removeAttribute("requesting")
        this.dispatchEvent(new CustomEvent("brut:submitinvalid"))
      }
      catch (e) {
        this.#requestErrorLogger("While parsing %s, got %s", text, e)
        resubmit = true
      }
      if (resubmit) {
        this.#submitFormThroughBrowser(form)
      }
    })
  }

  #errorMessagesFromServer(text) {
    const parser   = new DOMParser()
    const fragment = parser.parseFromString(text,"text/html")

    return fragment.querySelectorAll(ConstraintViolationMessage.tagName)
  }

  #setCustomValidityThatClearsOnChange(input,errorMessages) {
    input.setCustomValidity(errorMessages[0].textContent)
    input.reportValidity()
    input.addEventListener("change", () => input.setCustomValidity("") )
  }
}

class ErrorMessagesForInput {
  static mapInputsToErrorMessages(errorMessages,requestErrorLogger) {
    const errorMessagesForInputs = Array.from(errorMessages).map( (element) => {
      return new ErrorMessagesForInput({
        element: element,
        inputName: element.getAttribute("input-name"),
        document: document,
      })
    })

    const inputsToMessages = {}

    errorMessagesForInputs.forEach( (errorMessagesForInput) => {
      if (errorMessagesForInput.allElementsFound()) {

        if (!inputsToMessages[errorMessagesForInput.inputName]) {
          inputsToMessages[errorMessagesForInput.inputName] = {
            input: errorMessagesForInput.input,
            messagesElement: errorMessagesForInput.messagesElement,
            errorMessages: []
          }
        }

        inputsToMessages[errorMessagesForInput.inputName].errorMessages.push(
          errorMessagesForInput.element
        )
      }
      else {
        requestErrorLogger(
          "Server message %o could not be shown to the user: %s",
          errorMessagesForInput.element,
          errorMessagesForInput.reasonNotAllElementsFound()
        )
      }
    })

    return inputsToMessages
  }

  constructor({element,inputName,document}) {
    this.element   = element
    this.inputName = inputName

    if (this.inputName)  {
      const selector = `${ConstraintViolationMessages.tagName}[input-name='${this.inputName}']`
      this.messagesElement = document.querySelector(selector)
      if (this.messagesElement) {
        this.closestForm = this.messagesElement.closest("form")
      }
      if (this.inputName && this.closestForm) {
        this.input = this.closestForm.elements.namedItem(this.inputName)
      }
    }
  }

  allElementsFound() {
    return !!this.input
  }

  reasonNotAllElementsFound() {
    let reason
    if (this.inputName) {
      if (this.messagesElement) {
        if (this.closestForm) {
          reason = `Form did not contain an input named ${this.inputName}`
        }
        else {
          reason = `Could not find a form that contained the ${ConstraintViolationMessages.tagName} element`  
        }
      }
      else {
        reason = `Could not find a ${ConstraintViolationMessages.tagName} element for ${this.inputName}`
      }
    }
    else {
      reason = "server message was missing an input-name"
    }
    return reason
  }
}

export default AjaxSubmit