2021-03-13 02:31:57 -07:00
|
|
|
import Cookie from 'cookie';
|
2021-08-31 09:56:59 -07:00
|
|
|
import queryString from 'query-string';
|
2021-04-19 16:59:26 -07:00
|
|
|
|
|
|
|
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
|
|
type ReqData = URLSearchParams | Dict | undefined | unknown;
|
|
|
|
type SelectedOption = { name: string; options: string[] };
|
|
|
|
|
2021-08-23 22:31:36 -07:00
|
|
|
/**
|
|
|
|
* Infer valid HTMLElement props based on element name.
|
|
|
|
*/
|
|
|
|
type InferredProps<
|
|
|
|
// Element name.
|
|
|
|
T extends keyof HTMLElementTagNameMap,
|
|
|
|
// Element type.
|
2021-08-31 07:56:53 -07:00
|
|
|
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T],
|
2021-08-23 22:31:36 -07:00
|
|
|
> = Partial<Record<keyof E, E[keyof E]>>;
|
2021-04-20 12:45:30 -07:00
|
|
|
|
2021-03-13 02:31:57 -07:00
|
|
|
export function isApiError(data: Record<string, unknown>): data is APIError {
|
2021-04-17 17:18:13 -07:00
|
|
|
return 'error' in data && 'exception' in data;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function hasError(data: Record<string, unknown>): data is ErrorBase {
|
2021-03-13 02:31:57 -07:00
|
|
|
return 'error' in data;
|
|
|
|
}
|
|
|
|
|
2021-08-17 16:50:29 -07:00
|
|
|
export function hasMore(data: APIAnswer<APIObjectBase>): data is APIAnswerWithNext<APIObjectBase> {
|
|
|
|
return typeof data.next === 'string';
|
|
|
|
}
|
|
|
|
|
2021-03-17 12:39:35 -07:00
|
|
|
/**
|
|
|
|
* Type guard to determine if a value is not null, undefined, or empty.
|
|
|
|
*/
|
2021-08-20 16:25:31 -07:00
|
|
|
export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
|
2021-03-17 12:39:35 -07:00
|
|
|
const badStrings = ['', 'null', 'undefined'];
|
2021-08-20 16:25:31 -07:00
|
|
|
if (Array.isArray(value)) {
|
|
|
|
return value.length > 0;
|
|
|
|
} else if (typeof value === 'string' && !badStrings.includes(value)) {
|
2021-03-17 12:39:35 -07:00
|
|
|
return true;
|
|
|
|
} else if (typeof value === 'number') {
|
|
|
|
return true;
|
|
|
|
} else if (typeof value === 'boolean') {
|
|
|
|
return true;
|
2021-08-30 06:43:32 -07:00
|
|
|
} else if (typeof value === 'object' && value !== null) {
|
|
|
|
return true;
|
2021-03-17 12:39:35 -07:00
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-08-24 14:52:24 -07:00
|
|
|
/**
|
|
|
|
* Type guard to determine if all elements of an array are not null or undefined.
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```js
|
|
|
|
* const elements = [document.getElementById("element1"), document.getElementById("element2")];
|
|
|
|
* if (all(elements)) {
|
|
|
|
* const [element1, element2] = elements;
|
|
|
|
* // element1 and element2 are now of type HTMLElement, not Nullable<HTMLElement>.
|
|
|
|
* }
|
|
|
|
* ```
|
|
|
|
*/
|
|
|
|
export function all<T extends unknown>(values: T[]): values is NonNullable<T>[] {
|
|
|
|
return values.every(value => typeof value !== 'undefined' && value !== null);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Deselect all selected options and reset the field value of a select element.
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```js
|
|
|
|
* const select = document.querySelectorAll<HTMLSelectElement>("select.example");
|
|
|
|
* select.value = "test";
|
|
|
|
* console.log(select.value);
|
|
|
|
* // test
|
|
|
|
* resetSelect(select);
|
|
|
|
* console.log(select.value);
|
|
|
|
* // ''
|
|
|
|
* ```
|
|
|
|
*/
|
|
|
|
export function resetSelect<S extends HTMLSelectElement>(select: S): void {
|
|
|
|
for (const option of select.options) {
|
|
|
|
if (option.selected) {
|
|
|
|
option.selected = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
select.value = '';
|
|
|
|
}
|
|
|
|
|
2021-07-29 17:33:10 -07:00
|
|
|
/**
|
|
|
|
* Type guard to determine if a value is an `Element`.
|
|
|
|
*/
|
|
|
|
export function isElement(obj: Element | null | undefined): obj is Element {
|
|
|
|
return typeof obj !== null && typeof obj !== 'undefined';
|
|
|
|
}
|
|
|
|
|
2021-03-13 02:31:57 -07:00
|
|
|
/**
|
|
|
|
* Retrieve the CSRF token from cookie storage.
|
|
|
|
*/
|
2021-08-31 07:56:53 -07:00
|
|
|
function getCsrfToken(): string {
|
2021-03-13 02:31:57 -07:00
|
|
|
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
|
|
|
|
if (typeof csrfToken === 'undefined') {
|
|
|
|
throw new Error('Invalid or missing CSRF token');
|
|
|
|
}
|
|
|
|
return csrfToken;
|
|
|
|
}
|
|
|
|
|
2021-08-31 07:56:53 -07:00
|
|
|
/**
|
|
|
|
* Get the NetBox `settings.BASE_PATH` from the `<html/>` element's data attributes.
|
|
|
|
*
|
|
|
|
* @returns If there is no `BASE_PATH` specified, the return value will be `''`.
|
|
|
|
*/ function getBasePath(): string {
|
|
|
|
const value = document.documentElement.getAttribute('data-netbox-base-path');
|
|
|
|
if (value === null) {
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
return value;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Build a NetBox URL that includes `settings.BASE_PATH` and enforces leading and trailing slashes.
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```js
|
|
|
|
* // With a BASE_PATH of 'netbox/'
|
|
|
|
* const url = buildUrl('/api/dcim/devices');
|
|
|
|
* console.log(url);
|
|
|
|
* // => /netbox/api/dcim/devices/
|
|
|
|
* ```
|
|
|
|
*
|
|
|
|
* @param path Relative path _after_ (excluding) the `BASE_PATH`.
|
|
|
|
*/
|
2021-08-31 09:56:59 -07:00
|
|
|
function buildUrl(destination: string): string {
|
|
|
|
// Separate the path from any URL search params.
|
|
|
|
const [pathname, search] = destination.split(/(?=\?)/g);
|
|
|
|
|
|
|
|
// If the `origin` exists in the API path (as in the case of paginated responses), remove it.
|
|
|
|
const origin = new RegExp(window.location.origin, 'g');
|
|
|
|
const path = pathname.replaceAll(origin, '');
|
|
|
|
|
2021-08-31 07:56:53 -07:00
|
|
|
const basePath = getBasePath();
|
2021-08-31 09:56:59 -07:00
|
|
|
|
|
|
|
// Combine `BASE_PATH` with this request's path, removing _all_ slashes.
|
2021-08-31 07:56:53 -07:00
|
|
|
let combined = [...basePath.split('/'), ...path.split('/')].filter(p => p);
|
2021-08-31 09:56:59 -07:00
|
|
|
|
2021-08-31 07:56:53 -07:00
|
|
|
if (combined[0] !== '/') {
|
2021-08-31 09:56:59 -07:00
|
|
|
// Ensure the URL has a leading slash.
|
2021-08-31 07:56:53 -07:00
|
|
|
combined = ['', ...combined];
|
|
|
|
}
|
|
|
|
if (combined[combined.length - 1] !== '/') {
|
2021-08-31 09:56:59 -07:00
|
|
|
// Ensure the URL has a trailing slash.
|
2021-08-31 07:56:53 -07:00
|
|
|
combined = [...combined, ''];
|
|
|
|
}
|
2021-08-31 09:56:59 -07:00
|
|
|
const url = combined.join('/');
|
|
|
|
// Construct an object from the URL search params so it can be re-serialized with the new URL.
|
|
|
|
const query = Object.fromEntries(new URLSearchParams(search).entries());
|
|
|
|
return queryString.stringifyUrl({ url, query });
|
2021-08-31 07:56:53 -07:00
|
|
|
}
|
|
|
|
|
2021-04-19 16:59:26 -07:00
|
|
|
export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
|
2021-08-31 07:56:53 -07:00
|
|
|
path: string,
|
2021-04-19 16:59:26 -07:00
|
|
|
method: Method,
|
|
|
|
data?: D,
|
2021-08-23 22:31:36 -07:00
|
|
|
): Promise<APIResponse<R>> {
|
2021-03-13 02:31:57 -07:00
|
|
|
const token = getCsrfToken();
|
2021-04-19 16:59:26 -07:00
|
|
|
const headers = new Headers({ 'X-CSRFToken': token });
|
|
|
|
|
|
|
|
let body;
|
|
|
|
if (typeof data !== 'undefined') {
|
|
|
|
body = JSON.stringify(data);
|
|
|
|
headers.set('content-type', 'application/json');
|
|
|
|
}
|
2021-08-31 07:56:53 -07:00
|
|
|
const url = buildUrl(path);
|
2021-04-19 16:59:26 -07:00
|
|
|
|
|
|
|
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
|
2021-04-17 17:18:13 -07:00
|
|
|
const contentType = res.headers.get('Content-Type');
|
|
|
|
if (typeof contentType === 'string' && contentType.includes('text')) {
|
|
|
|
const error = await res.text();
|
|
|
|
return { error } as ErrorBase;
|
|
|
|
}
|
2021-04-19 16:59:26 -07:00
|
|
|
const json = (await res.json()) as R | APIError;
|
2021-04-17 17:18:13 -07:00
|
|
|
if (!res.ok && Array.isArray(json)) {
|
|
|
|
const error = json.join('\n');
|
|
|
|
return { error } as ErrorBase;
|
2021-04-19 16:59:26 -07:00
|
|
|
} else if (!res.ok && 'detail' in json) {
|
|
|
|
return { error: json.detail } as ErrorBase;
|
2021-04-17 17:18:13 -07:00
|
|
|
}
|
|
|
|
return json;
|
|
|
|
}
|
|
|
|
|
2021-04-19 16:59:26 -07:00
|
|
|
export async function apiPatch<R extends Dict, D extends ReqData = Dict>(
|
|
|
|
url: string,
|
|
|
|
data: D,
|
2021-08-23 22:31:36 -07:00
|
|
|
): Promise<APIResponse<R>> {
|
2021-04-19 16:59:26 -07:00
|
|
|
return await apiRequest(url, 'PATCH', data);
|
|
|
|
}
|
|
|
|
|
2021-08-23 22:31:36 -07:00
|
|
|
export async function apiGetBase<R extends Dict>(url: string): Promise<APIResponse<R>> {
|
2021-04-19 16:59:26 -07:00
|
|
|
return await apiRequest<R>(url, 'GET');
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function apiPostForm<R extends Dict, D extends Dict>(
|
|
|
|
url: string,
|
|
|
|
data: D,
|
2021-08-23 22:31:36 -07:00
|
|
|
): Promise<APIResponse<R>> {
|
2021-04-17 17:18:13 -07:00
|
|
|
const body = new URLSearchParams();
|
|
|
|
for (const [k, v] of Object.entries(data)) {
|
|
|
|
body.append(k, String(v));
|
|
|
|
}
|
2021-04-19 16:59:26 -07:00
|
|
|
return await apiRequest<R, URLSearchParams>(url, 'POST', body);
|
2021-03-13 02:31:57 -07:00
|
|
|
}
|
|
|
|
|
2021-04-13 21:51:12 -07:00
|
|
|
/**
|
|
|
|
* Fetch data from the NetBox API (authenticated).
|
|
|
|
* @param url API endpoint
|
|
|
|
*/
|
|
|
|
export async function getApiData<T extends APIObjectBase>(
|
|
|
|
url: string,
|
2021-04-17 17:18:13 -07:00
|
|
|
): Promise<APIAnswer<T> | ErrorBase | APIError> {
|
2021-04-13 21:51:12 -07:00
|
|
|
return await apiGetBase<APIAnswer<T>>(url);
|
|
|
|
}
|
|
|
|
|
2021-03-13 02:31:57 -07:00
|
|
|
export function getElements<K extends keyof SVGElementTagNameMap>(
|
2021-03-19 09:25:27 -07:00
|
|
|
...key: K[]
|
2021-03-13 02:31:57 -07:00
|
|
|
): Generator<SVGElementTagNameMap[K]>;
|
|
|
|
export function getElements<K extends keyof HTMLElementTagNameMap>(
|
2021-03-19 09:25:27 -07:00
|
|
|
...key: K[]
|
2021-03-13 02:31:57 -07:00
|
|
|
): Generator<HTMLElementTagNameMap[K]>;
|
2021-03-19 09:25:27 -07:00
|
|
|
export function getElements<E extends Element>(...key: string[]): Generator<E>;
|
2021-03-13 02:31:57 -07:00
|
|
|
export function* getElements(
|
2021-03-19 09:25:27 -07:00
|
|
|
...key: (string | keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap)[]
|
2021-08-23 22:31:36 -07:00
|
|
|
): Generator<Element> {
|
2021-03-19 09:25:27 -07:00
|
|
|
for (const query of key) {
|
|
|
|
for (const element of document.querySelectorAll(query)) {
|
|
|
|
if (element !== null) {
|
|
|
|
yield element;
|
|
|
|
}
|
2021-03-13 02:31:57 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-22 18:56:37 -07:00
|
|
|
export function getElement<E extends HTMLElement>(id: string): Nullable<E> {
|
|
|
|
return document.getElementById(id) as Nullable<E>;
|
|
|
|
}
|
|
|
|
|
2021-07-29 17:33:10 -07:00
|
|
|
export function removeElements(...selectors: string[]): void {
|
|
|
|
for (const element of getElements(...selectors)) {
|
|
|
|
element.remove();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function elementWidth<E extends HTMLElement>(element: Nullable<E>): number {
|
|
|
|
let width = 0;
|
|
|
|
if (element !== null) {
|
|
|
|
const style = getComputedStyle(element);
|
|
|
|
const pre = style.width.replace('px', '');
|
|
|
|
width = parseFloat(pre);
|
|
|
|
}
|
|
|
|
return width;
|
|
|
|
}
|
|
|
|
|
2021-03-13 02:31:57 -07:00
|
|
|
/**
|
|
|
|
* scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an
|
|
|
|
* offset relative to div#content-title. This ensures we scroll to the element, but leave enough
|
|
|
|
* room to see said element.
|
|
|
|
*
|
|
|
|
* @param element Element to scroll to
|
|
|
|
* @param offset Y Offset. 0 by default, to take into account the NetBox header.
|
|
|
|
*/
|
|
|
|
export function scrollTo(element: Element, offset: number = 0): void {
|
|
|
|
let yOffset = offset;
|
|
|
|
const title = document.getElementById('content-title') as Nullable<HTMLDivElement>;
|
|
|
|
if (title !== null) {
|
|
|
|
// If the #content-title element exists, add it to the offset.
|
|
|
|
yOffset += title.getBoundingClientRect().bottom;
|
|
|
|
}
|
|
|
|
// Calculate the scrollTo target.
|
|
|
|
const top = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
|
|
|
// Scroll to the calculated location.
|
|
|
|
window.scrollTo({ top, behavior: 'smooth' });
|
|
|
|
return;
|
|
|
|
}
|
2021-04-19 16:59:26 -07:00
|
|
|
|
2021-04-21 10:19:13 -07:00
|
|
|
/**
|
|
|
|
* Iterate through a select element's options and return an array of options that are selected.
|
|
|
|
*
|
|
|
|
* @param base Select element.
|
|
|
|
* @returns Array of selected options.
|
|
|
|
*/
|
2021-04-19 16:59:26 -07:00
|
|
|
export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
|
|
|
|
let selected = [] as SelectedOption[];
|
|
|
|
for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
|
|
|
|
if (element !== null) {
|
|
|
|
const select = { name: element.name, options: [] } as SelectedOption;
|
|
|
|
for (const option of element.options) {
|
|
|
|
if (option.selected) {
|
|
|
|
select.options.push(option.value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
selected = [...selected, select];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return selected;
|
|
|
|
}
|
2021-04-20 01:18:24 -07:00
|
|
|
|
2021-04-21 10:19:13 -07:00
|
|
|
/**
|
|
|
|
* Get data that can only be accessed via Django context, and is thus already rendered in the HTML
|
|
|
|
* template.
|
|
|
|
*
|
|
|
|
* @see Templates requiring Django context data have a `{% block data %}` block.
|
|
|
|
*
|
|
|
|
* @param key Property name, which must exist on the HTML element. If not already prefixed with
|
|
|
|
* `data-`, `data-` will be prepended to the property.
|
|
|
|
* @returns Value if it exists, `null` if not.
|
|
|
|
*/
|
2021-04-20 01:18:24 -07:00
|
|
|
export function getNetboxData(key: string): string | null {
|
|
|
|
if (!key.startsWith('data-')) {
|
|
|
|
key = `data-${key}`;
|
|
|
|
}
|
|
|
|
for (const element of getElements('body > div#netbox-data > *')) {
|
|
|
|
const value = element.getAttribute(key);
|
|
|
|
if (isTruthy(value)) {
|
|
|
|
return value;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
2021-04-20 01:43:36 -07:00
|
|
|
|
2021-08-24 14:52:24 -07:00
|
|
|
/**
|
|
|
|
* Toggle visibility of an element.
|
|
|
|
*/
|
|
|
|
export function toggleVisibility<E extends HTMLElement | SVGElement>(
|
|
|
|
element: E | null,
|
|
|
|
action?: 'show' | 'hide',
|
|
|
|
): void {
|
|
|
|
if (element !== null) {
|
|
|
|
if (typeof action === 'undefined') {
|
|
|
|
// No action is passed, so we should toggle the existing state.
|
|
|
|
const current = window.getComputedStyle(element).display;
|
|
|
|
if (current === 'none') {
|
|
|
|
element.style.display = '';
|
|
|
|
} else {
|
|
|
|
element.style.display = 'none';
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (action === 'show') {
|
|
|
|
element.style.display = '';
|
|
|
|
} else {
|
|
|
|
element.style.display = 'none';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-20 01:43:36 -07:00
|
|
|
/**
|
|
|
|
* Toggle visibility of card loader.
|
|
|
|
*/
|
2021-08-23 22:31:36 -07:00
|
|
|
export function toggleLoader(action: 'show' | 'hide'): void {
|
2021-04-21 10:19:13 -07:00
|
|
|
for (const element of getElements<HTMLDivElement>('div.card-overlay')) {
|
2021-08-24 14:52:24 -07:00
|
|
|
toggleVisibility(element, action);
|
2021-04-20 01:43:36 -07:00
|
|
|
}
|
|
|
|
}
|
2021-04-20 12:45:30 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the value of every cell in a table.
|
|
|
|
* @param table Table Element
|
|
|
|
*/
|
|
|
|
export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
|
|
|
|
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
|
|
|
|
if (element !== null) {
|
|
|
|
if (isTruthy(element.innerText) && element.innerText !== '—') {
|
|
|
|
yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Recurse upward through an element's siblings until an element matching the query is found.
|
|
|
|
*
|
|
|
|
* @param base Base Element
|
|
|
|
* @param query CSS Query
|
2021-05-23 19:01:05 -07:00
|
|
|
* @param boundary Optionally specify a CSS Query which, when matched, recursion will cease.
|
2021-04-20 12:45:30 -07:00
|
|
|
*/
|
|
|
|
export function findFirstAdjacent<R extends HTMLElement, B extends Element = Element>(
|
|
|
|
base: B,
|
|
|
|
query: string,
|
2021-05-23 19:01:05 -07:00
|
|
|
boundary?: string,
|
2021-04-20 12:45:30 -07:00
|
|
|
): Nullable<R> {
|
2021-05-23 19:01:05 -07:00
|
|
|
function atBoundary<E extends Element | null>(element: E): boolean {
|
|
|
|
if (typeof boundary === 'string' && element !== null) {
|
|
|
|
if (element.matches(boundary)) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
2021-04-20 12:45:30 -07:00
|
|
|
function match<P extends Element | null>(parent: P): Nullable<R> {
|
2021-05-23 19:01:05 -07:00
|
|
|
if (parent !== null && parent.parentElement !== null && !atBoundary(parent)) {
|
2021-04-20 12:45:30 -07:00
|
|
|
for (const child of parent.parentElement.querySelectorAll<R>(query)) {
|
|
|
|
if (child !== null) {
|
|
|
|
return child;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return match(parent.parentElement.parentElement);
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
return match(base);
|
|
|
|
}
|
2021-04-21 10:19:13 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Helper for creating HTML elements.
|
|
|
|
*
|
|
|
|
* @param tag HTML element type.
|
|
|
|
* @param properties Properties/attributes to apply to the element.
|
|
|
|
* @param classes CSS classes to apply to the element.
|
|
|
|
* @param children Child elements.
|
|
|
|
*/
|
|
|
|
export function createElement<
|
2021-08-23 22:31:36 -07:00
|
|
|
// Element name.
|
2021-04-21 10:19:13 -07:00
|
|
|
T extends keyof HTMLElementTagNameMap,
|
2021-08-23 22:31:36 -07:00
|
|
|
// Element props.
|
|
|
|
P extends InferredProps<T>,
|
|
|
|
// Child element type.
|
2021-08-31 07:56:53 -07:00
|
|
|
C extends HTMLElement = HTMLElement,
|
2021-08-31 17:04:35 -07:00
|
|
|
>(
|
|
|
|
tag: T,
|
|
|
|
properties: P | null,
|
|
|
|
classes: Nullable<string[]> = null,
|
|
|
|
children: C[] = [],
|
|
|
|
): HTMLElementTagNameMap[T] {
|
2021-04-21 10:19:13 -07:00
|
|
|
// Create the base element.
|
|
|
|
const element = document.createElement<T>(tag);
|
|
|
|
|
2021-08-23 22:31:36 -07:00
|
|
|
if (properties !== null) {
|
|
|
|
for (const k of Object.keys(properties)) {
|
|
|
|
// Add each property to the element.
|
|
|
|
const key = k as keyof InferredProps<T>;
|
|
|
|
const value = properties[key] as NonNullable<P[keyof P]>;
|
|
|
|
if (key in element) {
|
|
|
|
element[key] = value;
|
|
|
|
}
|
2021-04-21 10:19:13 -07:00
|
|
|
}
|
|
|
|
}
|
2021-08-23 22:31:36 -07:00
|
|
|
|
2021-04-21 10:19:13 -07:00
|
|
|
// Add each CSS class to the element's class list.
|
2021-08-31 17:04:35 -07:00
|
|
|
if (classes !== null && classes.length > 0) {
|
|
|
|
element.classList.add(...classes);
|
|
|
|
}
|
2021-04-21 10:19:13 -07:00
|
|
|
|
|
|
|
for (const child of children) {
|
|
|
|
// Add each child element to the base element.
|
|
|
|
element.appendChild(child);
|
|
|
|
}
|
|
|
|
return element as HTMLElementTagNameMap[T];
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Convert Celsius to Fahrenheit, for NAPALM temperature sensors.
|
|
|
|
*
|
|
|
|
* @param celsius Degrees in Celsius.
|
|
|
|
* @returns Degrees in Fahrenheit.
|
|
|
|
*/
|
|
|
|
export function cToF(celsius: number): number {
|
|
|
|
return celsius * (9 / 5) + 32;
|
|
|
|
}
|
2021-08-17 16:50:29 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Deduplicate an array of objects based on the value of a property.
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```js
|
|
|
|
* const withDups = [{id: 1, name: 'One'}, {id: 2, name: 'Two'}, {id: 1, name: 'Other One'}];
|
|
|
|
* const withoutDups = uniqueByProperty(withDups, 'id');
|
|
|
|
* console.log(withoutDups);
|
|
|
|
* // [{id: 1, name: 'One'}, {id: 2, name: 'Two'}]
|
|
|
|
* ```
|
|
|
|
* @param arr Array of objects to deduplicate.
|
|
|
|
* @param prop Object property to use as a unique key.
|
|
|
|
* @returns Deduplicated array.
|
|
|
|
*/
|
|
|
|
export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[], prop: P): T[] {
|
|
|
|
const baseMap = new Map<T[P], T>();
|
|
|
|
for (const item of arr) {
|
|
|
|
const value = item[prop];
|
|
|
|
if (!baseMap.has(value)) {
|
|
|
|
baseMap.set(value, item);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return Array.from(baseMap.values());
|
|
|
|
}
|