apiSelect.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984
  1. import { readableColor } from 'color2k';
  2. import debounce from 'just-debounce-it';
  3. import queryString from 'query-string';
  4. import SlimSelect from 'slim-select';
  5. import { createToast } from '../../bs';
  6. import { hasUrl, hasExclusions, isTrigger } from '../util';
  7. import { DynamicParamsMap } from './dynamicParams';
  8. import { isStaticParams, isOption } from './types';
  9. import {
  10. hasMore,
  11. isTruthy,
  12. hasError,
  13. getElement,
  14. getApiData,
  15. isApiError,
  16. createElement,
  17. uniqueByProperty,
  18. findFirstAdjacent,
  19. } from '../../util';
  20. import type { Stringifiable } from 'query-string';
  21. import type { Option } from 'slim-select/dist/data';
  22. import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
  23. // Empty placeholder option.
  24. const EMPTY_PLACEHOLDER = {
  25. value: '',
  26. text: '',
  27. placeholder: true,
  28. } as Option;
  29. // Attributes which if truthy should render the option disabled.
  30. const DISABLED_ATTRIBUTES = ['occupied'] as string[];
  31. /**
  32. * Manage a single API-backed select element's state. Each API select element is likely controlled
  33. * or dynamically updated by one or more other API select (or static select) elements' values.
  34. */
  35. export class APISelect {
  36. /**
  37. * Base `<select/>` DOM element.
  38. */
  39. private readonly base: HTMLSelectElement;
  40. /**
  41. * Form field name.
  42. */
  43. public readonly name: string;
  44. /**
  45. * Form field placeholder.
  46. */
  47. public readonly placeholder: string;
  48. /**
  49. * Empty/placeholder option. Display text is optionally overridden via the `data-empty-option`
  50. * attribute.
  51. */
  52. public readonly emptyOption: Option;
  53. /**
  54. * Null option. When `data-null-option` attribute is a string, the value is used to created an
  55. * option of type `{text: '<value from data-null-option>': 'null'}`.
  56. */
  57. public readonly nullOption: Nullable<Option> = null;
  58. /**
  59. * Event that will initiate the API call to NetBox to load option data. By default, the trigger
  60. * is `'load'`, so data will be fetched when the element renders on the page.
  61. */
  62. private readonly trigger: Trigger;
  63. /**
  64. * If `true`, a refresh button will be added next to the search/filter `<input/>` element.
  65. */
  66. private readonly allowRefresh: boolean = true;
  67. /**
  68. * Event to be dispatched when dependent fields' values change.
  69. */
  70. private readonly loadEvent: InstanceType<typeof Event>;
  71. /**
  72. * Event to be dispatched when the scroll position of this element's optinos list is at the
  73. * bottom.
  74. */
  75. private readonly bottomEvent: InstanceType<typeof Event>;
  76. /**
  77. * SlimSelect instance for this element.
  78. */
  79. private readonly slim: InstanceType<typeof SlimSelect>;
  80. /**
  81. * Post-parsed URL query parameters for API queries.
  82. */
  83. private readonly queryParams: QueryFilter = new Map();
  84. /**
  85. * API query parameters that should be applied to API queries for this field. This will be
  86. * updated as other dependent fields' values change. This is a mapping of:
  87. *
  88. * Form Field Names → Object containing:
  89. * - Query parameter key name
  90. * - Query value
  91. *
  92. * This is different from `queryParams` in that it tracks all _possible_ related fields and their
  93. * values, even if they are empty. Further, the keys in `queryParams` correspond to the actual
  94. * query parameter keys, which are not necessarily the same as the form field names, depending on
  95. * the model. For example, `tenant_group` would be the field name, but `group_id` would be the
  96. * query parameter.
  97. */
  98. private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
  99. /**
  100. * API query parameters that are already known by the server and should not change.
  101. */
  102. private readonly staticParams: QueryFilter = new Map();
  103. /**
  104. * Mapping of URL template key/value pairs. If this element's URL contains Django template tags
  105. * (e.g., `{{key}}`), `key` will be added to `pathValue` and the `id_key` form element will be
  106. * tracked for changes. When the `id_key` element's value changes, the new value will be added
  107. * to this map. For example, if the template key is `rack`, and the `id_rack` field's value is
  108. * `1`, `pathValues` would be updated to reflect a `"rack" => 1` mapping. When the query URL is
  109. * updated, the URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
  110. */
  111. private readonly pathValues: PathFilter = new Map();
  112. /**
  113. * Original API query URL passed via the `data-href` attribute from the server. This is kept so
  114. * that the URL can be reconstructed as form values change.
  115. */
  116. private readonly url: string = '';
  117. /**
  118. * API query URL. This will be updated dynamically to include any query parameters in `queryParameters`.
  119. */
  120. private queryUrl: string = '';
  121. /**
  122. * Scroll position of options is at the bottom of the list, or not. Used to determine if
  123. * additional options should be fetched from the API.
  124. */
  125. private atBottom: boolean = false;
  126. /**
  127. * API URL for additional options, if applicable. `null` indicates no options remain.
  128. */
  129. private more: Nullable<string> = null;
  130. /**
  131. * Array of options values which should be considered disabled or static.
  132. */
  133. private disabledOptions: Array<string> = [];
  134. /**
  135. * Array of properties which if truthy on an API object should be considered disabled.
  136. */
  137. private disabledAttributes: Array<string> = DISABLED_ATTRIBUTES;
  138. constructor(base: HTMLSelectElement) {
  139. // Initialize readonly properties.
  140. this.base = base;
  141. this.name = base.name;
  142. if (hasUrl(base)) {
  143. const url = base.getAttribute('data-url') as string;
  144. this.url = url;
  145. this.queryUrl = url;
  146. }
  147. this.loadEvent = new Event(`netbox.select.onload.${base.name}`);
  148. this.bottomEvent = new Event(`netbox.select.atbottom.${base.name}`);
  149. this.placeholder = this.getPlaceholder();
  150. this.disabledOptions = this.getDisabledOptions();
  151. this.disabledAttributes = this.getDisabledAttributes();
  152. const emptyOption = base.getAttribute('data-empty-option');
  153. if (isTruthy(emptyOption)) {
  154. this.emptyOption = {
  155. text: emptyOption,
  156. value: '',
  157. };
  158. } else {
  159. this.emptyOption = EMPTY_PLACEHOLDER;
  160. }
  161. const nullOption = base.getAttribute('data-null-option');
  162. if (isTruthy(nullOption)) {
  163. this.nullOption = {
  164. text: nullOption,
  165. value: 'null',
  166. };
  167. }
  168. this.slim = new SlimSelect({
  169. select: this.base,
  170. allowDeselect: true,
  171. deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
  172. placeholder: this.placeholder,
  173. searchPlaceholder: 'Filter',
  174. onChange: () => this.handleSlimChange(),
  175. });
  176. // Initialize API query properties.
  177. this.getStaticParams();
  178. this.getDynamicParams();
  179. this.getPathKeys();
  180. // Populate static query parameters.
  181. for (const [key, value] of this.staticParams.entries()) {
  182. this.queryParams.set(key, value);
  183. }
  184. // Populate dynamic query parameters with any form values that are already known.
  185. for (const filter of this.dynamicParams.keys()) {
  186. this.updateQueryParams(filter);
  187. }
  188. // Populate dynamic path values with any form values that are already known.
  189. for (const filter of this.pathValues.keys()) {
  190. this.updatePathValues(filter);
  191. }
  192. this.queryParams.set('brief', [true]);
  193. this.updateQueryUrl();
  194. // Initialize element styling.
  195. this.resetClasses();
  196. this.setSlimStyles();
  197. // Initialize controlling elements.
  198. this.initResetButton();
  199. // Add the refresh button to the search element.
  200. this.initRefreshButton();
  201. // Add dependency event listeners.
  202. this.addEventListeners();
  203. // Determine if the fetch trigger has been set.
  204. const triggerAttr = this.base.getAttribute('data-fetch-trigger');
  205. // Determine if this element is part of collapsible element.
  206. const collapse = this.base.closest('.content-container .collapse');
  207. if (isTrigger(triggerAttr)) {
  208. this.trigger = triggerAttr;
  209. } else if (collapse !== null) {
  210. this.trigger = 'collapse';
  211. } else {
  212. this.trigger = 'open';
  213. }
  214. switch (this.trigger) {
  215. case 'collapse':
  216. if (collapse !== null) {
  217. // If this element is part of a collapsible element, only load the data when the
  218. // collapsible element is shown.
  219. // See: https://getbootstrap.com/docs/5.0/components/collapse/#events
  220. collapse.addEventListener('show.bs.collapse', () => this.loadData());
  221. collapse.addEventListener('hide.bs.collapse', () => this.resetOptions());
  222. }
  223. break;
  224. case 'open':
  225. // If the trigger is 'open', only load API data when the select element is opened.
  226. this.slim.beforeOpen = () => this.loadData();
  227. break;
  228. case 'load':
  229. // Otherwise, load the data immediately.
  230. Promise.all([this.loadData()]);
  231. break;
  232. }
  233. }
  234. /**
  235. * This instance's available options.
  236. */
  237. private get options(): Option[] {
  238. return this.slim.data.data.filter(isOption);
  239. }
  240. /**
  241. * Apply new options to both the SlimSelect instance and this manager's state.
  242. */
  243. private set options(optionsIn: Option[]) {
  244. let newOptions = optionsIn;
  245. // Ensure null option is present, if it exists.
  246. if (this.nullOption !== null) {
  247. newOptions = [this.nullOption, ...newOptions];
  248. }
  249. // Deduplicate options each time they're set.
  250. const deduplicated = uniqueByProperty(newOptions, 'value');
  251. // Determine if the new options have a placeholder.
  252. const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined';
  253. // Get the placeholder index (note: if there is no placeholder, the index will be `-1`).
  254. const placeholderIdx = deduplicated.findIndex(o => o.value === '');
  255. if (hasPlaceholder && placeholderIdx >= 0) {
  256. // If there is an existing placeholder, replace it.
  257. deduplicated[placeholderIdx] = this.emptyOption;
  258. } else {
  259. // If there is not a placeholder, add one to the front.
  260. deduplicated.unshift(this.emptyOption);
  261. }
  262. this.slim.setData(deduplicated);
  263. }
  264. /**
  265. * Remove all options and reset back to the generic placeholder.
  266. */
  267. private resetOptions(): void {
  268. this.options = [this.emptyOption];
  269. }
  270. /**
  271. * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
  272. */
  273. public disable(): void {
  274. if (this.slim.slim.singleSelected !== null) {
  275. if (!this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
  276. this.slim.slim.singleSelected.container.setAttribute('disabled', '');
  277. }
  278. } else if (this.slim.slim.multiSelected !== null) {
  279. if (!this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
  280. this.slim.slim.multiSelected.container.setAttribute('disabled', '');
  281. }
  282. }
  283. this.slim.disable();
  284. }
  285. /**
  286. * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
  287. */
  288. public enable(): void {
  289. if (this.slim.slim.singleSelected !== null) {
  290. if (this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
  291. this.slim.slim.singleSelected.container.removeAttribute('disabled');
  292. }
  293. } else if (this.slim.slim.multiSelected !== null) {
  294. if (this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
  295. this.slim.slim.multiSelected.container.removeAttribute('disabled');
  296. }
  297. }
  298. this.slim.enable();
  299. }
  300. /**
  301. * Add event listeners to this element and its dependencies so that when dependencies change
  302. * this element's options are updated.
  303. */
  304. private addEventListeners(): void {
  305. // Create a debounced function to fetch options based on the search input value.
  306. const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
  307. // Query the API when the input value changes or a value is pasted.
  308. this.slim.slim.search.input.addEventListener('keyup', event => {
  309. // Only search when necessary keys are pressed.
  310. if (!event.key.match(/^(Arrow|Enter|Tab).*/)) {
  311. return fetcher(event);
  312. }
  313. });
  314. this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
  315. // Watch every scroll event to determine if the scroll position is at bottom.
  316. this.slim.slim.list.addEventListener('scroll', () => this.handleScroll());
  317. // When the scroll position is at bottom, fetch additional options.
  318. this.base.addEventListener(`netbox.select.atbottom.${this.name}`, () =>
  319. this.fetchOptions(this.more, 'merge'),
  320. );
  321. // When the base select element is disabled or enabled, properly disable/enable this instance.
  322. this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
  323. this.handleDisableEnable(event),
  324. );
  325. // Create a unique iterator of all possible form fields which, when changed, should cause this
  326. // element to update its API query.
  327. // const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
  328. const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
  329. for (const dep of dependencies) {
  330. const filterElement = document.querySelector(`[name="${dep}"]`);
  331. if (filterElement !== null) {
  332. // Subscribe to dependency changes.
  333. filterElement.addEventListener('change', event => this.handleEvent(event));
  334. }
  335. // Subscribe to changes dispatched by this state manager.
  336. this.base.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event));
  337. }
  338. }
  339. /**
  340. * Load this element's options from the NetBox API.
  341. */
  342. private async loadData(): Promise<void> {
  343. try {
  344. this.disable();
  345. await this.getOptions('replace');
  346. } catch (err) {
  347. console.error(err);
  348. } finally {
  349. this.setOptionStyles();
  350. this.enable();
  351. this.base.dispatchEvent(this.loadEvent);
  352. }
  353. }
  354. /**
  355. * Get all options from the native select element that are already selected and do not contain
  356. * placeholder values.
  357. */
  358. private getPreselectedOptions(): HTMLOptionElement[] {
  359. return Array.from(this.base.options)
  360. .filter(option => option.selected)
  361. .filter(option => {
  362. if (option.value === '---------' || option.innerText === '---------') return false;
  363. return true;
  364. });
  365. }
  366. /**
  367. * Process a valid API response and add results to this instance's options.
  368. *
  369. * @param data Valid API response (not an error).
  370. */
  371. private async processOptions(
  372. data: APIAnswer<APIObjectBase>,
  373. action: ApplyMethod = 'merge',
  374. ): Promise<void> {
  375. // Get all already-selected options.
  376. const preSelected = this.getPreselectedOptions();
  377. // Get the values of all already-selected options.
  378. const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
  379. // Build SlimSelect options from all already-selected options.
  380. const preSelectedOptions = preSelected.map(option => ({
  381. value: option.value,
  382. text: option.innerText,
  383. selected: true,
  384. disabled: false,
  385. })) as Option[];
  386. let options = [] as Option[];
  387. for (const result of data.results) {
  388. let text = result.display;
  389. if (typeof result._depth === 'number' && result._depth > 0) {
  390. // If the object has a `_depth` property, indent its display text.
  391. text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
  392. }
  393. const data = {} as Record<string, string>;
  394. const value = result.id.toString();
  395. let style, selected, disabled;
  396. // Set any primitive k/v pairs as data attributes on each option.
  397. for (const [k, v] of Object.entries(result)) {
  398. if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
  399. const key = k.replaceAll('_', '-');
  400. data[key] = String(v);
  401. }
  402. // Set option to disabled if the result contains a matching key and is truthy.
  403. if (this.disabledAttributes.some(key => key.toLowerCase() === k.toLowerCase())) {
  404. if (typeof v === 'string' && v.toLowerCase() !== 'false') {
  405. disabled = true;
  406. } else if (typeof v === 'boolean' && v === true) {
  407. disabled = true;
  408. } else if (typeof v === 'number' && v > 0) {
  409. disabled = true;
  410. }
  411. }
  412. }
  413. // Set option to disabled if it is contained within the disabled array.
  414. if (selectedValues.some(option => this.disabledOptions.includes(option))) {
  415. disabled = true;
  416. }
  417. // Set pre-selected options.
  418. if (selectedValues.includes(value)) {
  419. selected = true;
  420. // If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
  421. // the rest of the form, resulting in that field's value being deleting from the object.
  422. disabled = false;
  423. }
  424. const option = {
  425. value,
  426. text,
  427. data,
  428. style,
  429. selected,
  430. disabled,
  431. } as Option;
  432. options = [...options, option];
  433. }
  434. switch (action) {
  435. case 'merge':
  436. this.options = [...this.options, ...options];
  437. break;
  438. case 'replace':
  439. this.options = [...preSelectedOptions, ...options];
  440. break;
  441. }
  442. if (hasMore(data)) {
  443. // If the `next` property in the API response is a URL, there are more options on the server
  444. // side to be fetched.
  445. this.more = data.next;
  446. } else {
  447. // If the `next` property in the API response is `null`, there are no more options on the
  448. // server, and no additional fetching needs to occur.
  449. this.more = null;
  450. }
  451. }
  452. /**
  453. * Fetch options from the given API URL and add them to the instance.
  454. *
  455. * @param url API URL
  456. */
  457. private async fetchOptions(url: Nullable<string>, action: ApplyMethod = 'merge'): Promise<void> {
  458. if (typeof url === 'string') {
  459. const data = await getApiData(url);
  460. if (hasError(data)) {
  461. if (isApiError(data)) {
  462. return this.handleError(data.exception, data.error);
  463. }
  464. return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error);
  465. }
  466. await this.processOptions(data, action);
  467. }
  468. }
  469. /**
  470. * Query the NetBox API for this element's options.
  471. */
  472. private async getOptions(action: ApplyMethod = 'merge'): Promise<void> {
  473. if (this.queryUrl.includes(`{{`)) {
  474. this.resetOptions();
  475. return;
  476. }
  477. await this.fetchOptions(this.queryUrl, action);
  478. }
  479. /**
  480. * Query the API for a specific search pattern and add the results to the available options.
  481. */
  482. private async handleSearch(event: Event) {
  483. const { value: q } = event.target as HTMLInputElement;
  484. const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } });
  485. await this.fetchOptions(url, 'merge');
  486. this.slim.data.search(q);
  487. this.slim.render();
  488. }
  489. /**
  490. * Determine if the user has scrolled to the bottom of the options list. If so, try to load
  491. * additional paginated options.
  492. */
  493. private handleScroll(): void {
  494. const atBottom =
  495. this.slim.slim.list.scrollTop + this.slim.slim.list.offsetHeight ===
  496. this.slim.slim.list.scrollHeight;
  497. if (this.atBottom && !atBottom) {
  498. this.atBottom = false;
  499. this.base.dispatchEvent(this.bottomEvent);
  500. } else if (!this.atBottom && atBottom) {
  501. this.atBottom = true;
  502. this.base.dispatchEvent(this.bottomEvent);
  503. }
  504. }
  505. /**
  506. * Event handler to be dispatched any time a dependency's value changes. For example, when the
  507. * value of `tenant_group` changes, `handleEvent` is called to get the current value of
  508. * `tenant_group` and update the query parameters and API query URL for the `tenant` field.
  509. */
  510. private handleEvent(event: Event): void {
  511. const target = event.target as HTMLSelectElement;
  512. // Update the element's URL after any changes to a dependency.
  513. this.updateQueryParams(target.name);
  514. this.updatePathValues(target.name);
  515. this.updateQueryUrl();
  516. // Load new data.
  517. Promise.all([this.loadData()]);
  518. }
  519. /**
  520. * Event handler to be dispatched when the base select element is disabled or enabled. When that
  521. * occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with
  522. * desired action.
  523. *
  524. * @param event Dispatched event matching pattern `netbox.select.disabled.<name>`
  525. */
  526. private handleDisableEnable(event: Event): void {
  527. const target = event.target as HTMLSelectElement;
  528. if (target.disabled === true) {
  529. this.disable();
  530. } else if (target.disabled === false) {
  531. this.enable();
  532. }
  533. }
  534. /**
  535. * When the API returns an error, show it to the user and reset this element's available options.
  536. *
  537. * @param title Error title
  538. * @param message Error message
  539. */
  540. private handleError(title: string, message: string): void {
  541. createToast('danger', title, message).show();
  542. this.resetOptions();
  543. }
  544. /**
  545. * `change` event callback to be called any time the value of a SlimSelect instance is changed.
  546. */
  547. private handleSlimChange(): void {
  548. const element = this.slim.slim;
  549. if (element) {
  550. // Toggle form validation classes when form values change. For example, if the field was
  551. // invalid and the value has now changed, remove the `.is-invalid` class.
  552. if (
  553. element.container.classList.contains('is-invalid') ||
  554. this.base.classList.contains('is-invalid')
  555. ) {
  556. element.container.classList.remove('is-invalid');
  557. this.base.classList.remove('is-invalid');
  558. }
  559. }
  560. this.base.dispatchEvent(this.loadEvent);
  561. }
  562. /**
  563. * Update the API query URL and underlying DOM element's `data-url` attribute.
  564. */
  565. private updateQueryUrl(): void {
  566. // Create new URL query parameters based on the current state of `queryParams` and create an
  567. // updated API query URL.
  568. const query = {} as Dict<Stringifiable[]>;
  569. for (const [key, value] of this.queryParams.entries()) {
  570. query[key] = value;
  571. }
  572. let url = this.url;
  573. // Replace any Django template variables in the URL with values from `pathValues` if set.
  574. for (const [key, value] of this.pathValues.entries()) {
  575. for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
  576. if (isTruthy(value)) {
  577. url = url.replaceAll(result[1], value.toString());
  578. }
  579. }
  580. }
  581. const newUrl = queryString.stringifyUrl({ url, query });
  582. if (this.queryUrl !== newUrl) {
  583. // Only update the URL if it has changed.
  584. this.queryUrl = newUrl;
  585. this.base.setAttribute('data-url', newUrl);
  586. }
  587. }
  588. /**
  589. * Update an element's API URL based on the value of another element on which this element
  590. * relies.
  591. *
  592. * @param fieldName DOM ID of the other element.
  593. */
  594. private updateQueryParams(fieldName: string): void {
  595. // Find the element dependency.
  596. const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
  597. if (element !== null) {
  598. // Initialize the element value as an array, in case there are multiple values.
  599. let elementValue = [] as Stringifiable[];
  600. if (element.multiple) {
  601. // If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
  602. elementValue = Array.from(element.options)
  603. .filter(o => o.selected)
  604. .map(o => o.value);
  605. } else if (element.value !== '') {
  606. // If this is single-select (most fields), use the element's value. This seemingly
  607. // redundant/verbose check is mainly for performance, so we're not running the above three
  608. // functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select
  609. // field's value changes.
  610. elementValue = [element.value];
  611. }
  612. if (elementValue.length > 0) {
  613. // If the field has a value, add it to the map.
  614. this.dynamicParams.updateValue(fieldName, elementValue);
  615. // Get the updated value.
  616. const current = this.dynamicParams.get(fieldName);
  617. if (typeof current !== 'undefined') {
  618. const { queryParam, queryValue } = current;
  619. let value = [] as Stringifiable[];
  620. if (this.staticParams.has(queryParam)) {
  621. // If the field is defined in `staticParams`, we should merge the dynamic value with
  622. // the static value.
  623. const staticValue = this.staticParams.get(queryParam);
  624. if (typeof staticValue !== 'undefined') {
  625. value = [...staticValue, ...queryValue];
  626. }
  627. } else {
  628. // If the field is _not_ defined in `staticParams`, we should replace the current value
  629. // with the new dynamic value.
  630. value = queryValue;
  631. }
  632. if (value.length > 0) {
  633. this.queryParams.set(queryParam, value);
  634. } else {
  635. this.queryParams.delete(queryParam);
  636. }
  637. }
  638. } else {
  639. // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
  640. const queryParam = this.dynamicParams.queryParam(fieldName);
  641. if (queryParam !== null) {
  642. this.queryParams.delete(queryParam);
  643. }
  644. }
  645. }
  646. }
  647. /**
  648. * Update `pathValues` based on the form value of another element.
  649. *
  650. * @param id DOM ID of the other element.
  651. */
  652. private updatePathValues(id: string): void {
  653. const key = id.replaceAll(/^id_/gi, '');
  654. const element = getElement<HTMLSelectElement>(`id_${key}`);
  655. if (element !== null) {
  656. // If this element's URL contains Django template tags ({{), replace the template tag
  657. // with the the dependency's value. For example, if the dependency is the `rack` field,
  658. // and the `rack` field's value is `1`, this element's URL would change from
  659. // `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
  660. const hasReplacement =
  661. this.url.includes(`{{`) && Boolean(this.url.match(new RegExp(`({{(${id})}})`, 'g')));
  662. if (hasReplacement) {
  663. if (isTruthy(element.value)) {
  664. // If the field has a value, add it to the map.
  665. this.pathValues.set(id, element.value);
  666. } else {
  667. // Otherwise, reset the value.
  668. this.pathValues.set(id, '');
  669. }
  670. }
  671. }
  672. }
  673. /**
  674. * Find the select element's placeholder text/label.
  675. */
  676. private getPlaceholder(): string {
  677. let placeholder = this.name;
  678. if (this.base.id) {
  679. const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement;
  680. // Set the placeholder text to the label value, if it exists.
  681. if (label !== null) {
  682. placeholder = `Select ${label.innerText.trim()}`;
  683. }
  684. }
  685. return placeholder;
  686. }
  687. /**
  688. * Get this element's disabled options by value. The `data-query-param-exclude` attribute will
  689. * contain a stringified JSON array of option values.
  690. */
  691. private getDisabledOptions(): string[] {
  692. let disabledOptions = [] as string[];
  693. if (hasExclusions(this.base)) {
  694. try {
  695. const exclusions = JSON.parse(
  696. this.base.getAttribute('data-query-param-exclude') ?? '[]',
  697. ) as string[];
  698. disabledOptions = [...disabledOptions, ...exclusions];
  699. } catch (err) {
  700. console.group(
  701. `Unable to parse data-query-param-exclude value on select element '${this.name}'`,
  702. );
  703. console.warn(err);
  704. console.groupEnd();
  705. }
  706. }
  707. return disabledOptions;
  708. }
  709. /**
  710. * Get this element's disabled attribute keys. For example, if `disabled-indicator` is set to
  711. * `'_occupied'` and an API object contains `{ _occupied: true }`, the option will be disabled.
  712. */
  713. private getDisabledAttributes(): string[] {
  714. let disabled = [...DISABLED_ATTRIBUTES] as string[];
  715. const attr = this.base.getAttribute('disabled-indicator');
  716. if (isTruthy(attr)) {
  717. disabled = [...disabled, attr];
  718. }
  719. return disabled;
  720. }
  721. /**
  722. * Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys
  723. * with empty values. As those keys' corresponding form fields' values change, `pathValues` will
  724. * be updated to reflect the new value.
  725. */
  726. private getPathKeys() {
  727. for (const result of this.url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
  728. this.pathValues.set(result[1], '');
  729. }
  730. }
  731. /**
  732. * Determine if a this instances' options should be filtered by the value of another select
  733. * element.
  734. *
  735. * Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of
  736. * objects containing information about how to handle the related field.
  737. */
  738. private getDynamicParams(): void {
  739. const serialized = this.base.getAttribute('data-dynamic-params');
  740. try {
  741. this.dynamicParams.addFromJson(serialized);
  742. } catch (err) {
  743. console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
  744. console.warn(err);
  745. console.groupEnd();
  746. }
  747. }
  748. /**
  749. * Determine if this instance's options should be filtered by static values passed from the
  750. * server.
  751. *
  752. * Looks for the DOM attribute `data-static-params`, the value of which is a JSON array of
  753. * objects containing key/value pairs to add to `this.staticParams`.
  754. */
  755. private getStaticParams(): void {
  756. const serialized = this.base.getAttribute('data-static-params');
  757. try {
  758. if (isTruthy(serialized)) {
  759. const deserialized = JSON.parse(serialized);
  760. if (isStaticParams(deserialized)) {
  761. for (const { queryParam, queryValue } of deserialized) {
  762. if (Array.isArray(queryValue)) {
  763. this.staticParams.set(queryParam, queryValue);
  764. } else {
  765. this.staticParams.set(queryParam, [queryValue]);
  766. }
  767. }
  768. }
  769. }
  770. } catch (err) {
  771. console.group(`Unable to determine static query parameters for select field '${this.name}'`);
  772. console.warn(err);
  773. console.groupEnd();
  774. }
  775. }
  776. /**
  777. * Set the underlying select element to the same size as the SlimSelect instance. This is
  778. * primarily for built-in HTML form validation (which doesn't really work) but it also makes
  779. * things feel cleaner in the DOM.
  780. */
  781. private setSlimStyles(): void {
  782. const { width, height } = this.slim.slim.container.getBoundingClientRect();
  783. this.base.style.opacity = '0';
  784. this.base.style.width = `${width}px`;
  785. this.base.style.height = `${height}px`;
  786. this.base.style.display = 'block';
  787. this.base.style.position = 'absolute';
  788. this.base.style.pointerEvents = 'none';
  789. }
  790. /**
  791. * Add scoped style elements specific to each SlimSelect option, if the color property exists.
  792. * As of this writing, this attribute only exist on Tags. The color property is used as the
  793. * background color, and a foreground color is detected based on the luminosity of the background
  794. * color.
  795. */
  796. private setOptionStyles(): void {
  797. for (const option of this.options) {
  798. // Only create style elements for options that contain a color attribute.
  799. if (
  800. 'data' in option &&
  801. 'id' in option &&
  802. typeof option.data !== 'undefined' &&
  803. typeof option.id !== 'undefined' &&
  804. 'color' in option.data
  805. ) {
  806. const id = option.id as string;
  807. const data = option.data as { color: string };
  808. // Create the style element.
  809. const style = document.createElement('style');
  810. // Append hash to color to make it a valid hex color.
  811. const bg = `#${data.color}`;
  812. // Detect the foreground color.
  813. const fg = readableColor(bg);
  814. // Add a unique identifier to the style element.
  815. style.setAttribute('data-netbox', id);
  816. // Scope the CSS to apply both the list item and the selected item.
  817. style.innerHTML = `
  818. div.ss-values div.ss-value[data-id="${id}"],
  819. div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
  820. {
  821. background-color: ${bg} !important;
  822. color: ${fg} !important;
  823. }
  824. `
  825. .replaceAll('\n', '')
  826. .trim();
  827. // Add the style element to the DOM.
  828. document.head.appendChild(style);
  829. }
  830. }
  831. }
  832. /**
  833. * Remove base element classes from SlimSelect instance.
  834. */
  835. private resetClasses(): void {
  836. const element = this.slim.slim;
  837. if (element) {
  838. for (const className of this.base.classList) {
  839. element.container.classList.remove(className);
  840. }
  841. }
  842. }
  843. /**
  844. * Initialize any adjacent reset buttons so that when clicked, the page is reloaded without
  845. * query parameters.
  846. */
  847. private initResetButton(): void {
  848. const resetButton = findFirstAdjacent<HTMLButtonElement>(
  849. this.base,
  850. 'button[data-reset-select]',
  851. );
  852. if (resetButton !== null) {
  853. resetButton.addEventListener('click', () => {
  854. window.location.assign(window.location.origin + window.location.pathname);
  855. });
  856. }
  857. }
  858. /**
  859. * Add a refresh button to the search container element. When clicked, the API data will be
  860. * reloaded.
  861. */
  862. private initRefreshButton(): void {
  863. if (this.allowRefresh) {
  864. const refreshButton = createElement(
  865. 'button',
  866. { type: 'button' },
  867. ['btn', 'btn-sm', 'btn-ghost-dark'],
  868. [createElement('i', null, ['mdi', 'mdi-reload'])],
  869. );
  870. refreshButton.addEventListener('click', () => this.loadData());
  871. refreshButton.type = 'button';
  872. this.slim.slim.search.container.appendChild(refreshButton);
  873. }
  874. }
  875. }