decred.org/dcrdex@v1.0.5/client/webserver/site/src/js/orders.ts (about)

     1  import Doc from './doc'
     2  import BasePage from './basepage'
     3  import * as OrderUtil from './orderutil'
     4  import * as intl from './locales'
     5  import { postJSON } from './http'
     6  import {
     7    app,
     8    PageElement,
     9    OrderFilter,
    10    Order
    11  } from './registry'
    12  
    13  const orderBatchSize = 50
    14  const animationLength = 500
    15  
    16  export default class OrdersPage extends BasePage {
    17    main: HTMLElement
    18    offset: string
    19    loading: boolean
    20    currentForm: PageElement
    21    orderTmpl: PageElement
    22    filterState: OrderFilter
    23    page: Record<string, PageElement>
    24  
    25    constructor (main: HTMLElement) {
    26      super()
    27      this.main = main
    28      // if offset is '', there are no more orders available to auto-load for
    29      // never-ending scrolling.
    30      this.offset = ''
    31      this.loading = false
    32      const page = this.page = Doc.idDescendants(main)
    33      this.orderTmpl = page.rowTmpl
    34      this.orderTmpl.remove()
    35  
    36      // filterState will store arrays of strings. The assets and statuses
    37      // sub-filters will need to be converted to ints for JSON encoding.
    38      const filterState: OrderFilter = this.filterState = {
    39        hosts: [],
    40        assets: [],
    41        statuses: []
    42      }
    43  
    44      const search = new URLSearchParams(window.location.search)
    45      const readFilter = (form: HTMLElement, filterKey: string) => {
    46        const v = search.get(filterKey)
    47        if (!v || v.length === 0) return
    48        const subFilter = v.split(',')
    49        if (v) {
    50          (filterState as any)[filterKey] = subFilter // Kinda janky
    51        }
    52        form.querySelectorAll('input').forEach(bttn => {
    53          if (subFilter.indexOf(bttn.value) >= 0) bttn.checked = true
    54        })
    55      }
    56      readFilter(page.hostFilter, 'hosts')
    57      readFilter(page.assetFilter, 'assets')
    58      readFilter(page.statusFilter, 'statuses')
    59  
    60      const applyButtons: HTMLElement[] = []
    61      const monitorFilter = (form: HTMLElement, filterKey: string) => {
    62        const applyBttn = form.querySelector('.apply-bttn') as HTMLElement
    63        applyButtons.push(applyBttn)
    64        Doc.bind(applyBttn, 'click', () => {
    65          this.submitFilter()
    66          applyButtons.forEach(bttn => Doc.hide(bttn))
    67        })
    68        form.querySelectorAll('input').forEach(bttn => {
    69          Doc.bind(bttn, 'change', () => {
    70            const subFilter = parseSubFilter(form)
    71            if (compareSubFilter(subFilter, (filterState as any)[filterKey])) {
    72              // Same as currently loaded. Hide the apply button.
    73              Doc.hide(applyBttn)
    74            } else {
    75              Doc.show(applyBttn)
    76            }
    77          })
    78        })
    79      }
    80  
    81      monitorFilter(page.hostFilter, 'hosts')
    82      monitorFilter(page.assetFilter, 'assets')
    83      monitorFilter(page.statusFilter, 'statuses')
    84  
    85      Doc.bind(this.main, 'scroll', () => {
    86        if (this.loading) return
    87        const belowBottom = page.ordersTable.offsetHeight - this.main.offsetHeight - this.main.scrollTop
    88        if (belowBottom < 0) {
    89          this.nextPage()
    90        }
    91      })
    92  
    93      page.forms.querySelectorAll('.form-closer').forEach(el => {
    94        Doc.bind(el, 'click', () => {
    95          Doc.hide(page.forms)
    96        })
    97      })
    98  
    99      // If the user clicks outside of a form, it should close the page overlay.
   100      Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => {
   101        if (!Doc.mouseInElement(e, this.currentForm)) {
   102          Doc.hide(page.forms)
   103        }
   104      })
   105  
   106      Doc.bind(page.exportOrders, 'click', () => {
   107        this.exportOrders()
   108      })
   109  
   110      page.showArchivedDateField.addEventListener('change', () => {
   111        if (page.showArchivedDateField.checked) Doc.show(page.archivedDateField)
   112        else Doc.hide(page.archivedDateField, page.deleteArchivedRecordsErr)
   113      })
   114  
   115      Doc.bind(page.deleteArchivedRecords, 'click', () => {
   116        const page = this.page
   117        page.showArchivedDateField.checked = false
   118        page.saveMatchesToFile.checked = false
   119        page.saveOrdersToFile.checked = false
   120        page.deleteArchivedRecordsErr.textContent = ''
   121        page.archivedRecordsLocation.textContent = ''
   122        page.deleteArchivedRecordsMsg.textContent = ''
   123        Doc.hide(page.deleteArchivedResult, page.deleteArchivedRecordsErr,
   124          page.deleteArchivedRecordsMsg, page.archivedRecordsLocation, page.archivedDateField)
   125        this.showForm(page.deleteArchivedRecordsForm)
   126      })
   127  
   128      Doc.bind(page.deleteArchivedRecordsSubmit, 'click', () => {
   129        let date = 0
   130        if (page.showArchivedDateField.checked) {
   131          date = Date.parse(page.olderThan.value || '')
   132          if (isNaN(date) || date <= 0) {
   133            Doc.showFormError(page.deleteArchivedRecordsErr, intl.prep(intl.ID_INVALID_DATE_ERR_MSG))
   134            return
   135          }
   136        }
   137        this.deleteArchivedRecords(date)
   138      })
   139  
   140      this.submitFilter()
   141    }
   142  
   143    /* showForm shows a modal form with a little animation. */
   144    async showForm (form: HTMLElement) {
   145      this.currentForm = form
   146      const page = this.page
   147      Doc.hide(page.deleteArchivedRecordsForm)
   148      form.style.right = '10000px'
   149      Doc.show(page.forms, form)
   150      const shift = (page.forms.offsetWidth + form.offsetWidth) / 2
   151      await Doc.animate(animationLength, progress => {
   152        form.style.right = `${(1 - progress) * shift}px`
   153      }, 'easeOutHard')
   154      form.style.right = '0px'
   155    }
   156  
   157    /* setOrders empties the order table and appends the specified orders. */
   158    setOrders (orders: Order[]) {
   159      Doc.empty(this.page.tableBody)
   160      this.appendOrders(orders)
   161    }
   162  
   163    /* appendOrders appends orders to the orders table. */
   164    appendOrders (orders: Order[]) {
   165      const tbody = this.page.tableBody
   166      for (const ord of orders) {
   167        const tr = this.orderTmpl.cloneNode(true) as HTMLElement
   168        const tmpl = Doc.parseTemplate(tr)
   169        let fromSymbol, toSymbol, fromUnit, toUnit, fromQty
   170        let toQty = ''
   171        const xc = app().exchanges[ord.host] || undefined
   172        if ((!app().assets[ord.baseID] && !xc.assets[ord.baseID]) || (!app().assets[ord.quoteID] && !xc.assets[ord.quoteID])) continue
   173        const [baseUnitInfo, quoteUnitInfo] = [app().unitInfo(ord.baseID, xc), app().unitInfo(ord.quoteID, xc)]
   174        if (ord.sell) {
   175          [fromSymbol, toSymbol] = [ord.baseSymbol, ord.quoteSymbol];
   176          [fromUnit, toUnit] = [baseUnitInfo.conventional.unit, quoteUnitInfo.conventional.unit]
   177          fromQty = Doc.formatCoinValue(ord.qty, baseUnitInfo)
   178          if (ord.type === OrderUtil.Limit) {
   179            toQty = Doc.formatCoinValue(ord.qty / OrderUtil.RateEncodingFactor * ord.rate, quoteUnitInfo)
   180          }
   181        } else {
   182          [fromSymbol, toSymbol] = [ord.quoteSymbol, ord.baseSymbol];
   183          [fromUnit, toUnit] = [quoteUnitInfo.conventional.unit, baseUnitInfo.conventional.unit]
   184          if (ord.type === OrderUtil.Market) {
   185            fromQty = Doc.formatCoinValue(ord.qty, baseUnitInfo)
   186          } else {
   187            fromQty = Doc.formatCoinValue(ord.qty / OrderUtil.RateEncodingFactor * ord.rate, quoteUnitInfo)
   188            toQty = Doc.formatCoinValue(ord.qty, baseUnitInfo)
   189          }
   190        }
   191  
   192        const mktID = `${baseUnitInfo.conventional.unit}-${quoteUnitInfo.conventional.unit}`
   193        tmpl.host.textContent = `${mktID} @ ${ord.host}`
   194  
   195        tmpl.fromQty.textContent = fromQty
   196        tmpl.fromLogo.src = Doc.logoPath(fromSymbol)
   197        tmpl.fromSymbol.textContent = fromUnit
   198        tmpl.toQty.textContent = toQty
   199        tmpl.toLogo.src = Doc.logoPath(toSymbol)
   200        tmpl.toSymbol.textContent = toUnit
   201        tmpl.type.textContent = `${OrderUtil.typeString(ord)} ${OrderUtil.sellString(ord)}`
   202        let rate = Doc.formatCoinValue(app().conventionalRate(ord.baseID, ord.quoteID, ord.rate, xc))
   203        if (ord.type === OrderUtil.Market) rate = OrderUtil.averageMarketOrderRateString(ord)
   204        tmpl.rate.textContent = rate
   205        tmpl.status.textContent = OrderUtil.statusString(ord)
   206        tmpl.filled.textContent = `${(OrderUtil.filled(ord) / ord.qty * 100).toFixed(1)}%`
   207        tmpl.settled.textContent = `${(OrderUtil.settled(ord) / ord.qty * 100).toFixed(1)}%`
   208        const dateTime = new Date(ord.submitTime).toLocaleString()
   209        tmpl.timeAgo.textContent = `${Doc.timeSince(ord.submitTime)} ago`
   210        tmpl.time.textContent = dateTime
   211        const link = Doc.tmplElement(tr, 'link')
   212        link.href = `order/${ord.id}`
   213        app().bindInternalNavigation(tr)
   214        tbody.appendChild(tr)
   215      }
   216      if (orders.length === orderBatchSize) {
   217        this.offset = orders[orders.length - 1].id
   218      } else {
   219        this.offset = ''
   220      }
   221    }
   222  
   223    /* submitFilter submits the current filter and reloads the order table. */
   224    async submitFilter () {
   225      const page = this.page
   226      this.offset = ''
   227      const filterState = this.filterState
   228      filterState.hosts = parseSubFilter(page.hostFilter)
   229      filterState.assets = parseSubFilter(page.assetFilter).map((s: string) => parseInt(s))
   230      filterState.statuses = parseSubFilter(page.statusFilter).map((s: string) => parseInt(s))
   231      this.setOrders(await this.fetchOrders())
   232    }
   233  
   234    /* fetchOrders fetches orders using the current filter. */
   235    async fetchOrders () {
   236      const loaded = app().loading(this.main)
   237      const res = await postJSON('/api/orders', this.currentFilter())
   238      loaded()
   239      return res.orders
   240    }
   241  
   242    /* exportOrders downloads a csv of the user's orders based on the current filter. */
   243    exportOrders () {
   244      this.offset = ''
   245      const filterState = this.currentFilter()
   246      const url = new URL(window.location.href)
   247      const search = new URLSearchParams('')
   248      const setQuery = (k: string) => {
   249        const subFilter = (filterState as any)[k]
   250        subFilter.forEach((v: any) => {
   251          search.append(k, v)
   252        })
   253      }
   254      setQuery('hosts')
   255      setQuery('assets')
   256      setQuery('statuses')
   257      url.search = search.toString()
   258      url.pathname = '/orders/export'
   259      window.open(url.toString())
   260    }
   261  
   262    /* deleteArchivedRecords removes the user's archived orders and matches
   263     * created before user specified date time in millisecond. Deleted archived
   264     * records are saved to a CSV file if the user specify so.
   265     */
   266    async deleteArchivedRecords (olderThanMs?: number) {
   267      const page = this.page
   268      const saveMatchesToFIle = page.saveMatchesToFile.checked || false
   269      const saveOrdersToFile = page.saveOrdersToFile.checked || false
   270      const reqBody = {
   271        olderThanMs: olderThanMs,
   272        saveMatchesToFile: saveMatchesToFIle,
   273        saveOrdersToFile: saveOrdersToFile
   274      }
   275      const loaded = app().loading(this.main)
   276      const res = await postJSON('/api/deletearchivedrecords', reqBody)
   277      loaded()
   278      if (!app().checkResponse(res)) {
   279        return Doc.showFormError(page.deleteArchivedRecordsErr, res.msg)
   280      }
   281  
   282      if (res.archivedRecordsDeleted > 0) {
   283        page.deleteArchivedRecordsMsg.textContent = intl.prep(intl.ID_DELETE_ARCHIVED_RECORDS_RESULT, { nRecords: res.archivedRecordsDeleted })
   284        if (saveMatchesToFIle || saveOrdersToFile) {
   285          page.archivedRecordsLocation.textContent = intl.prep(intl.ID_ARCHIVED_RECORDS_PATH, { path: res.archivedRecordsPath })
   286          Doc.show(page.archivedRecordsLocation)
   287        }
   288        // Update the order page.
   289        this.submitFilter()
   290      } else {
   291        page.deleteArchivedRecordsMsg.textContent = intl.prep(intl.ID_NO_ARCHIVED_RECORDS)
   292      }
   293      Doc.show(page.deleteArchivedResult, page.deleteArchivedRecordsMsg)
   294    }
   295  
   296    /*
   297     * currentFilter converts the local filter type (which is all strings) to the
   298     * server's filter type.
   299     */
   300    currentFilter (): OrderFilter {
   301      const filterState = this.filterState as OrderFilter
   302      return {
   303        hosts: filterState.hosts,
   304        assets: filterState.assets?.map((s: any) => parseInt(s)),
   305        statuses: filterState.statuses?.map((s: any) => parseInt(s)),
   306        n: orderBatchSize,
   307        offset: this.offset
   308      }
   309    }
   310  
   311    /*
   312     * nextPage resubmits the filter with the offset set to the last loaded order.
   313     */
   314    async nextPage () {
   315      if (this.offset === '' || this.loading) return
   316      this.loading = true
   317      Doc.show(this.page.orderLoader)
   318      const orders = await this.fetchOrders()
   319      this.loading = false
   320      Doc.hide(this.page.orderLoader)
   321      this.appendOrders(orders)
   322    }
   323  }
   324  
   325  /*
   326   * parseSubFilter parses a bool-map from the checkbox inputs in the specified
   327   * ancestor element.
   328   */
   329  function parseSubFilter (form: HTMLElement): string[] {
   330    const entries: string[] = []
   331    form.querySelectorAll('input').forEach(box => {
   332      if (box.checked) entries.push(box.value)
   333    })
   334    return entries
   335  }
   336  
   337  /* compareSubFilter compares the two filter arrays for unordered equivalence. */
   338  function compareSubFilter (filter1: any[], filter2: any[]): boolean {
   339    if (filter1.length !== filter2.length) return false
   340    for (const entry of filter1) {
   341      if (filter2.indexOf(entry) === -1) return false
   342    }
   343    return true
   344  }