All Downloads are FREE. Search and download functionalities are using the official Maven repository.

lucuma.ui.forms.FormInputEV.scala Maven / Gradle / Ivy

// Copyright (c) 2016-2022 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

package lucuma.ui.forms

import cats._
import cats.data.NonEmptyChain
import cats.syntax.all._
import eu.timepit.refined.types.string.NonEmptyString
import japgolly.scalajs.react._
import japgolly.scalajs.react.vdom.html_<^._
import lucuma.core.validation._
import lucuma.ui.input.AuditResult
import lucuma.ui.input.ChangeAuditor
import lucuma.ui.reusability._
import org.scalajs.dom.document
import org.scalajs.dom.ext.KeyCode
import org.scalajs.dom.html
import react.common._
import react.semanticui._
import react.semanticui.collections.form.FormInput
import react.semanticui.elements.icon.Icon
import react.semanticui.elements.input._
import react.semanticui.elements.label._

import scala.scalajs.js
import scala.scalajs.js.|

import scalajs.js.JSConverters._

/**
 * FormInput component that uses an ExternalValue to share the content of the field
 */
final case class FormInputEV[EV[_], A](
  id:              NonEmptyString,
  action:          js.UndefOr[ShorthandSB[VdomNode]] = js.undefined,
  actionPosition:  js.UndefOr[ActionPosition] = js.undefined,
  as:              js.UndefOr[AsC] = js.undefined,
  className:       js.UndefOr[String] = js.undefined,
  clazz:           js.UndefOr[Css] = js.undefined,
  content:         js.UndefOr[ShorthandS[VdomNode]] = js.undefined,
  control:         js.UndefOr[String] = js.undefined,
  disabled:        js.UndefOr[Boolean] = js.undefined,
  error:           js.UndefOr[ShorthandB[NonEmptyString]] = js.undefined,
  errorClazz:      js.UndefOr[Css] = js.undefined,
  errorPointing:   js.UndefOr[LabelPointing] = js.undefined,
  fluid:           js.UndefOr[Boolean] = js.undefined,
  focus:           js.UndefOr[Boolean] = js.undefined,
  icon:            js.UndefOr[ShorthandSB[Icon]] = js.undefined,
  iconPosition:    js.UndefOr[IconPosition] = js.undefined,
  inline:          js.UndefOr[Boolean] = js.undefined,
  input:           js.UndefOr[VdomNode] = js.undefined,
  inverted:        js.UndefOr[Boolean] = js.undefined,
  label:           js.UndefOr[ShorthandS[Label]] = js.undefined,
  labelPosition:   js.UndefOr[LabelPosition] = js.undefined,
  loading:         js.UndefOr[Boolean] = js.undefined,
  placeholder:     js.UndefOr[String] = js.undefined,
  required:        js.UndefOr[Boolean] = js.undefined,
  size:            js.UndefOr[SemanticSize] = js.undefined,
  tabIndex:        js.UndefOr[String | Double] = js.undefined,
  tpe:             js.UndefOr[String] = js.undefined,
  transparent:     js.UndefOr[Boolean] = js.undefined,
  width:           js.UndefOr[SemanticWidth] = js.undefined,
  value:           EV[A],
  validFormat:     InputValidFormat[A] = InputValidSplitEpi.id,
  changeAuditor:   ChangeAuditor = ChangeAuditor.accept,
  modifiers:       Seq[TagMod] = Seq.empty,
  onTextChange:    String => Callback = _ => Callback.empty,
  onValidChange:   FormInputEV.ChangeCallback[Boolean] = _ => Callback.empty,
  // Only use for extra actions, setting should be done through value.set
  onBlur:          FormInputEV.ChangeCallback[EitherErrors[A]] = (_: EitherErrors[A]) => Callback.empty
)(implicit val ev: ExternalValue[EV], val eq: Eq[A])
    extends ReactFnProps[FormInputEV[FormInputEV.AnyF, Any]](FormInputEV.component) {

  def valGet: String = ev.get(value).foldMap(validFormat.reverseGet)

  def valSet: InputEV.ChangeCallback[A] = ev.set(value)

  def withMods(mods: TagMod*): FormInputEV[EV, A] = copy(modifiers = modifiers ++ mods)
}

object FormInputEV {
  type AnyF[_]                     = Any
  protected type Props[EV[_], A]   = FormInputEV[EV, A]
  protected type ChangeCallback[A] = A => Callback

  // queries the dom based on id. Onus is on user to make id's unique.
  private def getInputElement(id: NonEmptyString): CallbackTo[Option[html.Input]] =
    CallbackTo(Option(document.querySelector(s"#${id.value}").asInstanceOf[html.Input]))

