All files / features/dynamic-loaded-options dynamic-option.js

71.2% Statements 136/191
69.23% Branches 9/13
53.33% Functions 8/15
71.2% Lines 136/191

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 1921x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 43x 43x 9x 9x 43x 43x 43x 1x 1x 1x 1x 1x 1x 1x 9x 9x 43x 43x 43x 43x 9x 9x 9x 9x 39x 39x 9x 17x 17x 9x 4x 4x 9x 9x 9x 9x           9x       9x 9x                     9x         9x 9x 9x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x           4x   4x 4x 4x 4x 1x 1x 1x 1x 1x                                     1x 1x 1x 1x 1x 1x 1x 1x 1x                   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x  
import { dataLoaderOf } from '../data-loading/fetch-data.js'
import { optionElementOfData, dataObjectOfOption } from '../../utils/option-data.js'
import { containerEl, getDynamicOptions } from '../../utils/dynamic-select-dom.js'
/** @import {DynamicSelect} from '../../utils/dynamic-select-dom.js' */
/** @import {OptionData} from '../../utils/option-data.js' */
 
/** @type {WeakMap<DynamicSelect, DynamicOptionsData>} */
const dataLoaderData = new WeakMap()
 
/**
 * @param {DynamicSelect} element - target element
 * @returns {DynamicOptionsData} dataloader of element
 */
export function dynamicOptionsOf (element) {
  let dataLoader = dataLoaderData.get(element)
  if (!dataLoader) {
    dataLoader = createDynamicOptionsDataFor(new WeakRef(element))
    dataLoaderData.set(element, dataLoader)
  }
  return dataLoader
}
 
/**
 * Creates a dataloader for an element
 * @param {WeakRef<DynamicSelect>} elementRef - weak reference of element. We do not want to have any strong reference chain pointing to
 * globally allocated `dataLoaderData`, effectively creating a memory leak
 * @returns {DynamicOptionsData} - created dataloader for element
 */
function createDynamicOptionsDataFor (elementRef) {
  const getElement = () => {
    const element = elementRef.deref()
    if (!element) { throw Error('element no longer exists') }
    return element
  }
  /** @type {DynamicOptionsData} */
  const api = {
    selectedValues: new Set(),
    get options () {
      return [...getDynamicOptions(getElement()).querySelectorAll(':scope > option')]
    },
    get optionsData () {
      return api.options.map(option => Object.freeze({ ...dataObjectOfOption(option), origin: 'fetch' }))
    },
    get optionsMap () {
      return Object.fromEntries(api.options.map(option => [option.value, option]))
    },
    status: 'empty',
    loadData: () => loadData(getElement()),
    loadNextData: () => loadNextData(getElement()),
    get values () {
      return Iterator.from(api.options)
        .filter(option => option.selected)
        .map(option => option.value)
        .toArray()
    },
    set values (values) {
      api.selectedValues = new Set(...values)
      api.options.forEach(option => { option.selected = values.includes(option.value) })
    },
 
    toggleValue (value, force) {
      const selected = force == null ? !api.selectedValues.has(value) : force
      if (selected) {
        api.selectedValues.delete(value)
      } else {
        api.selectedValues.add(value)
      }
      Iterator.from(api.options)
        .filter(option => option.value === value)
        .forEach(option => { option.selected = selected })
    },
    get selectedOptions () {
      return Iterator.from(api.options)
        .filter(option => option.selected)
        .toArray()
    },
  }
  return api
}
 
/**
 * Loads data of dynamic select. Loads first page if paginated
 * @param {DynamicSelect} element - target dynamic select element
 */
async function loadData (element) {
  const loader = dataLoaderOf(element)
  const dynamicOptionsElement = getDynamicOptions(element)
  const container = containerEl(element)
 
  try {
    const fetchData = loader.fetchData()
    const isAsync = loader.fetchHistory.at(-1)?.loadingMode === 'async'
    container.setAttribute('load-mode', isAsync ? 'async' : 'sync')
    container.toggleAttribute('data-loading', isAsync)
 
    const result = await fetchData
    const api = dynamicOptionsOf(element)
    const { selectedValues } = api
    const optionsMap = api.optionsMap
    for (const data of result.data) {
      const objectData = dataObjectOfData(data, selectedValues)
      if (optionsMap[objectData.value] == null) {
        dynamicOptionsElement.append(optionElementOfData(objectData))
      }
    }
  } catch (e) {
    console.error(e)
  } finally {
    container.removeAttribute('data-loading')
  }
}
 
/**
 * Loads next page.
 * @param {DynamicSelect} element - target dynamic select element
 */
async function loadNextData (element) {
  const loader = dataLoaderOf(element)
  const dynamicOptionsElement = getDynamicOptions(element)
  try {
    const result = await loader.fetchNextData()
    const api = dynamicOptionsOf(element)
    const { selectedValues } = api
    const optionsMap = api.optionsMap
    for (const data of result.data) {
      const objectData = dataObjectOfData(data, selectedValues)
      if (optionsMap[objectData.value] == null) {
        dynamicOptionsElement.append(optionElementOfData(objectData))
      }
    }
  } catch (e) {
    console.error(e)
  }
}
 
/**
 * get data object of option in a JSON represented format
 *
 * @param {*} optionData - target element in shadow DOM
 * @param {Set<string>} selectedValues - target element in shadow DOM
 * @returns {OptionData} option data
 */
export function dataObjectOfData (optionData, selectedValues) {
  const value = String(optionData.value)
  return {
    value,
    text: optionData.text ?? value,
    selected: selectedValues.has(value),
    origin: 'fetch',
    data: optionData,
  }
}
 
/**
 * get data object of option in a JSON represented format
 *
 * @param {OptionData} optionData - target element in shadow DOM
 * @param {Set<string>} selectedValues - target element in shadow DOM
 * @returns {HTMLOptionElement} option data
 */
export function optionFromData (optionData, selectedValues) {
  const option = optionElementOfData(optionData)
  option.selected = selectedValues.has(optionData.value)
  return option
}
 
/**
 * @typedef {object} DynamicOptionsData
 *
 * Manages dynamic select's dynamically generated options, be it by fetching it
 * from an URL or other source, such as calling `details.respondWith()` on
 * "fetch" event
 *
 * @property {HTMLOptionElement[]} options - List of dynamically generated options
 * @property {OptionData[]} optionsData - `options` mapped to data
 * @property {Set<string>} selectedValues - Dynamically selected values, does not reflect 100% to values:
 *   you can set any list of values on the selected values, but if the option does not exist it will
 *   be excluded on the `values`. When the option exists (is loaded asynchronously), the it will be
 *   it will be automatically added to `values`
 * @property {{[k: string]: HTMLOptionElement}} optionsMap - Map of value to HTMLOptionElement
 * @property {"loading"|"paginated"|"fullyLoaded"|"empty"} status Dynamic options state:
 *      - `"loading"`: loading data
 *      - `"paginated"`: data loaded and has a next page
 *      - `"fullyLoaded"`: data loaded. All pages are loaded if paginated
 *      - `"empty"`: no data loaded at all
 * @property {() => Promise<void>} loadData - loads data of dynamic select. Loads first page if paginated
 * @property {() => Promise<void>} loadNextData - Loads next page. Only used in paginated data.
 * @property {string[]} values - values applied to select
 * @property {HTMLOptionElement[]} selectedOptions - selected option elements
 * @property {(value: string, selected?: boolean) => void} toggleValue - toggle value of select
 */