1import { Controller } from "@hotwired/stimulus" 2import { get } from "@rails/request.js" 3 4const TOKENINPUT_FIELDS = ["initiatives", "assigned_to_ids", "created_by_ids"] 5const SELECT_FIELDS = ["cluster_id", "task_type", "status", "due_status"] 6const HIDDEN_FIELDS = ["sort_cols", "direction"] 7const FILTER_FIELDS = [...TOKENINPUT_FIELDS, ...SELECT_FIELDS] 8const FILTERS_OFF = { status: "all_statuses" } 9 10export default class extends Controller { 11 static outlets = ["forms--tokeninput"] 12 static targets = [ 13 "form", 14 "results", 15 "initiativesField", 16 "assignedToIdsField", 17 "createdByIdsField", 18 "clusterIdField", 19 "taskTypeField", 20 "statusField", 21 "dueStatusField", 22 "pageField", 23 "sortColsField", 24 "directionField" 25 ] 26 27 static values = { 28 currentUserId: Number, 29 initialFilters: Object, 30 storageKey: String 31 } 32 33 connect() { 34 this.isProgrammaticChange = false 35 this.initialTodosFetchTriggered = false 36 this.#fetchTodosWhenReady() 37 } 38 39 formsTokeninputOutletConnected() { 40 this.#fetchTodosWhenReady() 41 } 42 43 selectChanged(event) { 44 if (event.target === this.clusterIdFieldTarget) { 45 this.#clearFieldsOnClusterChange() 46 } 47 48 this.#saveFiltersAndReload() 49 } 50 51 tokeninputChanged() { 52 this.#saveFiltersAndReload() 53 } 54 55 clearSelect(event) { 56 const property = event.params.field 57 this.#resetSelect(this.#fieldTarget(property)) 58 59 if (property === "cluster_id") { 60 this.#clearFieldsOnClusterChange() 61 } 62 63 this.#saveFiltersAndReload() 64 } 65 66 clearAllFilters() { 67 this.isProgrammaticChange = true 68 69 this.#tokeninputFields().forEach((field) => this.#tokeninputController(field).clear()) 70 this.#selectFields().forEach((field) => this.#resetSelect(field)) 71 72 this.isProgrammaticChange = false 73 this.#saveFiltersAndReload() 74 } 75 76 applySort({ sortCols, direction }) { 77 this.#setHiddenValue(this.sortColsFieldTarget, sortCols) 78 this.#setHiddenValue(this.directionFieldTarget, direction) 79 this.#saveFiltersAndReload() 80 } 81 82 #fetchTodosWhenReady() { 83 if (this.initialTodosFetchTriggered || !this.#tokeninputsReady()) return 84 85 this.initialTodosFetchTriggered = true 86 this.#updateFilters(this.#getFilters()) 87 this.#reloadResults() 88 } 89 90 #getFilters() { 91 const initialFilters = this.#cleanSavedFilters(this.initialFiltersValue || {}) 92 if (this.#linkedToDo()) return { ...this.#withoutFilterFields(initialFilters), ...FILTERS_OFF } 93 94 const rememberedFilters = this.#readSavedFilters() 95 return { 96 ...(this.#hasSavedFilterSelections(initialFilters) || this.#hasSavedFilterSelections(rememberedFilters) ? {} : this.#defaultFilters()), 97 ...rememberedFilters, 98 ...initialFilters 99 } 100 } 101 102 #linkedToDo() { 103 return Boolean(window.task_dom_id) 104 } 105 106 #withoutFilterFields(filters) { 107 return Object.fromEntries(Object.entries(filters).filter(([field]) => !FILTER_FIELDS.includes(field))) 108 } 109 110 #saveFiltersAndReload() { 111 if (this.isProgrammaticChange) return 112 113 this.#writeSavedFilters(this.#currentFilters()) 114 this.#reloadResults() 115 } 116 117 #updateFilters(filters) { 118 this.isProgrammaticChange = true 119 120 SELECT_FIELDS.forEach((property) => this.#setSelectValue(this.#fieldTarget(property), filters[property])) 121 TOKENINPUT_FIELDS.forEach((property) => this.#setTokeninputValues(this.#fieldTarget(property), filters[property] || [])) 122 HIDDEN_FIELDS.forEach((property) => this.#setHiddenValue(this.#fieldTarget(property), filters[property])) 123 124 this.isProgrammaticChange = false 125 } 126 127 #defaultFilters() { 128 return { assigned_to_ids: [String(this.currentUserIdValue)] } 129 } 130 131 #currentFilters() { 132 return { 133 ...Object.fromEntries(SELECT_FIELDS.map((property) => [property, this.#getSelectValue(this.#fieldTarget(property))])), 134 ...Object.fromEntries(TOKENINPUT_FIELDS.map((property) => [property, this.#getTokeninputValues(this.#fieldTarget(property))])), 135 ...Object.fromEntries(HIDDEN_FIELDS.map((property) => [property, this.#getHiddenValue(this.#fieldTarget(property))])) 136 } 137 } 138 139 #getSelectValue(field) { 140 const value = field.value || null 141 return value && value !== this.#defaultValue(field) ? value : null 142 } 143 144 #getTokeninputValues(field) { 145 const controller = this.#tokeninputController(field) 146 147 const values = this.#cleanListValues(controller.valueArray) 148 return values.length > 0 ? values : null 149 } 150 151 #getHiddenValue(field) { 152 const value = field.value || null 153 return value && value !== this.#defaultValue(field) ? value : null 154 } 155 156 #setSelectValue(field, value) { 157 field.value = value || this.#defaultValue(field) 158 this.#updateSelectActiveState(field) 159 } 160 161 #resetSelect(field) { 162 this.#setSelectValue(field) 163 } 164 165 #setHiddenValue(field, value) { 166 field.value = value || this.#defaultValue(field) 167 } 168 169 #setTokeninputValues(field, values) { 170 const selectedValues = this.#cleanListValues(values || []) 171 const controller = this.#tokeninputController(field) 172 if (this.#listsMatch(controller.valueArray, selectedValues)) return 173 174 controller.clear()
175 selectedValues.forEach((value) => { 176 const option = this.#getOptionForToken(controller, value) 177 if (!option) return 178 179 const token = this.#tokenData(option) 180 controller.addToken(token.label, this.#serializedValue(token.value)) 181 }) 182 } 183 184 #getOptionForToken(controller, value) { 185 return controller.optionTargets.find((option) => { 186 const token = this.#tokenData(option) 187 return this.#serializedValue(token.value) === value 188 }) 189 } 190 191 #tokenData(option) { 192 return JSON.parse(option.getAttribute("data-forms--tokeninput-token-param")) 193 } 194 195 #serializedValue(value) { 196 return typeof value === "string" ? value : JSON.stringify(value) 197 } 198 199 #tokeninputController(field) { 200 return this.formsTokeninputOutlets.find((controller) => controller.element === field) 201 } 202 203 #clearFieldsOnClusterChange() { 204 this.#tokeninputFields() 205 .filter((field) => field.dataset.clearOnClusterChange === "true") 206 .forEach((field) => this.#tokeninputController(field).clear()) 207 } 208 209 #updateSelectActiveState(field) { 210 field.classList.toggle("field_active", field.value !== this.#defaultValue(field)) 211 } 212 213 #defaultValue(field) { 214 return field.dataset.defaultValue || "" 215 } 216 217 #fieldTarget(property) { 218 return this[`${this.#fieldTargetName(property)}Target`] 219 } 220 221 #fieldTargetName(property) { 222 return `${this.#camelize(property)}Field` 223 } 224 225 #camelize(value) { 226 return value.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) 227 } 228 229 #hasSavedFilterSelections(filters) { 230 return FILTER_FIELDS.some((field) => { 231 const value = filters[field] 232 return Array.isArray(value) ? value.length > 0 : Boolean(value) 233 }) 234 } 235 236 #tokeninputsReady() { 237 return this.formsTokeninputOutlets.length === this.#tokeninputFields().length 238 } 239 240 #readSavedFilters() { 241 try { 242 const parsed = JSON.parse(localStorage.getItem(this.storageKeyValue) || "{}") 243 return this.#cleanSavedFilters(parsed) 244 } catch (_error) { 245 return {} 246 } 247 } 248 249 #writeSavedFilters(filters) { 250 const cleanedFilters = this.#cleanSavedFilters(filters) 251 252 if (Object.keys(cleanedFilters).length === 0) { 253 localStorage.removeItem(this.storageKeyValue) 254 return 255 } 256 257 localStorage.setItem(this.storageKeyValue, JSON.stringify(cleanedFilters)) 258 } 259 260 #cleanSavedFilters(filters) { 261 const cleanedFilters = {} 262 263 if (filters.cluster_id) cleanedFilters.cluster_id = filters.cluster_id.toString() 264 if (filters.initiatives?.length) cleanedFilters.initiatives = this.#cleanListValues(filters.initiatives) 265 if (filters.assigned_to_ids?.length) cleanedFilters.assigned_to_ids = this.#cleanListValues(filters.assigned_to_ids) 266 if (filters.created_by_ids?.length) cleanedFilters.created_by_ids = this.#cleanListValues(filters.created_by_ids) 267 if (filters.task_type) cleanedFilters.task_type = filters.task_type.toString() 268 if (filters.status) cleanedFilters.status = filters.status.toString() 269 if (filters.due_status) cleanedFilters.due_status = filters.due_status.toString() 270 if (filters.sort_cols) cleanedFilters.sort_cols = filters.sort_cols.toString() 271 if (filters.direction) cleanedFilters.direction = filters.direction.toString() 272 273 return cleanedFilters 274 } 275 276 #cleanListValues(values) { 277 return [...new Set(values.map((value) => value.toString()))].sort() 278 } 279 280 #listsMatch(leftValues, rightValues) { 281 return new Set(leftValues.map((value) => value.toString())) 282 .symmetricDifference(new Set(rightValues.map((value) => value.toString()))) 283 .size === 0 284 } 285 286 async #reloadResults() { 287 this.#resetPaginationValue() 288 PopupMessages.HoverInProgress.display("", { width: 40 }) 289 290 try { 291 const query = new URLSearchParams(new FormData(this.formTarget)) 292 const response = await get(`${this.formTarget.action}?${query.toString()}`, { 293 responseKind: "turbo-stream" 294 }) 295 296 if (!response.ok) return 297 } finally { 298 PopupMessages.HoverInProgress.hide() 299 } 300 } 301 302 #resetPaginationValue() { 303 this.pageFieldTarget.value = "1" 304 } 305 306 #selectFields() { 307 return SELECT_FIELDS.map((property) =>
307 this.#fieldTarget(property)).filter(Boolean) 308 } 309 310 #tokeninputFields() { 311 return TOKENINPUT_FIELDS.map((property) => this.#fieldTarget(property)).filter(Boolean) 312 } 313}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.