github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/adapters/watchable.js (about) 1 import { get } from '@ember/object'; 2 import { assign } from '@ember/polyfills'; 3 import { inject as service } from '@ember/service'; 4 import { AbortError } from '@ember-data/adapter/error'; 5 import queryString from 'query-string'; 6 import ApplicationAdapter from './application'; 7 import removeRecord from '../utils/remove-record'; 8 import classic from 'ember-classic-decorator'; 9 10 @classic 11 export default class Watchable extends ApplicationAdapter { 12 @service watchList; 13 @service store; 14 15 // Overriding ajax is not advised, but this is a minimal modification 16 // that sets off a series of events that results in query params being 17 // available in handleResponse below. Unfortunately, this is the only 18 // place where what becomes requestData can be modified. 19 // 20 // It's either this weird side-effecting thing that also requires a change 21 // to ajaxOptions or overriding ajax completely. 22 ajax(url, type, options) { 23 const hasParams = hasNonBlockingQueryParams(options); 24 if (!hasParams || type !== 'GET') return super.ajax(url, type, options); 25 26 const params = { ...options.data }; 27 delete params.index; 28 29 // Options data gets appended as query params as part of ajaxOptions. 30 // In order to prevent doubling params, data should only include index 31 // at this point since everything else is added to the URL in advance. 32 options.data = options.data.index ? { index: options.data.index } : {}; 33 34 return super.ajax(`${url}?${queryString.stringify(params)}`, type, options); 35 } 36 37 findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) { 38 const params = assign(this.buildQuery(), additionalParams); 39 const url = this.urlForFindAll(type.modelName); 40 41 if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) { 42 params.index = this.watchList.getIndexFor(url); 43 } 44 45 const signal = get( 46 snapshotRecordArray || {}, 47 'adapterOptions.abortController.signal' 48 ); 49 return this.ajax(url, 'GET', { 50 signal, 51 data: params, 52 }); 53 } 54 55 findRecord(store, type, id, snapshot, additionalParams = {}) { 56 const originalUrl = this.buildURL( 57 type.modelName, 58 id, 59 snapshot, 60 'findRecord' 61 ); 62 let [url, params] = originalUrl.split('?'); 63 params = assign( 64 queryString.parse(params) || {}, 65 this.buildQuery(), 66 additionalParams 67 ); 68 69 if (get(snapshot || {}, 'adapterOptions.watch')) { 70 params.index = this.watchList.getIndexFor(originalUrl); 71 } 72 73 const signal = get(snapshot || {}, 'adapterOptions.abortController.signal'); 74 return this.ajax(url, 'GET', { 75 signal, 76 data: params, 77 }).catch((error) => { 78 if (error instanceof AbortError || error.name == 'AbortError') { 79 return; 80 } 81 throw error; 82 }); 83 } 84 85 query( 86 store, 87 type, 88 query, 89 snapshotRecordArray, 90 options, 91 additionalParams = {} 92 ) { 93 const url = this.buildURL(type.modelName, null, null, 'query', query); 94 let [urlPath, params] = url.split('?'); 95 params = assign( 96 queryString.parse(params) || {}, 97 this.buildQuery(), 98 additionalParams, 99 query 100 ); 101 102 if (get(options, 'adapterOptions.watch')) { 103 // The intended query without additional blocking query params is used 104 // to track the appropriate query index. 105 params.index = this.watchList.getIndexFor( 106 `${urlPath}?${queryString.stringify(query)}` 107 ); 108 } 109 110 const signal = get(options, 'adapterOptions.abortController.signal'); 111 return this.ajax(urlPath, 'GET', { 112 signal, 113 data: params, 114 }).then((payload) => { 115 const adapter = store.adapterFor(type.modelName); 116 117 // Query params may not necessarily map one-to-one to attribute names. 118 // Adapters are responsible for declaring param mappings. 119 const queryParamsToAttrs = Object.keys( 120 adapter.queryParamsToAttrs || {} 121 ).map((key) => ({ 122 queryParam: key, 123 attr: adapter.queryParamsToAttrs[key], 124 })); 125 126 // Remove existing records that match this query. This way if server-side 127 // deletes have occurred, the store won't have stale records. 128 store 129 .peekAll(type.modelName) 130 .filter((record) => 131 queryParamsToAttrs.some( 132 (mapping) => get(record, mapping.attr) === query[mapping.queryParam] 133 ) 134 ) 135 .forEach((record) => { 136 removeRecord(store, record); 137 }); 138 139 return payload; 140 }); 141 } 142 143 reloadRelationship( 144 model, 145 relationshipName, 146 options = { watch: false, abortController: null, replace: false } 147 ) { 148 const { watch, abortController, replace } = options; 149 const relationship = model.relationshipFor(relationshipName); 150 if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { 151 throw new Error( 152 `${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}` 153 ); 154 } else { 155 const url = model[relationship.kind](relationship.key).link(); 156 let params = {}; 157 158 if (watch) { 159 params.index = this.watchList.getIndexFor(url); 160 } 161 162 // Avoid duplicating existing query params by passing them to ajax 163 // in the URL and in options.data 164 if (url.includes('?')) { 165 const paramsInUrl = queryString.parse(url.split('?')[1]); 166 Object.keys(paramsInUrl).forEach((key) => { 167 delete params[key]; 168 }); 169 } 170 171 return this.ajax(url, 'GET', { 172 signal: abortController && abortController.signal, 173 data: params, 174 }).then( 175 (json) => { 176 const store = this.store; 177 const normalizeMethod = 178 relationship.kind === 'belongsTo' 179 ? 'normalizeFindBelongsToResponse' 180 : 'normalizeFindHasManyResponse'; 181 const serializer = store.serializerFor(relationship.type); 182 const modelClass = store.modelFor(relationship.type); 183 const normalizedData = serializer[normalizeMethod]( 184 store, 185 modelClass, 186 json 187 ); 188 if (replace) { 189 store.unloadAll(relationship.type); 190 } 191 store.push(normalizedData); 192 }, 193 (error) => { 194 if (error instanceof AbortError || error.name === 'AbortError') { 195 return relationship.kind === 'belongsTo' ? {} : []; 196 } 197 throw error; 198 } 199 ); 200 } 201 } 202 203 handleResponse(status, headers, payload, requestData) { 204 // Some browsers lowercase all headers. Others keep them 205 // case sensitive. 206 const newIndex = headers['x-nomad-index'] || headers['X-Nomad-Index']; 207 if (newIndex) { 208 this.watchList.setIndexFor(requestData.url, newIndex); 209 } 210 211 return super.handleResponse(...arguments); 212 } 213 } 214 215 function hasNonBlockingQueryParams(options) { 216 if (!options || !options.data) return false; 217 const keys = Object.keys(options.data); 218 if (!keys.length) return false; 219 if (keys.length === 1 && keys[0] === 'index') return false; 220 221 return true; 222 }