clean up typescript initialization

This commit is contained in:
checktheroads 2021-04-20 09:34:12 -07:00
parent 0aa8fc2fc2
commit 99f0e31810
26 changed files with 185 additions and 353 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,33 @@
import { Toast } from 'bootstrap';
import { Modal, Tab, Toast, Tooltip } from 'bootstrap';
import Masonry from 'masonry-layout';
import { getElements } from './util';
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
/**
* Initialize masonry-layout for homepage (or any other masonry layout cards).
*/
function initMasonry(): void {
for (const grid of getElements<HTMLDivElement>('.masonry')) {
new Masonry(grid, {
itemSelector: '.masonry-item',
percentPosition: true,
});
}
}
function initTooltips() {
for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) {
new Tooltip(tooltip, { container: 'body', boundary: 'window' });
}
}
function initModals() {
for (const modal of getElements('[data-bs-toggle="modal"]')) {
new Modal(modal);
}
}
export function createToast(
level: ToastLevel,
title: string,
@ -71,16 +97,33 @@ export function createToast(
}
/**
* Find any active messages from django.contrib.messages and show them in a toast.
* Open the tab specified in the URL. For example, /dcim/device-types/1/#tab_frontports will
* change the open tab to the Front Ports tab.
*/
export function initMessageToasts(): void {
const elements = document.querySelectorAll<HTMLDivElement>(
'body > div#django-messages > div.django-message.toast',
);
for (const element of elements) {
if (element !== null) {
const toast = new Toast(element);
toast.show();
function initTabs() {
const { hash } = location;
if (hash && hash.match(/^\#tab_.+$/)) {
// The tab element will have a data-bs-target attribute with a value of the object type for
// the corresponding tab. Once we drop the `tab_` prefix, the hash will match the target
// element's data-bs-target value. For example, `#tab_frontports` becomes `#frontports`.
const target = hash.replace('tab_', '');
for (const element of getElements(`ul.nav.nav-tabs .nav-link[data-bs-target="${target}"]`)) {
// Instantiate a Bootstrap tab instance.
// See https://getbootstrap.com/docs/5.0/components/navs-tabs/#javascript-behavior
const tab = new Tab(element);
// Show the tab.
tab.show();
}
}
}
/**
* Enable any defined Bootstrap Tooltips.
*
* @see https://getbootstrap.com/docs/5.0/components/tooltips
*/
export function initBootstrap(): void {
for (const func of [initTooltips, initModals, initMasonry, initTabs]) {
func();
}
}

View File

@ -1,5 +1,5 @@
import { createToast } from './toast';
import { isTruthy, getElements, apiPatch, hasError } from './util';
import { createToast } from './bs';
import { isTruthy, getElements, apiPatch, hasError, slugify } from './util';
/**
* Add onClick callback for toggling rack elevation images.
@ -91,8 +91,39 @@ function initConnectionToggle() {
}
}
/**
* If a slug field exists, add event listeners to handle automatically generating its value.
*/
function initReslug(): void {
const slugField = document.getElementById('id_slug') as HTMLInputElement;
const slugButton = document.getElementById('reslug') as HTMLButtonElement;
if (slugField === null || slugButton === null) {
return;
}
const sourceId = slugField.getAttribute('slug-source');
const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement;
if (sourceField === null) {
console.error('Unable to find field for slug field.');
return;
}
const slugLengthAttr = slugField.getAttribute('maxlength');
let slugLength = 50;
if (slugLengthAttr) {
slugLength = Number(slugLengthAttr);
}
sourceField.addEventListener('blur', () => {
slugField.value = slugify(sourceField.value, slugLength);
});
slugButton.addEventListener('click', () => {
slugField.value = slugify(sourceField.value, slugLength);
});
}
export function initButtons() {
for (const func of [initRackElevation, initConnectionToggle]) {
for (const func of [initRackElevation, initConnectionToggle, initReslug]) {
func();
}
}

View File

@ -1,4 +1,4 @@
import { createToast } from '../toast';
import { createToast } from '../bs';
import { apiGetBase, getNetboxData, hasError, toggleLoader } from '../util';
/**

View File

@ -1,4 +1,4 @@
import { createToast } from '../toast';
import { createToast } from '../bs';
import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util';
/**

View File

@ -23,7 +23,7 @@ export function getFormData(element: HTMLFormElement): URLSearchParams {
/**
* Set the value of the number input field based on the selection of the dropdown.
*/
export function initSpeedSelector(): void {
function initSpeedSelector(): void {
for (const element of getElements<HTMLAnchorElement>('a.set_speed')) {
if (element !== null) {
function handleClick(event: Event) {
@ -266,7 +266,7 @@ function initScopeSelector() {
}
export function initForms() {
for (const func of [initFormElements, initMoveButtons, initScopeSelector]) {
for (const func of [initFormElements, initMoveButtons, initSpeedSelector, initScopeSelector]) {
func();
}
}

View File

@ -1,4 +1,4 @@
import { createToast } from './toast';
import { createToast } from './bs';
import { apiGetBase, hasError } from './util';
let timeout: number = 1000;

View File

@ -0,0 +1,16 @@
import { Toast } from 'bootstrap';
/**
* Find any active messages from django.contrib.messages and show them in a toast.
*/
export function initMessages(): void {
const elements = document.querySelectorAll<HTMLDivElement>(
'body > div#django-messages > div.django-message.toast',
);
for (const element of elements) {
if (element !== null) {
const toast = new Toast(element);
toast.show();
}
}
}

View File

@ -1,121 +1,34 @@
import { Modal, Tooltip } from 'bootstrap';
import Masonry from 'masonry-layout';
import { initApiSelect, initStaticSelect, initColorSelect } from './select';
import { initDateSelector } from './dateSelector';
import { initMessageToasts } from './toast';
import { initSpeedSelector, initForms } from './forms';
import { initForms } from './forms';
import { initBootstrap } from './bs';
import { initSearch } from './search';
import { initSelect } from './select';
import { initButtons } from './buttons';
import { initSecrets } from './secrets';
import { initMessages } from './messages';
import { initClipboard } from './clipboard';
import { initSearchBar, initInterfaceFilter } from './search';
import { initGenerateKeyPair, initLockUnlock, initGetSessionKey } from './secrets';
import { initTabs } from './tabs';
import { initDateSelector } from './dateSelector';
import { initTableConfig } from './tableConfig';
import { getElements } from './util';
const INITIALIZERS = [
initSearchBar,
initMasonry,
bindReslug,
initApiSelect,
initStaticSelect,
initDateSelector,
initSpeedSelector,
initColorSelect,
initButtons,
initClipboard,
initGenerateKeyPair,
initLockUnlock,
initGetSessionKey,
initInterfaceFilter,
initTableConfig,
initTabs,
] as (() => void)[];
/**
* Enable any defined Bootstrap Tooltips.
*
* @see https://getbootstrap.com/docs/5.0/components/tooltips
*/
function initBootstrap(): void {
if (document !== null) {
for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) {
new Tooltip(tooltip, { container: 'body', boundary: 'window' });
}
for (const modal of getElements('[data-bs-toggle="modal"]')) {
new Modal(modal);
}
initMessageToasts();
initForms();
function init() {
for (const init of [
initBootstrap,
initMessages,
initForms,
initSearch,
initSelect,
initDateSelector,
initButtons,
initClipboard,
initSecrets,
initTableConfig,
]) {
init();
}
}
/**
* Initialize masonry-layout for homepage (or any other masonry layout cards).
*/
function initMasonry(): void {
if (document !== null) {
for (const grid of getElements('.masonry')) {
new Masonry(grid, {
itemSelector: '.masonry-item',
percentPosition: true,
});
}
}
}
/**
* Create a slug from any input string.
*
* @param slug Original string.
* @param chars Maximum number of characters.
* @returns Slugified string.
*/
function slugify(slug: string, chars: number): string {
return slug
.replace(/[^\-\.\w\s]/g, '') // Remove unneeded chars
.replace(/^[\s\.]+|[\s\.]+$/g, '') // Trim leading/trailing spaces
.replace(/[\-\.\s]+/g, '-') // Convert spaces and decimals to hyphens
.toLowerCase() // Convert to lowercase
.substring(0, chars); // Trim to first chars chars
}
/**
* If a slug field exists, add event listeners to handle automatically generating its value.
*/
function bindReslug(): void {
const slugField = document.getElementById('id_slug') as HTMLInputElement;
const slugButton = document.getElementById('reslug') as HTMLButtonElement;
if (slugField === null || slugButton === null) {
return;
}
const sourceId = slugField.getAttribute('slug-source');
const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement;
if (sourceField === null) {
console.error('Unable to find field for slug field.');
return;
}
const slugLengthAttr = slugField.getAttribute('maxlength');
let slugLength = 50;
if (slugLengthAttr) {
slugLength = Number(slugLengthAttr);
}
sourceField.addEventListener('blur', () => {
slugField.value = slugify(sourceField.value, slugLength);
});
slugButton.addEventListener('click', () => {
slugField.value = slugify(sourceField.value, slugLength);
});
}
if (document.readyState !== 'loading') {
initBootstrap();
} else {
document.addEventListener('DOMContentLoaded', initBootstrap);
}
for (const init of INITIALIZERS) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}

View File

@ -8,7 +8,7 @@ function isSearchButton(el: any): el is SearchFilterButton {
return el?.dataset?.searchValue ?? null !== null;
}
export function initSearchBar() {
function initSearchBar() {
const dropdown = document.getElementById('object-type-selector');
const selectedValue = document.getElementById('selected-value') as HTMLSpanElement;
const selectedType = document.getElementById('search-obj-type') as HTMLInputElement;
@ -41,7 +41,7 @@ export function initSearchBar() {
/**
* Initialize Interface Table Filter Elements.
*/
export function initInterfaceFilter() {
function initInterfaceFilter() {
for (const element of getElements<HTMLInputElement>('input.interface-filter')) {
/**
* Filter on-page table by input text.
@ -79,3 +79,9 @@ export function initInterfaceFilter() {
element.addEventListener('keyup', handleInput);
}
}
export function initSearch() {
for (const func of [initSearchBar, initInterfaceFilter]) {
func();
}
}

View File

@ -1,11 +1,11 @@
import { Modal } from 'bootstrap';
import { createToast } from './bs';
import { apiGetBase, apiPostForm, getElements, isApiError, hasError } from './util';
import { createToast } from './toast';
/**
* Initialize Generate Private Key Pair Elements.
*/
export function initGenerateKeyPair() {
function initGenerateKeyPair() {
const element = document.getElementById('new_keypair_modal') as HTMLDivElement;
const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;
// If the elements are not loaded, stop.
@ -86,7 +86,7 @@ function toggleSecretButtons(id: string, action: 'lock' | 'unlock') {
/**
* Initialize Lock & Unlock button event listeners & callbacks.
*/
export function initLockUnlock() {
function initLockUnlock() {
const privateKeyModalElem = document.getElementById('privkey_modal');
if (privateKeyModalElem === null) {
return;
@ -184,7 +184,7 @@ function requestSessionKey(privateKey: string) {
/**
* Initialize Request Session Key Elements.
*/
export function initGetSessionKey() {
function initGetSessionKey() {
for (const element of getElements<HTMLButtonElement>('#request_session_key')) {
/**
* Send the user's input private key to the API to get a session key, which will be stored as
@ -200,3 +200,9 @@ export function initGetSessionKey() {
element.addEventListener('click', handleClick);
}
}
export function initSecrets() {
for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey]) {
func();
}
}

View File

@ -1,167 +0,0 @@
import Choices from "choices.js";
import queryString from "query-string";
import { getApiData, isApiError } from "../util";
import { createToast } from "../toast";
import type { Choices as TChoices } from "choices.js";
interface CustomSelect extends HTMLSelectElement {
dataset: {
url: string;
};
}
function isCustomSelect(el: HTMLSelectElement): el is CustomSelect {
return typeof el?.dataset?.url === "string";
}
/**
* Determine if a select element should be filtered by the value of another select element.
*
* Looks for the DOM attribute `data-query-param-<name of other field>`, which would look like:
* `["$<name>"]`
*
* If the attribute exists, parse out the raw value. In the above example, this would be `name`.
* @param element Element to scan
* @returns Attribute name, or null if it was not found.
*/
function getFilteredBy<T extends HTMLElement>(element: T): string[] {
const pattern = new RegExp(/\[|\]|"|\$/g);
const keys = Object.keys(element.dataset);
const filteredBy = [] as string[];
for (const key of keys) {
if (key.includes("queryParam")) {
const value = element.dataset[key];
if (typeof value !== "undefined") {
const parsed = JSON.parse(value) as string | string[];
if (Array.isArray(parsed)) {
filteredBy.push(parsed[0].replaceAll(pattern, ""));
} else {
filteredBy.push(value.replaceAll(pattern, ""));
}
}
}
if (key === "url" && element.dataset.url?.includes(`{{`)) {
/**
* If the URL contains a Django/Jinja template variable tag we need to extract the variable
* name and consider this a field to monitor for changes.
*/
const value = element.dataset.url.match(/\{\{(.+)\}\}/);
if (value !== null) {
filteredBy.push(value[1]);
}
}
}
return filteredBy;
}
export function initApiSelect() {
const elements = document.querySelectorAll(
".netbox-select2-api"
) as NodeListOf<HTMLSelectElement>;
for (const element of elements) {
if (isCustomSelect(element)) {
let { url } = element.dataset;
const instance = new Choices(element, {
noChoicesText: "No Options Available",
itemSelectText: "",
});
/**
* Retrieve all objects for this object type.
*
* @param choiceUrl Optionally override the URL for filtering. If not set, the URL
* from the DOM attributes is used.
* @returns Data parsed into Choices.JS Choices.
*/
async function getChoices(
choiceUrl: string = url
): Promise<TChoices.Choice[]> {
if (choiceUrl.includes(`{{`)) {
return [];
}
return getApiData(choiceUrl).then((data) => {
if (isApiError(data)) {
const toast = createToast("danger", data.exception, data.error);
toast.show();
return [];
}
const { results } = data;
const options = [] as TChoices.Choice[];
if (results.length !== 0) {
for (const result of results) {
const choice = {
value: result.id.toString(),
label: result.name,
} as TChoices.Choice;
options.push(choice);
}
}
return options;
});
}
const filteredBy = getFilteredBy(element);
if (filteredBy.length !== 0) {
for (const filter of filteredBy) {
// Find element with the `name` attribute matching this element's filtered-by attribute.
const groupElem = document.querySelector(
`[name=${filter}]`
) as HTMLSelectElement;
if (groupElem !== null) {
/**
* When the group's selection changes, re-query the dependant element's options, but
* filtered to results matching the group's ID.
*
* @param event Group's DOM event.
*/
function handleEvent(event: Event) {
let filterUrl: string | undefined;
const target = event.target as HTMLSelectElement;
if (target.value) {
if (url.includes(`{{`)) {
/**
* If the URL contains a Django/Jinja template variable tag, we need to replace
* the tag with the event's value.
*/
url = url.replaceAll(/\{\{(.+)\}\}/g, target.value);
element.setAttribute("data-url", url);
}
let queryKey = filter;
if (filter?.includes("_group")) {
/**
* For example, a tenant's group relationship field is `group`, but the field
* name is `tenant_group`.
*/
queryKey = "group";
}
filterUrl = queryString.stringifyUrl({
url,
query: { [`${queryKey}_id`]: groupElem.value },
});
}
instance.setChoices(
() => getChoices(filterUrl),
undefined,
undefined,
true
);
}
groupElem.addEventListener("addItem", handleEvent);
groupElem.addEventListener("removeItem", handleEvent);
}
}
}
instance.setChoices(() => getChoices());
}
}
}

View File

@ -1,2 +0,0 @@
export * from "./api";
export * from "./static";

View File

@ -1,16 +0,0 @@
import Choices from "choices.js";
export function initStaticSelect() {
const elements = document.querySelectorAll(
".netbox-select2-static"
) as NodeListOf<HTMLSelectElement>;
for (const element of elements) {
if (element !== null) {
new Choices(element, {
noChoicesText: "No Options Available",
itemSelectText: "",
});
}
}
}

View File

@ -1,7 +1,7 @@
import SlimSelect from 'slim-select';
import queryString from 'query-string';
import { getApiData, isApiError, getElements, isTruthy } from '../util';
import { createToast } from '../toast';
import { getApiData, isApiError, getElements, isTruthy, hasError } from '../util';
import { createToast } from '../bs';
import { setOptionStyles, getFilteredBy, toggle } from './util';
import type { Option } from 'slim-select/dist/data';
@ -38,7 +38,7 @@ const REPLACE_PATTERNS = [
[new RegExp(/termination_(a|b)_(.+)/g), '$2_id'],
// A tenant's group relationship field is `group`, but the field name is `tenant_group`.
[new RegExp(/tenant_(group)/g), '$1_id'],
// Append `_id` to any fields
// Append `_id` to any fields
[new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'],
] as ReplaceTuple[];
@ -72,9 +72,12 @@ async function getOptions(
.map(option => option.value);
return getApiData(url).then(data => {
if (isApiError(data)) {
const toast = createToast('danger', data.exception, data.error);
toast.show();
if (hasError(data)) {
if (isApiError(data)) {
createToast('danger', data.exception, data.error).show();
return [PLACEHOLDER];
}
createToast('danger', `Error Fetching Options for field ${select.name}`, data.error).show();
return [PLACEHOLDER];
}

View File

@ -1,3 +1,9 @@
export * from './api';
export * from './static';
export * from './color';
import { initApiSelect } from './api';
import { initColorSelect } from './color';
import { initStaticSelect } from './static';
export function initSelect() {
for (const func of [initApiSelect, initColorSelect, initStaticSelect]) {
func();
}
}

View File

@ -1,4 +1,4 @@
import { createToast } from './toast';
import { createToast } from './bs';
import { getElements, apiPatch, hasError, getSelectedOptions } from './util';
/**

View File

@ -1,23 +0,0 @@
import { Tab } from 'bootstrap';
import { getElements } from './util';
/**
* Open the tab specified in the URL. For example, /dcim/device-types/1/#tab_frontports will
* change the open tab to the Front Ports tab.
*/
export function initTabs() {
const { hash } = location;
if (hash && hash.match(/^\#tab_.+$/)) {
// The tab element will have a data-bs-target attribute with a value of the object type for
// the corresponding tab. Once we drop the `tab_` prefix, the hash will match the target
// element's data-bs-target value. For example, `#tab_frontports` becomes `#frontports`.
const target = hash.replace('tab_', '');
for (const element of getElements(`ul.nav.nav-tabs .nav-link[data-bs-target="${target}"]`)) {
// Instantiate a Bootstrap tab instance.
// See https://getbootstrap.com/docs/5.0/components/navs-tabs/#javascript-behavior
const tab = new Tab(element);
// Show the tab.
tab.show();
}
}
}

View File

@ -13,6 +13,22 @@ export function hasError(data: Record<string, unknown>): data is ErrorBase {
return 'error' in data;
}
/**
* Create a slug from any input string.
*
* @param slug Original string.
* @param chars Maximum number of characters.
* @returns Slugified string.
*/
export function slugify(slug: string, chars: number): string {
return slug
.replace(/[^\-\.\w\s]/g, '') // Remove unneeded chars
.replace(/^[\s\.]+|[\s\.]+$/g, '') // Trim leading/trailing spaces
.replace(/[\-\.\s]+/g, '-') // Convert spaces and decimals to hyphens
.toLowerCase() // Convert to lowercase
.substring(0, chars); // Trim to first chars chars
}
/**
* Type guard to determine if a value is not null, undefined, or empty.
*/