decred.org/dcrdex@v1.0.3/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 }