2
0
Эх сурвалжийг харах

clean up typescript initialization

checktheroads 4 жил өмнө
parent
commit
99f0e31810

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/config.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/config.js.map


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/jobs.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/jobs.js.map


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/lldp.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/netbox.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 53 - 10
netbox/project-static/src/toast.ts → netbox/project-static/src/bs.ts

@@ -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();
+  }
+}

+ 34 - 3
netbox/project-static/src/buttons.ts

@@ -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();
   }
 }

+ 1 - 1
netbox/project-static/src/device/config.ts

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

+ 1 - 1
netbox/project-static/src/device/lldp.ts

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

+ 2 - 2
netbox/project-static/src/forms.ts

@@ -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();
   }
 }

+ 1 - 1
netbox/project-static/src/jobs.ts

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

+ 16 - 0
netbox/project-static/src/messages.ts

@@ -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();
+    }
+  }
+}

+ 24 - 111
netbox/project-static/src/netbox.ts

@@ -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 { 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();
-  }
-}
-
-/**
- * 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;
-  }
+import { initDateSelector } from './dateSelector';
 
-  const slugLengthAttr = slugField.getAttribute('maxlength');
-  let slugLength = 50;
+import { initTableConfig } from './tableConfig';
 
-  if (slugLengthAttr) {
-    slugLength = Number(slugLengthAttr);
+function init() {
+  for (const init of [
+    initBootstrap,
+    initMessages,
+    initForms,
+    initSearch,
+    initSelect,
+    initDateSelector,
+    initButtons,
+    initClipboard,
+    initSecrets,
+    initTableConfig,
+  ]) {
+    init();
   }
-  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);
 }

+ 8 - 2
netbox/project-static/src/search.ts

@@ -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();
+  }
+}

+ 10 - 4
netbox/project-static/src/secrets.ts

@@ -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();
+  }
+}

+ 0 - 167
netbox/project-static/src/select-choices/api.ts

@@ -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());
-    }
-  }
-}

+ 0 - 2
netbox/project-static/src/select-choices/index.ts

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

+ 0 - 16
netbox/project-static/src/select-choices/static.ts

@@ -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: "",
-      });
-    }
-  }
-}

+ 9 - 6
netbox/project-static/src/select/api.ts

@@ -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];
     }
 

+ 9 - 3
netbox/project-static/src/select/index.ts

@@ -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();
+  }
+}

+ 1 - 1
netbox/project-static/src/tableConfig.ts

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

+ 0 - 23
netbox/project-static/src/tabs.ts

@@ -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();
-    }
-  }
-}

+ 16 - 0
netbox/project-static/src/util.ts

@@ -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.
  */

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно