mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Multi-column configuration
Summary: Creator panel allows now to edit multiple columns at once for some options that are common for them. Options that are not common are disabled. List of options that can be edited for multiple columns: - Column behavior (but limited to empty/formula columns) - Alignment and wrapping - Default style - Number options (for numeric columns) - Column types (but only for empty/formula columns) If multiple columns of the same type are selected, most of the options are available to change, except formula, trigger formula and conditional styles. Editing column label or column id is disabled by default for multiple selection. Not related: some tests were fixed due to the change in the column label and id widget in grist-core (disabled attribute was replaced by readonly). Test Plan: Updated and new tests. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3598
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
|
||||
@@ -16,7 +16,12 @@ import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiH
|
||||
Observable, styled} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, cursor: ko.Computed<CursorPos>) {
|
||||
export function buildNameConfig(
|
||||
owner: MultiHolder,
|
||||
origColumn: ColumnRec,
|
||||
cursor: ko.Computed<CursorPos>,
|
||||
disabled: ko.Computed<boolean> // Whether the name is editable (it's not editable for multiple selected columns).
|
||||
) {
|
||||
const untieColId = origColumn.untieColIdFromLabel;
|
||||
|
||||
const editedLabel = Observable.create(owner, '');
|
||||
@@ -37,6 +42,12 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
|
||||
})
|
||||
);
|
||||
|
||||
const toggleUntieColId = () => {
|
||||
if (!origColumn.disableModify.peek() && !disabled.peek()) {
|
||||
untieColId.saveOnly(!untieColId.peek()).catch(reportError);
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
cssLabel('COLUMN LABEL AND ID'),
|
||||
cssRow(
|
||||
@@ -45,12 +56,13 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
|
||||
editor = cssInput(fromKo(origColumn.label),
|
||||
async val => { await origColumn.label.saveOnly(val); editedLabel.set(''); },
|
||||
dom.on('input', (ev, elem) => { if (!untieColId.peek()) { editedLabel.set(elem.value); } }),
|
||||
dom.boolAttr('disabled', origColumn.disableModify),
|
||||
dom.boolAttr('readonly', use => use(origColumn.disableModify) || use(disabled)),
|
||||
testId('field-label'),
|
||||
),
|
||||
cssInput(editableColId,
|
||||
saveColId,
|
||||
dom.boolAttr(`readonly`, use => use(origColumn.disableModify) || !use(origColumn.untieColIdFromLabel)),
|
||||
dom.boolAttr('readonly',
|
||||
use => use(disabled) || use(origColumn.disableModify) || !use(origColumn.untieColIdFromLabel)),
|
||||
cssCodeBlock.cls(''),
|
||||
{style: 'margin-top: 8px'},
|
||||
testId('field-col-id'),
|
||||
@@ -60,8 +72,8 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
|
||||
cssColTieConnectors(),
|
||||
cssToggleButton(icon('FieldReference'),
|
||||
cssToggleButton.cls('-selected', (use) => !use(untieColId)),
|
||||
dom.on('click', () => !origColumn.disableModify.peek() && untieColId.saveOnly(!untieColId.peek())),
|
||||
cssToggleButton.cls("-disabled", origColumn.disableModify),
|
||||
dom.on('click', toggleUntieColId),
|
||||
cssToggleButton.cls("-disabled", use => use(origColumn.disableModify) || use(disabled)),
|
||||
testId('field-derive-id')
|
||||
),
|
||||
)
|
||||
@@ -78,12 +90,13 @@ type BuildEditor = (
|
||||
onSave?: SaveHandler,
|
||||
onCancel?: () => void) => void;
|
||||
|
||||
type BEHAVIOR = "empty"|"formula"|"data";
|
||||
|
||||
export function buildFormulaConfig(
|
||||
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
|
||||
) {
|
||||
|
||||
// If we can't modify anything about the column.
|
||||
const disableModify = Computed.create(owner, use => use(origColumn.disableModify));
|
||||
|
||||
// Intermediate state - user wants to specify formula, but haven't done yet
|
||||
const maybeFormula = Observable.create(owner, false);
|
||||
|
||||
@@ -93,7 +106,7 @@ export function buildFormulaConfig(
|
||||
// If this column belongs to a summary table.
|
||||
const isSummaryTable = Computed.create(owner, use => Boolean(use(use(origColumn.table).summarySourceTable)));
|
||||
|
||||
// Column behaviour. There are 3 types of behaviors:
|
||||
// Column behavior. There are 3 types of behaviors:
|
||||
// - empty: isFormula and formula == ''
|
||||
// - formula: isFormula and formula != ''
|
||||
// - data: not isFormula nd formula == ''
|
||||
@@ -123,31 +136,89 @@ export function buildFormulaConfig(
|
||||
owner.autoDispose(origColumn.formula.subscribe(clearState));
|
||||
owner.autoDispose(origColumn.isFormula.subscribe(clearState));
|
||||
|
||||
// User might have selected multiple columns, in that case all elements will be disabled, except the menu.
|
||||
// If user has selected only empty or formula columns, we offer to reset all or to convert to data.
|
||||
// If user has selected any data column, we offer only to reset all.
|
||||
const viewSection = Computed.create(owner, use => {
|
||||
return use(gristDoc.currentView)?.viewSection;
|
||||
});
|
||||
const isMultiSelect = Computed.create(owner, use => {
|
||||
const vs = use(viewSection);
|
||||
return !!vs && use(vs.selectedFields).length > 1;
|
||||
});
|
||||
|
||||
// If all columns are empty or have formulas.
|
||||
const multiType = Computed.create(owner, use => {
|
||||
if (!use(isMultiSelect)) { return false; }
|
||||
const vs = use(viewSection);
|
||||
if (!vs) { return false; }
|
||||
return use(vs.columnsBehavior);
|
||||
});
|
||||
|
||||
// If all columns are empty or have formulas.
|
||||
const isFormulaLike = Computed.create(owner, use => {
|
||||
if (!use(isMultiSelect)) { return false; }
|
||||
const vs = use(viewSection);
|
||||
if (!vs) { return false; }
|
||||
return use(vs.columnsAllIsFormula);
|
||||
});
|
||||
|
||||
// Helper to get all selected columns refs.
|
||||
const selectedColumns = () => viewSection.get()?.selectedFields.peek().map(f => f.column.peek()) || [];
|
||||
const selectedColumnIds = () => selectedColumns().map(f => f.id.peek()) || [];
|
||||
|
||||
// Clear and reset all option for multiple selected columns.
|
||||
const clearAndResetAll = () => selectOption(
|
||||
() => Promise.all([
|
||||
gristDoc.clearColumns(selectedColumnIds())
|
||||
]),
|
||||
'Clear and reset', 'CrossSmall'
|
||||
);
|
||||
|
||||
// Convert to data option for multiple selected columns.
|
||||
const convertToDataAll = () => selectOption(
|
||||
() => gristDoc.convertIsFormula(selectedColumnIds(), {toFormula: false, noRecalc: true}),
|
||||
'Convert columns to data', 'Database',
|
||||
dom.cls('disabled', isSummaryTable)
|
||||
);
|
||||
|
||||
// Menu helper that will show normal menu with some default options
|
||||
const menu = (label: DomContents, options: DomElementArg[]) =>
|
||||
cssRow(
|
||||
selectMenu(
|
||||
label,
|
||||
() => options,
|
||||
() => !isMultiSelect.get() ? options : [
|
||||
isFormulaLike.get() ? convertToDataAll() : null,
|
||||
clearAndResetAll(),
|
||||
],
|
||||
testId("field-behaviour"),
|
||||
// HACK: Menu helper will add tabindex to this element, which will make
|
||||
// this element focusable and will steal focus from clipboard. This in turn,
|
||||
// will not dispose the formula editor when menu is clicked.
|
||||
(el) => el.removeAttribute("tabindex"),
|
||||
dom.cls(cssBlockedCursor.className, origColumn.disableModify),
|
||||
dom.cls("disabled", origColumn.disableModify)),
|
||||
dom.cls(cssBlockedCursor.className, disableModify),
|
||||
dom.cls("disabled", disableModify)),
|
||||
);
|
||||
|
||||
// Behaviour label
|
||||
|
||||
// Behavior label
|
||||
const behaviorName = Computed.create(owner, behavior, (use, type) => {
|
||||
if (type === 'formula') { return "Formula Column"; }
|
||||
if (type === 'data') { return "Data Column"; }
|
||||
return "Empty Column";
|
||||
if (use(isMultiSelect)) {
|
||||
const commonType = use(multiType);
|
||||
if (commonType === 'formula') { return "Formula Columns"; }
|
||||
if (commonType === 'data') { return "Data Columns"; }
|
||||
if (commonType === 'mixed') { return "Mixed Behavior"; }
|
||||
return "Empty Columns";
|
||||
} else {
|
||||
if (type === 'formula') { return "Formula Column"; }
|
||||
if (type === 'data') { return "Data Column"; }
|
||||
return "Empty Column";
|
||||
}
|
||||
});
|
||||
const behaviorIcon = Computed.create<IconName>(owner, (use) => {
|
||||
return use(behaviorName) === "Data Column" ? "Database" : "Script";
|
||||
return use(behaviorName).startsWith("Data Column") ? "Database" : "Script";
|
||||
});
|
||||
const behaviourLabel = () => selectTitle(behaviorName, behaviorIcon);
|
||||
const behaviorLabel = () => selectTitle(behaviorName, behaviorIcon);
|
||||
|
||||
// Actions on select menu:
|
||||
|
||||
@@ -226,6 +297,11 @@ export function buildFormulaConfig(
|
||||
}
|
||||
};
|
||||
|
||||
// Should we disable all other action buttons and formula editor. For now
|
||||
// we will disable them when multiple columns are selected, or any of the column selected
|
||||
// can't be modified.
|
||||
const disableOtherActions = Computed.create(owner, use => use(disableModify) || use(isMultiSelect));
|
||||
|
||||
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
|
||||
// Helper that will create different flavors for formula builder.
|
||||
const formulaBuilder = (onSave: SaveHandler) => [
|
||||
@@ -233,6 +309,7 @@ export function buildFormulaConfig(
|
||||
origColumn,
|
||||
buildEditor,
|
||||
"Enter formula",
|
||||
disableOtherActions,
|
||||
onSave,
|
||||
clearState)),
|
||||
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
|
||||
@@ -241,30 +318,30 @@ export function buildFormulaConfig(
|
||||
return dom.maybe(behavior, (type: BEHAVIOR) => [
|
||||
cssLabel('COLUMN BEHAVIOR'),
|
||||
...(type === "empty" ? [
|
||||
menu(behaviourLabel(), [
|
||||
menu(behaviorLabel(), [
|
||||
convertToDataOption(),
|
||||
]),
|
||||
cssEmptySeparator(),
|
||||
cssRow(textButton(
|
||||
"Set formula",
|
||||
dom.on("click", setFormula),
|
||||
dom.prop("disabled", origColumn.disableModify),
|
||||
dom.prop("disabled", disableOtherActions),
|
||||
testId("field-set-formula")
|
||||
)),
|
||||
cssRow(textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", setTrigger),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(origColumn.disableModify)),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
testId("field-set-trigger")
|
||||
)),
|
||||
cssRow(textButton(
|
||||
"Make into data column",
|
||||
dom.on("click", convertToData),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(origColumn.disableModify)),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
testId("field-set-data")
|
||||
))
|
||||
] : type === "formula" ? [
|
||||
menu(behaviourLabel(), [
|
||||
menu(behaviorLabel(), [
|
||||
convertToDataOption(),
|
||||
clearAndResetOption(),
|
||||
]),
|
||||
@@ -274,11 +351,11 @@ export function buildFormulaConfig(
|
||||
"Convert to trigger formula",
|
||||
dom.on("click", convertFormulaToTrigger),
|
||||
dom.hide(maybeFormula),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(origColumn.disableModify)),
|
||||
dom.prop("disabled", use => use(isSummaryTable) || use(disableOtherActions)),
|
||||
testId("field-set-trigger")
|
||||
))
|
||||
] : /* type == 'data' */ [
|
||||
menu(behaviourLabel(),
|
||||
menu(behaviorLabel(),
|
||||
[
|
||||
dom.domComputed(origColumn.hasTriggerFormula, (hasTrigger) => hasTrigger ?
|
||||
// If we have trigger, we will convert it directly to a formula column
|
||||
@@ -293,7 +370,10 @@ export function buildFormulaConfig(
|
||||
dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [
|
||||
cssLabel('TRIGGER FORMULA'),
|
||||
formulaBuilder(onSaveConvertToTrigger),
|
||||
dom.create(buildFormulaTriggers, origColumn, maybeTrigger)
|
||||
dom.create(buildFormulaTriggers, origColumn, {
|
||||
disabled: disableOtherActions,
|
||||
notTrigger: maybeTrigger,
|
||||
})
|
||||
]),
|
||||
// Else offer a way to convert to trigger formula.
|
||||
dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [
|
||||
@@ -301,7 +381,7 @@ export function buildFormulaConfig(
|
||||
cssRow(textButton(
|
||||
"Set trigger formula",
|
||||
dom.on("click", convertDataColumnToTriggerColumn),
|
||||
dom.prop("disabled", origColumn.disableModify),
|
||||
dom.prop("disabled", disableOtherActions),
|
||||
testId("field-set-trigger")
|
||||
))
|
||||
])
|
||||
@@ -313,11 +393,12 @@ function buildFormula(
|
||||
column: ColumnRec,
|
||||
buildEditor: BuildEditor,
|
||||
placeholder: string,
|
||||
disabled: Observable<boolean>,
|
||||
onSave?: SaveHandler,
|
||||
onCancel?: () => void) {
|
||||
return cssFieldFormula(column.formula, {placeholder, maxLines: 2},
|
||||
dom.cls('formula_field_sidepane'),
|
||||
cssFieldFormula.cls('-disabled', column.disableModify),
|
||||
cssFieldFormula.cls('-disabled', disabled),
|
||||
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
|
||||
dom.cls('disabled'),
|
||||
{tabIndex: '-1'},
|
||||
|
||||
@@ -188,6 +188,21 @@ export class RightPanel extends Disposable {
|
||||
return vsi && vsi.activeFieldBuilder();
|
||||
}));
|
||||
|
||||
const selectedColumns = owner.autoDispose(ko.computed(() => {
|
||||
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
|
||||
return vsi && vsi.selectedColumns ? vsi.selectedColumns() : null;
|
||||
}));
|
||||
|
||||
const isMultiSelect = owner.autoDispose(ko.pureComputed(() => {
|
||||
const list = selectedColumns();
|
||||
return Boolean(list && list.length > 1);
|
||||
}));
|
||||
|
||||
owner.autoDispose(selectedColumns.subscribe(cols => {
|
||||
this._gristDoc.viewModel.activeSection()?.selectedFields(cols || []);
|
||||
}));
|
||||
this._gristDoc.viewModel.activeSection()?.selectedFields(selectedColumns.peek() || []);
|
||||
|
||||
const docModel = this._gristDoc.docModel;
|
||||
const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0));
|
||||
const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef));
|
||||
@@ -206,24 +221,44 @@ export class RightPanel extends Disposable {
|
||||
const {buildNameConfig, buildFormulaConfig} = ViewPane.FieldConfig;
|
||||
return dom.maybe(isColumnValid, () =>
|
||||
buildConfigContainer(
|
||||
dom.create(buildNameConfig, origColumn, cursor),
|
||||
cssSection(
|
||||
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
|
||||
),
|
||||
cssSeparator(),
|
||||
dom.create(buildFormulaConfig, origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),
|
||||
cssSection(
|
||||
dom.create(buildFormulaConfig,
|
||||
origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),
|
||||
),
|
||||
cssSeparator(),
|
||||
cssLabel('COLUMN TYPE'),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => [
|
||||
builder.buildSelectTypeDom(),
|
||||
builder.buildSelectWidgetDom(),
|
||||
builder.buildConfigDom()
|
||||
cssLabel('COLUMN TYPE'),
|
||||
cssSection(
|
||||
builder.buildSelectTypeDom(),
|
||||
),
|
||||
cssSection(
|
||||
builder.buildSelectWidgetDom(),
|
||||
),
|
||||
cssSection(
|
||||
builder.buildConfigDom(),
|
||||
),
|
||||
builder.buildColorConfigDom(),
|
||||
cssSection(
|
||||
builder.buildSettingOptions(),
|
||||
dom.maybe(isMultiSelect, () => disabledSection())
|
||||
),
|
||||
]),
|
||||
cssSeparator(),
|
||||
dom.maybe(refSelect.isForeignRefCol, () => [
|
||||
cssLabel('Add referenced columns'),
|
||||
cssRow(refSelect.buildDom()),
|
||||
cssSeparator()
|
||||
]),
|
||||
cssLabel('TRANSFORM'),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()),
|
||||
cssSection(
|
||||
dom.maybe(refSelect.isForeignRefCol, () => [
|
||||
cssLabel('Add referenced columns'),
|
||||
cssRow(refSelect.buildDom()),
|
||||
cssSeparator()
|
||||
]),
|
||||
cssLabel('TRANSFORM'),
|
||||
dom.maybe<FieldBuilder|null>(fieldBuilder, builder => builder.buildTransformDom()),
|
||||
dom.maybe(isMultiSelect, () => disabledSection()),
|
||||
testId('panel-transform'),
|
||||
),
|
||||
this._disableIfReadonly(),
|
||||
)
|
||||
);
|
||||
@@ -239,7 +274,7 @@ export class RightPanel extends Disposable {
|
||||
// Custom save handler.
|
||||
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
|
||||
// Custom cancel handler.
|
||||
onCancel?: () => void,) {
|
||||
onCancel?: () => void) {
|
||||
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
|
||||
if (!vsi) { return; }
|
||||
const editRowModel = vsi.moveEditRowToCursor();
|
||||
@@ -527,6 +562,12 @@ export class RightPanel extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
function disabledSection() {
|
||||
return cssOverlay(
|
||||
testId('panel-disabled-section'),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildConfigContainer(...args: DomElementArg[]): HTMLElement {
|
||||
return cssConfigContainer(
|
||||
// The `position: relative;` style is needed for the overlay for the readonly mode. Note that
|
||||
@@ -774,3 +815,7 @@ const cssTextInput = styled(textInput, `
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
position: relative;
|
||||
`);
|
||||
|
||||
@@ -20,7 +20,10 @@ import isEqual = require('lodash/isEqual');
|
||||
/**
|
||||
* Build UI to select triggers for formulas in data columns (such for default values).
|
||||
*/
|
||||
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disable: Observable<boolean>|null = null) {
|
||||
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, options: {
|
||||
notTrigger?: Observable<boolean>|null // if column is not yet a trigger,
|
||||
disabled?: Observable<boolean>
|
||||
}) {
|
||||
// Set up observables to translate between the UI representation of triggers, and what we
|
||||
// actually store.
|
||||
// - We store the pair (recalcWhen, recalcDeps). When recalcWhen is DEFAULT, recalcDeps lists
|
||||
@@ -74,12 +77,26 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disa
|
||||
return deps.map(dep => use(docModel.columns.getRowModel(dep)?.label)).join(", ");
|
||||
});
|
||||
|
||||
|
||||
const changesDisabled = Computed.create(owner, use => {
|
||||
return Boolean(
|
||||
(options.disabled && use(options.disabled)) ||
|
||||
(options.notTrigger && use(options.notTrigger))
|
||||
);
|
||||
});
|
||||
|
||||
const newRowsDisabled = Computed.create(owner, use => {
|
||||
return Boolean(
|
||||
use(applyOnChanges) || use(changesDisabled)
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
cssRow(
|
||||
labeledSquareCheckbox(
|
||||
applyToNew,
|
||||
'Apply to new records',
|
||||
dom.boolAttr('disabled', (use) => (disable && use(disable)) || use(applyOnChanges)),
|
||||
dom.boolAttr('disabled', newRowsDisabled),
|
||||
testId('field-formula-apply-to-new'),
|
||||
),
|
||||
),
|
||||
@@ -90,7 +107,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disa
|
||||
'Apply on changes to:' :
|
||||
'Apply on record changes'
|
||||
),
|
||||
dom.boolAttr('disabled', (use) => disable ? use(disable) : false),
|
||||
dom.boolAttr('disabled', changesDisabled),
|
||||
testId('field-formula-apply-on-changes'),
|
||||
),
|
||||
),
|
||||
@@ -100,6 +117,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disa
|
||||
cssSelectSummary(dom.text(summaryText)),
|
||||
icon('Dropdown'),
|
||||
testId('field-triggers-select'),
|
||||
dom.cls('disabled', use => !!options.disabled && use(options.disabled)),
|
||||
elem => {
|
||||
setPopupToCreateDom(elem, ctl => buildTriggerSelectors(ctl, column.table.peek(), column, setRecalc),
|
||||
{...defaultMenuOptions, placement: 'bottom-end'});
|
||||
|
||||
Reference in New Issue
Block a user