mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Polish forms
Summary: - Updates styling of form submitted page. - Tweaks styling of checkboxes, labels, and questions on form page. - Adds new form 404 page. - Adds checkbox to not show warning again when publishing or un-publishing a form. - Excludes formula, hidden, and attachment columns in submitted form data. - Adds placeholder text to form configuration inputs. - Improves dark mode styling in Form widget. - Updates default title and description of new forms. - Updates styling of Form widget buttons. - Fixes form success text input handling. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4170
This commit is contained in:
@@ -369,11 +369,8 @@ class RefListModel extends Question {
|
||||
public renderInput() {
|
||||
return dom('div',
|
||||
dom.prop('name', this.model.colId),
|
||||
dom.forEach(this.choices, (choice) => css.cssLabel(
|
||||
dom('input',
|
||||
dom.prop('name', this.model.colId),
|
||||
{type: 'checkbox', value: String(choice[0]), style: 'margin-right: 5px;'}
|
||||
),
|
||||
dom.forEach(this.choices, (choice) => css.cssCheckboxLabel(
|
||||
squareCheckbox(observable(false)),
|
||||
String(choice[1] ?? '')
|
||||
)),
|
||||
dom.maybe(use => use(this.choices).length === 0, () => [
|
||||
|
||||
@@ -361,7 +361,7 @@ export class FormView extends Disposable {
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return style.cssFormView(
|
||||
return style.cssFormView(
|
||||
testId('editor'),
|
||||
style.cssFormEditBody(
|
||||
style.cssFormContainer(
|
||||
@@ -427,120 +427,145 @@ export class FormView extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private async _publish() {
|
||||
confirmModal(t('Publish your form?'),
|
||||
t('Publish'),
|
||||
async () => {
|
||||
const page = this.viewSection.view().page();
|
||||
if (!page) {
|
||||
throw new Error('Unable to publish form: undefined page');
|
||||
}
|
||||
let validShare = page.shareRef() !== 0;
|
||||
// If page is shared, make sure home server is aware of it.
|
||||
if (validShare) {
|
||||
try {
|
||||
const pageShare = page.share();
|
||||
const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());
|
||||
validShare = !!serverShare;
|
||||
} catch(ex) {
|
||||
// TODO: for now ignore the error, but the UI should be updated to not show editor
|
||||
if (ex.code === 'AUTH_NO_OWNER') {
|
||||
return;
|
||||
}
|
||||
throw ex;
|
||||
private async _handleClickPublish() {
|
||||
if (this.gristDoc.appModel.dismissedPopups.get().includes('publishForm')) {
|
||||
await this._publishForm();
|
||||
} else {
|
||||
confirmModal(t('Publish your form?'),
|
||||
t('Publish'),
|
||||
async (dontShowAgain) => {
|
||||
await this._publishForm();
|
||||
if (dontShowAgain) {
|
||||
this.gristDoc.appModel.dismissedPopup('publishForm').set(true);
|
||||
}
|
||||
}
|
||||
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
|
||||
if (!validShare) {
|
||||
const shareRef = await this.gristDoc.docModel.docData.sendAction([
|
||||
'AddRecord',
|
||||
'_grist_Shares',
|
||||
null,
|
||||
{
|
||||
linkId: uuidv4(),
|
||||
options: JSON.stringify({
|
||||
publish: true,
|
||||
}),
|
||||
}
|
||||
]);
|
||||
await this.gristDoc.docModel.docData.sendAction(['UpdateRecord', '_grist_Pages', page.id(), {shareRef}]);
|
||||
} else {
|
||||
const share = page.share();
|
||||
share.optionsObj.update({publish: true});
|
||||
await share.optionsObj.save();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
this.viewSection.shareOptionsObj.update({
|
||||
form: true,
|
||||
publish: true,
|
||||
});
|
||||
await this.viewSection.shareOptionsObj.save();
|
||||
});
|
||||
},
|
||||
{
|
||||
explanation: (
|
||||
dom('div',
|
||||
style.cssParagraph(
|
||||
t(
|
||||
'Publishing your form will generate a share link. Anyone with the link can ' +
|
||||
'see the empty form and submit a response.'
|
||||
},
|
||||
{
|
||||
explanation: (
|
||||
dom('div',
|
||||
style.cssParagraph(
|
||||
t(
|
||||
'Publishing your form will generate a share link. Anyone with the link can ' +
|
||||
'see the empty form and submit a response.'
|
||||
),
|
||||
),
|
||||
),
|
||||
style.cssParagraph(
|
||||
t(
|
||||
'Users are limited to submitting ' +
|
||||
'entries (records in your table) and reading pre-set values in designated ' +
|
||||
'fields, such as reference and choice columns.'
|
||||
style.cssParagraph(
|
||||
t(
|
||||
'Users are limited to submitting ' +
|
||||
'entries (records in your table) and reading pre-set values in designated ' +
|
||||
'fields, such as reference and choice columns.'
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
},
|
||||
);
|
||||
)
|
||||
),
|
||||
hideDontShowAgain: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _unpublish() {
|
||||
confirmModal(t('Unpublish your form?'),
|
||||
t('Unpublish'),
|
||||
async () => {
|
||||
await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => {
|
||||
this.viewSection.shareOptionsObj.update({
|
||||
publish: false,
|
||||
});
|
||||
await this.viewSection.shareOptionsObj.save();
|
||||
|
||||
const view = this.viewSection.view();
|
||||
if (view.viewSections().peek().every(vs => !vs.shareOptionsObj.prop('publish')())) {
|
||||
const share = this._pageShare.get();
|
||||
if (!share) { return; }
|
||||
|
||||
share.optionsObj.update({
|
||||
publish: false,
|
||||
});
|
||||
await share.optionsObj.save();
|
||||
private async _publishForm() {
|
||||
const page = this.viewSection.view().page();
|
||||
if (!page) {
|
||||
throw new Error('Unable to publish form: undefined page');
|
||||
}
|
||||
let validShare = page.shareRef() !== 0;
|
||||
// If page is shared, make sure home server is aware of it.
|
||||
if (validShare) {
|
||||
try {
|
||||
const pageShare = page.share();
|
||||
const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());
|
||||
validShare = !!serverShare;
|
||||
} catch(ex) {
|
||||
// TODO: for now ignore the error, but the UI should be updated to not show editor
|
||||
if (ex.code === 'AUTH_NO_OWNER') {
|
||||
return;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => {
|
||||
if (!validShare) {
|
||||
const shareRef = await this.gristDoc.docModel.docData.sendAction([
|
||||
'AddRecord',
|
||||
'_grist_Shares',
|
||||
null,
|
||||
{
|
||||
linkId: uuidv4(),
|
||||
options: JSON.stringify({
|
||||
publish: true,
|
||||
}),
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
explanation: (
|
||||
dom('div',
|
||||
style.cssParagraph(
|
||||
t(
|
||||
'Unpublishing the form will disable the share link so that users accessing ' +
|
||||
'your form via that link will see an error.'
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
},
|
||||
);
|
||||
]);
|
||||
await this.gristDoc.docModel.docData.sendAction(['UpdateRecord', '_grist_Pages', page.id(), {shareRef}]);
|
||||
} else {
|
||||
const share = page.share();
|
||||
share.optionsObj.update({publish: true});
|
||||
await share.optionsObj.save();
|
||||
}
|
||||
|
||||
await this.save();
|
||||
this.viewSection.shareOptionsObj.update({
|
||||
form: true,
|
||||
publish: true,
|
||||
});
|
||||
await this.viewSection.shareOptionsObj.save();
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleClickUnpublish() {
|
||||
if (this.gristDoc.appModel.dismissedPopups.get().includes('unpublishForm')) {
|
||||
await this._unpublishForm();
|
||||
} else {
|
||||
confirmModal(t('Unpublish your form?'),
|
||||
t('Unpublish'),
|
||||
async (dontShowAgain) => {
|
||||
await this._unpublishForm();
|
||||
if (dontShowAgain) {
|
||||
this.gristDoc.appModel.dismissedPopup('unpublishForm').set(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
explanation: (
|
||||
dom('div',
|
||||
style.cssParagraph(
|
||||
t(
|
||||
'Unpublishing the form will disable the share link so that users accessing ' +
|
||||
'your form via that link will see an error.'
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
hideDontShowAgain: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _unpublishForm() {
|
||||
await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => {
|
||||
this.viewSection.shareOptionsObj.update({
|
||||
publish: false,
|
||||
});
|
||||
await this.viewSection.shareOptionsObj.save();
|
||||
|
||||
const view = this.viewSection.view();
|
||||
if (view.viewSections().peek().every(vs => !vs.shareOptionsObj.prop('publish')())) {
|
||||
const share = this._pageShare.get();
|
||||
if (!share) { return; }
|
||||
|
||||
share.optionsObj.update({
|
||||
publish: false,
|
||||
});
|
||||
await share.optionsObj.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _buildPublisher() {
|
||||
return style.cssSwitcher(
|
||||
this._buildSwitcherMessage(),
|
||||
style.cssButtonGroup(
|
||||
style.cssIconButton(
|
||||
style.cssSmallIconButton(
|
||||
style.cssIconButton.cls('-frameless'),
|
||||
icon('Revert'),
|
||||
testId('reset'),
|
||||
@@ -608,14 +633,14 @@ export class FormView extends Disposable {
|
||||
dom('div', 'Unpublish'),
|
||||
dom.show(this.gristDoc.appModel.isOwner()),
|
||||
style.cssIconButton.cls('-warning'),
|
||||
dom.on('click', () => this._unpublish()),
|
||||
dom.on('click', () => this._handleClickUnpublish()),
|
||||
testId('unpublish'),
|
||||
)
|
||||
: style.cssIconButton(
|
||||
dom('div', 'Publish'),
|
||||
dom.show(this.gristDoc.appModel.isOwner()),
|
||||
cssButton.cls('-primary'),
|
||||
dom.on('click', () => this._publish()),
|
||||
dom.on('click', () => this._handleClickPublish()),
|
||||
testId('publish'),
|
||||
);
|
||||
}),
|
||||
@@ -685,6 +710,9 @@ export class FormView extends Disposable {
|
||||
// If formula column, no.
|
||||
if (c.isFormula() && c.formula()) { return false; }
|
||||
|
||||
// Attachments are currently unsupported in forms.
|
||||
if (c.pureType() === 'Attachments') { return false; }
|
||||
|
||||
return true;
|
||||
});
|
||||
toAdd.sort((a, b) => a.parentPos() - b.parentPos());
|
||||
@@ -714,9 +742,8 @@ defaults(FormView.prototype, BaseView.prototype);
|
||||
Object.assign(FormView.prototype, BackboneEvents);
|
||||
|
||||
// Default values when form is reset.
|
||||
const FORM_TITLE = "## **My Super Form**";
|
||||
const FORM_DESC = "This is the UI design work in progress on Grist Forms. We are working hard to " +
|
||||
"give you the best possible experience with this feature";
|
||||
const FORM_TITLE = "## **Form Title**";
|
||||
const FORM_DESC = "Your form description goes here.";
|
||||
|
||||
const SECTION_TITLE = '### **Header**';
|
||||
const SECTION_DESC = 'Description';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {textarea} from 'app/client/ui/inputs';
|
||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {basicButton, bigBasicButton, bigBasicButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {colors, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {BindableValue, dom, DomElementArg, IDomArgs, Observable, styled, subscribeBindable} from 'grainjs';
|
||||
import {marked} from 'marked';
|
||||
@@ -37,8 +37,6 @@ export const cssFormContainer = styled('div', `
|
||||
gap: 8px;
|
||||
`);
|
||||
|
||||
|
||||
|
||||
export const cssFieldEditor = styled('div.hover_border.field_editor', `
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -51,18 +49,18 @@ export const cssFieldEditor = styled('div.hover_border.field_editor', `
|
||||
transition: transform 0.2s ease-in-out;
|
||||
&:hover:not(:has(.hover_border:hover),&-cut) {
|
||||
--hover-visible: visible;
|
||||
outline: 1px solid ${colors.lightGreen};
|
||||
outline: 1px solid ${theme.controlPrimaryBg};
|
||||
}
|
||||
&-selected:not(&-cut) {
|
||||
background: #F7F7F7;
|
||||
outline: 1px solid ${colors.lightGreen};
|
||||
background: ${theme.lightHover};
|
||||
outline: 1px solid ${theme.controlPrimaryBg};
|
||||
--selected-block: block;
|
||||
}
|
||||
&:active:not(:has(&:active)) {
|
||||
outline: 1px solid ${colors.darkGreen};
|
||||
outline: 1px solid ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
&-drag-hover {
|
||||
outline: 2px dashed ${colors.lightGreen};
|
||||
outline: 2px dashed ${theme.controlPrimaryBg};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
&-cut {
|
||||
@@ -100,14 +98,6 @@ export const cssSection = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssLabel = styled('label', `
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
user-select: none;
|
||||
display: block;
|
||||
margin: 0px;
|
||||
`);
|
||||
|
||||
export const cssCheckboxLabel = styled('label', `
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
@@ -139,7 +129,7 @@ export const cssEditableLabel = styled(textarea, `
|
||||
cursor: pointer;
|
||||
min-height: 1.5rem;
|
||||
|
||||
color: ${colors.darkText};
|
||||
color: ${theme.mediumText};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -149,12 +139,12 @@ export const cssEditableLabel = styled(textarea, `
|
||||
&-edit {
|
||||
cursor: auto;
|
||||
background: ${theme.inputBg};
|
||||
outline: 2px solid black;
|
||||
outline: 2px solid ${theme.accessRulesFormulaEditorFocus};
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
&-normal {
|
||||
color: ${colors.darkText};
|
||||
color: ${theme.mediumText};
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -172,15 +162,16 @@ export const cssDesc = styled('div', `
|
||||
`);
|
||||
|
||||
export const cssInput = styled('input', `
|
||||
background-color: ${theme.inputDisabledBg};
|
||||
font-size: inherit;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #D9D9D9;
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor-events: none;
|
||||
pointer-events: none;
|
||||
|
||||
&-invalid {
|
||||
color: red;
|
||||
color: ${theme.inputInvalid};
|
||||
}
|
||||
&[type="number"], &[type="date"], &[type="datetime-local"], &[type="text"] {
|
||||
width: 100%;
|
||||
@@ -190,19 +181,19 @@ export const cssInput = styled('input', `
|
||||
export const cssSelect = styled('select', `
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
background-color: ${theme.inputDisabledBg};
|
||||
font-size: inherit;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #D9D9D9;
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor-events: none;
|
||||
pointer-events: none;
|
||||
|
||||
&-invalid {
|
||||
color: red;
|
||||
color: ${theme.inputInvalid};
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
export const cssFieldEditorContent = styled('div', `
|
||||
|
||||
`);
|
||||
@@ -221,14 +212,6 @@ export const cssSelectedOverlay = styled('div._cssSelectedOverlay', `
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
export const cssControlsLabel = styled('div', `
|
||||
background: ${colors.lightGreen};
|
||||
color: ${colors.light};
|
||||
padding: 1px 2px;
|
||||
min-width: 24px;
|
||||
`);
|
||||
|
||||
export const cssPlusButton = styled('div', `
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
@@ -242,32 +225,18 @@ export const cssCircle = styled('div', `
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: ${colors.lightGreen};
|
||||
color: ${colors.light};
|
||||
background-color: ${theme.addNewCircleSmallBg};
|
||||
color: ${theme.addNewCircleSmallFg};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.${cssPlusButton.className}:hover & {
|
||||
background: ${colors.darkGreen};
|
||||
background: ${theme.addNewCircleSmallHoverBg};
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssPlusIcon = styled(icon, `
|
||||
--icon-color: ${colors.light};
|
||||
`);
|
||||
|
||||
export const cssAddText = styled('div', `
|
||||
color: ${colors.slate};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
z-index: 1;
|
||||
&:before {
|
||||
content: "Add a field";
|
||||
}
|
||||
.${cssPlusButton.className}-hover &:before {
|
||||
content: "Drop here";
|
||||
}
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
`);
|
||||
|
||||
export const cssPadding = styled('div', `
|
||||
@@ -300,27 +269,27 @@ export const cssColumn = styled('div', `
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-right: 8px;
|
||||
--icon-color: ${colors.slate};
|
||||
--icon-color: ${theme.lightText};
|
||||
align-self: stretch;
|
||||
transition: height 0.2s ease-in-out;
|
||||
border: 2px dashed ${colors.darkGrey};
|
||||
background: ${colors.lightGrey};
|
||||
color: ${colors.slate};
|
||||
border: 2px dashed ${theme.inputBorder};
|
||||
background: ${theme.lightHover};
|
||||
color: ${theme.lightText};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
border: 2px dashed ${colors.slate};
|
||||
border: 2px dashed ${theme.lightText};
|
||||
}
|
||||
|
||||
&-empty:hover, &-add-button:hover {
|
||||
border: 2px dashed ${colors.slate};
|
||||
border: 2px dashed ${theme.lightText};
|
||||
}
|
||||
|
||||
&-drag-over {
|
||||
outline: 2px dashed ${colors.lightGreen};
|
||||
outline: 2px dashed ${theme.controlPrimaryBg};
|
||||
}
|
||||
|
||||
&-add-button {
|
||||
@@ -352,13 +321,10 @@ export const cssButtonGroup = styled('div', `
|
||||
`);
|
||||
|
||||
|
||||
export const cssIconLink = styled(basicButtonLink, `
|
||||
padding: 3px 8px;
|
||||
font-size: ${vars.smallFontSize};
|
||||
export const cssIconLink = styled(bigBasicButtonLink, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 24px;
|
||||
|
||||
&-standard {
|
||||
background-color: ${theme.leftPanelBg};
|
||||
@@ -379,13 +345,21 @@ export const cssIconLink = styled(basicButtonLink, `
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssIconButton = styled(basicButton, `
|
||||
padding: 3px 8px;
|
||||
font-size: ${vars.smallFontSize};
|
||||
export const cssSmallIconButton = styled(basicButton, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&-frameless {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssIconButton = styled(bigBasicButton, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 24px;
|
||||
|
||||
&-standard {
|
||||
background-color: ${theme.leftPanelBg};
|
||||
@@ -412,6 +386,9 @@ export const cssMarkdownRendered = styled('div', `
|
||||
& textarea {
|
||||
font-size: 15px;
|
||||
}
|
||||
&-edit textarea {
|
||||
outline: 2px solid ${theme.accessRulesFormulaEditorFocus};
|
||||
}
|
||||
& strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -425,7 +402,7 @@ export const cssMarkdownRendered = styled('div', `
|
||||
text-align: right;
|
||||
}
|
||||
& hr {
|
||||
border-color: ${colors.darkGrey};
|
||||
border-color: ${theme.inputBorder};
|
||||
margin: 8px 0px;
|
||||
}
|
||||
&-separator {
|
||||
@@ -506,7 +483,7 @@ export const cssDrag = styled(icon, `
|
||||
top: calc(50% - 16px / 2);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
--icon-color: ${colors.lightGreen};
|
||||
--icon-color: ${theme.controlPrimaryBg};
|
||||
&-top {
|
||||
top: 16px;
|
||||
}
|
||||
@@ -569,7 +546,7 @@ export const cssRemoveButton = styled('div', `
|
||||
right: 11px;
|
||||
top: 11px;
|
||||
border-radius: 3px;
|
||||
background: ${colors.darkGrey};
|
||||
background: ${theme.attachmentsEditorButtonHoverBg};
|
||||
display: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
@@ -582,7 +559,7 @@ export const cssRemoveButton = styled('div', `
|
||||
width: 13px;
|
||||
}
|
||||
&:hover {
|
||||
background: ${colors.mediumGreyOpaque};
|
||||
background: ${theme.controlSecondaryHoverBg};
|
||||
cursor: pointer;
|
||||
}
|
||||
.${cssFieldEditor.className}-selected > &,
|
||||
|
||||
Reference in New Issue
Block a user