feat(dees-stepper): add footer menu actions with form-aware step validation
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-11 - 3.71.0 - feat(dees-stepper)
|
||||||
|
add footer menu actions with form-aware step validation
|
||||||
|
|
||||||
|
- replace step footer submit handling with configurable menuOptions actions
|
||||||
|
- disable the primary footer action until required form fields are completed and show a completion hint
|
||||||
|
- dispatch form data before running primary step actions and clean up form subscriptions on destroy
|
||||||
|
- adjust overlay host positioning so the stepper container controls viewport layering correctly
|
||||||
|
|
||||||
## 2026-04-11 - 3.70.1 - fix(dees-modal)
|
## 2026-04-11 - 3.70.1 - fix(dees-modal)
|
||||||
use icon font sizing for modal header buttons
|
use icon font sizing for modal header buttons
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog',
|
name: '@design.estate/dees-catalog',
|
||||||
version: '3.70.1',
|
version: '3.71.0',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ const demoSteps: IStep[] = [
|
|||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-text key="email" label="Work Email" required></dees-input-text>
|
<dees-input-text key="email" label="Work Email" required></dees-input-text>
|
||||||
<dees-input-text key="password" label="Create Password" type="password" required></dees-input-text>
|
<dees-input-text key="password" label="Create Password" type="password" required></dees-input-text>
|
||||||
<dees-form-submit>Continue</dees-form-submit>
|
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
validationFunc: async (stepperArg, elementArg) => {
|
menuOptions: [
|
||||||
const deesForm = elementArg.querySelector('dees-form');
|
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
|
||||||
deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Profile Details',
|
title: 'Profile Details',
|
||||||
@@ -22,13 +20,11 @@ const demoSteps: IStep[] = [
|
|||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-text key="firstName" label="First Name" required></dees-input-text>
|
<dees-input-text key="firstName" label="First Name" required></dees-input-text>
|
||||||
<dees-input-text key="lastName" label="Last Name" required></dees-input-text>
|
<dees-input-text key="lastName" label="Last Name" required></dees-input-text>
|
||||||
<dees-form-submit>Continue</dees-form-submit>
|
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
validationFunc: async (stepperArg, elementArg) => {
|
menuOptions: [
|
||||||
const deesForm = elementArg.querySelector('dees-form');
|
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
|
||||||
deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Contact Information',
|
title: 'Contact Information',
|
||||||
@@ -36,13 +32,11 @@ const demoSteps: IStep[] = [
|
|||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-phone key="phone" label="Mobile Number" required></dees-input-phone>
|
<dees-input-phone key="phone" label="Mobile Number" required></dees-input-phone>
|
||||||
<dees-input-text key="company" label="Company"></dees-input-text>
|
<dees-input-text key="company" label="Company"></dees-input-text>
|
||||||
<dees-form-submit>Continue</dees-form-submit>
|
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
validationFunc: async (stepperArg, elementArg) => {
|
menuOptions: [
|
||||||
const deesForm = elementArg.querySelector('dees-form');
|
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
|
||||||
deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Team Size',
|
title: 'Team Size',
|
||||||
@@ -59,13 +53,11 @@ const demoSteps: IStep[] = [
|
|||||||
]}
|
]}
|
||||||
required
|
required
|
||||||
></dees-input-dropdown>
|
></dees-input-dropdown>
|
||||||
<dees-form-submit>Continue</dees-form-submit>
|
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
validationFunc: async (stepperArg, elementArg) => {
|
menuOptions: [
|
||||||
const deesForm = elementArg.querySelector('dees-form');
|
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
|
||||||
deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Goals',
|
title: 'Goals',
|
||||||
@@ -81,13 +73,11 @@ const demoSteps: IStep[] = [
|
|||||||
]}
|
]}
|
||||||
required
|
required
|
||||||
></dees-input-multitoggle>
|
></dees-input-multitoggle>
|
||||||
<dees-form-submit>Continue</dees-form-submit>
|
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
validationFunc: async (stepperArg, elementArg) => {
|
menuOptions: [
|
||||||
const deesForm = elementArg.querySelector('dees-form');
|
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
|
||||||
deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Brand Preferences',
|
title: 'Brand Preferences',
|
||||||
@@ -95,13 +85,11 @@ const demoSteps: IStep[] = [
|
|||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-text key="brandColor" label="Primary brand color"></dees-input-text>
|
<dees-input-text key="brandColor" label="Primary brand color"></dees-input-text>
|
||||||
<dees-input-text key="tone" label="Preferred tone (e.g. friendly, formal)"></dees-input-text>
|
<dees-input-text key="tone" label="Preferred tone (e.g. friendly, formal)"></dees-input-text>
|
||||||
<dees-form-submit>Continue</dees-form-submit>
|
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
validationFunc: async (stepperArg, elementArg) => {
|
menuOptions: [
|
||||||
const deesForm = elementArg.querySelector('dees-form');
|
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
|
||||||
deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Integrations',
|
title: 'Integrations',
|
||||||
@@ -112,13 +100,11 @@ const demoSteps: IStep[] = [
|
|||||||
label="Integrations in use"
|
label="Integrations in use"
|
||||||
placeholder="Add integration"
|
placeholder="Add integration"
|
||||||
></dees-input-list>
|
></dees-input-list>
|
||||||
<dees-form-submit>Continue</dees-form-submit>
|
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
validationFunc: async (stepperArg, elementArg) => {
|
menuOptions: [
|
||||||
const deesForm = elementArg.querySelector('dees-form');
|
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
|
||||||
deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Review & Launch',
|
title: 'Review & Launch',
|
||||||
@@ -127,6 +113,9 @@ const demoSteps: IStep[] = [
|
|||||||
<p>Almost there! Review your selections and launch whenever you're ready.</p>
|
<p>Almost there! Review your selections and launch whenever you're ready.</p>
|
||||||
</dees-panel>
|
</dees-panel>
|
||||||
`,
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Launch', action: async (stepper) => stepper!.goNext() },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ import { themeDefaultStyles } from '../../00theme.js';
|
|||||||
import { cssGeistFontFamily } from '../../00fonts.js';
|
import { cssGeistFontFamily } from '../../00fonts.js';
|
||||||
import { zIndexRegistry } from '../../00zindex.js';
|
import { zIndexRegistry } from '../../00zindex.js';
|
||||||
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
|
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
|
||||||
|
import type { DeesForm } from '../../00group-form/dees-form/dees-form.js';
|
||||||
import '../dees-tile/dees-tile.js';
|
import '../dees-tile/dees-tile.js';
|
||||||
|
|
||||||
export interface IStep {
|
export interface IStep {
|
||||||
title: string;
|
title: string;
|
||||||
content: TemplateResult;
|
content: TemplateResult;
|
||||||
footerContent?: TemplateResult;
|
menuOptions?: plugins.tsclass.website.IMenuItem<DeesStepper>[];
|
||||||
validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise<any>;
|
validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise<any>;
|
||||||
onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise<any>;
|
onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise<any>;
|
||||||
validationFuncCalled?: boolean;
|
validationFuncCalled?: boolean;
|
||||||
@@ -83,6 +84,14 @@ export class DeesStepper extends DeesElement {
|
|||||||
@property({ type: Number, attribute: false })
|
@property({ type: Number, attribute: false })
|
||||||
accessor stepperZIndex: number = 1000;
|
accessor stepperZIndex: number = 1000;
|
||||||
|
|
||||||
|
@property({ type: Object, attribute: false })
|
||||||
|
accessor activeForm: DeesForm | null = null;
|
||||||
|
|
||||||
|
@property({ type: Boolean, attribute: false })
|
||||||
|
accessor activeFormValid: boolean = true;
|
||||||
|
|
||||||
|
private activeFormSubscription?: { unsubscribe: () => void };
|
||||||
|
|
||||||
private windowLayer?: DeesWindowLayer;
|
private windowLayer?: DeesWindowLayer;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -102,12 +111,18 @@ export class DeesStepper extends DeesElement {
|
|||||||
color: var(--dees-color-text-primary);
|
color: var(--dees-color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* In overlay mode the host is "transparent" to layout — the inner
|
||||||
|
* .stepperContainer.overlay is what pins to the viewport and carries the
|
||||||
|
* z-index. Keeping :host unpositioned avoids nesting the stacking context
|
||||||
|
* under an auto-z-index parent (which was trapping .stepperContainer
|
||||||
|
* below DeesWindowLayer's sibling layers). This mirrors how dees-modal
|
||||||
|
* keeps its own :host unpositioned and lets .modalContainer drive layout.
|
||||||
|
*/
|
||||||
:host([overlay]) {
|
:host([overlay]) {
|
||||||
position: fixed;
|
position: static;
|
||||||
top: 0;
|
width: 0;
|
||||||
left: 0;
|
height: 0;
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepperContainer {
|
.stepperContainer {
|
||||||
@@ -242,12 +257,87 @@ export class DeesStepper extends DeesElement {
|
|||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-footer {
|
/* --- Footer: modal-style bottom buttons --- */
|
||||||
|
.bottomButtons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
height: 36px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton {
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--dees-color-border-subtle);
|
||||||
|
color: var(--dees-color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
}
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
.bottomButtons .bottomButton:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton:hover {
|
||||||
|
background: var(--dees-color-hover);
|
||||||
|
color: var(--dees-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton:active {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(0 0% 13%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton.primary {
|
||||||
|
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton.primary:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton.primary:active {
|
||||||
|
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.12)', 'hsl(213.1 93.9% 67.8% / 0.12)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomButtons .bottomButton.disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dees-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hint shown on the left of the footer when the active step's form has
|
||||||
|
unfilled required fields. Uses margin-right: auto to push right-aligned
|
||||||
|
buttons to the right while keeping the hint flush-left. */
|
||||||
|
.bottomButtons .stepHint {
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--dees-color-text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -280,8 +370,22 @@ export class DeesStepper extends DeesElement {
|
|||||||
<div class="title">${stepArg.title}</div>
|
<div class="title">${stepArg.title}</div>
|
||||||
<div class="content">${stepArg.content}</div>
|
<div class="content">${stepArg.content}</div>
|
||||||
</div>
|
</div>
|
||||||
${stepArg.footerContent
|
${stepArg.menuOptions && stepArg.menuOptions.length > 0
|
||||||
? html`<div slot="footer" class="step-footer">${stepArg.footerContent}</div>`
|
? html`<div slot="footer" class="bottomButtons">
|
||||||
|
${isSelected && this.activeForm !== null && !this.activeFormValid
|
||||||
|
? html`<div class="stepHint">Complete form to continue</div>`
|
||||||
|
: ''}
|
||||||
|
${stepArg.menuOptions.map((actionArg, actionIndex) => {
|
||||||
|
const isPrimary = actionIndex === stepArg.menuOptions!.length - 1;
|
||||||
|
const isDisabled = isPrimary && this.activeForm !== null && !this.activeFormValid;
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="bottomButton ${isPrimary ? 'primary' : ''} ${isDisabled ? 'disabled' : ''}"
|
||||||
|
@click=${() => this.handleMenuOptionClick(actionArg, isPrimary)}
|
||||||
|
>${actionArg.name}</div>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
</dees-tile>`;
|
</dees-tile>`;
|
||||||
})}
|
})}
|
||||||
@@ -316,6 +420,7 @@ export class DeesStepper extends DeesElement {
|
|||||||
if (!selectedStepElement) {
|
if (!selectedStepElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.scanActiveForm(selectedStepElement);
|
||||||
if (!stepperContainer.style.paddingTop) {
|
if (!stepperContainer.style.paddingTop) {
|
||||||
stepperContainer.style.paddingTop = `${
|
stepperContainer.style.paddingTop = `${
|
||||||
stepperContainer.offsetHeight / 2 - selectedStepElement.offsetHeight / 2
|
stepperContainer.offsetHeight / 2 - selectedStepElement.offsetHeight / 2
|
||||||
@@ -383,6 +488,74 @@ export class DeesStepper extends DeesElement {
|
|||||||
this.selectedStep = nextStep;
|
this.selectedStep = nextStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the currently selected step for a <dees-form> in its content. When
|
||||||
|
* found, subscribes to the form's RxJS changeSubject so the primary
|
||||||
|
* menuOption button can auto-enable/disable as required fields are filled.
|
||||||
|
*
|
||||||
|
* If the form reference is the same as the previous activation (e.g. on a
|
||||||
|
* same-step re-render), we just recompute validity without re-subscribing.
|
||||||
|
*/
|
||||||
|
private scanActiveForm(selectedStepElement: HTMLElement) {
|
||||||
|
const form = selectedStepElement.querySelector('dees-form') as DeesForm | null;
|
||||||
|
if (form === this.activeForm) {
|
||||||
|
this.recomputeFormValid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.activeFormSubscription?.unsubscribe();
|
||||||
|
this.activeFormSubscription = undefined;
|
||||||
|
this.activeForm = form;
|
||||||
|
if (!form) {
|
||||||
|
this.activeFormValid = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Initial check before subscribing, in case the form's firstUpdated fires
|
||||||
|
// synchronously between scan and subscribe.
|
||||||
|
this.recomputeFormValid();
|
||||||
|
this.activeFormSubscription = form.changeSubject.subscribe(() => {
|
||||||
|
this.recomputeFormValid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes activeFormValid by checking every required field in the active
|
||||||
|
* form for a non-empty value. Mirrors dees-form.updateRequiredStatus's logic
|
||||||
|
* but stores the result on the stepper instead of mutating a submit button.
|
||||||
|
*/
|
||||||
|
private recomputeFormValid() {
|
||||||
|
const form = this.activeForm;
|
||||||
|
if (!form) {
|
||||||
|
this.activeFormValid = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields = form.getFormElements();
|
||||||
|
this.activeFormValid = fields.every(
|
||||||
|
(field) => !field.required || (field.value !== null && field.value !== undefined && field.value !== ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for menuOption buttons in the footer. For the primary (last)
|
||||||
|
* button, if an active form is present, gates on required-field validity and
|
||||||
|
* triggers the form's gatherAndDispatch() before running the action. The
|
||||||
|
* action is awaited so any async work (e.g. goNext → scroll animation)
|
||||||
|
* completes before the click handler returns.
|
||||||
|
*/
|
||||||
|
private async handleMenuOptionClick(
|
||||||
|
optionArg: plugins.tsclass.website.IMenuItem<DeesStepper>,
|
||||||
|
isPrimary: boolean,
|
||||||
|
) {
|
||||||
|
const form = this.activeForm;
|
||||||
|
if (isPrimary && form) {
|
||||||
|
if (!this.activeFormValid) return;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
form.addEventListener('formData', () => resolve(), { once: true });
|
||||||
|
form.gatherAndDispatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await optionArg.action(this);
|
||||||
|
}
|
||||||
|
|
||||||
public async destroy() {
|
public async destroy() {
|
||||||
const domtools = await this.domtoolsPromise;
|
const domtools = await this.domtoolsPromise;
|
||||||
const container = this.shadowRoot!.querySelector('.stepperContainer');
|
const container = this.shadowRoot!.querySelector('.stepperContainer');
|
||||||
@@ -395,6 +568,11 @@ export class DeesStepper extends DeesElement {
|
|||||||
await this.windowLayer.destroy();
|
await this.windowLayer.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tear down form subscription to avoid leaks when the overlay closes.
|
||||||
|
this.activeFormSubscription?.unsubscribe();
|
||||||
|
this.activeFormSubscription = undefined;
|
||||||
|
this.activeForm = null;
|
||||||
|
|
||||||
// Unregister from z-index registry
|
// Unregister from z-index registry
|
||||||
zIndexRegistry.unregister(this);
|
zIndexRegistry.unregister(this);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user