znamenica/dneslov

View on GitHub
app/components/DynamicField.jsx

Summary

Maintainability
D
2 days
Test Coverage
import { Component } from 'react'
import PropTypes from 'prop-types'
import { mixin, flow } from 'lodash-decorators'
import { Autocomplete } from 'materialize-css'
import axios from 'axios'
import { merge } from 'merge-anything'

import ErrorSpan from 'ErrorSpan'
import Chip from 'Chip'
import Validation from 'Validation'
import Subscribed from 'mixins/Subscribed'
import ValueToObject from 'mixins/ValueToObject'
import RepathTo from 'mixins/RepathTo'

@mixin(Subscribed)
@mixin(Validation)
@mixin(ValueToObject)
@mixin(RepathTo)
export default class DynamicField extends Component {
   static defaultProps = {
      pathname: null,
      key_name: null,
      value_name: null,
      name: 'text_id',
      humanized_name: 'text',
      value: "", // previously stored value
      humanized_value: undefined, // previously stored value name
      default: "", // default value
      humanized_default: undefined, // default value name
      begin: null,
      end: null,
      selectable: false,
      wrapperClassName: null,
      title: null,
      placeholder: null,
      subscribeTo: null,
      validations: {},
   }

   static propTypes = {
      pathname: PropTypes.string.isRequired,
      key_name: PropTypes.string.isRequired,
      value_name: PropTypes.string.isRequired,
      humanized_name: PropTypes.string.isRequired,
      name: PropTypes.string.isRequired,
      begin: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
      end: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
      selectable: PropTypes.bool.isRequired,
      wrapperClassName: PropTypes.string.isRequired,
      title: PropTypes.string.isRequired,
      placeholder: PropTypes.string.isRequired,
      subscribeTo: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
      validations: PropTypes.object.isRequired,
   }

   data = {list: {}, total: 0}
   state = {start: this.props.begin, end: this.props.end}

   // system
   constructor(props) {
      super(props)
      console.debug("[componentDidMount:constructor] <<< ", props.value, props.default)

      if (props.value) {
         this.data = { list: { [props.humanized_value]: props.value }, total: 1 }
      } else if (props.default) {
         this.data = { list: { [props.humanized_default]: props.default }, total: 1 }

         if (this.props.default) {
            this.updateTo(this.props.humanized_default, false)
         }
      }

      this.onKeyDown = this.onKeyDown.bind(this)
      this.onSelectionChange = this.onSelectionChange.bind(this)
      this.onSelectStart = this.onSelectStart.bind(this)
   }

   @flow('componentDidMountBefore')
   componentDidMount() {
      //console.debug("[componentDidMount1]1 ** ", this, this.props, this.data, this.props.value)
      console.debug("[componentDidMount1]1 ** ", this.props.name, this, this.props.value)
      this.setup()

      document.addEventListener('keydown', this.onKeyDown)
      document.addEventListener('selectionchange', this.onSelectionChange, { passive: true })
      this.$span.addEventListener('selectstart', this.onSelectStart, { passive: true })
//      if (this.isRangeEnabled()) {
//         let range = new Range

///         console.log("wwwwww", this.$span, this.props.begin, this.props.end)
//         range.setStart(this.$span, this.props.begin)
         //range.setEnd(this.$span, this.props.end)

//         if (this.props.begin < this.props.end) {
 //           this.setState({range: range})
//         }
//      }
   }

   @flow('componentWillUnmountBefore')
   componentWillUnmount() {
      console.debug("[componentWillUnmount] <<<")
      this.destroy()
      this.$span.removeEventListener('selectstart', this.onSelectStart)
      document.removeEventListener('selectionchange', this.onSelectionChange)
      document.removeEventListener('keypress', this.onKeyDown)
   }

   componentDidUpdate() {
      console.debug("[componentDidUpdate] <<<")
      if (this.$input) {
         this.setup()
         this.autoUpdate()
      }
   }

   shouldComponentUpdate(nextProps, nextState) {
      return nextState.pendingRange || nextState.selectApplied ||
             this.props.value !== nextProps.value ||
             this.props.humanized_value !== nextProps.humanized_value
   }

   //events
   onChange(e) {
      let humanized_value = e.target.value

      console.log("[onChange] * update to", humanized_value)
      this.updateTo(humanized_value, false)

      console.debug("[onChange] ** analyzing:", this.triggered,
         humanized_value, this.data, this.data && this.data.total,
         this.data && Object.keys(this.data.list).length)

      if (!this.triggered || humanized_value &&
          (!humanized_value.includes(this.triggered) &&
           !this.triggered.includes(humanized_value) ||
           this.data && (this.data.total > Object.keys(this.data.list).length &&
            humanized_value.includes(this.triggered) ||
            this.triggered.includes(humanized_value)))) {
         this.getDataFor(humanized_value)
      }
   }