  protected def buildComponent[EV[_], A] = {
    def audit(
      auditor:         ChangeAuditor,
      value:           String,
      setDisplayValue: String => Callback,
      inputElement:    Option[html.Input],
      setCursor:       Option[(Int, Int)] => Callback,
      lastKeyCode:     Int
    ): CallbackTo[String] = {
      val cursor: Option[(Int, Int)] = inputElement.map(i => (i.selectionStart, i.selectionEnd))

      def setStateCursorFromInput(offset: Int): Callback =
        setCursor(cursor.map { case (start, end) => (start + offset, end + offset) })

      val cursorOffsetForReject: Int =
        lastKeyCode match {
          case KeyCode.Backspace => 1
          case KeyCode.Delete    => 0
          case _                 => -1
        }

      val c = cursor match {
        case Some((start, _)) => start
        case _                => value.length
      }

      auditor.audit(value, c) match {
        case AuditResult.Accept                  =>
          setCursor(none) >> setDisplayValue(value).as(value)
        case AuditResult.NewString(newS, offset) =>
          setStateCursorFromInput(offset) >> setDisplayValue(newS).as(newS)
        case AuditResult.Reject                  =>
          setStateCursorFromInput(cursorOffsetForReject) >> CallbackTo(value)
      }

    }

    def validate(
      displayValue:  String,
      validFormat:   InputValidFormat[A],
      onValidChange: FormInputEV.ChangeCallback[Boolean],
      cb:            EitherErrors[A] => Callback = _ => Callback.empty
    ): Callback = {
      val validated = validFormat.getValid(displayValue)
      onValidChange(validated.isRight) >> cb(validated)
    }

    ScalaFnComponent
      .withHooks[Props[EV, A]]
      .useStateBy(props => props.valGet)             // displayValue
      .useState(none[(Int, Int)])                    // cursor
      .useRef(0)                                     // lastKeyCode
      .useRef(none[html.Input])                      // inputElement
      .useState(none[NonEmptyChain[NonEmptyString]]) // errors
      .useEffectWithDepsBy((props, _, _, _, _, _) => props.valGet)(
        (_, displayValue, _, _, _, errors) =>
          newValue => displayValue.setState(newValue) >> errors.setState(none)
      )
      .useEffectOnMountBy((props, displayValue, cursor, lastKeyCode, inputElement, _) =>
        getInputElement(props.id) >>= (element =>
          inputElement.set(element) >>
            audit(
              props.changeAuditor,
              props.valGet,
              displayValue.setState,
              element,
              cursor.setState,
              lastKeyCode.value
            )
        ) >>= (value => validate(value, props.validFormat, props.onValidChange))
      )
      .useEffectBy((_, _, cursor, _, inputElement, _) =>
        (for {
          i <- inputElement.value
          c <- cursor.value
        } yield Callback(i.setSelectionRange(c._1, c._2))).orEmpty
      )
      .render { (props, displayValue, cursor, lastKeyCode, inputElement, errors) =>
        def errorLabel(errors: NonEmptyChain[NonEmptyString]): js.UndefOr[ShorthandB[Label]] = {
          val vdoms = errors.toList.map[VdomNode](_.value)
          val list  = vdoms.head +: vdoms.tail.flatMap[VdomNode](e => List(<.br, <.br, e))
          Label(
            content = React.Fragment(list: _*),
            clazz = props.errorClazz,
            pointing = props.errorPointing
          )(^.position.absolute)
        }

        val error: Option[Boolean | VdomNode | Label | Unit] = props.error.toOption
          .flatMap[Boolean | VdomNode | Label | Unit] {
            _ match {
              case b: Boolean => errors.value.map(errorLabel).getOrElse(b).toOption
              case e          => // We can't pattern match against NonEmptyString, but we know it is one.
                val nes = e.asInstanceOf[NonEmptyString]
                errors.value
                  .map(ve => errorLabel(nes +: ve))
                  .getOrElse(errorLabel(NonEmptyChain(nes)))
                  .toOption
            }
          }
          .orElse(errors.value.flatMap(x => errorLabel(x).toOption))

        val onTextChange: ReactEventFromInput => Callback =
          (e: ReactEventFromInput) => {
            // Capture the value outside setState, react reuses the events
            val v = e.target.value
            audit(
              props.changeAuditor,
              v,
              displayValue.setState,
              inputElement.value,
              cursor.setState,
              lastKeyCode.value
            ).flatMap(newS =>
              // First update the internal state, then call the outside listener
              errors.setState(none) >>
                props.onTextChange(newS) >>
                validate(displayValue.value, props.validFormat, props.onValidChange)
            )
          }

        val submit: Callback =
          validate(
            displayValue.value,
            props.validFormat,
            props.onValidChange,
            { validated =>
              val validatedCB = validated match {
                case Right(a) =>
                  implicit val eq = props.eq

                  // Only set if resulting A changed.
                  if (props.ev.get(props.value).exists(_ =!= a))
                    props.valSet(a)
                  else // A didn't change, but redisplay formatted string.
                    displayValue.setState(props.valGet)
                case Left(e)  =>
                  errors.setState(e.some)
              }
              validatedCB >> props.onBlur(validated)
            }
          )

        val onKeyDown: ReactKeyboardEventFromInput => Callback = e =>
          // TODO keyCode can be undefined (despite the facade). This happens when selecting a value in form auto-fill.
          if (e.keyCode === KeyCode.Enter)
            submit
          else
            lastKeyCode.set(e.keyCode) >> cursor.setState(none)

        FormInput(
          action = props.action,
          actionPosition = props.actionPosition,
          as = props.as,
          className = props.className,
          clazz = props.clazz,
          content = props.content,
          control = props.control,
          disabled = props.disabled,
          error = error.orUndefined,
          fluid = props.fluid,
          focus = props.focus,
          icon = props.icon,
          iconPosition = props.iconPosition,
          inline = props.inline,
          input = props.input,
          inverted = props.inverted,
          label = props.label,
          labelPosition = props.labelPosition,
          loading = props.loading,
          onChangeE = onTextChange,
          placeholder = props.placeholder,
          required = props.required,
          size = props.size,
          tabIndex = props.tabIndex,
          tpe = props.tpe,
          transparent = props.transparent,
          width = props.width,
          value = displayValue.value
        )(
          props.modifiers :+
            (^.id := props.id.value) :+
            (^.onKeyDown ==> onKeyDown) :+
            (^.onBlur --> submit): _*
        )
      }
  }

  protected val component = buildComponent[AnyF, Any]
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy