feat: add config download functionality
This commit is contained in:
@@ -1,106 +1,55 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
test.describe('Environment Management', () => {
|
test.describe('Environment Management', () => {
|
||||||
test('should not allow removing DEFAULT environment', async ({ page }) => {
|
test('should not allow removing DEFAULT environment', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("Create new")');
|
await page.click('button:has-text("Create new")');
|
||||||
|
|
||||||
// Try to remove DEFAULT - should be blocked
|
|
||||||
const removeButton = page.locator('button.btn-danger[title="Remove environment"]');
|
const removeButton = page.locator('button.btn-danger[title="Remove environment"]');
|
||||||
|
|
||||||
// The button should be disabled for DEFAULT
|
|
||||||
await expect(removeButton).toBeDisabled();
|
await expect(removeButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove non-DEFAULT environment', async ({ page }) => {
|
test('should remove non-DEFAULT environment', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("Create new")');
|
await page.click('button:has-text("Create new")');
|
||||||
|
page.once('dialog', async dialog => { await dialog.accept('toRemove'); });
|
||||||
// Create a new environment
|
|
||||||
page.once('dialog', async dialog => {
|
|
||||||
await dialog.accept('toRemove');
|
|
||||||
});
|
|
||||||
await page.click('button.btn-success[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify we have 2 envs
|
|
||||||
await expect(page.locator('#environments option')).toHaveCount(2);
|
await expect(page.locator('#environments option')).toHaveCount(2);
|
||||||
|
page.once('dialog', async dialog => { await dialog.accept(); });
|
||||||
// Remove the new environment
|
|
||||||
page.once('dialog', async dialog => {
|
|
||||||
await dialog.accept(); // Confirm removal
|
|
||||||
});
|
|
||||||
await page.click('button.btn-danger[title="Remove environment"]');
|
await page.click('button.btn-danger[title="Remove environment"]');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Verify we're back to 1 env (DEFAULT)
|
|
||||||
await expect(page.locator('#environments option')).toHaveCount(1);
|
await expect(page.locator('#environments option')).toHaveCount(1);
|
||||||
await expect(page.locator('#environments')).toContainText('DEFAULT');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create new environment and switch without errors', async ({ page }) => {
|
test('should create new environment and switch without errors', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("Create new")');
|
await page.click('button:has-text("Create new")');
|
||||||
|
page.once('dialog', async dialog => { await dialog.accept('env1'); });
|
||||||
// Verify DEFAULT environment is loaded
|
|
||||||
await expect(page.locator('#environments')).toContainText('DEFAULT');
|
|
||||||
|
|
||||||
// Create a new environment
|
|
||||||
page.once('dialog', async dialog => {
|
|
||||||
await dialog.accept('env1');
|
|
||||||
});
|
|
||||||
await page.click('button.btn-success[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify new environment is created
|
|
||||||
await expect(page.locator('#environments option')).toHaveCount(2);
|
await expect(page.locator('#environments option')).toHaveCount(2);
|
||||||
|
|
||||||
// Switch back to DEFAULT (by index 0)
|
|
||||||
await page.locator('#environments').selectOption({ index: 0 });
|
await page.locator('#environments').selectOption({ index: 0 });
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Verify the page is still working
|
|
||||||
await expect(page.locator('#environments')).toBeVisible();
|
|
||||||
|
|
||||||
// Switch to env1 (by text) - this should NOT cause error
|
|
||||||
await page.locator('#environments').selectOption('env1');
|
await page.locator('#environments').selectOption('env1');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Verify the page is still working (no white screen of death)
|
|
||||||
await expect(page.locator('#environments')).toBeVisible();
|
await expect(page.locator('#environments')).toBeVisible();
|
||||||
await expect(page.locator('#environments option')).toHaveCount(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create multiple environments and switch between them', async ({ page }) => {
|
test('should create multiple environments and switch between them', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("Create new")');
|
await page.click('button:has-text("Create new")');
|
||||||
|
page.once('dialog', async dialog => { await dialog.accept('env1'); });
|
||||||
// Create env1
|
|
||||||
page.once('dialog', async dialog => {
|
|
||||||
await dialog.accept('env1');
|
|
||||||
});
|
|
||||||
await page.click('button.btn-success[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
page.once('dialog', async dialog => { await dialog.accept('env2'); });
|
||||||
// Create env2
|
|
||||||
page.once('dialog', async dialog => {
|
|
||||||
await dialog.accept('env2');
|
|
||||||
});
|
|
||||||
await page.click('button.btn-success[title="Add environment"]');
|
await page.click('button.btn-success[title="Add environment"]');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify we have 3 envs (DEFAULT + env1 + env2)
|
|
||||||
await expect(page.locator('#environments option')).toHaveCount(3);
|
await expect(page.locator('#environments option')).toHaveCount(3);
|
||||||
|
|
||||||
// Switch to each env and verify page doesn't crash
|
|
||||||
await page.locator('#environments').selectOption({ index: 0 });
|
await page.locator('#environments').selectOption({ index: 0 });
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
await expect(page.locator('#environments')).toBeVisible();
|
|
||||||
|
|
||||||
await page.locator('#environments').selectOption('env1');
|
await page.locator('#environments').selectOption('env1');
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
await expect(page.locator('#environments')).toBeVisible();
|
|
||||||
|
|
||||||
await page.locator('#environments').selectOption('env2');
|
await page.locator('#environments').selectOption('env2');
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
await expect(page.locator('#environments')).toBeVisible();
|
await expect(page.locator('#environments')).toBeVisible();
|
||||||
@@ -109,105 +58,83 @@ test.describe('Environment Management', () => {
|
|||||||
test('should add params and edit template manually', async ({ page }) => {
|
test('should add params and edit template manually', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("Create new")');
|
await page.click('button:has-text("Create new")');
|
||||||
|
|
||||||
// Step 1: Add a param to DEFAULT
|
|
||||||
const nameInput = page.locator('input[placeholder="name"]').first();
|
const nameInput = page.locator('input[placeholder="name"]').first();
|
||||||
const valueInput = page.locator('input[placeholder="value"]').first();
|
const valueInput = page.locator('input[placeholder="value"]').first();
|
||||||
const addButton = page.locator('button.btn-success').first();
|
const addButton = page.locator('button.btn-success').first();
|
||||||
|
|
||||||
await nameInput.fill('host');
|
await nameInput.fill('host');
|
||||||
await valueInput.fill('localhost:8080');
|
await valueInput.fill('localhost:8080');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Add second param
|
|
||||||
await nameInput.fill('port');
|
await nameInput.fill('port');
|
||||||
await valueInput.fill('9090');
|
await valueInput.fill('9090');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Step 2: Switch to Content Template tab
|
|
||||||
await page.click('a:has-text("Content Template")');
|
await page.click('a:has-text("Content Template")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify the tab content is visible (check for Edit button)
|
|
||||||
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
|
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
|
||||||
|
|
||||||
// Step 3: Click Edit button
|
|
||||||
await page.click('button:has-text("Edit")');
|
await page.click('button:has-text("Edit")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Step 4: Verify textarea is visible
|
|
||||||
const textarea = page.locator('textarea');
|
const textarea = page.locator('textarea');
|
||||||
await expect(textarea).toBeVisible();
|
await expect(textarea).toBeVisible();
|
||||||
|
|
||||||
// Step 5: Edit the template manually - add a new key
|
|
||||||
await textarea.fill('{\n "!!! host": "@host@",\n "!!! port": "@port@",\n "!!! custom": "@custom@"\n}');
|
await textarea.fill('{\n "!!! host": "@host@",\n "!!! port": "@port@",\n "!!! custom": "@custom@"\n}');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Step 6: Click Save
|
|
||||||
await page.click('button:has-text("Save")');
|
await page.click('button:has-text("Save")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Step 7: Verify the template was saved (Edit button should be visible again)
|
|
||||||
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
|
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
|
||||||
|
|
||||||
// Verify the content contains the new key
|
|
||||||
const pageContent = await page.content();
|
const pageContent = await page.content();
|
||||||
expect(pageContent).toContain('!!! custom');
|
expect(pageContent).toContain('!!! custom');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not duplicate params when placeholder already exists in template', async ({ page }) => {
|
test('should not duplicate params when placeholder already exists', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("Create new")');
|
await page.click('button:has-text("Create new")');
|
||||||
|
|
||||||
// Step 1: Add a param to DEFAULT
|
|
||||||
const nameInput = page.locator('input[placeholder="name"]').first();
|
const nameInput = page.locator('input[placeholder="name"]').first();
|
||||||
const valueInput = page.locator('input[placeholder="value"]').first();
|
const valueInput = page.locator('input[placeholder="value"]').first();
|
||||||
const addButton = page.locator('button.btn-success').first();
|
const addButton = page.locator('button.btn-success').first();
|
||||||
|
|
||||||
await nameInput.fill('host');
|
await nameInput.fill('host');
|
||||||
await valueInput.fill('localhost:8080');
|
await valueInput.fill('localhost:8080');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Step 2: Switch to Content Template tab
|
|
||||||
await page.click('a:has-text("Content Template")');
|
await page.click('a:has-text("Content Template")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Step 3: Click Edit and manually add @host@ usage in a custom field
|
|
||||||
await page.click('button:has-text("Edit")');
|
await page.click('button:has-text("Edit")');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
const textarea = page.locator('textarea');
|
const textarea = page.locator('textarea');
|
||||||
// Add a custom field that uses @host@ placeholder
|
|
||||||
await textarea.fill('{\n "!!! host": "@host@",\n "apiUrl": "http://@host@/api"\n}');
|
await textarea.fill('{\n "!!! host": "@host@",\n "apiUrl": "http://@host@/api"\n}');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Step 4: Save
|
|
||||||
await page.click('button:has-text("Save")');
|
await page.click('button:has-text("Save")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Step 5: Add ANOTHER param with same name (host) - should not create duplicate
|
|
||||||
await page.click('a:has-text("Env")');
|
await page.click('a:has-text("Env")');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
await nameInput.fill('host');
|
await nameInput.fill('host');
|
||||||
await valueInput.fill('updated-host:9090');
|
await valueInput.fill('updated-host:9090');
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Step 6: Switch back to Content Template and verify no duplicate
|
|
||||||
await page.click('a:has-text("Content Template")');
|
await page.click('a:has-text("Content Template")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Count occurrences of "!!! host" - should be exactly 1
|
|
||||||
const templateContent = await page.locator('.config-template-editor').textContent();
|
const templateContent = await page.locator('.config-template-editor').textContent();
|
||||||
const hostKeyCount = (templateContent.match(/!!! host/g) || []).length;
|
const hostKeyCount = (templateContent.match(/!!! host/g) || []).length;
|
||||||
expect(hostKeyCount).toBe(1);
|
expect(hostKeyCount).toBe(1);
|
||||||
|
|
||||||
// The @host@ placeholder should appear twice (once in !!! host, once in apiUrl)
|
|
||||||
const hostPlaceholderCount = (templateContent.match(/@host@/g) || []).length;
|
const hostPlaceholderCount = (templateContent.match(/@host@/g) || []).length;
|
||||||
expect(hostPlaceholderCount).toBe(2);
|
expect(hostPlaceholderCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should download config file with correct filename', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.click('button:has-text("Create new")');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.click('button:has-text("Download")')
|
||||||
|
]);
|
||||||
|
const filename = download.suggestedFilename();
|
||||||
|
expect(filename).toMatch(/^config_\d{2}-\d{2}-\d{2}-\d{4}\.json\.xml$/);
|
||||||
|
// Save and read the file to verify content
|
||||||
|
await download.saveAs('temp-config.xml');
|
||||||
|
const contentStr = fs.readFileSync('temp-config.xml', 'utf8');
|
||||||
|
expect(contentStr).toContain('engine');
|
||||||
|
expect(contentStr).toContain('DEFAULT');
|
||||||
|
expect(contentStr).toContain('template');
|
||||||
|
fs.unlinkSync('temp-config.xml');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function App() {
|
|||||||
AppState.Instance.loadConfig(x);
|
AppState.Instance.loadConfig(x);
|
||||||
setEnvs(x.envs);
|
setEnvs(x.envs);
|
||||||
setConfig(x);
|
setConfig(x);
|
||||||
}} />
|
}} config={config} />
|
||||||
</div>
|
</div>
|
||||||
{envs.length > 0 ?
|
{envs.length > 0 ?
|
||||||
(<div className="row">
|
(<div className="row">
|
||||||
|
|||||||
39
src/builders/ConfigBuilder.ts
Normal file
39
src/builders/ConfigBuilder.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Config } from "../models/Config";
|
||||||
|
import { EnvBuilder } from "./EnvBuilder";
|
||||||
|
|
||||||
|
export class ConfigBuilder {
|
||||||
|
/**
|
||||||
|
* Builds a full XML config file with all environments and template.
|
||||||
|
*/
|
||||||
|
public static buildFullXml(config: Config): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("<engine>");
|
||||||
|
|
||||||
|
// Add all environments
|
||||||
|
for (const env of config.envs) {
|
||||||
|
const envXml = new EnvBuilder().buildEnv(env);
|
||||||
|
lines.push(envXml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add template
|
||||||
|
lines.push(" <template>");
|
||||||
|
lines.push(config.template.content);
|
||||||
|
lines.push(" </template>");
|
||||||
|
|
||||||
|
lines.push("</engine>");
|
||||||
|
return lines.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates filename with timestamp: config_yy-dd-MM-HHmm.json.xml
|
||||||
|
*/
|
||||||
|
public static generateFilename(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const yy = now.getFullYear().toString().slice(-2);
|
||||||
|
const dd = String(now.getDate()).padStart(2, '0');
|
||||||
|
const MM = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const HH = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
return `config_${yy}-${dd}-${MM}-${HH}${mm}.json.xml`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ export class EnvBuilder implements IBuilder<Env> {
|
|||||||
this._src = v;
|
this._src = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds XML for a single environment (static method for direct use)
|
||||||
|
*/
|
||||||
|
public buildEnv(env: Env): string {
|
||||||
|
this.src = env;
|
||||||
|
return this.build();
|
||||||
|
}
|
||||||
|
|
||||||
build(): string {
|
build(): string {
|
||||||
return this
|
return this
|
||||||
.open()
|
.open()
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { Env } from "../models/Env";
|
import { Env } from "../models/Env";
|
||||||
import { EnvBuilder } from "./EnvBuilder";
|
import { EnvBuilder } from "./EnvBuilder";
|
||||||
|
import { ConfigBuilder } from "./ConfigBuilder";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface IBuilder<T> {
|
export interface IBuilder<T> {
|
||||||
get src(): T;
|
get src(): T;
|
||||||
@@ -24,3 +21,5 @@ export class Builder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ConfigBuilder };
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Env } from "../models/Env";
|
import { Env } from "../models/Env";
|
||||||
import { ConfigReader } from "../models/ConfigReader";
|
import { ConfigReader } from "../models/ConfigReader";
|
||||||
import { Config } from "../models/Config";
|
import { Config } from "../models/Config";
|
||||||
|
import { ConfigBuilder } from "../builders/ConfigBuilder";
|
||||||
|
|
||||||
|
export function FileChooser(props: { onSelected: (x: Config) => void, config?: Config }) {
|
||||||
export function FileChooser(props: { onSelected: (x: Config) => void }) {
|
|
||||||
async function handleFile(x: React.ChangeEvent<HTMLInputElement>) {
|
async function handleFile(x: React.ChangeEvent<HTMLInputElement>) {
|
||||||
let file = x.target.files![0];
|
let file = x.target.files![0];
|
||||||
|
|
||||||
@@ -23,11 +23,42 @@ export function FileChooser(props: { onSelected: (x: Config) => void }) {
|
|||||||
props.onSelected(cfg);
|
props.onSelected(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
if (!props.config) {
|
||||||
|
alert("No configuration loaded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlContent = ConfigBuilder.buildFullXml(props.config);
|
||||||
|
const filename = ConfigBuilder.generateFilename();
|
||||||
|
|
||||||
|
// Create blob and download
|
||||||
|
const blob = new Blob([xmlContent], { type: "text/xml" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="col-2">
|
<div className="col-2">
|
||||||
<button className="btn btn-primary" onClick={handleNew} >Create new</button>
|
<button className="btn btn-primary" onClick={handleNew} >Create new</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!props.config}
|
||||||
|
title="Download full config template"
|
||||||
|
>
|
||||||
|
⬇ Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="col-1">or</div>
|
<div className="col-1">or</div>
|
||||||
|
|
||||||
<div className="col">
|
<div className="col">
|
||||||
|
|||||||
11
src/test/ConfigBuilder.test.ts
Normal file
11
src/test/ConfigBuilder.test.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ConfigBuilder } from '../builders/ConfigBuilder';
|
||||||
|
|
||||||
|
describe('ConfigBuilder', () => {
|
||||||
|
describe('generateFilename', () => {
|
||||||
|
it('should generate filename with correct format', () => {
|
||||||
|
const filename = ConfigBuilder.generateFilename();
|
||||||
|
expect(filename).toMatch(/^config_\d{2}-\d{2}-\d{2}-\d{4}\.json\.xml$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user