   onSelectFromList(humanized_value, e) {
      console.log("[onSelectFromList] * fix to:", humanized_value)
      this.updateTo(humanized_value)
   }

   onKeyDown(e) {
      if (e.key === "Enter" && e.target == this.$input) {
         console.log("[onKeyDown] * fix to", this.$input.value)
         e.preventDefault()
         if (this.data.list[this.$input.value]) {
            this.updateTo(this.$input.value)
         }
      }
   }

   onChipAct() {
      let object = this.valueToObject(this.props.name, null),
          ce = new CustomEvent('dneslov-update-path', { detail: { value: object, path: this.props.name }})

      console.debug("[onSubscribedChanged:onChipAct] <<< ", object)
      console.log("[onChipAct] * unfix with", object)
      document.dispatchEvent(ce)
   }

   onSelectStart() {
      if (this.isRangeEnabled()) {
         this.setState({selectStart: true, selectApplied: false})
      }
   }

   onSelectionChange() {
      if (this.isRangeEnabled() && this.state.selectStart) {
         let selection = document.getSelection(),
             range = selection.getRangeAt(0)

         if (range.collapsed) {
            this.setState({pendingRange: null})
         } else {
            this.setState({pendingRange: range})
         }
      }
   }

   //actions
   setup() {
      if (this.$input) {
         this.input = Autocomplete.init(this.$input, {
            data: {},
            limit: 20,
            minLength: 1,
            onAutocomplete: this.onSelectFromList.bind(this)
         })
      }
   }

   destroy() {
      if (this.input) {
         this.input.destroy()
      }

      this.input = false
   }

   //actions
   autoUpdate() {
      console.log("[autoUpdate] * this.input:", this.input)
      let list = Object.keys(this.data.list).reduce((h, x) => { h[x] = null; return h }, {})

      console.debug("[autoUpdate] ** list:", list)
      this.input.updateData(list)
   }

   updateTo(humanizedValue, autofix = true) {
      console.log("[updateTo] <<< humanizedValue:", humanizedValue, "autofix:", autofix)
      let ce, detail, valueDetail = {}, value

      if (autofix || this.data.total == 1) {
         value = this.data.list[humanizedValue]
      }

      if (value) {
         valueDetail = this.valueToObject(this.props.name, value)
      }

      detail = merge({}, this.valueToObject(this.props.humanized_name, humanizedValue), valueDetail)

      console.debug("[updateTo] ** detail:", detail, "valueDetail:", valueDetail, "huName: ", this.props.humanized_name)

      ce = new CustomEvent('dneslov-update-path', merge({}, { detail: { value: detail, path: this.props.name }}))
      document.dispatchEvent(ce)
   }

   getContext(contextIn) {
      let ctx = contextIn || this.props.context_value || {}

      return Object.entries(ctx).reduce((res, [name, valueIn]) => {
         if (typeof(valueIn) == "function") {
            let value = valueIn()

            if (value) {
               res[name] = value
            }
         } else {
            res[name] = valueIn
         }

         return res
      }, {})
   }

   getDataFor(text) {
      console.debug("[getDataFor] <<<")
      let data = merge(this.getContext(), { t: text })

      if (this.props.value_context) {
         Object.entries(this.getContext(this.props.value_context)).forEach(([key, value]) => {
            data["by_" + key] = value
         })
      }

      this.triggered = text

      var request = {
         data: data,
         url: '/' + this.props.pathname + '.json',
      }

      console.log("[getDataFor] * load send", data, 'to /' + this.props.pathname + '.json')
      axios.get(request.url, { params: request.data })
        .then(this.onLoadSuccess.bind(this))
        .catch(this.onLoadFailure.bind(this))
   }

   onLoadFailure() {
      console.debug("[onLoadFailure] <<<")
      this.triggered = undefined
   }

   onLoadSuccess(response) {
      console.debug("[onLoadSuccess] <<<")

      var dynamic_data = response.data

      this.storeDynamicData(dynamic_data)

      console.log("[onLoadSuccess] *", dynamic_data, "for: ",  this.triggered, "with response:", response)

      if (this.$input) {
         console.log("[onLoadSuccess] * update autocomplete for", this.props.humanized_value)
         this.autoUpdate()
         this.input.open()
         this.updateTo(this.props.humanized_value, false)
      }
   }

   storeDynamicData(dynamic_data) {
      console.debug("[storeDynamicData] <<<", dynamic_data)
      this.data = {
         total: dynamic_data.total,
         list: dynamic_data.list.reduce((h, x) => {
            h[x[this.props.key_name]] = x[this.props.value_name]
            return h
         }, {}),
      }
      console.log("[storeDynamicData] * after store", this.data)
   }

   getElementIndex(el) {
      return Array.prototype.indexOf.call(el.parentNode.children, el)
   }

   onApplyRange() {
      let r = this.state.pendingRange,
          beginName = this.repathTo(this.props.name, "begin"),
          endName = this.repathTo(this.props.name, "end"),
          valueDetail, ce,
          //indexStart = this.getElementIndex(r.startContainer),
          //indexEnd = r.endContainer.getElementIndex(),
          posEnd = r.endOffset, posStart = r.startOffset, posOffset = 0,
          prev = r.startContainer.parentNode.previousElementSibling?.firstChild,
          next = r.startContainer

      //console.debug("[onApplyRange] wwww ** pendingRange:", r.startContainer.constructor.name, r, "ce:", ce)
      //console.debug("[onApplyRange] wwww ** indexStart:", indexStart, "indexEnd:", indexEnd)
      //console.debug("[onApplyRange] wwww ** posEnd:", posEnd, "posStart:", posStart, "posOffset", posOffset)
      //console.debug("[onApplyRange] wwww ** prev:", prev, "next:", next)

      while (prev) {
         posOffset += prev.length
         //prevSib = prev.parentNode.previousElementSibling
         //console.debug("[onApplyRange] wwww prev:", prev, "l",prev.length, "sib:", prevSib && prevSib.firstChild)

         //prev = prevSib && prevSib.firstChild
         prev = prev.parentNode.previousElementSibling?.firstChild
      }
      posStart += posOffset

      while (next && next != r.endContainer) {
         posOffset += next.length
         //let nextSib = next.parentNode.nextElementSibling
         //console.debug("[onApplyRange] wwww next:", next, "l", next.length, "sib:", nextSib && nextSib.firstChild)

         //next = nextSib && nextSib.firstChild
         next = next.parentNode.nextElementSibling?.firstChild
      }
      posEnd += posOffset

      valueDetail = merge({}, this.valueToObject(beginName, posStart), this.valueToObject(endName, posEnd))
      ce = new CustomEvent('dneslov-update-path', merge({}, { detail: { value: valueDetail, path: this.props.name }}))
      console.debug("[onApplyRange] wwww ** posOffset, ** posStart:", posStart, "posEnd:", posEnd, "ce", ce)

      document.dispatchEvent(ce)
      this.setState({
         pendingRange: null,
         start: posStart,
         end: posEnd,
         selectStart: null,
         selectApplied: true
      })
   }

   className() {
      return [ "input-field",
               this.props.wrapperClassName,
               this.getErrorText(this.props.value) && 'invalid' ].
         filter((x) => { return x }).join(" ")
   }

   spanClassName() {
      return this.isMultiline() && ["multiline"] || []
   }

   getApplierPositionCss() {
      let rect = this.state.pendingRange.getBoundingClientRect(),
          span = document.elementFromPoint(rect.x, rect.y).getBoundingClientRect(),
          top = rect.bottom - span.top + 16, left = rect.right - span.left + 16
      console.debug("[getApplierPositionCss] ** rect:", top, left)

      return {top: `${top}px`, left: `${left}px`, position: "absolute"}
   }

   // conditional
   isRangeEnabled() {
      return this.props.selectable
   }

   isMultiline() {
      return this.props.display_scheme == "12-12-12-12"
   }

   spanValue() {
      let value = this.props.humanized_value

      if (this.isRangeEnabled() && Number.isInteger(this.state.start)) {
         let pre = value.slice(0, this.state.start),
             mid = value.slice(this.state.start, this.state.end),
             post = value.slice(this.state.end, -1)

         return [<span className="plain">{pre}</span>,
                 <span className="plain selected">{mid}</span>,
                 <span className="plain">{post}</span>]
      } else {
         return <span className="plain">{value}</span>
      }
   }

   // render
   render() {
      console.log("[render] * props:", this.props, "state: ", this.state)
      console.debug("[componentDidMount1]render ** ", this.getErrorText(this.props.value))

      return (
         <div
            ref={e => this.$span = e}
            className={this.className()}>
            {this.props.value &&
               <div
                  className="chip">
                  <span
                     className={this.spanClassName()}>
                     {this.spanValue()}</span>
                  <i
                     className='material-icons unfix'
                     onClick={this.onChipAct.bind(this)}>
                     close</i>
               {this.isRangeEnabled() && this.state.pendingRange &&
                  <a
                     style={this.getApplierPositionCss()}
                     onClick={() => {this.onApplyRange()}}
                     className="popup btn-floating btn-small waves-effect waves-light terracota">
                     <i className="small material-icons">fingerprint</i></a>}
               </div>}
            {!this.props.value &&
               <input
                  type='text'
                  className={"dynamic " + (this.getErrorText(this.props.value) && 'invalid')}
                  ref={e => this.$input = e}
                  key={'input-' + this.props.name}
                  id={this.props.name}
                  name={this.props.name}
                  placeholder={this.props.placeholder}
                  value={this.props.humanized_value || ''}
                  onChange={this.onChange.bind(this)} />}
            <label
               className='active'
               htmlFor={this.props.name}>
               {this.props.title}
               <ErrorSpan
                  error={this.getErrorText(this.props.value)} /></label></div>)}}