mirror of
https://github.com/EGroupware/egroupware.git
synced 2025-01-18 11:58:24 +01:00
Get some more automatic tests working again, remove JS files
This commit is contained in:
parent
e2d3c5f1e8
commit
0f66624047
@ -489,7 +489,9 @@ const Et2InputWidgetMixin = <T extends Constructor<LitElement>>(superclass : T)
|
|||||||
|
|
||||||
// Set attributes for the form / autofill. It's the individual widget's
|
// Set attributes for the form / autofill. It's the individual widget's
|
||||||
// responsibility to do something appropriate with these properties.
|
// responsibility to do something appropriate with these properties.
|
||||||
if(this.autocomplete == "on" && window.customElements.get(this.localName).getPropertyOptions("name") != "undefined")
|
if(this.autocomplete == "on" && window.customElements.get(this.localName).getPropertyOptions("name") != "undefined" &&
|
||||||
|
this.getArrayMgr("content") !== null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
this.name = this.getArrayMgr("content").explodeKey(this.id).pop();
|
this.name = this.getArrayMgr("content").explodeKey(this.id).pop();
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -945,7 +945,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
|
|||||||
else if(event.key == "Escape")
|
else if(event.key == "Escape")
|
||||||
{
|
{
|
||||||
this._handleSearchAbort(event);
|
this._handleSearchAbort(event);
|
||||||
this.dropdown.hide();
|
this.hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,285 +0,0 @@
|
|||||||
/**
|
|
||||||
* Some static options, no need to transfer them over and over.
|
|
||||||
* We still need the same thing on the server side to validate, so they
|
|
||||||
* have to match. See Etemplate\Widget\Select::typeOptions()
|
|
||||||
* The type specific legacy options wind up in attrs.other, but should be explicitly
|
|
||||||
* defined and set.
|
|
||||||
*
|
|
||||||
* @param {type} widget
|
|
||||||
*/
|
|
||||||
import { sprintf } from "../../egw_action/egw_action_common";
|
|
||||||
import { cleanSelectOptions, find_select_options } from "./FindSelectOptions";
|
|
||||||
/**
|
|
||||||
* Base class for things that have static options
|
|
||||||
*
|
|
||||||
* We keep static options separate and concatenate them in to allow for extra options without
|
|
||||||
* overwriting them when we get static options from the server
|
|
||||||
*/
|
|
||||||
export const Et2StaticSelectMixin = (superclass) => {
|
|
||||||
class Et2StaticSelectOptions extends (superclass) {
|
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
this.static_options = [];
|
|
||||||
this.fetchComplete = Promise.resolve();
|
|
||||||
// Trigger the options to get rendered into the DOM
|
|
||||||
this.requestUpdate("select_options");
|
|
||||||
}
|
|
||||||
get select_options() {
|
|
||||||
// @ts-ignore
|
|
||||||
const options = super.select_options || [];
|
|
||||||
// make sure result is unique
|
|
||||||
return [...new Map([...(this.static_options || []), ...options].map(item => [item.value, item])).values()];
|
|
||||||
}
|
|
||||||
set select_options(new_options) {
|
|
||||||
// @ts-ignore IDE doesn't recognise property
|
|
||||||
super.select_options = new_options;
|
|
||||||
}
|
|
||||||
set_static_options(new_static_options) {
|
|
||||||
this.static_options = new_static_options;
|
|
||||||
this.requestUpdate("select_options");
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Override the parent fix_bad_value() to wait for server-side options
|
|
||||||
* to come back before we check to see if the value is not there.
|
|
||||||
*/
|
|
||||||
fix_bad_value() {
|
|
||||||
this.fetchComplete.then(() => {
|
|
||||||
// @ts-ignore Doesn't know it's an Et2Select
|
|
||||||
if (typeof super.fix_bad_value == "function") {
|
|
||||||
// @ts-ignore Doesn't know it's an Et2Select
|
|
||||||
super.fix_bad_value();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Et2StaticSelectOptions;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Some options change, or are too complicated to have twice, so we get the
|
|
||||||
* options from the server once, then keep them to use if they're needed again.
|
|
||||||
* We use the options string to keep the different possibilities (eg. categories
|
|
||||||
* for different apps) separate.
|
|
||||||
*
|
|
||||||
* @param {et2_selectbox} widget Selectbox we're looking at
|
|
||||||
* @param {string} options_string
|
|
||||||
* @param {Object} attrs Widget attributes (not yet fully set)
|
|
||||||
* @param {boolean} return_promise true: always return a promise
|
|
||||||
* @returns {Object[]|Promise<Object[]>} Array of options, or empty and they'll get filled in later, or Promise
|
|
||||||
*/
|
|
||||||
export const StaticOptions = new class StaticOptionsType {
|
|
||||||
cached_server_side(widget, type, options_string, return_promise) {
|
|
||||||
// normalize options by removing trailing commas
|
|
||||||
options_string = options_string.replace(/,+$/, '');
|
|
||||||
const cache_id = widget.nodeName + '_' + options_string;
|
|
||||||
const cache_owner = widget.egw().getCache('Et2Select');
|
|
||||||
let cache = cache_owner[cache_id];
|
|
||||||
if (typeof cache === 'undefined') {
|
|
||||||
// Fetch with json instead of jsonq because there may be more than
|
|
||||||
// one widget listening for the response by the time it gets back,
|
|
||||||
// and we can't do that when it's queued.
|
|
||||||
const req = widget.egw().json('EGroupware\\Api\\Etemplate\\Widget\\Select::ajax_get_options', [type, options_string, widget.value]).sendRequest();
|
|
||||||
if (typeof cache === 'undefined') {
|
|
||||||
cache_owner[cache_id] = req;
|
|
||||||
}
|
|
||||||
cache = req;
|
|
||||||
}
|
|
||||||
if (typeof cache.then === 'function') {
|
|
||||||
// pending, wait for it
|
|
||||||
const promise = cache.then((response) => {
|
|
||||||
cache = cache_owner[cache_id] = response.response[0].data || undefined;
|
|
||||||
if (return_promise)
|
|
||||||
return cache;
|
|
||||||
// Set select_options in attributes in case we get a response before
|
|
||||||
// the widget is finished loading (otherwise it will re-set to {})
|
|
||||||
//widget.select_options = cache;
|
|
||||||
// Avoid errors if widget is destroyed before the timeout
|
|
||||||
if (widget && typeof widget.id !== 'undefined') {
|
|
||||||
if (typeof widget.set_static_options == "function") {
|
|
||||||
widget.set_static_options(cache);
|
|
||||||
}
|
|
||||||
else if (typeof widget.set_select_options == "function") {
|
|
||||||
widget.set_select_options(find_select_options(widget, {}, cache));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return return_promise ? promise : [];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Check that the value is in there
|
|
||||||
// Make sure we are not requesting server for an empty value option or
|
|
||||||
// other widgets but select-timezone as server won't find anything and
|
|
||||||
// it will fall into an infinitive loop, e.g. select-cat widget.
|
|
||||||
if (widget.value && widget.value != "" && widget.value != "0" && type == "select-timezone") {
|
|
||||||
var missing_option = true;
|
|
||||||
for (var i = 0; i < cache.length && missing_option; i++) {
|
|
||||||
if (cache[i].value == widget.value) {
|
|
||||||
missing_option = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try again - ask the server with the current value this time
|
|
||||||
if (missing_option) {
|
|
||||||
delete cache_owner[cache_id];
|
|
||||||
return this.cached_server_side(widget, type, options_string);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (widget.value && widget && widget.get_value() !== widget.value) {
|
|
||||||
egw.window.setTimeout(function () {
|
|
||||||
// Avoid errors if widget is destroyed before the timeout
|
|
||||||
if (this.widget && typeof this.widget.id !== 'undefined') {
|
|
||||||
this.widget.set_value(this.widget.options.value);
|
|
||||||
}
|
|
||||||
}.bind({ widget: widget }), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return return_promise ? Promise.resolve(cache) : cache;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cached_from_file(widget, file) {
|
|
||||||
const cache_owner = widget.egw().getCache('Et2Select');
|
|
||||||
let cache = cache_owner[file];
|
|
||||||
if (typeof cache === 'undefined') {
|
|
||||||
cache_owner[file] = cache = widget.egw().window.fetch(file)
|
|
||||||
.then((response) => {
|
|
||||||
// Get the options
|
|
||||||
if (!response.ok) {
|
|
||||||
throw response;
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(options => {
|
|
||||||
var _a;
|
|
||||||
// Need to clean the options because file may be key=>value, may have option list, may be mixed
|
|
||||||
cache_owner[file] = (_a = cleanSelectOptions(options)) !== null && _a !== void 0 ? _a : [];
|
|
||||||
return cache_owner[file];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else if (cache && typeof cache.then === "undefined") {
|
|
||||||
return Promise.resolve(cache);
|
|
||||||
}
|
|
||||||
return cache;
|
|
||||||
}
|
|
||||||
priority(widget) {
|
|
||||||
return [
|
|
||||||
{ value: "1", label: 'low' },
|
|
||||||
{ value: "2", label: 'normal' },
|
|
||||||
{ value: "3", label: 'high' },
|
|
||||||
{ value: "0", label: 'undefined' }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
bool(widget) {
|
|
||||||
return [
|
|
||||||
{ value: "0", label: 'no' },
|
|
||||||
{ value: "1", label: 'yes' }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
month(widget) {
|
|
||||||
return [
|
|
||||||
{ value: "1", label: 'January' },
|
|
||||||
{ value: "2", label: 'February' },
|
|
||||||
{ value: "3", label: 'March' },
|
|
||||||
{ value: "4", label: 'April' },
|
|
||||||
{ value: "5", label: 'May' },
|
|
||||||
{ value: "6", label: 'June' },
|
|
||||||
{ value: "7", label: 'July' },
|
|
||||||
{ value: "8", label: 'August' },
|
|
||||||
{ value: "9", label: 'September' },
|
|
||||||
{ value: "10", label: 'October' },
|
|
||||||
{ value: "11", label: 'November' },
|
|
||||||
{ value: "12", label: 'December' }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
number(widget, attrs = {
|
|
||||||
min: undefined,
|
|
||||||
max: undefined,
|
|
||||||
interval: undefined,
|
|
||||||
format: undefined
|
|
||||||
}) {
|
|
||||||
var _a, _b, _c, _d;
|
|
||||||
var options = [];
|
|
||||||
var min = (_a = attrs.min) !== null && _a !== void 0 ? _a : parseFloat(widget.min);
|
|
||||||
var max = (_b = attrs.max) !== null && _b !== void 0 ? _b : parseFloat(widget.max);
|
|
||||||
var interval = (_c = attrs.interval) !== null && _c !== void 0 ? _c : parseFloat(widget.interval);
|
|
||||||
var format = (_d = attrs.format) !== null && _d !== void 0 ? _d : '%d';
|
|
||||||
// leading zero specified in interval
|
|
||||||
if (widget.leading_zero && widget.leading_zero[0] == '0') {
|
|
||||||
format = '%0' + ('' + interval).length + 'd';
|
|
||||||
}
|
|
||||||
// Suffix
|
|
||||||
if (widget.suffix) {
|
|
||||||
format += widget.egw().lang(widget.suffix);
|
|
||||||
}
|
|
||||||
// Avoid infinite loop if interval is the wrong direction
|
|
||||||
if ((min <= max) != (interval > 0)) {
|
|
||||||
interval = -interval;
|
|
||||||
}
|
|
||||||
for (var i = 0, n = min; n <= max && i <= 100; n += interval, ++i) {
|
|
||||||
options.push({ value: "" + n, label: sprintf(format, n) });
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
percent(widget) {
|
|
||||||
return this.number(widget);
|
|
||||||
}
|
|
||||||
year(widget, attrs) {
|
|
||||||
if (typeof attrs != 'object') {
|
|
||||||
attrs = {};
|
|
||||||
}
|
|
||||||
var t = new Date();
|
|
||||||
attrs.min = t.getFullYear() + parseInt(widget.min);
|
|
||||||
attrs.max = t.getFullYear() + parseInt(widget.max);
|
|
||||||
return this.number(widget, attrs);
|
|
||||||
}
|
|
||||||
day(widget, attrs) {
|
|
||||||
attrs.other = [1, 31, 1];
|
|
||||||
return this.number(widget, attrs);
|
|
||||||
}
|
|
||||||
hour(widget, attrs) {
|
|
||||||
var options = [];
|
|
||||||
var timeformat = widget.egw().preference('common', 'timeformat');
|
|
||||||
for (var h = 0; h <= 23; ++h) {
|
|
||||||
options.push({
|
|
||||||
value: h,
|
|
||||||
label: timeformat == 12 ?
|
|
||||||
((12 ? h % 12 : 12) + ' ' + (h < 12 ? egw.lang('am') : egw.lang('pm'))) :
|
|
||||||
sprintf('%02d', h)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
app(widget, attrs) {
|
|
||||||
var options = ',' + (attrs.other || []).join(',');
|
|
||||||
return this.cached_server_side(widget, 'select-app', options);
|
|
||||||
}
|
|
||||||
cat(widget) {
|
|
||||||
var options = [widget.globalCategories, /*?*/ , widget.application, widget.parentCat];
|
|
||||||
if (typeof options[3] == 'undefined') {
|
|
||||||
options[3] = widget.application ||
|
|
||||||
// When the widget is first created, it doesn't have a parent and can't find it's instanceManager
|
|
||||||
(widget.getInstanceManager() && widget.getInstanceManager().app) ||
|
|
||||||
widget.egw().app_name();
|
|
||||||
}
|
|
||||||
return this.cached_server_side(widget, 'select-cat', options.join(','), true);
|
|
||||||
}
|
|
||||||
country(widget, attrs, return_promise) {
|
|
||||||
var options = ',';
|
|
||||||
return this.cached_server_side(widget, 'select-country', options, return_promise);
|
|
||||||
}
|
|
||||||
state(widget, attrs) {
|
|
||||||
var options = attrs.country_code ? attrs.country_code : 'de';
|
|
||||||
return this.cached_server_side(widget, 'select-state', options);
|
|
||||||
}
|
|
||||||
dow(widget, attrs) {
|
|
||||||
var options = (widget.rows || "") + ',' + (attrs.other || []).join(',');
|
|
||||||
return this.cached_server_side(widget, 'select-dow', options, true);
|
|
||||||
}
|
|
||||||
lang(widget, attrs) {
|
|
||||||
var options = ',' + (attrs.other || []).join(',');
|
|
||||||
return this.cached_server_side(widget, 'select-lang', options);
|
|
||||||
}
|
|
||||||
timezone(widget, attrs) {
|
|
||||||
var options = ',' + (attrs.other || []).join(',');
|
|
||||||
return this.cached_server_side(widget, 'select-timezone', options);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
//# sourceMappingURL=StaticOptions.js.map
|
|
@ -65,7 +65,7 @@ describe("Select widget", () =>
|
|||||||
await element.updateComplete;
|
await element.updateComplete;
|
||||||
|
|
||||||
/** TESTING **/
|
/** TESTING **/
|
||||||
assert.isNotNull(element.querySelector("[value='option']"), "Missing static option");
|
assert.isNotNull(element.select.querySelector("[value='option']"), "Missing static option");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("directly in sel_options", async() =>
|
it("directly in sel_options", async() =>
|
||||||
@ -85,7 +85,7 @@ describe("Select widget", () =>
|
|||||||
await element.updateComplete;
|
await element.updateComplete;
|
||||||
|
|
||||||
/** TESTING **/
|
/** TESTING **/
|
||||||
assert.equal(element.querySelectorAll("sl-option").length, 2);
|
assert.equal(element.select.querySelectorAll("sl-option").length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges static options with sel_options", async() =>
|
it("merges static options with sel_options", async() =>
|
||||||
@ -106,9 +106,7 @@ describe("Select widget", () =>
|
|||||||
await element.updateComplete;
|
await element.updateComplete;
|
||||||
|
|
||||||
/** TESTING **/
|
/** TESTING **/
|
||||||
|
let option_keys = Object.values(element.select.querySelectorAll("sl-option")).map(o => o.value);
|
||||||
// @ts-ignore o.value isn't known by TypeScript, but it's there
|
|
||||||
let option_keys = Object.values(element.querySelectorAll("sl-option")).map(o => o.value);
|
|
||||||
assert.include(option_keys, "option", "Static option missing");
|
assert.include(option_keys, "option", "Static option missing");
|
||||||
assert.includeMembers(option_keys, ["1", "2", "option"], "Option mis-match");
|
assert.includeMembers(option_keys, ["1", "2", "option"], "Option mis-match");
|
||||||
assert.equal(option_keys.length, 3);
|
assert.equal(option_keys.length, 3);
|
||||||
|
@ -9,7 +9,7 @@ import {Et2Box} from "../../Layout/Et2Box/Et2Box";
|
|||||||
import {Et2Select} from "../Et2Select";
|
import {Et2Select} from "../Et2Select";
|
||||||
import {Et2Textbox} from "../../Et2Textbox/Et2Textbox";
|
import {Et2Textbox} from "../../Et2Textbox/Et2Textbox";
|
||||||
|
|
||||||
let keep_import : Et2Textbox = new Et2Textbox();
|
let keep_import : Et2Textbox = null;
|
||||||
|
|
||||||
// Stub global egw for cssImage to find
|
// Stub global egw for cssImage to find
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -65,6 +65,7 @@ describe("Search actions", () =>
|
|||||||
'</et2-select>';
|
'</et2-select>';
|
||||||
|
|
||||||
container.loadFromXML(parser.parseFromString(node, "text/xml"));
|
container.loadFromXML(parser.parseFromString(node, "text/xml"));
|
||||||
|
await elementUpdated(container);
|
||||||
|
|
||||||
const change = sinon.spy();
|
const change = sinon.spy();
|
||||||
let element = <Et2Select>container.getWidgetById('select');
|
let element = <Et2Select>container.getWidgetById('select');
|
||||||
@ -72,7 +73,7 @@ describe("Search actions", () =>
|
|||||||
|
|
||||||
await elementUpdated(element);
|
await elementUpdated(element);
|
||||||
|
|
||||||
element.value = "two";
|
element.select.querySelector("[value='two']").dispatchEvent(new Event("click"));
|
||||||
|
|
||||||
await elementUpdated(element);
|
await elementUpdated(element);
|
||||||
|
|
||||||
@ -96,7 +97,7 @@ describe("Trigger search", () =>
|
|||||||
// Create an element to test with, and wait until it's ready
|
// Create an element to test with, and wait until it's ready
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
element = await fixture<Et2Select>(html`
|
element = await fixture<Et2Select>(html`
|
||||||
<et2-select label="I'm a select" search=true>
|
<et2-select label="I'm a select" search>
|
||||||
<sl-option value="one">One</sl-option>
|
<sl-option value="one">One</sl-option>
|
||||||
<sl-option value="two">Two</sl-option>
|
<sl-option value="two">Two</sl-option>
|
||||||
<sl-option value="three">Three</sl-option>
|
<sl-option value="three">Three</sl-option>
|
||||||
@ -111,6 +112,7 @@ describe("Trigger search", () =>
|
|||||||
|
|
||||||
await element.updateComplete;
|
await element.updateComplete;
|
||||||
await element._searchInputNode.updateComplete;
|
await element._searchInputNode.updateComplete;
|
||||||
|
await elementUpdated(element);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() =>
|
afterEach(() =>
|
||||||
@ -125,11 +127,11 @@ describe("Trigger search", () =>
|
|||||||
let searchSpy = sinon.spy(element, "startSearch");
|
let searchSpy = sinon.spy(element, "startSearch");
|
||||||
|
|
||||||
// Send two keypresses, but we need to explicitly set the value
|
// Send two keypresses, but we need to explicitly set the value
|
||||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
|
||||||
element._searchInputNode.value = "o";
|
element._searchInputNode.value = "o";
|
||||||
|
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
||||||
assert(searchSpy.notCalled);
|
assert(searchSpy.notCalled);
|
||||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "n"}));
|
|
||||||
element._searchInputNode.value = "on";
|
element._searchInputNode.value = "on";
|
||||||
|
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "n"}));
|
||||||
assert(searchSpy.notCalled);
|
assert(searchSpy.notCalled);
|
||||||
|
|
||||||
// Skip the timeout
|
// Skip the timeout
|
||||||
@ -145,8 +147,8 @@ describe("Trigger search", () =>
|
|||||||
let searchSpy = sinon.spy(element, "startSearch");
|
let searchSpy = sinon.spy(element, "startSearch");
|
||||||
|
|
||||||
// Send two keypresses, but we need to explicitly set the value
|
// Send two keypresses, but we need to explicitly set the value
|
||||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
|
||||||
element._searchInputNode.value = "t";
|
element._searchInputNode.value = "t";
|
||||||
|
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
|
||||||
assert(searchSpy.notCalled);
|
assert(searchSpy.notCalled);
|
||||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Enter"}));
|
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Enter"}));
|
||||||
|
|
||||||
@ -161,8 +163,8 @@ describe("Trigger search", () =>
|
|||||||
let searchSpy = sinon.spy(element, "startSearch");
|
let searchSpy = sinon.spy(element, "startSearch");
|
||||||
|
|
||||||
// Send two keypresses, but we need to explicitly set the value
|
// Send two keypresses, but we need to explicitly set the value
|
||||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "t"}));
|
|
||||||
element._searchInputNode.value = "t";
|
element._searchInputNode.value = "t";
|
||||||
|
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "t"}));
|
||||||
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Escape"}));
|
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Escape"}));
|
||||||
|
|
||||||
assert(searchSpy.notCalled, "startSearch() was called");
|
assert(searchSpy.notCalled, "startSearch() was called");
|
||||||
|
Loading…
Reference in New Issue
Block a user