Fixes #6856: Properly handle existence of next property in API select responses

This commit is contained in:
Matt 2021-08-17 16:50:29 -07:00
parent 6d1b981ecb
commit 664b02d735
11 changed files with 148 additions and 26 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

@ -40,6 +40,8 @@ type APIAnswer<T> = {
results: T[];
};
type APIAnswerWithNext<T> = Exclude<APIAnswer<T>, 'next'> & { next: string };
type ErrorBase = {
error: string;
};

View File

@ -1,16 +1,19 @@
import queryString from 'query-string';
import debounce from 'just-debounce-it';
import { readableColor } from 'color2k';
import SlimSelect from 'slim-select';
import { createToast } from '../bs';
import { hasUrl, hasExclusions, isTrigger } from './util';
import {
isTruthy,
hasMore,
hasError,
getElement,
getApiData,
isApiError,
getElements,
createElement,
uniqueByProperty,
findFirstAdjacent,
} from '../util';
@ -88,6 +91,12 @@ class APISelect {
*/
private readonly loadEvent: InstanceType<typeof Event>;
/**
* Event to be dispatched when the scroll position of this element's optinos list is at the
* bottom.
*/
private readonly bottomEvent: InstanceType<typeof Event>;
/**
* SlimSelect instance for this element.
*/
@ -132,6 +141,17 @@ class APISelect {
*/
private queryUrl: string = '';
/**
* Scroll position of options is at the bottom of the list, or not. Used to determine if
* additional options should be fetched from the API.
*/
private atBottom: boolean = false;
/**
* API URL for additional options, if applicable. `null` indicates no options remain.
*/
private more: Nullable<string> = null;
/**
* This element's options come from the server pre-sorted and should not be sorted client-side.
* Determined by the existence of the `pre-sorted` attribute on the base `<select/>` element, or
@ -170,6 +190,8 @@ class APISelect {
}
this.loadEvent = new Event(`netbox.select.onload.${base.name}`);
this.bottomEvent = new Event(`netbox.select.atbottom.${base.name}`);
this.placeholder = this.getPlaceholder();
this.disabledOptions = this.getDisabledOptions();
this.disabledAttributes = this.getDisabledAttributes();
@ -257,7 +279,7 @@ class APISelect {
/**
* This instance's available options.
*/
public get options(): Option[] {
private get options(): Option[] {
return this._options;
}
@ -271,9 +293,10 @@ class APISelect {
if (!this.preSorted) {
newOptions = optionsIn.sort((a, b) => (a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1));
}
this._options = newOptions;
this.slim.setData(newOptions);
// Deduplicate options each time they're set.
const deduplicated = uniqueByProperty(newOptions, 'value');
this._options = deduplicated;
this.slim.setData(deduplicated);
}
/**
@ -318,6 +341,21 @@ class APISelect {
* this element's options are updated.
*/
private addEventListeners(): void {
// Create a debounced function to fetch options based on the search input value.
const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
// Query the API when the input value changes or a value is pasted.
this.slim.slim.search.input.addEventListener('keyup', event => fetcher(event));
this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
// Watch every scroll event to determine if the scroll position is at bottom.
this.slim.slim.list.addEventListener('scroll', () => this.handleScroll());
// When the scroll position is at bottom, fetch additional options.
this.base.addEventListener(`netbox.select.atbottom.${this.name}`, () =>
this.fetchOptions(this.more),
);
// Create a unique iterator of all possible form fields which, when changed, should cause this
// element to update its API query.
const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
@ -350,14 +388,11 @@ class APISelect {
}
/**
* Query the NetBox API for this element's options.
* Process a valid API response and add results to this instance's options.
*
* @param data Valid API response (not an error).
*/
private async getOptions(): Promise<void> {
if (this.queryUrl.includes(`{{`)) {
this.options = [PLACEHOLDER];
return;
}
private async processOptions(data: APIAnswer<APIObjectBase>): Promise<void> {
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
// existing object. When we fetch options from the API later, we can set any of the options
// contained in this array to `selected`.
@ -366,19 +401,7 @@ class APISelect {
.map(option => option.getAttribute('value'))
.filter(isTruthy);
const data = await getApiData(this.queryUrl);
if (hasError(data)) {
if (isApiError(data)) {
return this.handleError(data.exception, data.error);
}
return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error);
}
const { results } = data;
const options = [PLACEHOLDER] as Option[];
for (const result of results) {
for (const result of data.results) {
let text = result.display;
if (typeof result._depth === 'number') {
@ -432,9 +455,77 @@ class APISelect {
disabled,
} as Option;
options.push(option);
this.options = [...this.options, option];
}
if (hasMore(data)) {
// If the `next` property in the API response is a URL, there are more options on the server
// side to be fetched.
this.more = data.next;
} else {
// If the `next` property in the API response is `null`, there are no more options on the
// server, and no additional fetching needs to occur.
this.more = null;
}
}
/**
* Fetch options from the given API URL and add them to the instance.
*
* @param url API URL
*/
private async fetchOptions(url: Nullable<string>): Promise<void> {
if (typeof url === 'string') {
const data = await getApiData(url);
if (hasError(data)) {
if (isApiError(data)) {
return this.handleError(data.exception, data.error);
}
return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error);
}
await this.processOptions(data);
}
}
/**
* Query the NetBox API for this element's options.
*/
private async getOptions(): Promise<void> {
if (this.queryUrl.includes(`{{`)) {
this.options = [PLACEHOLDER];
return;
}
await this.fetchOptions(this.queryUrl);
}
/**
* Query the API for a specific search pattern and add the results to the available options.
*/
private async handleSearch(event: Event) {
const { value: q } = event.target as HTMLInputElement;
const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } });
await this.fetchOptions(url);
this.slim.data.search(q);
this.slim.render();
}
/**
* Determine if the user has scrolled to the bottom of the options list. If so, try to load
* additional paginated options.
*/
private handleScroll(): void {
const atBottom =
this.slim.slim.list.scrollTop + this.slim.slim.list.offsetHeight ===
this.slim.slim.list.scrollHeight;
if (this.atBottom && !atBottom) {
this.atBottom = false;
this.base.dispatchEvent(this.bottomEvent);
} else if (!this.atBottom && atBottom) {
this.atBottom = true;
this.base.dispatchEvent(this.bottomEvent);
}
this.options = options;
}
/**

View File

@ -23,6 +23,10 @@ export function hasError(data: Record<string, unknown>): data is ErrorBase {
return 'error' in data;
}
export function hasMore(data: APIAnswer<APIObjectBase>): data is APIAnswerWithNext<APIObjectBase> {
return typeof data.next === 'string';
}
/**
* Create a slug from any input string.
*
@ -350,3 +354,28 @@ export function createElement<
export function cToF(celsius: number): number {
return celsius * (9 / 5) + 32;
}
/**
* 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());
}