mirror of
https://github.com/discourse/discourse.git
synced 2025-05-24 00:01:13 +08:00
DEV: adds a new dropdown widget usable in any widget (#9297)
This commit is contained in:
263
app/assets/javascripts/discourse/widgets/widget-dropdown.js
Normal file
263
app/assets/javascripts/discourse/widgets/widget-dropdown.js
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import { createWidget } from "discourse/widgets/widget";
|
||||||
|
import hbs from "discourse/widgets/hbs-compiler";
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
widget-dropdown
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
{{attach
|
||||||
|
widget="widget-dropdown"
|
||||||
|
attrs=(hash
|
||||||
|
id=id
|
||||||
|
label=label
|
||||||
|
content=content
|
||||||
|
onChange=onChange
|
||||||
|
options=(hash)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
Mandatory attributes:
|
||||||
|
|
||||||
|
- id: must be unique in the application
|
||||||
|
|
||||||
|
- label or translatedLabel:
|
||||||
|
- label: an i18n key to be translated and displayed on the header
|
||||||
|
- translatedLabel: an already translated label to display on the header
|
||||||
|
|
||||||
|
- onChange: action called when a click happens on a row, content[rowIndex] will be passed as params
|
||||||
|
|
||||||
|
Optional attributes:
|
||||||
|
|
||||||
|
- class: adds css class to the dropdown
|
||||||
|
- content: list of items to display, if undefined or empty dropdown won't display
|
||||||
|
Example content:
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{ id: 1, label: "foo.bar" },
|
||||||
|
"separator",
|
||||||
|
{ id: 2, translatedLabel: "FooBar" },
|
||||||
|
{ id: 3 label: "foo.baz", icon: "times" },
|
||||||
|
{ id: 4, html: "<b>foo</b>" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- options: accepts a hash of optional attributes
|
||||||
|
- headerClass: adds css class to the dropdown header
|
||||||
|
- bodyClass: adds css class to the dropdown header
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WidgetDropdownHeaderClass = {
|
||||||
|
tagName: "button",
|
||||||
|
|
||||||
|
transform(attrs) {
|
||||||
|
return {
|
||||||
|
label: attrs.translatedLabel ? attrs.translatedLabel : I18n.t(attrs.label)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
let classes = ["widget-dropdown-header", "btn", "btn-default"];
|
||||||
|
if (attrs.class) {
|
||||||
|
classes = classes.concat(attrs.class.split(" "));
|
||||||
|
}
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
},
|
||||||
|
|
||||||
|
click(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.sendWidgetAction("_onTrigger");
|
||||||
|
},
|
||||||
|
|
||||||
|
template: hbs`
|
||||||
|
{{#if attrs.icon}}
|
||||||
|
{{d-icon attrs.icon}}
|
||||||
|
{{/if}}
|
||||||
|
<span class="label">
|
||||||
|
{{transformed.label}}
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
createWidget("widget-dropdown-header", WidgetDropdownHeaderClass);
|
||||||
|
|
||||||
|
export const WidgetDropdownItemClass = {
|
||||||
|
tagName: "div",
|
||||||
|
|
||||||
|
transform(attrs) {
|
||||||
|
return {
|
||||||
|
content:
|
||||||
|
attrs.item === "separator"
|
||||||
|
? "<hr>"
|
||||||
|
: attrs.item.html
|
||||||
|
? attrs.item.html
|
||||||
|
: attrs.item.translatedLabel
|
||||||
|
? attrs.item.translatedLabel
|
||||||
|
: I18n.t(attrs.item.label)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAttributes(attrs) {
|
||||||
|
return { "data-id": attrs.item.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
return [
|
||||||
|
"widget-dropdown-item",
|
||||||
|
attrs.item === "separator" ? "separator" : `item-${attrs.item.id}`
|
||||||
|
].join(" ");
|
||||||
|
},
|
||||||
|
|
||||||
|
click(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.sendWidgetAction("_onChange", this.attrs.item);
|
||||||
|
},
|
||||||
|
|
||||||
|
template: hbs`
|
||||||
|
{{#if attrs.item.icon}}
|
||||||
|
{{d-icon attrs.item.icon}}
|
||||||
|
{{/if}}
|
||||||
|
{{{transformed.content}}}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
createWidget("widget-dropdown-item", WidgetDropdownItemClass);
|
||||||
|
|
||||||
|
export const WidgetDropdownClass = {
|
||||||
|
tagName: "div",
|
||||||
|
|
||||||
|
init(attrs) {
|
||||||
|
if (!attrs) {
|
||||||
|
throw "A widget-dropdown expects attributes.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attrs.id) {
|
||||||
|
throw "A widget-dropdown expects a unique `id` attribute.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attrs.label && !attrs.translatedLabel) {
|
||||||
|
throw "A widget-dropdown expects at least a `label` or `translatedLabel`";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildKey: attrs => {
|
||||||
|
return attrs.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAttributes(attrs) {
|
||||||
|
return { id: attrs.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return {
|
||||||
|
opened: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
buildClasses(attrs) {
|
||||||
|
const classes = ["widget-dropdown"];
|
||||||
|
classes.push(this.state.opened ? "opened" : "closed");
|
||||||
|
return classes.join(" ") + " " + (attrs.class || "");
|
||||||
|
},
|
||||||
|
|
||||||
|
transform(attrs) {
|
||||||
|
const options = attrs.options || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
bodyClass: `widget-dropdown-body ${options.bodyClass || ""}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
clickOutside() {
|
||||||
|
this.state.opened = false;
|
||||||
|
this.scheduleRerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
_onChange(params) {
|
||||||
|
this.state.opened = false;
|
||||||
|
if (this.attrs.onChange) {
|
||||||
|
if (typeof this.attrs.onChange === "string") {
|
||||||
|
this.sendWidgetAction(this.attrs.onChange, params);
|
||||||
|
} else {
|
||||||
|
this.attrs.onChange(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onTrigger() {
|
||||||
|
if (this.state.opened) {
|
||||||
|
this.state.opened = false;
|
||||||
|
this._closeDropdown(this.attrs.id);
|
||||||
|
} else {
|
||||||
|
this.state.opened = true;
|
||||||
|
this._openDropdown(this.attrs.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._popper && this._popper.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._popper) {
|
||||||
|
this._popper.destroy();
|
||||||
|
this._popper = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
template: hbs`
|
||||||
|
{{#if attrs.content}}
|
||||||
|
{{attach
|
||||||
|
widget="widget-dropdown-header"
|
||||||
|
attrs=(hash
|
||||||
|
icon=attrs.icon
|
||||||
|
label=attrs.label
|
||||||
|
translatedLabel=attrs.translatedLabel
|
||||||
|
class=this.transformed.options.headerClass
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
<div class={{transformed.bodyClass}}>
|
||||||
|
{{#each attrs.content as |item|}}
|
||||||
|
{{attach
|
||||||
|
widget="widget-dropdown-item"
|
||||||
|
attrs=(hash item=item)
|
||||||
|
}}
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
`,
|
||||||
|
|
||||||
|
_closeDropdown() {
|
||||||
|
this._popper && this._popper.destroy();
|
||||||
|
},
|
||||||
|
|
||||||
|
_openDropdown(id) {
|
||||||
|
const dropdownHeader = document.querySelector(
|
||||||
|
`#${id} .widget-dropdown-header`
|
||||||
|
);
|
||||||
|
const dropdownBody = document.querySelector(`#${id} .widget-dropdown-body`);
|
||||||
|
|
||||||
|
if (dropdownHeader && dropdownBody) {
|
||||||
|
/* global Popper:true */
|
||||||
|
this._popper = Popper.createPopper(dropdownHeader, dropdownBody, {
|
||||||
|
strategy: "fixed",
|
||||||
|
placement: "bottom-start",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "offset",
|
||||||
|
options: {
|
||||||
|
offset: [0, 5]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createWidget("widget-dropdown", WidgetDropdownClass);
|
@ -0,0 +1,51 @@
|
|||||||
|
.widget-dropdown {
|
||||||
|
margin: 1em;
|
||||||
|
display: inline-flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.closed {
|
||||||
|
.widget-dropdown-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-dropdown-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.25em;
|
||||||
|
background: $secondary;
|
||||||
|
margin-top: 5px;
|
||||||
|
z-index: z("dropdown");
|
||||||
|
border: 1px solid $primary-low;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25em;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.d-icon {
|
||||||
|
color: $primary-medium;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.separator {
|
||||||
|
padding: 0;
|
||||||
|
background: $primary-low;
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $tertiary-low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-dropdown-header {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
298
test/javascripts/widgets/widget-dropdown-test.js
Normal file
298
test/javascripts/widgets/widget-dropdown-test.js
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { moduleForWidget, widgetTest } from "helpers/widget-test";
|
||||||
|
|
||||||
|
moduleForWidget("widget-dropdown");
|
||||||
|
|
||||||
|
const DEFAULT_CONTENT = {
|
||||||
|
content: [
|
||||||
|
{ id: 1, label: "foo" },
|
||||||
|
{ id: 2, translatedLabel: "FooBar" },
|
||||||
|
"separator",
|
||||||
|
{ id: 3, translatedLabel: "With icon", icon: "times" },
|
||||||
|
{ id: 4, html: "<b>baz</b>" }
|
||||||
|
],
|
||||||
|
label: "foo"
|
||||||
|
};
|
||||||
|
|
||||||
|
async function clickRowById(id) {
|
||||||
|
await click(`#my-dropdown .widget-dropdown-item.item-${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowById(id) {
|
||||||
|
return find(`#my-dropdown .widget-dropdown-item.item-${id}`)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
await click("#my-dropdown .widget-dropdown-header");
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerLabel() {
|
||||||
|
return find(
|
||||||
|
"#my-dropdown .widget-dropdown-header .label"
|
||||||
|
)[0].innerText.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function header() {
|
||||||
|
return find("#my-dropdown .widget-dropdown-header")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function body() {
|
||||||
|
return find("#my-dropdown .widget-dropdown-body")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE = `
|
||||||
|
{{mount-widget
|
||||||
|
widget="widget-dropdown"
|
||||||
|
args=(hash
|
||||||
|
id="my-dropdown"
|
||||||
|
icon=icon
|
||||||
|
label=label
|
||||||
|
class=class
|
||||||
|
translatedLabel=translatedLabel
|
||||||
|
content=content
|
||||||
|
options=options
|
||||||
|
)
|
||||||
|
}}`;
|
||||||
|
|
||||||
|
widgetTest("dropdown id", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.ok(exists("#my-dropdown"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("label", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
_translations: I18n.translations,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
I18n.translations = { en: { js: { foo: "FooBaz" } } };
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
afterEach() {
|
||||||
|
I18n.translations = this._translations;
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.equal(headerLabel(), "FooBaz");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("translatedLabel", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
_translations: I18n.translations,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
I18n.translations = { en: { js: { foo: "FooBaz" } } };
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
this.set("translatedLabel", "BazFoo");
|
||||||
|
},
|
||||||
|
|
||||||
|
afterEach() {
|
||||||
|
I18n.translations = this._translations;
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.equal(headerLabel(), this.translatedLabel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("content", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.equal(rowById(1).dataset.id, 1, "it creates rows");
|
||||||
|
assert.equal(rowById(2).dataset.id, 2, "it creates rows");
|
||||||
|
assert.equal(rowById(3).dataset.id, 3, "it creates rows");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("onChange action", {
|
||||||
|
template: `
|
||||||
|
<div id="test"></div>
|
||||||
|
{{mount-widget
|
||||||
|
widget="widget-dropdown"
|
||||||
|
args=(hash
|
||||||
|
id="my-dropdown"
|
||||||
|
label=label
|
||||||
|
content=content
|
||||||
|
onChange=(action "onChange")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
`,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
|
||||||
|
this.on(
|
||||||
|
"onChange",
|
||||||
|
item => (this._element.querySelector("#test").innerText = item.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(assert) {
|
||||||
|
await clickRowById(2);
|
||||||
|
assert.equal(find("#test").text(), 2, "it calls the onChange actions");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("can be opened and closed", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(assert) {
|
||||||
|
assert.ok(exists("#my-dropdown.closed"));
|
||||||
|
await toggle();
|
||||||
|
assert.ok(exists("#my-dropdown.opened"));
|
||||||
|
await toggle();
|
||||||
|
assert.ok(exists("#my-dropdown.closed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("icon", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
this.set("icon", "times");
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.ok(exists(header().querySelector(".d-icon-times")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("class", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
this.set("class", "activated");
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.ok(exists("#my-dropdown.activated"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("content with translatedLabel", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.equal(rowById(2).innerText.trim(), "FooBar");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("content with label", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
I18n.translations = { en: { js: { foo: "FooBaz" } } };
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.equal(rowById(1).innerText.trim(), "FooBaz");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("content with icon", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.ok(exists(rowById(3).querySelector(".d-icon-times")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("content with html", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.equal(rowById(4).innerHTML.trim(), "<span><b>baz</b></span>");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("separator", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.ok(
|
||||||
|
find(
|
||||||
|
"#my-dropdown .widget-dropdown-item:nth-child(3)"
|
||||||
|
)[0].classList.contains("separator")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("hides widget if no content", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties({ content: null, label: "foo" });
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.notOk(exists("#my-dropdown .widget-dropdown-header"));
|
||||||
|
assert.notOk(exists("#my-dropdown .widget-dropdown-body"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("headerClass option", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
this.set("options", { headerClass: "btn-small and-text" });
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.ok(header().classList.contains("widget-dropdown-header"));
|
||||||
|
assert.ok(header().classList.contains("btn-small"));
|
||||||
|
assert.ok(header().classList.contains("and-text"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
widgetTest("bodyClass option", {
|
||||||
|
template: TEMPLATE,
|
||||||
|
|
||||||
|
beforeEach() {
|
||||||
|
this.setProperties(DEFAULT_CONTENT);
|
||||||
|
this.set("options", { bodyClass: "gigantic and-yet-small" });
|
||||||
|
},
|
||||||
|
|
||||||
|
test(assert) {
|
||||||
|
assert.ok(body().classList.contains("widget-dropdown-body"));
|
||||||
|
assert.ok(body().classList.contains("gigantic"));
|
||||||
|
assert.ok(body().classList.contains("and-yet-small"));
|
||||||
|
}
|
||||||
|
});
|
Reference in New Issue
Block a user