mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
update select to handle display property
This commit is contained in:
11
netbox/project-static/src/global.d.ts
vendored
11
netbox/project-static/src/global.d.ts
vendored
@ -1,3 +1,7 @@
|
||||
type Primitives = string | number | boolean | undefined | null;
|
||||
|
||||
type JSONAble = Primitives | Primitives[] | { [k: string]: JSONAble } | JSONAble[];
|
||||
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
type APIAnswer<T> = {
|
||||
@ -16,18 +20,19 @@ type APIError = {
|
||||
|
||||
type APIObjectBase = {
|
||||
id: number;
|
||||
display?: string;
|
||||
name: string;
|
||||
url: string;
|
||||
[k: string]: unknown;
|
||||
[k: string]: JSONAble;
|
||||
};
|
||||
|
||||
interface APIReference {
|
||||
type APIReference = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
_depth: number;
|
||||
}
|
||||
};
|
||||
|
||||
interface ObjectWithGroup extends APIObjectBase {
|
||||
group: Nullable<APIReference>;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import SlimSelect from 'slim-select';
|
||||
import queryString from 'query-string';
|
||||
import { getApiData, isApiError, getElements } from '../util';
|
||||
import { getApiData, isApiError, getElements, isTruthy } from '../util';
|
||||
import { createToast } from '../toast';
|
||||
import { setOptionStyles, getFilteredBy, toggle } from './util';
|
||||
|
||||
@ -14,6 +14,8 @@ type WithExclude = {
|
||||
queryParamExclude: string;
|
||||
};
|
||||
|
||||
type ReplaceTuple = [RegExp, string];
|
||||
|
||||
interface CustomSelect<T extends Record<string, string>> extends HTMLSelectElement {
|
||||
dataset: T;
|
||||
}
|
||||
@ -30,6 +32,15 @@ function hasExclusions(el: HTMLSelectElement): el is CustomSelect<WithExclude> {
|
||||
|
||||
const DISABLED_ATTRIBUTES = ['occupied'] as string[];
|
||||
|
||||
// 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'],
|
||||
// For example, a tenant's group relationship field is `group`, but the field name
|
||||
// is `tenant_group`.
|
||||
[new RegExp(/.+_(group)/g), '$1_id'],
|
||||
] as ReplaceTuple[];
|
||||
|
||||
const PLACEHOLDER = {
|
||||
value: '',
|
||||
text: '',
|
||||
@ -43,15 +54,22 @@ const PLACEHOLDER = {
|
||||
*
|
||||
* @returns Data parsed into SlimSelect options.
|
||||
*/
|
||||
async function getChoices(
|
||||
async function getOptions(
|
||||
url: string,
|
||||
displayField: string,
|
||||
selectOptions: string[],
|
||||
select: HTMLSelectElement,
|
||||
disabledOptions: string[],
|
||||
): Promise<Option[]> {
|
||||
if (url.includes(`{{`)) {
|
||||
return [PLACEHOLDER];
|
||||
}
|
||||
|
||||
// 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`.
|
||||
const selectOptions = Array.from(select.options)
|
||||
.filter(option => option.value !== '')
|
||||
.map(option => option.value);
|
||||
|
||||
return getApiData(url).then(data => {
|
||||
if (isApiError(data)) {
|
||||
const toast = createToast('danger', data.exception, data.error);
|
||||
@ -62,78 +80,110 @@ async function getChoices(
|
||||
const { results } = data;
|
||||
const options = [PLACEHOLDER] as Option[];
|
||||
|
||||
if (results.length !== 0) {
|
||||
for (const result of results) {
|
||||
const data = {} as Record<string, string>;
|
||||
const value = result.id.toString();
|
||||
for (const result of results) {
|
||||
const text = getDisplayName(result, select);
|
||||
const data = {} as Record<string, string>;
|
||||
const value = result.id.toString();
|
||||
|
||||
// Set any primitive k/v pairs as data attributes on each option.
|
||||
for (const [k, v] of Object.entries(result)) {
|
||||
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
||||
const key = k.replaceAll('_', '-');
|
||||
data[key] = String(v);
|
||||
}
|
||||
// Set any primitive k/v pairs as data attributes on each option.
|
||||
for (const [k, v] of Object.entries(result)) {
|
||||
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
||||
const key = k.replaceAll('_', '-');
|
||||
data[key] = String(v);
|
||||
}
|
||||
|
||||
let style, selected, disabled;
|
||||
|
||||
// Set pre-selected options.
|
||||
if (selectOptions.includes(value)) {
|
||||
selected = true;
|
||||
}
|
||||
|
||||
// Set option to disabled if it is contained within the disabled array.
|
||||
if (selectOptions.some(option => disabledOptions.includes(option))) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
// Set option to disabled if the result contains a matching key and is truthy.
|
||||
if (DISABLED_ATTRIBUTES.some(key => Object.keys(result).includes(key) && result[key])) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
const choice = {
|
||||
value,
|
||||
text: result[displayField],
|
||||
data,
|
||||
style,
|
||||
selected,
|
||||
disabled,
|
||||
} as Option;
|
||||
|
||||
options.push(choice);
|
||||
}
|
||||
|
||||
let style, selected, disabled;
|
||||
|
||||
// Set pre-selected options.
|
||||
if (selectOptions.includes(value)) {
|
||||
selected = true;
|
||||
}
|
||||
|
||||
// Set option to disabled if it is contained within the disabled array.
|
||||
if (selectOptions.some(option => disabledOptions.includes(option))) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
// Set option to disabled if the result contains a matching key and is truthy.
|
||||
if (DISABLED_ATTRIBUTES.some(key => Object.keys(result).includes(key) && result[key])) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
const option = {
|
||||
value,
|
||||
text,
|
||||
data,
|
||||
style,
|
||||
selected,
|
||||
disabled,
|
||||
} as Option;
|
||||
|
||||
options.push(option);
|
||||
}
|
||||
return options;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the select element's placeholder text/label.
|
||||
*/
|
||||
function getPlaceholder(select: HTMLSelectElement): string {
|
||||
let placeholder = select.name;
|
||||
if (select.id) {
|
||||
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
||||
|
||||
// Set the placeholder text to the label value, if it exists.
|
||||
if (label !== null) {
|
||||
placeholder = `Select ${label.innerText.trim()}`;
|
||||
}
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find this field's display name.
|
||||
* @param select
|
||||
* @returns
|
||||
*/
|
||||
function getDisplayName(result: APIObjectBase, select: HTMLSelectElement): string {
|
||||
let displayName = result.display;
|
||||
|
||||
const legacyDisplayProperty = select.getAttribute('display-field');
|
||||
|
||||
if (
|
||||
typeof displayName === 'undefined' &&
|
||||
legacyDisplayProperty !== null &&
|
||||
legacyDisplayProperty in result
|
||||
) {
|
||||
displayName = result[legacyDisplayProperty] as string;
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
displayName = result.name;
|
||||
}
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize select elements that rely on the NetBox API to build their options.
|
||||
*/
|
||||
export function initApiSelect() {
|
||||
for (const select of getElements<HTMLSelectElement>('.netbox-select2-api')) {
|
||||
// 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`.
|
||||
const selectOptions = Array.from(select.options)
|
||||
.filter(option => option.value !== '')
|
||||
.map(option => option.value);
|
||||
|
||||
const filteredBy = getFilteredBy(select);
|
||||
const filterMap = new Map<string, string>();
|
||||
const event = new Event(`netbox.select.load.${select.name}`);
|
||||
const filterMap = getFilteredBy(select);
|
||||
// Initialize an event, so other elements relying on this element can subscribe to this
|
||||
// element's value.
|
||||
const event = new Event(`netbox.select.onload.${select.name}`);
|
||||
// Query Parameters - will have attributes added below.
|
||||
const query = {} as Record<string, string>;
|
||||
// List of OTHER elements THIS element relies on for query filtering.
|
||||
const groupBy = [] as HTMLSelectElement[];
|
||||
|
||||
if (isCustomSelect(select)) {
|
||||
let { url } = select.dataset;
|
||||
const displayField = select.getAttribute('display-field') ?? 'name';
|
||||
|
||||
let placeholder: string = select.name;
|
||||
if (select.id) {
|
||||
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
|
||||
|
||||
// Set the placeholder text to the label value, if it exists.
|
||||
if (label !== null) {
|
||||
placeholder = `Select ${label.innerText.trim()}`;
|
||||
}
|
||||
}
|
||||
const placeholder = getPlaceholder(select);
|
||||
|
||||
let disabledOptions = [] as string[];
|
||||
if (hasExclusions(select)) {
|
||||
@ -152,6 +202,20 @@ export function initApiSelect() {
|
||||
allowDeselect: true,
|
||||
deselectLabel: `<i class="bi bi-x-circle" style="color:currentColor;"></i>`,
|
||||
placeholder,
|
||||
onChange() {
|
||||
const element = instance.slim.container ?? null;
|
||||
if (element !== null) {
|
||||
// Reset validity classes if the field was invalid.
|
||||
if (
|
||||
element.classList.contains('is-invalid') ||
|
||||
select.classList.contains('is-invalid')
|
||||
) {
|
||||
select.classList.remove('is-invalid');
|
||||
element.classList.remove('is-invalid');
|
||||
}
|
||||
}
|
||||
select.dispatchEvent(event);
|
||||
},
|
||||
});
|
||||
|
||||
// Disable the element while data has not been loaded.
|
||||
@ -162,29 +226,115 @@ export function initApiSelect() {
|
||||
instance.slim.container.classList.remove(className);
|
||||
}
|
||||
|
||||
for (let [key, value] of filterMap) {
|
||||
if (value === '') {
|
||||
// An empty value is set if the key contains a `$`, indicating reliance on another field.
|
||||
const elem = document.querySelector(`[name=${key}]`) as HTMLSelectElement;
|
||||
if (elem !== null) {
|
||||
groupBy.push(elem);
|
||||
if (elem.value !== '') {
|
||||
// If the element's form value exists, add it to the map.
|
||||
value = elem.value;
|
||||
filterMap.set(key, elem.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-empty value indicates a static query parameter.
|
||||
for (const [pattern, replacement] of REPLACE_PATTERNS) {
|
||||
// Check the query param key to see if we should modify it.
|
||||
if (key.match(pattern)) {
|
||||
key = key.replaceAll(pattern, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes(`{{`) && value !== '') {
|
||||
// If the URL contains a Django/Jinja template variable, we need to replace the
|
||||
// tag with the event's value.
|
||||
url = url.replaceAll(new RegExp(`{{${key}}}`, 'g'), value);
|
||||
select.setAttribute('data-url', url);
|
||||
}
|
||||
|
||||
// Add post-replaced key/value pairs to the query object.
|
||||
if (isTruthy(value)) {
|
||||
query[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
url = queryString.stringifyUrl({ url, query });
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
|
||||
if (isTruthy(target.value)) {
|
||||
let name = target.name;
|
||||
|
||||
for (const [pattern, replacement] of REPLACE_PATTERNS) {
|
||||
// Check the query param key to see if we should modify it.
|
||||
if (name.match(pattern)) {
|
||||
name = name.replaceAll(pattern, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes(`{{`) && target.name && target.value) {
|
||||
// If the URL (still) contains a Django/Jinja template variable, we need to replace
|
||||
// the tag with the event's value.
|
||||
url = url.replaceAll(new RegExp(`{{${target.name}}}`, 'g'), target.value);
|
||||
select.setAttribute('data-url', url);
|
||||
}
|
||||
|
||||
if (filterMap.get(target.name) === '') {
|
||||
// Update empty filter map values now that there is a value.
|
||||
filterMap.set(target.name, target.value);
|
||||
}
|
||||
// Add post-replaced key/value pairs to the query object.
|
||||
query[name] = target.value;
|
||||
// Create a URL with all relevant query parameters.
|
||||
url = queryString.stringifyUrl({ url, query });
|
||||
}
|
||||
|
||||
// Disable the element while data is loading.
|
||||
toggle('disable', instance);
|
||||
// Load new data.
|
||||
getOptions(url, select, disabledOptions)
|
||||
.then(data => instance.setData(data))
|
||||
.finally(() => {
|
||||
// Re-enable the element after data has loaded.
|
||||
toggle('enable', instance);
|
||||
// Inform any event listeners that data has updated.
|
||||
select.dispatchEvent(event);
|
||||
});
|
||||
// Stop event bubbling.
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
for (const group of groupBy) {
|
||||
// Re-fetch data when the group changes.
|
||||
group.addEventListener('change', handleEvent);
|
||||
|
||||
// Subscribe this instance (the child that relies on `group`) to any changes of the
|
||||
// group's value, so we can re-render options.
|
||||
select.addEventListener(`netbox.select.onload.${group.name}`, handleEvent);
|
||||
}
|
||||
|
||||
// Load data.
|
||||
getChoices(url, displayField, selectOptions, disabledOptions)
|
||||
getOptions(url, select, disabledOptions)
|
||||
.then(options => instance.setData(options))
|
||||
.finally(() => {
|
||||
// Set option styles, if the field calls for it (color selectors).
|
||||
setOptionStyles(instance);
|
||||
// Inform any event listeners that data has updated.
|
||||
select.dispatchEvent(event);
|
||||
// Enable the element after data has loaded.
|
||||
toggle('enable', instance);
|
||||
// Inform any event listeners that data has updated.
|
||||
select.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Reset validity classes if the field was invalid.
|
||||
instance.onChange = () => {
|
||||
const element = instance.slim.container ?? null;
|
||||
if (element !== null) {
|
||||
if (element.classList.contains('is-invalid') || select.classList.contains('is-invalid')) {
|
||||
select.classList.remove('is-invalid');
|
||||
element.classList.remove('is-invalid');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set the underlying select element to the same size as the SlimSelect instance.
|
||||
// This is primarily for built-in HTML form validation, which doesn't really work,
|
||||
// but it also makes things seem cleaner in the DOM.
|
||||
@ -195,80 +345,6 @@ export function initApiSelect() {
|
||||
select.style.display = 'block';
|
||||
select.style.position = 'absolute';
|
||||
select.style.pointerEvents = 'none';
|
||||
|
||||
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) {
|
||||
if (groupElem.value) {
|
||||
// Add the group's value to the filtered-by map.
|
||||
filterMap.set(filter, groupElem.value);
|
||||
// If the URL contains a Django/Jinja template variable tag, we need to replace the tag
|
||||
// with the event's value.
|
||||
if (url.includes(`{{`)) {
|
||||
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), groupElem.value);
|
||||
select.setAttribute('data-url', url);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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 = url;
|
||||
|
||||
const target = event.target as HTMLSelectElement;
|
||||
|
||||
if (target.value) {
|
||||
let filterValue = target.name;
|
||||
|
||||
if (typeof filterValue === 'undefined') {
|
||||
filterMap.set(target.name, target.value);
|
||||
}
|
||||
|
||||
if (url.includes(`{{`) && typeof filterValue !== 'undefined') {
|
||||
// If the URL contains a Django/Jinja template variable tag, we need to replace
|
||||
// the tag with the event's value.
|
||||
url = url.replaceAll(new RegExp(`{{${filter}}}`, 'g'), filterValue);
|
||||
select.setAttribute('data-url', url);
|
||||
}
|
||||
|
||||
let queryKey = filterValue;
|
||||
if (filter?.includes('_group')) {
|
||||
// For example, a tenant's group relationship field is `group`, but the field
|
||||
// name is `tenant_group`.
|
||||
queryKey = 'group';
|
||||
}
|
||||
|
||||
if (typeof queryKey !== 'undefined') {
|
||||
filterUrl = queryString.stringifyUrl({
|
||||
url,
|
||||
query: { [`${queryKey}_id`]: groupElem.value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Disable the element while data is loading.
|
||||
toggle('disable', instance);
|
||||
// Load new data.
|
||||
getChoices(filterUrl, displayField, selectOptions, disabledOptions)
|
||||
.then(data => instance.setData(data))
|
||||
.finally(() => {
|
||||
// Re-enable the element after data has loaded.
|
||||
toggle('enable', instance);
|
||||
});
|
||||
}
|
||||
// Re-fetch data when the group changes.
|
||||
groupElem.addEventListener('change', handleEvent);
|
||||
|
||||
// Subscribe this instance (the child that relies on groupElem) to any changes of the
|
||||
// group's value, so we can re-render options.
|
||||
select.addEventListener(`netbox.select.onload.${groupElem.name}`, handleEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -92,38 +92,66 @@ div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
|
||||
* 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.
|
||||
* @returns Map of attributes to values. An empty value indicates a dynamic property that will
|
||||
* be updated later.
|
||||
*/
|
||||
export function getFilteredBy<T extends HTMLElement>(element: T): string[] {
|
||||
export function getFilteredBy<T extends HTMLElement>(element: T): Map<string, string> {
|
||||
const pattern = new RegExp(/\[|\]|"|\$/g);
|
||||
const keys = Object.keys(element.dataset);
|
||||
const filteredBy = [] as string[];
|
||||
const keyPattern = new RegExp(/data-query-param-/g);
|
||||
|
||||
// Extract data attributes.
|
||||
const keys = Object.values(element.attributes)
|
||||
.map(v => v.name)
|
||||
.filter(v => v.includes('data'));
|
||||
|
||||
const filterMap = new Map<string, string>();
|
||||
|
||||
// Process the URL attribute in a separate loop so that it comes first.
|
||||
for (const key of keys) {
|
||||
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(/\{\{(.+)\}\}/);
|
||||
const url = element.getAttribute('data-url');
|
||||
if (key === 'data-url' && url !== null && 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 = url.match(/\{\{(.+)\}\}/);
|
||||
if (value !== null) {
|
||||
filteredBy.push(value[1]);
|
||||
filterMap.set(value[1], '');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.includes('queryParam') && key !== 'queryParamExclude') {
|
||||
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.match(keyPattern) && key !== 'data-query-param-exclude') {
|
||||
const value = element.getAttribute(key);
|
||||
if (value !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as string | string[];
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
if (item.match(/^\$.+$/g)) {
|
||||
const replaced = item.replaceAll(pattern, '');
|
||||
filterMap.set(replaced, '');
|
||||
} else {
|
||||
filterMap.set(key.replaceAll(keyPattern, ''), item);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (parsed.match(/^\$.+$/g)) {
|
||||
const replaced = parsed.replaceAll(pattern, '');
|
||||
filterMap.set(replaced, '');
|
||||
} else {
|
||||
filterMap.set(key.replaceAll(keyPattern, ''), parsed);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
if (value.match(/^\$.+$/g)) {
|
||||
const replaced = value.replaceAll(pattern, '');
|
||||
filterMap.set(replaced, '');
|
||||
} else {
|
||||
filterMap.set(key.replaceAll(keyPattern, ''), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return filteredBy;
|
||||
return filterMap;
|
||||
}
|
||||
|
@ -4,6 +4,23 @@ export function isApiError(data: Record<string, unknown>): data is APIError {
|
||||
return 'error' in data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const badStrings = ['', 'null', 'undefined'];
|
||||
if (typeof value === 'string' && !badStrings.includes(value)) {
|
||||
return true;
|
||||
} else if (typeof value === 'number') {
|
||||
return true;
|
||||
} else if (typeof value === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the CSRF token from cookie storage.
|
||||
*/
|
||||
|
Reference in New Issue
Block a user