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

Closes #23442: Replace clipboard.js and query-string with native browser APIs (#23444)

Copy buttons keep their existing markup (.copy-content with data-clipboard-text
or data-clipboard-target) but are now handled by a single delegated listener,
using the Clipboard API in secure contexts and falling back to execCommand('copy')
on plain-HTTP deployments. DynamicTomSelect builds its request URLs with URL and
URLSearchParams.
Jeremy Stretch 5 өдөр өмнө
parent
commit
2cc8f84669

+ 0 - 2
docs/development/web-ui.md

@@ -24,12 +24,10 @@ All UI styling is written in [Sass](https://sass-lang.com/), which is an extensi
 The following software is employed by the NetBox user interface.
 
 * [Bootstrap 5](https://getbootstrap.com/) - A popular CSS & JS framework
-* [clipboard.js](https://clipboardjs.com/) - A lightweight package for enabling copy-to-clipboard functionality
 * [flatpickr](https://flatpickr.js.org/) - A lightweight date & time selection widget
 * [gridstack.js](https://gridstackjs.com/) - Enables interactive grid layouts (for the dashboard)
 * [HTMX](https://htmx.org/) - Enables dynamic web interfaces through the use of HTML element attributes
 * [Material Design Icons](https://pictogrammers.com/library/mdi/) - An extensive open source collection of graphical icons, delivered as a web font
-* [query-string](https://www.npmjs.com/package/query-string) - Assists with parsing URL query strings
 * [Tabler](https://tabler.io/) - A web application UI toolkit & theme based on Bootstrap 5
 * [Tom Select](https://tom-select.js.org/) - Provides dynamic selection form fields
 

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


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


+ 0 - 90
netbox/project-static/package-lock.json

@@ -13,7 +13,6 @@
         "@popperjs/core": "2.11.8",
         "@tabler/core": "1.4.0",
         "bootstrap": "5.3.8",
-        "clipboard": "2.0.11",
         "flatpickr": "4.6.13",
         "graphiql": "4.1.2",
         "graphql": "16.12.0",
@@ -21,7 +20,6 @@
         "htmx.org": "2.0.10",
         "inter-ui": "4.1.1",
         "js-cookie": "3.0.8",
-        "query-string": "9.5.1",
         "react": "18.3.1",
         "react-dom": "18.3.1",
         "tom-select": "2.6.2"
@@ -2518,17 +2516,6 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
-    "node_modules/clipboard": {
-      "version": "2.0.11",
-      "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz",
-      "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==",
-      "license": "MIT",
-      "dependencies": {
-        "good-listener": "^1.2.2",
-        "select": "^1.1.2",
-        "tiny-emitter": "^2.0.0"
-      }
-    },
     "node_modules/clsx": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
@@ -2635,15 +2622,6 @@
         }
       }
     },
-    "node_modules/decode-uri-component": {
-      "version": "0.5.0",
-      "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.5.0.tgz",
-      "integrity": "sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=14.16"
-      }
-    },
     "node_modules/deep-is": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2651,12 +2629,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/delegate": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
-      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==",
-      "license": "MIT"
-    },
     "node_modules/detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2970,18 +2942,6 @@
         "flat-cache": "^6.1.23"
       }
     },
-    "node_modules/filter-obj": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz",
-      "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/find-up": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -3095,15 +3055,6 @@
         "node": ">=10.13.0"
       }
     },
-    "node_modules/good-listener": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
-      "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==",
-      "license": "MIT",
-      "dependencies": {
-        "delegate": "^3.1.2"
-      }
-    },
     "node_modules/graphiql": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/graphiql/-/graphiql-4.1.2.tgz",
@@ -3686,23 +3637,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/query-string": {
-      "version": "9.5.1",
-      "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.5.1.tgz",
-      "integrity": "sha512-/zO3RwuRCMTIcEgq6YMv4OrtEE1XzBG7w5N6zc6ydYnkWYOWsnLI/5894hYEzfESfMOT4cHCsRTIdxsSl1KjGg==",
-      "license": "MIT",
-      "dependencies": {
-        "decode-uri-component": "^0.5.0",
-        "filter-obj": "^5.1.0",
-        "split-on-first": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/react": {
       "version": "18.3.1",
       "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4351,12 +4285,6 @@
         "loose-envify": "^1.1.0"
       }
     },
-    "node_modules/select": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
-      "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==",
-      "license": "MIT"
-    },
     "node_modules/semver": {
       "version": "7.8.5",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -4421,18 +4349,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/split-on-first": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz",
-      "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/style-mod": {
       "version": "4.1.4",
       "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
@@ -4501,12 +4417,6 @@
       "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
       "license": "MIT"
     },
-    "node_modules/tiny-emitter": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
-      "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
-      "license": "MIT"
-    },
     "node_modules/tinyglobby": {
       "version": "0.2.17",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",

+ 0 - 2
netbox/project-static/package.json

@@ -24,7 +24,6 @@
     "@popperjs/core": "2.11.8",
     "@tabler/core": "1.4.0",
     "bootstrap": "5.3.8",
-    "clipboard": "2.0.11",
     "flatpickr": "4.6.13",
     "graphiql": "4.1.2",
     "graphql": "16.12.0",
@@ -32,7 +31,6 @@
     "htmx.org": "2.0.10",
     "inter-ui": "4.1.1",
     "js-cookie": "3.0.8",
-    "query-string": "9.5.1",
     "react": "18.3.1",
     "react-dom": "18.3.1",
     "tom-select": "2.6.2"

+ 94 - 5
netbox/project-static/src/clipboard.ts

@@ -1,8 +1,97 @@
-import Clipboard from 'clipboard';
-import { getElements } from './util';
+/**
+ * Get the text to be copied by a `.copy-content` element. This is either the literal value of its
+ * `data-clipboard-text` attribute, or the content of the element matched by the CSS selector in its
+ * `data-clipboard-target` attribute.
+ */
+function getCopyText(trigger: HTMLElement): Nullable<string> {
+  const text = trigger.getAttribute('data-clipboard-text');
+  if (text) {
+    return text;
+  }
+  const selector = trigger.getAttribute('data-clipboard-target');
+  let target: Nullable<Element> = null;
+  if (selector) {
+    try {
+      target = document.querySelector(selector);
+    } catch {
+      // An invalid selector (such as an ID beginning with a digit) raises a SyntaxError
+      console.warn(`Invalid data-clipboard-target selector: ${selector}`);
+      return null;
+    }
+  }
+  if (
+    target instanceof HTMLInputElement ||
+    target instanceof HTMLTextAreaElement ||
+    target instanceof HTMLSelectElement
+  ) {
+    return target.value;
+  }
+  if (target instanceof HTMLElement) {
+    return target.innerText;
+  }
+  console.warn('Found no content to copy for', trigger);
+  return null;
+}
 
-export function initClipboard(): void {
-  for (const element of getElements('.copy-content')) {
-    new Clipboard(element);
+/**
+ * Copy text using a temporary <textarea> and the legacy `execCommand()` API, for contexts in which
+ * the Clipboard API is unavailable (outside HTTPS or localhost) or the browser refuses the request.
+ */
+function copyTextLegacy(text: string, trigger: HTMLElement): void {
+  const textarea = document.createElement('textarea');
+  textarea.value = text;
+  // Prevent mobile browsers from opening a keyboard, or zooming in on focus
+  textarea.readOnly = true;
+  textarea.style.fontSize = '12pt';
+  textarea.style.position = 'fixed';
+  textarea.style.top = '0';
+  textarea.style.left = '0';
+  textarea.style.opacity = '0';
+
+  const previousFocus = document.activeElement;
+  (trigger.closest('.modal') ?? document.body).append(textarea);
+  textarea.focus({ preventScroll: true });
+  textarea.select();
+  // iOS ignores select() on a read-only field
+  textarea.setSelectionRange(0, text.length);
+  if (!document.execCommand('copy')) {
+    console.warn('Failed to copy text to the clipboard');
+  }
+  textarea.remove();
+  if (previousFocus instanceof HTMLElement) {
+    previousFocus.focus({ preventScroll: true });
   }
 }
+
+/**
+ * Copy text to the clipboard, falling back to the legacy method if the Clipboard API fails.
+ */
+function copyText(text: string, trigger: HTMLElement): void {
+  if (navigator.clipboard) {
+    navigator.clipboard.writeText(text).catch(() => copyTextLegacy(text, trigger));
+  } else {
+    copyTextLegacy(text, trigger);
+  }
+}
+
+function handleClick(event: MouseEvent): void {
+  if (!(event.target instanceof Element)) {
+    return;
+  }
+  const trigger = event.target.closest<HTMLElement>('.copy-content');
+  if (trigger === null) {
+    return;
+  }
+  const text = getCopyText(trigger);
+  if (text !== null) {
+    copyText(text, trigger);
+  }
+}
+
+/**
+ * Enable copy-to-clipboard buttons. A single listener on the document handles every `.copy-content`
+ * element, including those swapped in later by HTMX.
+ */
+export function initClipboard(): void {
+  document.addEventListener('click', handleClick);
+}

+ 0 - 2
netbox/project-static/src/htmx.ts

@@ -1,6 +1,5 @@
 import { initForms } from './forms';
 import { initButtons } from './buttons';
-import { initClipboard } from './clipboard';
 import { initSelects } from './select';
 import { initObjectSelector } from './objectSelector';
 import { initBootstrap } from './bs';
@@ -19,7 +18,6 @@ type HtmxSettleDetail = {
 
 function initDepedencies(): void {
   initButtons();
-  initClipboard();
   initForms();
   initSelects();
   initObjectSelector();

+ 17 - 8
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -5,13 +5,11 @@ import type {
   TomInput,
 } from 'tom-select/dist/esm/types/index.js';
 import { addClasses, removeClasses } from 'tom-select/dist/esm/vanilla.js';
-import queryString from 'query-string';
-import type { Stringifiable } from 'query-string';
 import { DynamicParamsMap } from './dynamicParamsMap';
 import { NetBoxTomSelect } from './netboxTomSelect';
 
 // Transitional
-import { QueryFilter, PathFilter } from '../types';
+import { QueryFilter, QueryValue, PathFilter } from '../types';
 import { getElement, replaceAll } from '../../util';
 
 // Extends NetBoxTomSelect to provide enhanced fetching of options via the REST API
@@ -210,7 +208,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
 
     // Create new URL query parameters based on the current state of `queryParams` and create an
     // updated API query URL.
-    const query = {} as Dict<Stringifiable[]>;
+    const query = {} as Dict<QueryValue[]>;
     for (const [key, value] of this.queryParams.entries()) {
       query[key] = value;
     }
@@ -219,7 +217,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
     for (const [key, value] of this.pathValues.entries()) {
       for (const result of apiUrl.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
         if (value) {
-          url = replaceAll(url, result[1], value.toString());
+          url = replaceAll(url, result[1], value);
         } else {
           // No value is available to replace the token; abort.
           return '';
@@ -236,7 +234,18 @@ export class DynamicTomSelect extends NetBoxTomSelect {
     query['brief'] = [true];
     query['limit'] = [this.settings.maxOptions];
 
-    return queryString.stringifyUrl({ url, query });
+    // Merge the parameters into any query string already present on the URL, replacing any of the
+    // same name. Each value in an array is sent as a repeated parameter (`?site_id=1&site_id=2`).
+    const requestUrl = new URL(url, document.baseURI);
+    for (const [key, values] of Object.entries(query)) {
+      requestUrl.searchParams.delete(key);
+      for (const value of values) {
+        if (value !== undefined) {
+          requestUrl.searchParams.append(key, value === null ? '' : String(value));
+        }
+      }
+    }
+    return requestUrl.href;
   }
 
   // Compile TomOption data from an API result
@@ -324,7 +333,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
     const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
     if (element !== null) {
       // Initialize the element value as an array, in case there are multiple values.
-      let elementValue = [] as Stringifiable[];
+      let elementValue = [] as QueryValue[];
 
       if (element.multiple) {
         // If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
@@ -347,7 +356,7 @@ export class DynamicTomSelect extends NetBoxTomSelect {
 
         if (typeof current !== 'undefined') {
           const { queryParam, queryValue } = current;
-          let value = [] as Stringifiable[];
+          let value = [] as QueryValue[];
 
           if (this.staticParams.has(queryParam)) {
             // If the field is defined in `staticParams`, we should merge the dynamic value with

+ 13 - 10
netbox/project-static/src/select/types.ts

@@ -1,13 +1,16 @@
-import type { Stringifiable } from 'query-string';
+/**
+ * A primitive value which can be sent as a URL query parameter value. `null` is sent as an empty
+ * value (`?key=`), and `undefined` is omitted.
+ */
+export type QueryValue = string | number | boolean | null | undefined;
 
 /**
- * Map of string keys to primitive array values accepted by `query-string`. Keys are used as
- * URL query parameter keys. Values correspond to query param values, enforced as an array
- * for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
- * `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
- * `?site_id=1`.
+ * Map of string keys to primitive array values. Keys are used as URL query parameter keys. Values
+ * correspond to query param values, enforced as an array for easier handling. For example, a
+ * mapping of `{ site_id: [1, 2] }` is serialized as `?site_id=1&site_id=2`. Likewise,
+ * `{ site_id: [1] }` is serialized as `?site_id=1`.
  */
-export type QueryFilter = Map<string, Stringifiable[]>;
+export type QueryFilter = Map<string, QueryValue[]>;
 
 /**
  * JSON data structure from `data-dynamic-params` attribute.
@@ -32,16 +35,16 @@ export type DataDynamicParam = {
  */
 export type QueryParam = {
   queryParam: string;
-  queryValue: Stringifiable[];
+  queryValue: QueryValue[];
 };
 
 /**
- * Map of string keys to primitive values. Used to track variables within URLs from the server. For
+ * Map of string keys to string values. Used to track variables within URLs from the server. For
  * example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
  * value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
  * `/api/value/thing`.
  */
-export type PathFilter = Map<string, Stringifiable>;
+export type PathFilter = Map<string, string>;
 
 /**
  * Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute

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