Fixes #6990: Fix query param and query filter handling in API select

This commit is contained in:
Matt 2021-08-20 16:25:31 -07:00
parent 12f3c2596f
commit a3d5e04946
12 changed files with 97 additions and 24 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.

Binary file not shown.

Binary file not shown.

View File

@ -17,12 +17,34 @@ import {
findFirstAdjacent,
} from '../util';
import type { Stringifiable } from 'query-string';
import type { Option } from 'slim-select/dist/data';
type QueryFilter = Map<string, string | number | boolean>;
/**
* 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`.
*/
type QueryFilter = Map<string, Stringifiable[]>;
/**
* Map of string keys to primitive 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`.
*/
type PathFilter = Map<string, Stringifiable>;
/**
* Merge or replace incoming options with current options.
*/
type ApplyMethod = 'merge' | 'replace';
/**
* Trigger for which the select instance should fetch its data from the NetBox API.
*/
export type Trigger =
/**
* Load data when the select element is opened.
@ -40,11 +62,9 @@ export type Trigger =
// Various one-off patterns to replace in query param keys.
const REPLACE_PATTERNS = [
// Don't query `termination_a_device=1`, but rather `device=1`.
[new RegExp(/termination_(a|b)_(.+)/g), '$2_id'],
[new RegExp(/termination_(a|b)_(.+)/g), '$2'],
// 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
[new RegExp(/^([A-Za-z0-9]+)(_id)?$/g), '$1_id'],
[new RegExp(/tenant_(group)/g), '$1'],
] as [RegExp, string][];
// Empty placeholder option.
@ -130,7 +150,7 @@ class APISelect {
* `1`, `pathValues` would be updated to reflect a `"rack" => 1` mapping. When the query URL is
* updated, the URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
*/
private readonly pathValues: QueryFilter = new Map();
private readonly pathValues: PathFilter = new Map();
/**
* Original API query URL passed via the `data-href` attribute from the server. This is kept so
@ -226,7 +246,7 @@ class APISelect {
this.updatePathValues(filter);
}
this.queryParams.set('brief', true);
this.queryParams.set('brief', [true]);
this.updateQueryUrl();
// Initialize element styling.
@ -608,7 +628,7 @@ class APISelect {
private updateQueryUrl(): void {
// Create new URL query parameters based on the current state of `queryParams` and create an
// updated API query URL.
const query = {} as Dict<string | number | boolean>;
const query = {} as Dict<Stringifiable[]>;
for (const [key, value] of this.queryParams.entries()) {
query[key] = value;
}
@ -651,11 +671,32 @@ class APISelect {
}
}
if (isTruthy(element.value)) {
// Force related keys to end in `_id`, if they don't already.
if (key.substring(key.length - 3) !== '_id') {
key = `${key}_id`;
}
// Initialize the element value as an array, in case there are multiple values.
let elementValue = [] as Stringifiable[];
if (element.multiple) {
// If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
elementValue = Array.from(element.options)
.filter(o => o.selected)
.map(o => o.value);
} else if (element.value !== '') {
// If this is single-select (most fields), use the element's value. This seemingly
// redundant/verbose check is mainly for performance, so we're not running the above three
// functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select
// field's value changes.
elementValue = [element.value];
}
if (elementValue.length > 0) {
// If the field has a value, add it to the map.
if (this.filterParams.has(id)) {
// If this element is tracking the neighbor element, add its value to the map.
this.queryParams.set(key, element.value);
// If this instance is filtered by the neighbor element, add its value to the map.
this.queryParams.set(key, elementValue);
}
} else {
// Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
@ -771,6 +812,34 @@ class APISelect {
.map(v => v.name)
.filter(v => v.includes('data'));
/**
* Properly handle preexistence of keys, value types, and deduplication when adding a filter to
* `filterParams`.
*
* _Note: This is an unnamed function so that it can access `this`._
*/
const addFilter = (key: string, value: Stringifiable): void => {
const current = this.filterParams.get(key);
if (typeof current !== 'undefined') {
// This instance is already filtered by `key`, so we should add the new `value`.
// Merge and deduplicate the current filter parameter values with the incoming value.
const next = Array.from(
new Set<Stringifiable>([...(current as Stringifiable[]), value]),
);
this.filterParams.set(key, next);
} else {
// This instance is not already filtered by `key`, so we should add a new mapping.
if (value === '') {
// Don't add placeholder values.
this.filterParams.set(key, []);
} else {
// If the value is not a placeholder, add it.
this.filterParams.set(key, [value]);
}
}
};
for (const key of keys) {
if (key.match(keyPattern) && key !== 'data-query-param-exclude') {
const value = this.base.getAttribute(key);
@ -778,29 +847,33 @@ class APISelect {
try {
const parsed = JSON.parse(value) as string | string[];
if (Array.isArray(parsed)) {
// Query param contains multiple values.
for (const item of parsed) {
if (item.match(/^\$.+$/g)) {
const replaced = item.replaceAll(pattern, '');
this.filterParams.set(replaced, '');
// Value is an unfulfilled variable.
addFilter(item.replaceAll(pattern, ''), '');
} else {
this.filterParams.set(key.replaceAll(keyPattern, ''), item);
// Value has been fulfilled and is a real value to query.
addFilter(key.replaceAll(keyPattern, ''), item);
}
}
} else {
if (parsed.match(/^\$.+$/g)) {
const replaced = parsed.replaceAll(pattern, '');
this.filterParams.set(replaced, '');
// Value is an unfulfilled variable.
addFilter(parsed.replaceAll(pattern, ''), '');
} else {
this.filterParams.set(key.replaceAll(keyPattern, ''), parsed);
// Value has been fulfilled and is a real value to query.
addFilter(key.replaceAll(keyPattern, ''), parsed);
}
}
} catch (err) {
console.warn(err);
if (value.match(/^\$.+$/g)) {
const replaced = value.replaceAll(pattern, '');
this.filterParams.set(replaced, '');
// Value is an unfulfilled variable.
addFilter(value.replaceAll(pattern, ''), '');
} else {
this.filterParams.set(key.replaceAll(keyPattern, ''), value);
// Value has been fulfilled and is a real value to query.
addFilter(key.replaceAll(keyPattern, ''), value);
}
}
}

View File

@ -46,11 +46,11 @@ export function slugify(slug: string, chars: number): string {
/**
* Type guard to determine if a value is not null, undefined, or empty.
*/
export function isTruthy<V extends string | number | boolean | null | undefined>(
value: V,
): value is NonNullable<V> {
export function isTruthy<V extends unknown>(value: V): value is NonNullable<V> {
const badStrings = ['', 'null', 'undefined'];
if (typeof value === 'string' && !badStrings.includes(value)) {
if (Array.isArray(value)) {
return value.length > 0;
} else if (typeof value === 'string' && !badStrings.includes(value)) {
return true;
} else if (typeof value === 'number') {
return true;