mirror of
https://github.com/cupcakearmy/svelte-i18n.git
synced 2024-09-28 15:14:45 +02:00
test: 💍 add some tests for the cli
This commit is contained in:
parent
60ba73682f
commit
15ed03185a
@ -1,53 +1,35 @@
|
|||||||
import {
|
import {
|
||||||
Node,
|
Node,
|
||||||
ObjectExpression,
|
ObjectExpression,
|
||||||
Property,
|
|
||||||
ImportDeclaration,
|
ImportDeclaration,
|
||||||
ImportSpecifier,
|
ImportSpecifier,
|
||||||
CallExpression,
|
CallExpression,
|
||||||
Identifier,
|
Identifier,
|
||||||
|
Literal,
|
||||||
} from 'estree'
|
} from 'estree'
|
||||||
import delve from 'dlv'
|
import delve from 'dlv'
|
||||||
import { walk } from 'estree-walker'
|
import { walk } from 'estree-walker'
|
||||||
import { Ast } from 'svelte/types/compiler/interfaces'
|
import { Ast } from 'svelte/types/compiler/interfaces'
|
||||||
import { parse } from 'svelte/compiler'
|
import { parse } from 'svelte/compiler'
|
||||||
|
|
||||||
|
import { deepSet } from './includes/deepSet'
|
||||||
|
import { getObjFromExpression } from './includes/getObjFromExpression'
|
||||||
|
import { Message } from './types'
|
||||||
|
|
||||||
const LIB_NAME = 'svelte-i18n'
|
const LIB_NAME = 'svelte-i18n'
|
||||||
const DEFINE_MESSAGES_METHOD_NAME = 'defineMessages'
|
const DEFINE_MESSAGES_METHOD_NAME = 'defineMessages'
|
||||||
const FORMAT_METHOD_NAMES = new Set(['format', '_'])
|
const FORMAT_METHOD_NAMES = new Set(['format', '_', 't'])
|
||||||
|
const IGNORED_UTILITIES = new Set(['number', 'date', 'time'])
|
||||||
interface Message {
|
|
||||||
node: Node
|
|
||||||
meta: {
|
|
||||||
id?: string
|
|
||||||
default?: string
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNumberString = (n: string) => !Number.isNaN(parseInt(n))
|
|
||||||
|
|
||||||
function deepSet(obj: any, path: string, value: any) {
|
|
||||||
const parts = path.replace(/\[(\w+)\]/gi, '.$1').split('.')
|
|
||||||
return parts.reduce((ref, part, i) => {
|
|
||||||
if (part in ref) return (ref = ref[part])
|
|
||||||
|
|
||||||
if (i < parts.length - 1) {
|
|
||||||
if (isNumberString(parts[i + 1])) {
|
|
||||||
return (ref = ref[part] = [])
|
|
||||||
}
|
|
||||||
return (ref = ref[part] = {})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (ref[part] = value)
|
|
||||||
}, obj)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFormatCall(node: Node, imports: Set<string>) {
|
function isFormatCall(node: Node, imports: Set<string>) {
|
||||||
if (node.type !== 'CallExpression') return false
|
if (node.type !== 'CallExpression') return false
|
||||||
|
|
||||||
let identifier: Identifier
|
let identifier: Identifier
|
||||||
if (node.callee.type === 'MemberExpression') {
|
if (
|
||||||
|
node.callee.type === 'MemberExpression' &&
|
||||||
|
node.callee.property.type === 'Identifier' &&
|
||||||
|
!IGNORED_UTILITIES.has(node.callee.property.name)
|
||||||
|
) {
|
||||||
identifier = node.callee.object as Identifier
|
identifier = node.callee.object as Identifier
|
||||||
} else if (node.callee.type === 'Identifier') {
|
} else if (node.callee.type === 'Identifier') {
|
||||||
identifier = node.callee
|
identifier = node.callee
|
||||||
@ -92,22 +74,6 @@ function getFormatSpecifiers(decl: ImportDeclaration) {
|
|||||||
) as ImportSpecifier[]
|
) as ImportSpecifier[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getObjFromExpression(exprNode: Node | ObjectExpression) {
|
|
||||||
if (exprNode.type !== 'ObjectExpression') return null
|
|
||||||
return exprNode.properties.reduce<Message>(
|
|
||||||
(acc, prop: Property) => {
|
|
||||||
// we only want primitives
|
|
||||||
if (prop.value.type !== 'Literal') return acc
|
|
||||||
if (prop.value.value !== Object(prop.value.value)) {
|
|
||||||
const key = (prop.key as Identifier).name as string
|
|
||||||
acc.meta[key] = prop.value.value
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{ node: exprNode, meta: {} }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectFormatCalls(ast: Ast) {
|
export function collectFormatCalls(ast: Ast) {
|
||||||
const importDecls = getLibImportDeclarations(ast)
|
const importDecls = getLibImportDeclarations(ast)
|
||||||
|
|
||||||
@ -122,14 +88,14 @@ export function collectFormatCalls(ast: Ast) {
|
|||||||
if (imports.size === 0) return []
|
if (imports.size === 0) return []
|
||||||
|
|
||||||
const calls: CallExpression[] = []
|
const calls: CallExpression[] = []
|
||||||
function formatCallsWalker(node: Node) {
|
function enter(node: Node) {
|
||||||
if (isFormatCall(node, imports)) {
|
if (isFormatCall(node, imports)) {
|
||||||
calls.push(node as CallExpression)
|
calls.push(node as CallExpression)
|
||||||
this.skip()
|
this.skip()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(ast.instance as any, { enter: formatCallsWalker })
|
walk(ast.instance as any, { enter })
|
||||||
walk(ast.html as any, { enter: formatCallsWalker })
|
walk(ast.html as any, { enter })
|
||||||
|
|
||||||
return calls
|
return calls
|
||||||
}
|
}
|
||||||
@ -157,7 +123,9 @@ export function collectMessageDefinitions(ast: Ast) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return definitions.flatMap(definitionDict =>
|
return definitions.flatMap(definitionDict =>
|
||||||
definitionDict.properties.map(propNode => propNode.value)
|
definitionDict.properties.map(
|
||||||
|
propNode => propNode.value as ObjectExpression
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,19 +141,18 @@ export function collectMessages(markup: string): Message[] {
|
|||||||
return getObjFromExpression(pathNode)
|
return getObjFromExpression(pathNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathNode.type !== 'Literal' || typeof pathNode.value !== 'string') {
|
const node = pathNode as Literal
|
||||||
return null
|
const id = node.value as string
|
||||||
}
|
|
||||||
|
|
||||||
if (options && options.type === 'ObjectExpression') {
|
if (options && options.type === 'ObjectExpression') {
|
||||||
const messageObj = getObjFromExpression(options)
|
const messageObj = getObjFromExpression(options)
|
||||||
messageObj.meta.id = pathNode.value
|
messageObj.meta.id = id
|
||||||
return messageObj
|
return messageObj
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: pathNode,
|
node,
|
||||||
meta: { id: pathNode.value },
|
meta: { id },
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
17
src/cli/includes/deepSet.ts
Normal file
17
src/cli/includes/deepSet.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const isNumberString = (n: string) => !Number.isNaN(parseInt(n))
|
||||||
|
|
||||||
|
export function deepSet(obj: any, path: string, value: any) {
|
||||||
|
const parts = path.replace(/\[(\w+)\]/gi, '.$1').split('.')
|
||||||
|
return parts.reduce((ref, part, i) => {
|
||||||
|
if (part in ref) return (ref = ref[part])
|
||||||
|
|
||||||
|
if (i < parts.length - 1) {
|
||||||
|
if (isNumberString(parts[i + 1])) {
|
||||||
|
return (ref = ref[part] = [])
|
||||||
|
}
|
||||||
|
return (ref = ref[part] = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (ref[part] = value)
|
||||||
|
}, obj)
|
||||||
|
}
|
18
src/cli/includes/getObjFromExpression.ts
Normal file
18
src/cli/includes/getObjFromExpression.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ObjectExpression, Property, Identifier } from 'estree'
|
||||||
|
|
||||||
|
import { Message } from '../types'
|
||||||
|
|
||||||
|
export function getObjFromExpression(exprNode: ObjectExpression) {
|
||||||
|
return exprNode.properties.reduce<Message>(
|
||||||
|
(acc, prop: Property) => {
|
||||||
|
if (prop.value.type !== 'Literal') return acc
|
||||||
|
// we only want primitives
|
||||||
|
if (prop.value.value !== Object(prop.value.value)) {
|
||||||
|
const key = (prop.key as Identifier).name as string
|
||||||
|
acc.meta[key] = prop.value.value
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ node: exprNode, meta: {} }
|
||||||
|
)
|
||||||
|
}
|
10
src/cli/types/index.ts
Normal file
10
src/cli/types/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Node } from 'estree'
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
node: Node
|
||||||
|
meta: {
|
||||||
|
id?: string
|
||||||
|
default?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
}
|
@ -10,13 +10,27 @@ import {
|
|||||||
} from '../../src/cli/extract'
|
} from '../../src/cli/extract'
|
||||||
|
|
||||||
describe('collecting format calls', () => {
|
describe('collecting format calls', () => {
|
||||||
it('should return nothing if there are no imports from the library', () => {
|
test('returns nothing if there are no imports', () => {
|
||||||
const ast = parse(`<script>const $_ = () => 0; $_();</script>`)
|
const ast = parse(`<script>
|
||||||
|
import Foo from 'foo';
|
||||||
|
const $_ = () => 0; $_();
|
||||||
|
</script>`)
|
||||||
const calls = collectFormatCalls(ast)
|
const calls = collectFormatCalls(ast)
|
||||||
expect(calls).toHaveLength(0)
|
expect(calls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should collect all format calls in the instance script', () => {
|
test('returns nothing if there are no format imports', () => {
|
||||||
|
const ast = parse(
|
||||||
|
`<script>
|
||||||
|
import { init } from 'svelte-i18n';
|
||||||
|
init({})
|
||||||
|
</script>`
|
||||||
|
)
|
||||||
|
const calls = collectFormatCalls(ast)
|
||||||
|
expect(calls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collects all format calls in the instance script', () => {
|
||||||
const ast = parse(`<script>
|
const ast = parse(`<script>
|
||||||
import { format, _ } from 'svelte-i18n'
|
import { format, _ } from 'svelte-i18n'
|
||||||
$format('foo')
|
$format('foo')
|
||||||
@ -30,24 +44,12 @@ describe('collecting format calls', () => {
|
|||||||
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
|
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should collect all format calls with renamed imports', () => {
|
test('collects all format calls with renamed imports', () => {
|
||||||
const ast = parse(`<script>
|
const ast = parse(`<script>
|
||||||
import { format as _x, _ as intl } from 'svelte-i18n'
|
import { format as _x, _ as intl, t as f } from 'svelte-i18n'
|
||||||
$_x('foo')
|
$_x('foo')
|
||||||
$intl({ id: 'bar' })
|
$intl({ id: 'bar' })
|
||||||
</script>`)
|
$f({ id: 'bar' })
|
||||||
const calls = collectFormatCalls(ast)
|
|
||||||
expect(calls).toHaveLength(2)
|
|
||||||
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
|
|
||||||
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should collect all format utility calls', () => {
|
|
||||||
const ast = parse(`<script>
|
|
||||||
import { _ } from 'svelte-i18n'
|
|
||||||
$_.title('foo')
|
|
||||||
$_.capitalize({ id: 'bar' })
|
|
||||||
$_.number(10000)
|
|
||||||
</script>`)
|
</script>`)
|
||||||
const calls = collectFormatCalls(ast)
|
const calls = collectFormatCalls(ast)
|
||||||
expect(calls).toHaveLength(3)
|
expect(calls).toHaveLength(3)
|
||||||
@ -55,20 +57,47 @@ describe('collecting format calls', () => {
|
|||||||
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
|
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
|
||||||
expect(calls[2]).toMatchObject({ type: 'CallExpression' })
|
expect(calls[2]).toMatchObject({ type: 'CallExpression' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('collects all string format utility calls', () => {
|
||||||
|
const ast = parse(`<script>
|
||||||
|
import { _ } from 'svelte-i18n'
|
||||||
|
$_.title('foo')
|
||||||
|
$_.capitalize({ id: 'bar' })
|
||||||
|
$_.lower({ id: 'bar' })
|
||||||
|
$_.upper({ id: 'bar' })
|
||||||
|
</script>`)
|
||||||
|
const calls = collectFormatCalls(ast)
|
||||||
|
expect(calls).toHaveLength(4)
|
||||||
|
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
|
||||||
|
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
|
||||||
|
expect(calls[2]).toMatchObject({ type: 'CallExpression' })
|
||||||
|
expect(calls[3]).toMatchObject({ type: 'CallExpression' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores date, time and number calls', () => {
|
||||||
|
const ast = parse(`<script>
|
||||||
|
import { _ } from 'svelte-i18n'
|
||||||
|
$_.number(1000)
|
||||||
|
$_.date(new Date())
|
||||||
|
$_.time(new Date())
|
||||||
|
</script>`)
|
||||||
|
const calls = collectFormatCalls(ast)
|
||||||
|
expect(calls).toHaveLength(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('collecting message definitions', () => {
|
describe('collecting message definitions', () => {
|
||||||
it('should return nothing if there are no imports from the library', () => {
|
test('returns nothing if there are no imports from the library', () => {
|
||||||
const ast = parse(
|
const ast = parse(
|
||||||
`<script>
|
`<script>
|
||||||
import foo from 'foo';
|
import foo from 'foo';
|
||||||
import { dictionary } from 'svelte-i18n';
|
import { dictionary } from 'svelte-i18n';
|
||||||
</script>`,
|
</script>`
|
||||||
)
|
)
|
||||||
expect(collectMessageDefinitions(ast)).toHaveLength(0)
|
expect(collectMessageDefinitions(ast)).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get all message definition objects', () => {
|
test('gets all message definition objects', () => {
|
||||||
const ast = parse(`<script>
|
const ast = parse(`<script>
|
||||||
import { defineMessages } from 'svelte-i18n';
|
import { defineMessages } from 'svelte-i18n';
|
||||||
defineMessages({ foo: { id: 'foo' }, bar: { id: 'bar' } })
|
defineMessages({ foo: { id: 'foo' }, bar: { id: 'bar' } })
|
||||||
@ -84,10 +113,11 @@ describe('collecting message definitions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('collecting messages', () => {
|
describe('collecting messages', () => {
|
||||||
it('should collect all messages in both instance and html ASTs', () => {
|
test('collects all messages in both instance and html ASTs', () => {
|
||||||
const markup = `
|
const markup = `
|
||||||
<script>
|
<script>
|
||||||
import { _, defineMessages } from 'svelte-i18n';
|
import { _, defineMessages } from 'svelte-i18n';
|
||||||
|
|
||||||
console.log($_({ id: 'foo' }))
|
console.log($_({ id: 'foo' }))
|
||||||
console.log($_.title({ id: 'page.title' }))
|
console.log($_.title({ id: 'page.title' }))
|
||||||
|
|
||||||
@ -96,10 +126,13 @@ describe('collecting messages', () => {
|
|||||||
disabled: { id: 'disabled', default: 'Disabled' }
|
disabled: { id: 'disabled', default: 'Disabled' }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>{$_('msg_1')}</div>
|
<div>{$_('msg_1')}</div>
|
||||||
<div>{$_({id: 'msg_2'})}</div>
|
<div>{$_({id: 'msg_2'})}</div>
|
||||||
<div>{$_('msg_3', { default: 'Message'})}</div>`
|
<div>{$_('msg_3', { default: 'Message'})}</div>`
|
||||||
|
|
||||||
const messages = collectMessages(markup)
|
const messages = collectMessages(markup)
|
||||||
|
|
||||||
expect(messages).toHaveLength(7)
|
expect(messages).toHaveLength(7)
|
||||||
expect(messages).toEqual(
|
expect(messages).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@ -114,15 +147,16 @@ describe('collecting messages', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
meta: { id: 'enabled', default: 'Enabled' },
|
meta: { id: 'enabled', default: 'Enabled' },
|
||||||
}),
|
}),
|
||||||
]),
|
])
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('messages extraction', () => {
|
describe('messages extraction', () => {
|
||||||
it('should return a object built based on all found message paths', () => {
|
test('returns a object built based on all found message paths', () => {
|
||||||
const markup = `
|
const markup = `<script>
|
||||||
<script>import { _ } from 'svelte-i18n';</script>
|
import { _ } from 'svelte-i18n';
|
||||||
|
</script>
|
||||||
|
|
||||||
<h1>{$_.title('title')}</h1>
|
<h1>{$_.title('title')}</h1>
|
||||||
<h2>{$_({ id: 'subtitle'})}</h2>
|
<h2>{$_({ id: 'subtitle'})}</h2>
|
||||||
@ -131,16 +165,16 @@ describe('messages extraction', () => {
|
|||||||
expect(dict).toMatchObject({ title: '', subtitle: '' })
|
expect(dict).toMatchObject({ title: '', subtitle: '' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates deep nested properties', () => {
|
test('creates deep nested properties', () => {
|
||||||
const markup = `
|
const markup = `
|
||||||
<script>import { _ } from 'svelte-i18n';</script>
|
<script>import { _ } from 'svelte-i18n';</script>
|
||||||
|
|
||||||
<h1>{$_.title('home.page.title')}</h1>
|
<h1>{$_.title('home.page.title')}</h1>
|
||||||
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
|
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{$_('list[0]')}</li>
|
<li>{$_('list.0')}</li>
|
||||||
<li>{$_('list[1]')}</li>
|
<li>{$_('list.1')}</li>
|
||||||
<li>{$_('list[2]')}</li>
|
<li>{$_('list.2')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
`
|
`
|
||||||
const dict = extractMessages(markup)
|
const dict = extractMessages(markup)
|
||||||
@ -150,38 +184,38 @@ describe('messages extraction', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates a shallow dictionary', () => {
|
test('creates a shallow dictionary', () => {
|
||||||
const markup = `
|
const markup = `
|
||||||
<script>import { _ } from 'svelte-i18n';</script>
|
<script>import { _ } from 'svelte-i18n';</script>
|
||||||
|
|
||||||
<h1>{$_.title('home.page.title')}</h1>
|
<h1>{$_.title('home.page.title')}</h1>
|
||||||
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
|
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{$_('list[0]')}</li>
|
<li>{$_('list.0')}</li>
|
||||||
<li>{$_('list[1]')}</li>
|
<li>{$_('list.1')}</li>
|
||||||
<li>{$_('list[2]')}</li>
|
<li>{$_('list.2')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
`
|
`
|
||||||
const dict = extractMessages(markup, { shallow: true })
|
const dict = extractMessages(markup, { shallow: true })
|
||||||
expect(dict).toMatchObject({
|
expect(dict).toMatchObject({
|
||||||
'home.page.title': '',
|
'home.page.title': '',
|
||||||
'home.page.subtitle': '',
|
'home.page.subtitle': '',
|
||||||
'list[0]': '',
|
'list.0': '',
|
||||||
'list[1]': '',
|
'list.1': '',
|
||||||
'list[2]': '',
|
'list.2': '',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allow to pass a initial dictionary and only append non-existing props', () => {
|
test('allow to pass a initial dictionary and only append non-existing props', () => {
|
||||||
const markup = `
|
const markup = `
|
||||||
<script>import { _ } from 'svelte-i18n';</script>
|
<script>import { _ } from 'svelte-i18n';</script>
|
||||||
|
|
||||||
<h1>{$_.title('home.page.title')}</h1>
|
<h1>{$_.title('home.page.title')}</h1>
|
||||||
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
|
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{$_('list[0]')}</li>
|
<li>{$_('list.0')}</li>
|
||||||
<li>{$_('list[1]')}</li>
|
<li>{$_('list.1')}</li>
|
||||||
<li>{$_('list[2]')}</li>
|
<li>{$_('list.2')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
`
|
`
|
||||||
const dict = extractMessages(markup, {
|
const dict = extractMessages(markup, {
|
||||||
@ -205,7 +239,7 @@ describe('messages extraction', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allow to pass a initial dictionary and only append shallow non-existing props', () => {
|
test('allow to pass a initial dictionary and only append shallow non-existing props', () => {
|
||||||
const markup = `
|
const markup = `
|
||||||
<script>import { _ } from 'svelte-i18n';</script>
|
<script>import { _ } from 'svelte-i18n';</script>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user