Merge pull request #988 from umbraco/feature/dictionary-export-import
Dictionary - Export and Import
This commit is contained in:
@@ -5,7 +5,7 @@ export interface UmbImportDictionaryModalData {
|
||||
}
|
||||
|
||||
export interface UmbImportDictionaryModalValue {
|
||||
temporaryFileId?: string;
|
||||
temporaryFileId: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { UmbPickerInputContext } from '@umbraco-cms/backoffice/picker-input';
|
||||
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
|
||||
import { UMB_DICTIONARY_ITEM_PICKER_MODAL } from '@umbraco-cms/backoffice/modal';
|
||||
import { DictionaryItemItemResponseModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
|
||||
export class UmbDictionaryItemPickerContext extends UmbPickerInputContext<DictionaryItemItemResponseModel> {
|
||||
constructor(host: UmbControllerHostElement) {
|
||||
super(host, 'Umb.Repository.Dictionary', UMB_DICTIONARY_ITEM_PICKER_MODAL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { UmbDictionaryItemPickerContext } from './dictionary-item-input.context.js';
|
||||
import { css, html, customElement, property, state, ifDefined, repeat } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui';
|
||||
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
|
||||
import type { DictionaryItemItemResponseModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
|
||||
@customElement('umb-dictionary-item-input')
|
||||
export class UmbDictionaryItemInputElement extends FormControlMixin(UmbLitElement) {
|
||||
/**
|
||||
* This is a minimum amount of selected items in this input.
|
||||
* @type {number}
|
||||
* @attr
|
||||
* @default 0
|
||||
*/
|
||||
@property({ type: Number })
|
||||
public get min(): number {
|
||||
return this.#pickerContext.min;
|
||||
}
|
||||
public set min(value: number) {
|
||||
this.#pickerContext.min = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Min validation message.
|
||||
* @type {boolean}
|
||||
* @attr
|
||||
* @default
|
||||
*/
|
||||
@property({ type: String, attribute: 'min-message' })
|
||||
minMessage = 'This field need more items';
|
||||
|
||||
/**
|
||||
* This is a maximum amount of selected items in this input.
|
||||
* @type {number}
|
||||
* @attr
|
||||
* @default Infinity
|
||||
*/
|
||||
@property({ type: Number })
|
||||
public get max(): number {
|
||||
return this.#pickerContext.max;
|
||||
}
|
||||
public set max(value: number) {
|
||||
this.#pickerContext.max = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max validation message.
|
||||
* @type {boolean}
|
||||
* @attr
|
||||
* @default
|
||||
*/
|
||||
@property({ type: String, attribute: 'min-message' })
|
||||
maxMessage = 'This field exceeds the allowed amount of items';
|
||||
|
||||
public get selectedIds(): Array<string> {
|
||||
return this.#pickerContext.getSelection();
|
||||
}
|
||||
public set selectedIds(ids: Array<string>) {
|
||||
this.#pickerContext.setSelection(ids);
|
||||
}
|
||||
|
||||
@property()
|
||||
public set value(idsString: string) {
|
||||
// Its with full purpose we don't call super.value, as thats being handled by the observation of the context selection.
|
||||
this.selectedIds = idsString.split(/[ ,]+/);
|
||||
}
|
||||
|
||||
@state()
|
||||
private _items?: Array<DictionaryItemItemResponseModel>;
|
||||
|
||||
#pickerContext = new UmbDictionaryItemPickerContext(this);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.addValidator(
|
||||
'rangeUnderflow',
|
||||
() => this.minMessage,
|
||||
() => !!this.min && this.#pickerContext.getSelection().length < this.min,
|
||||
);
|
||||
|
||||
this.addValidator(
|
||||
'rangeOverflow',
|
||||
() => this.maxMessage,
|
||||
() => !!this.max && this.#pickerContext.getSelection().length > this.max,
|
||||
);
|
||||
|
||||
this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(',')));
|
||||
this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems));
|
||||
}
|
||||
|
||||
protected getFormElement() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
${this._items
|
||||
? html` <uui-ref-list
|
||||
>${repeat(
|
||||
this._items,
|
||||
(item) => item.id,
|
||||
(item) => this._renderItem(item),
|
||||
)}
|
||||
</uui-ref-list>`
|
||||
: ''}
|
||||
${this.#renderAddButton()}
|
||||
`;
|
||||
}
|
||||
|
||||
#renderAddButton() {
|
||||
if (this.max > 0 && this.selectedIds.length >= this.max) return;
|
||||
return html`<uui-button
|
||||
id="add-button"
|
||||
look="placeholder"
|
||||
@click=${() => this.#pickerContext.openPicker()}
|
||||
label=${this.localize.term('general_add')}></uui-button>`;
|
||||
}
|
||||
|
||||
private _renderItem(item: DictionaryItemItemResponseModel) {
|
||||
if (!item.id) return;
|
||||
return html`
|
||||
<uui-ref-node name=${ifDefined(item.name)} detail=${ifDefined(item.id)}>
|
||||
<!-- TODO: implement is trashed <uui-tag size="s" slot="tag" color="danger">Trashed</uui-tag> -->
|
||||
<uui-action-bar slot="actions">
|
||||
<uui-button
|
||||
@click=${() => this.#pickerContext.requestRemoveItem(item.id!)}
|
||||
label=${this.localize.term('actions_remove')}></uui-button>
|
||||
</uui-action-bar>
|
||||
</uui-ref-node>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
#add-button {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export default UmbDictionaryItemInputElement;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-dictionary-item-input': UmbDictionaryItemInputElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dictionary-item-input/dictionary-item-input.element.js';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UmbDictionaryRepository } from '../../repository/dictionary.repository.js';
|
||||
import { UmbTextStyles } from "@umbraco-cms/backoffice/style";
|
||||
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action';
|
||||
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
|
||||
import {
|
||||
@@ -22,18 +22,30 @@ export default class UmbExportDictionaryEntityAction extends UmbEntityActionBase
|
||||
}
|
||||
|
||||
async execute() {
|
||||
// TODO: what to do if modal service is not available?
|
||||
if (!this.#modalContext) return;
|
||||
|
||||
const modalContext = this.#modalContext?.open(UMB_EXPORT_DICTIONARY_MODAL, { unique: this.unique });
|
||||
|
||||
// TODO: get type from modal result
|
||||
const { includeChildren } = await modalContext.onSubmit();
|
||||
if (includeChildren === undefined) return;
|
||||
|
||||
// Export the file
|
||||
const result = await this.repository?.export(this.unique, includeChildren);
|
||||
const blobContent = await result?.data;
|
||||
|
||||
// TODO => get location header to route to new item
|
||||
console.log(result);
|
||||
if (!blobContent) return;
|
||||
const blob = new Blob([blobContent], { type: 'text/plain' });
|
||||
const a = document.createElement('a');
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Download
|
||||
a.href = url;
|
||||
a.download = `${this.unique}.udt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Clean up
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import '../../components/dictionary-item-input/dictionary-item-input.element.js';
|
||||
import UmbDictionaryItemInputElement from '../../components/dictionary-item-input/dictionary-item-input.element.js';
|
||||
import { UmbDictionaryRepository } from '../../repository/dictionary.repository.js';
|
||||
import { css, html, customElement, query, state, when } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
@@ -7,167 +9,213 @@ import {
|
||||
UmbModalBaseElement,
|
||||
} from '@umbraco-cms/backoffice/modal';
|
||||
import { ImportDictionaryRequestModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
import { UmbId } from '@umbraco-cms/backoffice/id';
|
||||
|
||||
interface DictionaryItemPreview {
|
||||
name: string;
|
||||
children: Array<DictionaryItemPreview>;
|
||||
}
|
||||
|
||||
@customElement('umb-import-dictionary-modal')
|
||||
export class UmbImportDictionaryModalLayout extends UmbModalBaseElement<
|
||||
UmbImportDictionaryModalData,
|
||||
UmbImportDictionaryModalValue
|
||||
> {
|
||||
@state()
|
||||
private _parentId?: string;
|
||||
|
||||
@state()
|
||||
private _temporaryFileId?: string;
|
||||
|
||||
@query('#form')
|
||||
private _form!: HTMLFormElement;
|
||||
|
||||
#fileReader;
|
||||
|
||||
#fileContent: Array<DictionaryItemPreview> = [];
|
||||
|
||||
#handleClose() {
|
||||
this.modalContext?.reject();
|
||||
}
|
||||
|
||||
#submit() {
|
||||
// TODO: Gotta do a temp file upload before submitting, so that the server can use it
|
||||
console.log('submit:', this._temporaryFileId, this._parentId);
|
||||
//this.modalContext?.submit({ temporaryFileId: this._temporaryFileId, parentId: this._parentId });
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#fileReader = new FileReader();
|
||||
this.#fileReader.onload = (e) => {
|
||||
if (typeof e.target?.result === 'string') {
|
||||
const fileContent = e.target.result;
|
||||
this.#dictionaryItemBuilder(fileContent);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._parentId = this.data?.unique ?? undefined;
|
||||
}
|
||||
|
||||
#dictionaryItemBuilder(htmlString: string) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlString, 'text/xml');
|
||||
const elements = doc.childNodes;
|
||||
|
||||
this.#fileContent = this.#makeDictionaryItems(elements);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
#makeDictionaryItems(nodeList: NodeListOf<ChildNode>): Array<DictionaryItemPreview> {
|
||||
const items: Array<DictionaryItemPreview> = [];
|
||||
const list: Array<Element> = [];
|
||||
nodeList.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'DictionaryItem') {
|
||||
list.push(node as Element);
|
||||
}
|
||||
});
|
||||
|
||||
list.forEach((item) => {
|
||||
items.push({
|
||||
name: item.getAttribute('Name') ?? '',
|
||||
children: this.#makeDictionaryItems(item.childNodes) ?? undefined,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
#onUpload(e: Event) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this._form);
|
||||
const file = formData.get('file') as Blob;
|
||||
|
||||
this.#fileReader.readAsText(file);
|
||||
this._temporaryFileId = file ? UmbId.new() : undefined;
|
||||
}
|
||||
|
||||
#onParentChange(event: CustomEvent) {
|
||||
this._parentId = (event.target as UmbDictionaryItemInputElement).selectedIds[0] || undefined;
|
||||
//console.log((event.target as UmbDictionaryItemInputElement).selectedIds[0] || undefined);
|
||||
}
|
||||
|
||||
async #onFileInput() {
|
||||
requestAnimationFrame(() => {
|
||||
this._form.requestSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
#onClear() {
|
||||
this._temporaryFileId = '';
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <umb-body-layout headline=${this.localize.term('general_import')}>
|
||||
<uui-box>
|
||||
${when(
|
||||
this._temporaryFileId,
|
||||
() => this.#renderImportDestination(),
|
||||
() => this.#renderUploadZone(),
|
||||
)}
|
||||
</uui-box>
|
||||
<uui-button
|
||||
slot="actions"
|
||||
type="button"
|
||||
label=${this.localize.term('general_cancel')}
|
||||
@click=${this.#handleClose}></uui-button>
|
||||
</umb-body-layout>`;
|
||||
}
|
||||
|
||||
#renderFileContents(items: Array<DictionaryItemPreview>): any {
|
||||
return html`${items.map((item: DictionaryItemPreview) => {
|
||||
return html`${item.name}
|
||||
<div>${this.#renderFileContents(item.children)}</div>`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
#renderImportDestination() {
|
||||
return html`
|
||||
<div id="wrapper">
|
||||
<div>
|
||||
<strong><umb-localize key="visuallyHiddenTexts_dictionaryItems">Dictionary items</umb-localize>:</strong>
|
||||
<div id="item-list">${this.#renderFileContents(this.#fileContent)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong><umb-localize key="actions_chooseWhereToImport">Choose where to import</umb-localize>:</strong>
|
||||
Work in progress<br />
|
||||
${
|
||||
this._parentId
|
||||
// TODO
|
||||
// <umb-dictionary-item-input
|
||||
// @change=${this.#onParentChange}
|
||||
// .selectedIds=${this._parentId ? [this._parentId] : []}
|
||||
// max="1">
|
||||
// </umb-dictionary-item-input>
|
||||
}
|
||||
</div>
|
||||
|
||||
${this.#renderNavigate()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderNavigate() {
|
||||
return html`<div id="nav">
|
||||
<uui-button label=${this.localize.term('general_import')} look="secondary" @click=${this.#onClear}>
|
||||
<uui-icon name="icon-arrow-left"></uui-icon>
|
||||
${this.localize.term('general_back')}
|
||||
</uui-button>
|
||||
<uui-button
|
||||
type="button"
|
||||
label=${this.localize.term('general_import')}
|
||||
look="primary"
|
||||
@click=${this.#submit}></uui-button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
#renderUploadZone() {
|
||||
return html`<umb-localize key="dictionary_importDictionaryItemHelp"></umb-localize>
|
||||
<uui-form>
|
||||
<form id="form" name="form" @submit=${this.#onUpload}>
|
||||
<uui-form-layout-item>
|
||||
<uui-label for="file" slot="label" required>${this.localize.term('formFileUpload_pickFile')}</uui-label>
|
||||
<uui-input-file
|
||||
accept=".udt"
|
||||
name="file"
|
||||
id="file"
|
||||
@input=${this.#onFileInput}
|
||||
required
|
||||
required-message=${this.localize.term('formFileUpload_pickFile')}></uui-input-file>
|
||||
</uui-form-layout-item>
|
||||
</form>
|
||||
</uui-form>`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
UmbTextStyles,
|
||||
css`
|
||||
uui-input {
|
||||
width: 100%;
|
||||
}
|
||||
#item-list {
|
||||
padding: var(--uui-size-3) var(--uui-size-4);
|
||||
border: 1px solid var(--uui-color-border);
|
||||
border-radius: var(--uui-border-radius);
|
||||
}
|
||||
#item-list div {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--uui-size-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@query('#form')
|
||||
private _form!: HTMLFormElement;
|
||||
|
||||
@state()
|
||||
private _uploadedDictionaryTempId?: string;
|
||||
|
||||
@state()
|
||||
private _showUploadView = true;
|
||||
|
||||
@state()
|
||||
private _showImportView = false;
|
||||
|
||||
@state()
|
||||
private _showErrorView = false;
|
||||
|
||||
@state()
|
||||
private _selection: Array<string> = [];
|
||||
|
||||
#detailRepo = new UmbDictionaryRepository(this);
|
||||
|
||||
async #importDictionary() {
|
||||
if (!this._uploadedDictionaryTempId) return;
|
||||
|
||||
this.modalContext?.submit({
|
||||
temporaryFileId: this._uploadedDictionaryTempId,
|
||||
parentId: this._selection[0],
|
||||
});
|
||||
}
|
||||
|
||||
#handleClose() {
|
||||
this.modalContext?.reject();
|
||||
}
|
||||
|
||||
#submitForm() {
|
||||
this._form?.requestSubmit();
|
||||
}
|
||||
|
||||
async #handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this._form.checkValidity()) return;
|
||||
|
||||
const formData = new FormData(this._form);
|
||||
|
||||
const uploadData: ImportDictionaryRequestModel = {
|
||||
temporaryFileId: formData.get('file')?.toString() ?? '',
|
||||
};
|
||||
|
||||
// TODO: fix this upload experience. We need to update our form so it gets temporary file id from the server:
|
||||
const { data } = await this.#detailRepo.upload(uploadData);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
this._uploadedDictionaryTempId = data;
|
||||
// TODO: We need to find another way to gather the data of the uploaded dictionary, to represent the dictionaryItems? See further below.
|
||||
//this._uploadedDictionary = data;
|
||||
|
||||
if (!this._uploadedDictionaryTempId) {
|
||||
this._showErrorView = true;
|
||||
this._showImportView = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._showErrorView = false;
|
||||
this._showUploadView = false;
|
||||
this._showImportView = true;
|
||||
}
|
||||
|
||||
/*
|
||||
#handleSelectionChange(e: CustomEvent) {
|
||||
e.stopPropagation();
|
||||
const element = e.target as UmbTreeElement;
|
||||
this._selection = element.selection;
|
||||
}
|
||||
*/
|
||||
|
||||
#renderUploadView() {
|
||||
return html`<p>
|
||||
To import a dictionary item, find the ".udt" file on your computer by clicking the "Import" button (you'll be
|
||||
asked for confirmation on the next screen)
|
||||
</p>
|
||||
<uui-form>
|
||||
<form id="form" name="form" @submit=${this.#handleSubmit}>
|
||||
<uui-form-layout-item>
|
||||
<uui-label for="file" slot="label" required>File</uui-label>
|
||||
<div>
|
||||
<uui-input-file
|
||||
accept=".udt"
|
||||
name="file"
|
||||
id="file"
|
||||
required
|
||||
required-message="File is required"></uui-input-file>
|
||||
</div>
|
||||
</uui-form-layout-item>
|
||||
</form>
|
||||
</uui-form>
|
||||
<uui-button slot="actions" type="button" label="Cancel" @click=${this.#handleClose}></uui-button>
|
||||
<uui-button slot="actions" type="button" label="Import" look="primary" @click=${this.#submitForm}></uui-button>`;
|
||||
}
|
||||
|
||||
/// TODO => Tree view needs isolation and single-select option
|
||||
#renderImportView() {
|
||||
//TODO: gather this data in some other way, we cannot use the feedback from the server anymore. can we use info about the file directly? or is a change to the end point required?
|
||||
/*
|
||||
if (!this._uploadedDictionary?.dictionaryItems) return;
|
||||
|
||||
return html`
|
||||
<b>Dictionary items</b>
|
||||
<ul>
|
||||
${repeat(
|
||||
this._uploadedDictionary.dictionaryItems,
|
||||
(item) => item.name,
|
||||
(item) => html`<li>${item.name}</li>`
|
||||
)}
|
||||
</ul>
|
||||
<hr />
|
||||
<b>Choose where to import dictionary items (optional)</b>
|
||||
<umb-tree
|
||||
alias="Umb.Tree.Dictionary"
|
||||
@selection-change=${this.#handleSelectionChange}
|
||||
.selection=${this._selection}
|
||||
selectable></umb-tree>
|
||||
|
||||
<uui-button slot="actions" type="button" label="Cancel" @click=${this.#handleClose}></uui-button>
|
||||
<uui-button
|
||||
slot="actions"
|
||||
type="button"
|
||||
label="Import"
|
||||
look="primary"
|
||||
@click=${this.#importDictionary}></uui-button>
|
||||
`;
|
||||
*/
|
||||
}
|
||||
|
||||
// TODO => Determine what to display when dictionary import/upload fails
|
||||
#renderErrorView() {
|
||||
return html`Something went wrong`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <umb-body-layout headline="Import">
|
||||
${when(this._showUploadView, () => this.#renderUploadView())}
|
||||
${when(this._showImportView, () => this.#renderImportView())}
|
||||
${when(this._showErrorView, () => this.#renderErrorView())}
|
||||
</umb-body-layout>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default UmbImportDictionaryModalLayout;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UmbDictionaryRepository } from '../../repository/dictionary.repository.js';
|
||||
import { UmbTextStyles } from "@umbraco-cms/backoffice/style";
|
||||
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action';
|
||||
import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
|
||||
import {
|
||||
@@ -22,18 +22,12 @@ export default class UmbImportDictionaryEntityAction extends UmbEntityActionBase
|
||||
}
|
||||
|
||||
async execute() {
|
||||
// TODO: what to do if modal service is not available?
|
||||
if (!this.#modalContext) return;
|
||||
|
||||
const modalContext = this.#modalContext?.open(UMB_IMPORT_DICTIONARY_MODAL, { unique: this.unique });
|
||||
|
||||
// TODO: get type from modal result
|
||||
const { temporaryFileId, parentId } = await modalContext.onSubmit();
|
||||
if (!temporaryFileId) return;
|
||||
const { parentId, temporaryFileId } = await modalContext.onSubmit();
|
||||
|
||||
const result = await this.repository?.import(temporaryFileId, parentId);
|
||||
|
||||
// TODO => get location header to route to new item
|
||||
console.log(result);
|
||||
await this.repository?.import(temporaryFileId, parentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './repository/index.js';
|
||||
export * from './tree/index.js';
|
||||
export * from './components/index.js';
|
||||
|
||||
Reference in New Issue
Block a user