2
0

util.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. import Cookie from 'cookie';
  2. type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
  3. type ReqData = URLSearchParams | Dict | undefined | unknown;
  4. type SelectedOption = { name: string; options: string[] };
  5. /**
  6. * Infer valid HTMLElement props based on element name.
  7. */
  8. type InferredProps<
  9. // Element name.
  10. T extends keyof HTMLElementTagNameMap,
  11. // Element type.
  12. E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T],
  13. > = Partial<Record<keyof E, E[keyof E]>>;
  14. export function isApiError(data: Record<string, unknown>): data is APIError {
  15. return 'error' in data && 'exception' in data;
  16. }
  17. export function hasError<E extends ErrorBase = ErrorBase>(
  18. data: Record<string, unknown>,
  19. ): data is E {
  20. return 'error' in data;
  21. }
  22. export function hasMore(data: APIAnswer<APIObjectBase>): data is APIAnswerWithNext<APIObjectBase> {
  23. return typeof data.next === 'string';
  24. }
  25. /**
  26. * Type guard to determine if a value is not null, undefined, or empty.
  27. */
  28. export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
  29. const badStrings = ['', 'null', 'undefined'];
  30. if (Array.isArray(value)) {
  31. return value.length > 0;
  32. } else if (typeof value === 'string' && !badStrings.includes(value)) {
  33. return true;
  34. } else if (typeof value === 'number') {
  35. return true;
  36. } else if (typeof value === 'boolean') {
  37. return true;
  38. } else if (typeof value === 'object' && value !== null) {
  39. return true;
  40. }
  41. return false;
  42. }
  43. /**
  44. * Type guard to determine if all elements of an array are not null or undefined.
  45. *
  46. * @example
  47. * ```js
  48. * const elements = [document.getElementById("element1"), document.getElementById("element2")];
  49. * if (all(elements)) {
  50. * const [element1, element2] = elements;
  51. * // element1 and element2 are now of type HTMLElement, not Nullable<HTMLElement>.
  52. * }
  53. * ```
  54. */
  55. export function all<T extends unknown>(values: T[]): values is NonNullable<T>[] {
  56. return values.every(value => typeof value !== 'undefined' && value !== null);
  57. }
  58. /**
  59. * Deselect all selected options and reset the field value of a select element.
  60. *
  61. * @example
  62. * ```js
  63. * const select = document.querySelectorAll<HTMLSelectElement>("select.example");
  64. * select.value = "test";
  65. * console.log(select.value);
  66. * // test
  67. * resetSelect(select);
  68. * console.log(select.value);
  69. * // ''
  70. * ```
  71. */
  72. export function resetSelect<S extends HTMLSelectElement>(select: S): void {
  73. for (const option of select.options) {
  74. if (option.selected) {
  75. option.selected = false;
  76. }
  77. }
  78. select.value = '';
  79. }
  80. /**
  81. * Type guard to determine if a value is an `Element`.
  82. */
  83. export function isElement(obj: Element | null | undefined): obj is Element {
  84. return typeof obj !== null && typeof obj !== 'undefined';
  85. }
  86. /**
  87. * Retrieve the CSRF token from cookie storage.
  88. */
  89. function getCsrfToken(): string {
  90. const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
  91. if (typeof csrfToken === 'undefined') {
  92. throw new Error('Invalid or missing CSRF token');
  93. }
  94. return csrfToken;
  95. }
  96. export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
  97. url: string,
  98. method: Method,
  99. data?: D,
  100. ): Promise<APIResponse<R>> {
  101. const token = getCsrfToken();
  102. const headers = new Headers({ 'X-CSRFToken': token });
  103. let body;
  104. if (typeof data !== 'undefined') {
  105. body = JSON.stringify(data);
  106. headers.set('content-type', 'application/json');
  107. }
  108. const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
  109. const contentType = res.headers.get('Content-Type');
  110. if (typeof contentType === 'string' && contentType.includes('text')) {
  111. const error = await res.text();
  112. return { error } as ErrorBase;
  113. }
  114. const json = (await res.json()) as R | APIError;
  115. if (!res.ok && Array.isArray(json)) {
  116. const error = json.join('\n');
  117. return { error } as ErrorBase;
  118. } else if (!res.ok && 'detail' in json) {
  119. return { error: json.detail } as ErrorBase;
  120. }
  121. return json;
  122. }
  123. export async function apiPatch<R extends Dict, D extends ReqData = Dict>(
  124. url: string,
  125. data: D,
  126. ): Promise<APIResponse<R>> {
  127. return await apiRequest(url, 'PATCH', data);
  128. }
  129. export async function apiGetBase<R extends Dict>(url: string): Promise<APIResponse<R>> {
  130. return await apiRequest<R>(url, 'GET');
  131. }
  132. export async function apiPostForm<R extends Dict, D extends Dict>(
  133. url: string,
  134. data: D,
  135. ): Promise<APIResponse<R>> {
  136. const body = new URLSearchParams();
  137. for (const [k, v] of Object.entries(data)) {
  138. body.append(k, String(v));
  139. }
  140. return await apiRequest<R, URLSearchParams>(url, 'POST', body);
  141. }
  142. /**
  143. * Fetch data from the NetBox API (authenticated).
  144. * @param url API endpoint
  145. */
  146. export async function getApiData<T extends APIObjectBase>(
  147. url: string,
  148. ): Promise<APIAnswer<T> | ErrorBase | APIError> {
  149. return await apiGetBase<APIAnswer<T>>(url);
  150. }
  151. export function getElements<K extends keyof SVGElementTagNameMap>(
  152. ...key: K[]
  153. ): Generator<SVGElementTagNameMap[K]>;
  154. export function getElements<K extends keyof HTMLElementTagNameMap>(
  155. ...key: K[]
  156. ): Generator<HTMLElementTagNameMap[K]>;
  157. export function getElements<E extends Element>(...key: string[]): Generator<E>;
  158. export function* getElements(
  159. ...key: (string | keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap)[]
  160. ): Generator<Element> {
  161. for (const query of key) {
  162. for (const element of document.querySelectorAll(query)) {
  163. if (element !== null) {
  164. yield element;
  165. }
  166. }
  167. }
  168. }
  169. export function getElement<E extends HTMLElement>(id: string): Nullable<E> {
  170. return document.getElementById(id) as Nullable<E>;
  171. }
  172. export function removeElements(...selectors: string[]): void {
  173. for (const element of getElements(...selectors)) {
  174. element.remove();
  175. }
  176. }
  177. export function elementWidth<E extends HTMLElement>(element: Nullable<E>): number {
  178. let width = 0;
  179. if (element !== null) {
  180. const style = getComputedStyle(element);
  181. const pre = style.width.replace('px', '');
  182. width = parseFloat(pre);
  183. }
  184. return width;
  185. }
  186. /**
  187. * scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an
  188. * offset relative to div#content-title. This ensures we scroll to the element, but leave enough
  189. * room to see said element.
  190. *
  191. * @param element Element to scroll to
  192. * @param offset Y Offset. 0 by default, to take into account the NetBox header.
  193. */
  194. export function scrollTo(element: Element, offset: number = 0): void {
  195. let yOffset = offset;
  196. const title = document.getElementById('content-title') as Nullable<HTMLDivElement>;
  197. if (title !== null) {
  198. // If the #content-title element exists, add it to the offset.
  199. yOffset += title.getBoundingClientRect().bottom;
  200. }
  201. // Calculate the scrollTo target.
  202. const top = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
  203. // Scroll to the calculated location.
  204. window.scrollTo({ top, behavior: 'smooth' });
  205. return;
  206. }
  207. /**
  208. * Iterate through a select element's options and return an array of options that are selected.
  209. *
  210. * @param base Select element.
  211. * @returns Array of selected options.
  212. */
  213. export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
  214. let selected = [] as SelectedOption[];
  215. for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
  216. if (element !== null) {
  217. const select = { name: element.name, options: [] } as SelectedOption;
  218. for (const option of element.options) {
  219. if (option.selected) {
  220. select.options.push(option.value);
  221. }
  222. }
  223. selected = [...selected, select];
  224. }
  225. }
  226. return selected;
  227. }
  228. /**
  229. * Get data that can only be accessed via Django context, and is thus already rendered in the HTML
  230. * template.
  231. *
  232. * @see Templates requiring Django context data have a `{% block data %}` block.
  233. *
  234. * @param key Property name, which must exist on the HTML element. If not already prefixed with
  235. * `data-`, `data-` will be prepended to the property.
  236. * @returns Value if it exists, `null` if not.
  237. */
  238. export function getNetboxData(key: string): string | null {
  239. if (!key.startsWith('data-')) {
  240. key = `data-${key}`;
  241. }
  242. for (const element of getElements('body > div#netbox-data > *')) {
  243. const value = element.getAttribute(key);
  244. if (isTruthy(value)) {
  245. return value;
  246. }
  247. }
  248. return null;
  249. }
  250. /**
  251. * Toggle visibility of an element.
  252. */
  253. export function toggleVisibility<E extends HTMLElement | SVGElement>(
  254. element: E | null,
  255. action?: 'show' | 'hide',
  256. ): void {
  257. if (element !== null) {
  258. if (typeof action === 'undefined') {
  259. // No action is passed, so we should toggle the existing state.
  260. const current = window.getComputedStyle(element).display;
  261. if (current === 'none') {
  262. element.style.display = '';
  263. } else {
  264. element.style.display = 'none';
  265. }
  266. } else {
  267. if (action === 'show') {
  268. element.style.display = '';
  269. } else {
  270. element.style.display = 'none';
  271. }
  272. }
  273. }
  274. }
  275. /**
  276. * Toggle visibility of card loader.
  277. */
  278. export function toggleLoader(action: 'show' | 'hide'): void {
  279. for (const element of getElements<HTMLDivElement>('div.card-overlay')) {
  280. toggleVisibility(element, action);
  281. }
  282. }
  283. /**
  284. * Get the value of every cell in a table.
  285. * @param table Table Element
  286. */
  287. export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
  288. for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
  289. if (element !== null) {
  290. if (isTruthy(element.innerText) && element.innerText !== '—') {
  291. yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
  292. }
  293. }
  294. }
  295. }
  296. /**
  297. * Recurse upward through an element's siblings until an element matching the query is found.
  298. *
  299. * @param base Base Element
  300. * @param query CSS Query
  301. * @param boundary Optionally specify a CSS Query which, when matched, recursion will cease.
  302. */
  303. export function findFirstAdjacent<R extends HTMLElement, B extends Element = Element>(
  304. base: B,
  305. query: string,
  306. boundary?: string,
  307. ): Nullable<R> {
  308. function atBoundary<E extends Element | null>(element: E): boolean {
  309. if (typeof boundary === 'string' && element !== null) {
  310. if (element.matches(boundary)) {
  311. return true;
  312. }
  313. }
  314. return false;
  315. }
  316. function match<P extends Element | null>(parent: P): Nullable<R> {
  317. if (parent !== null && parent.parentElement !== null && !atBoundary(parent)) {
  318. for (const child of parent.parentElement.querySelectorAll<R>(query)) {
  319. if (child !== null) {
  320. return child;
  321. }
  322. }
  323. return match(parent.parentElement.parentElement);
  324. }
  325. return null;
  326. }
  327. return match(base);
  328. }
  329. /**
  330. * Helper for creating HTML elements.
  331. *
  332. * @param tag HTML element type.
  333. * @param properties Properties/attributes to apply to the element.
  334. * @param classes CSS classes to apply to the element.
  335. * @param children Child elements.
  336. */
  337. export function createElement<
  338. // Element name.
  339. T extends keyof HTMLElementTagNameMap,
  340. // Element props.
  341. P extends InferredProps<T>,
  342. // Child element type.
  343. C extends HTMLElement = HTMLElement,
  344. >(
  345. tag: T,
  346. properties: P | null,
  347. classes: Nullable<string[]> = null,
  348. children: C[] = [],
  349. ): HTMLElementTagNameMap[T] {
  350. // Create the base element.
  351. const element = document.createElement<T>(tag);
  352. if (properties !== null) {
  353. for (const k of Object.keys(properties)) {
  354. // Add each property to the element.
  355. const key = k as keyof InferredProps<T>;
  356. const value = properties[key] as NonNullable<P[keyof P]>;
  357. if (key in element) {
  358. element[key] = value;
  359. }
  360. }
  361. }
  362. // Add each CSS class to the element's class list.
  363. if (classes !== null && classes.length > 0) {
  364. element.classList.add(...classes);
  365. }
  366. for (const child of children) {
  367. // Add each child element to the base element.
  368. element.appendChild(child);
  369. }
  370. return element as HTMLElementTagNameMap[T];
  371. }
  372. /**
  373. * Convert Celsius to Fahrenheit, for NAPALM temperature sensors.
  374. *
  375. * @param celsius Degrees in Celsius.
  376. * @returns Degrees in Fahrenheit.
  377. */
  378. export function cToF(celsius: number): number {
  379. return celsius * (9 / 5) + 32;
  380. }
  381. /**
  382. * Deduplicate an array of objects based on the value of a property.
  383. *
  384. * @example
  385. * ```js
  386. * const withDups = [{id: 1, name: 'One'}, {id: 2, name: 'Two'}, {id: 1, name: 'Other One'}];
  387. * const withoutDups = uniqueByProperty(withDups, 'id');
  388. * console.log(withoutDups);
  389. * // [{id: 1, name: 'One'}, {id: 2, name: 'Two'}]
  390. * ```
  391. * @param arr Array of objects to deduplicate.
  392. * @param prop Object property to use as a unique key.
  393. * @returns Deduplicated array.
  394. */
  395. export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[], prop: P): T[] {
  396. const baseMap = new Map<T[P], T>();
  397. for (const item of arr) {
  398. const value = item[prop];
  399. if (!baseMap.has(value)) {
  400. baseMap.set(value, item);
  401. }
  402. }
  403. return Array.from(baseMap.values());
  404. }