fix: 物料/用户增删改后列表的搜索关键字被丢弃
写操作后原地重新拉取列表时,search 参数被写死成缺省的空串:搜索框里还显示着 用户输入的关键字,列表却退回全量。用户会把全量列表当成筛选结果,接下来的作废/ 删除操作就打在被误认的那一条上。store 层一直有这个缺陷却没被拦住,因为页面 那层没有测试。 修法照 stock.js / approval.js 已有的约定(store 存一份 filters,写操作原地刷新 时取出来用): - materials.js:编辑/作废/恢复沿用 search 和 status - users.js:编辑/删除沿用 search - 新增走反方向,清空筛选并回第 1 页:新建的项不一定匹配当前关键字,沿用搜索 就看不见它,用户会以为没建成功。物料按 id 倒序,回第 1 页正好能看见 - Materials.vue / Users.vue:新增成功后把搜索框一起清空,否则 store 清了筛选而 搜索框没清,同样是表里不一 测试补 14 条(两个 store + 两个页面)。全部做过变异验证:把实现改回原样后, 6 条 store 断言和 2 条页面断言确实变红,且只有它们变红。
This commit is contained in:
parent
fbd0bc1341
commit
bcee877278
|
|
@ -12,6 +12,11 @@ export const useMaterialsStore = defineStore('materials', () => {
|
|||
const loading = ref(false)
|
||||
const pagination = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||
|
||||
// 列表当前生效的筛选条件。编辑/作废/恢复后会在原地重新拉取列表,那次拉取必须沿用
|
||||
// 它们,否则列表会悄悄退回全量,而搜索框里还显示着用户输入的关键字——用户会把
|
||||
// 全量列表当成筛选结果,接着的作废操作就打在误认的那一条上。
|
||||
const filters = ref({ search: '', status: 'all' })
|
||||
|
||||
const categoryMap = {
|
||||
'A01': '标准件', 'A02': '成品件', 'A03': '简易加工件', 'A04': '精密加工件',
|
||||
'A05': '钣金件', 'A06': '塑胶件', 'A07': '其他',
|
||||
|
|
@ -30,6 +35,7 @@ export const useMaterialsStore = defineStore('materials', () => {
|
|||
}
|
||||
|
||||
async function fetchMaterials(page = 1, search = '', status = 'all') {
|
||||
filters.value = { search, status }
|
||||
loading.value = true
|
||||
try {
|
||||
const pageSize = pagination.value.pageSize
|
||||
|
|
@ -76,19 +82,23 @@ export const useMaterialsStore = defineStore('materials', () => {
|
|||
|
||||
async function createMaterial(data) {
|
||||
const res = await materialsApi.create(data)
|
||||
await fetchMaterials(1)
|
||||
// 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的物料不一定
|
||||
// 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。列表按 id 倒序,回到
|
||||
// 第 1 页正好能看见它。清空的是 store 这份状态,页面那份(搜索框)由 Materials.vue
|
||||
// 同步重置。
|
||||
await fetchMaterials(1, '', 'all')
|
||||
return res
|
||||
}
|
||||
|
||||
async function updateMaterial(id, data) {
|
||||
const res = await materialsApi.update(id, data)
|
||||
await fetchMaterials(pagination.value.currentPage)
|
||||
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
|
||||
return res
|
||||
}
|
||||
|
||||
async function obsoleteMaterial(id) {
|
||||
const res = await materialsApi.obsolete(id)
|
||||
await fetchMaterials(pagination.value.currentPage)
|
||||
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
|
||||
// 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新
|
||||
await refreshMaterialOptions()
|
||||
return res
|
||||
|
|
@ -96,7 +106,7 @@ export const useMaterialsStore = defineStore('materials', () => {
|
|||
|
||||
async function restoreMaterial(id) {
|
||||
const res = await materialsApi.restore(id)
|
||||
await fetchMaterials(pagination.value.currentPage)
|
||||
await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status)
|
||||
await refreshMaterialOptions()
|
||||
return res
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { useMaterialsStore } from './materials'
|
|||
|
||||
vi.mock('../api/materials', () => ({
|
||||
materialsApi: {
|
||||
getAll: vi.fn()
|
||||
getAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
obsolete: vi.fn(),
|
||||
restore: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
|
|
@ -74,3 +78,120 @@ describe('materials store selection options', () => {
|
|||
expect(store.materialOptions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('materials store 的筛选保持', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
materialsApi.create.mockResolvedValue({ data: {} })
|
||||
materialsApi.update.mockResolvedValue({ data: {} })
|
||||
materialsApi.obsolete.mockResolvedValue({ data: {} })
|
||||
materialsApi.restore.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
// 编辑/作废/恢复都在原地重新拉取列表,那次拉取必须沿用搜索框里的关键字:否则列表
|
||||
// 会悄悄退回全量,而搜索框里还显示着用户输入的内容——用户会把全量列表当成筛选结果,
|
||||
// 接着的作废操作就打在误认的那一条上。
|
||||
it('编辑物料后刷新列表要沿用当前的搜索关键字', async () => {
|
||||
const store = useMaterialsStore()
|
||||
|
||||
await store.fetchMaterials(2, '螺丝')
|
||||
materialsApi.getAll.mockClear()
|
||||
|
||||
await store.updateMaterial(7, { name: '内六角螺丝' })
|
||||
|
||||
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||
search: '螺丝',
|
||||
status: 'all',
|
||||
limit: 20,
|
||||
offset: 20
|
||||
})
|
||||
})
|
||||
|
||||
// 作废和恢复除了列表还要刷两份选项,所以 getAll 会被调用多次,只看第一次(列表那次)。
|
||||
it('作废物料后刷新列表要沿用当前的搜索关键字', async () => {
|
||||
const store = useMaterialsStore()
|
||||
|
||||
await store.fetchMaterials(1, '螺丝')
|
||||
materialsApi.getAll.mockClear()
|
||||
|
||||
await store.obsoleteMaterial(7)
|
||||
|
||||
expect(materialsApi.getAll).toHaveBeenNthCalledWith(1, {
|
||||
search: '螺丝',
|
||||
status: 'all',
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('恢复物料后刷新列表要沿用当前的搜索关键字', async () => {
|
||||
const store = useMaterialsStore()
|
||||
|
||||
await store.fetchMaterials(1, '螺丝')
|
||||
materialsApi.getAll.mockClear()
|
||||
|
||||
await store.restoreMaterial(7)
|
||||
|
||||
expect(materialsApi.getAll).toHaveBeenNthCalledWith(1, {
|
||||
search: '螺丝',
|
||||
status: 'all',
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
})
|
||||
|
||||
// 状态筛选目前还没有页面在用,但 store 的接口本来就有这一维,原地刷新时漏掉它会
|
||||
// 埋下同一个坑:以后页面加上状态下拉,列表又会悄悄退回全量。
|
||||
it('刷新时一并沿用当前的状态筛选', async () => {
|
||||
const store = useMaterialsStore()
|
||||
|
||||
await store.fetchMaterials(1, '', 'obsolete')
|
||||
materialsApi.getAll.mockClear()
|
||||
|
||||
await store.updateMaterial(7, { name: '内六角螺丝' })
|
||||
|
||||
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||
search: '',
|
||||
status: 'obsolete',
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
})
|
||||
|
||||
// 这处刻意与上面相反:新建的物料不一定匹配当前关键字,沿用搜索就看不见它,用户会
|
||||
// 以为没建成功。列表按 id 倒序,回到第 1 页正好能看见它。
|
||||
it('新建物料后要清空筛选并回到第 1 页', async () => {
|
||||
const store = useMaterialsStore()
|
||||
|
||||
await store.fetchMaterials(3, '螺丝', 'active')
|
||||
materialsApi.getAll.mockClear()
|
||||
|
||||
await store.createMaterial({ name: '新物料' })
|
||||
|
||||
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||
search: '',
|
||||
status: 'all',
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
})
|
||||
|
||||
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||
const store = useMaterialsStore()
|
||||
|
||||
await store.fetchMaterials(2, '螺丝', 'obsolete')
|
||||
materialsApi.getAll.mockClear()
|
||||
|
||||
await store.fetchMaterials()
|
||||
|
||||
expect(materialsApi.getAll).toHaveBeenCalledWith({
|
||||
search: '',
|
||||
status: 'all',
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ export const useUsersStore = defineStore('users', () => {
|
|||
const loading = ref(false)
|
||||
const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||
|
||||
// 列表当前生效的筛选条件。编辑/删除后会在原地重新拉取列表,那次拉取必须沿用它们,
|
||||
// 否则列表会悄悄退回全量,而搜索框里还显示着用户输入的关键字——用户会把全量列表
|
||||
// 当成筛选结果,接着的删除操作就打在误认的那一条上。
|
||||
const filters = ref({ search: '' })
|
||||
|
||||
async function fetchUsers(pageNum = 1, search = '') {
|
||||
filters.value = { search }
|
||||
loading.value = true
|
||||
try {
|
||||
const pageSize = page.value.pageSize
|
||||
|
|
@ -24,19 +30,22 @@ export const useUsersStore = defineStore('users', () => {
|
|||
|
||||
async function createUser(data) {
|
||||
const res = await usersApi.create(data)
|
||||
await fetchUsers(1)
|
||||
// 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的用户不一定
|
||||
// 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。清空的是 store 这份
|
||||
// 状态,页面那份(搜索框)由 Users.vue 同步重置。
|
||||
await fetchUsers(1, '')
|
||||
return res
|
||||
}
|
||||
|
||||
async function updateUser(id, data) {
|
||||
const res = await usersApi.update(id, data)
|
||||
await fetchUsers(page.value.currentPage)
|
||||
await fetchUsers(page.value.currentPage, filters.value.search)
|
||||
return res
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
const res = await usersApi.delete(id)
|
||||
await fetchUsers(page.value.currentPage)
|
||||
await fetchUsers(page.value.currentPage, filters.value.search)
|
||||
return res
|
||||
}
|
||||
|
||||
|
|
|
|||
90
frontend/src/stores/users.test.js
Normal file
90
frontend/src/stores/users.test.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usersApi } from '../api/users'
|
||||
import { useUsersStore } from './users'
|
||||
|
||||
vi.mock('../api/users', () => ({
|
||||
usersApi: {
|
||||
getAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('users store 的筛选保持', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
usersApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
usersApi.create.mockResolvedValue({ data: {} })
|
||||
usersApi.update.mockResolvedValue({ data: {} })
|
||||
usersApi.delete.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
// 编辑/删除都在原地重新拉取列表,那次拉取必须沿用搜索框里的用户名:否则列表会悄悄
|
||||
// 退回全量,而搜索框里还显示着用户输入的内容——用户会把全量列表当成筛选结果,接着
|
||||
// 的删除操作就打在误认的那一条上。
|
||||
it('编辑用户后刷新列表要沿用当前的搜索关键字', async () => {
|
||||
const store = useUsersStore()
|
||||
|
||||
await store.fetchUsers(2, 'zhangsan')
|
||||
usersApi.getAll.mockClear()
|
||||
|
||||
await store.updateUser(7, { username: 'zhangsan', role_ids: [2] })
|
||||
|
||||
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||
limit: 20,
|
||||
offset: 20,
|
||||
search: 'zhangsan'
|
||||
})
|
||||
})
|
||||
|
||||
it('删除用户后刷新列表要沿用当前的搜索关键字', async () => {
|
||||
const store = useUsersStore()
|
||||
|
||||
await store.fetchUsers(1, 'zhangsan')
|
||||
usersApi.getAll.mockClear()
|
||||
|
||||
await store.deleteUser(7)
|
||||
|
||||
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
search: 'zhangsan'
|
||||
})
|
||||
})
|
||||
|
||||
// 这处刻意与上面相反:新建的用户不一定匹配当前关键字,沿用搜索就看不见它,用户会
|
||||
// 以为没建成功。
|
||||
it('新建用户后要清空筛选并回到第 1 页', async () => {
|
||||
const store = useUsersStore()
|
||||
|
||||
await store.fetchUsers(3, 'zhangsan')
|
||||
usersApi.getAll.mockClear()
|
||||
|
||||
await store.createUser({ username: 'lisi', role_ids: [2] })
|
||||
|
||||
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
search: ''
|
||||
})
|
||||
})
|
||||
|
||||
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||
const store = useUsersStore()
|
||||
|
||||
await store.fetchUsers(2, 'zhangsan')
|
||||
usersApi.getAll.mockClear()
|
||||
|
||||
await store.fetchUsers()
|
||||
|
||||
expect(usersApi.getAll).toHaveBeenCalledWith({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
search: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -71,6 +71,10 @@ function codeInput(wrapper) {
|
|||
return wrapper.find('.material-code-field input')
|
||||
}
|
||||
|
||||
function setupState(wrapper) {
|
||||
return wrapper.vm.$.setupState
|
||||
}
|
||||
|
||||
async function openAddDialog(wrapper) {
|
||||
await findButton(wrapper, '新增物料').trigger('click')
|
||||
await flushPromises()
|
||||
|
|
@ -231,3 +235,63 @@ describe('新增物料时的编码联动', () => {
|
|||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('新增物料后列表与搜索框保持一致', () => {
|
||||
beforeEach(() => {
|
||||
materialCategoriesApi.list.mockResolvedValue({ data: categories })
|
||||
unitsApi.list.mockResolvedValue({ data: [] })
|
||||
codeRulesApi.next.mockReset()
|
||||
codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } })
|
||||
materialsStore.createMaterial.mockReset()
|
||||
materialsStore.updateMaterial.mockReset()
|
||||
materialsStore.createMaterial.mockResolvedValue({ data: {} })
|
||||
materialsStore.updateMaterial.mockResolvedValue({ data: {} })
|
||||
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
// 页面里第一个 ElSelect 就是物料搜索框。
|
||||
async function searchBy(wrapper, keyword) {
|
||||
await wrapper.findAllComponents(SelectStub)[0].vm.pick(keyword)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
// store 里新建后的那次刷新是刻意清空筛选的(好让刚建的物料出现在列表里),页面这份
|
||||
// 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着关键字、列表却是全量的
|
||||
// 表里不一,用户会把全量列表当成筛选结果。
|
||||
test('新增物料成功后搜索框要清空', async () => {
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
|
||||
await searchBy(wrapper, '螺丝')
|
||||
expect(setupState(wrapper).searchKeyword).toBe('螺丝')
|
||||
|
||||
await openAddDialog(wrapper)
|
||||
await pickCategories(wrapper)
|
||||
setupState(wrapper).form.name = '内六角螺丝'
|
||||
await setupState(wrapper).handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(materialsStore.createMaterial).toHaveBeenCalled()
|
||||
expect(setupState(wrapper).searchKeyword).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。
|
||||
test('编辑物料成功后搜索框要保留', async () => {
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
|
||||
await searchBy(wrapper, '螺丝')
|
||||
|
||||
setupState(wrapper).showEditModal({
|
||||
id: 7, category_id: 12, material_code: 'A010201', name: '螺丝'
|
||||
})
|
||||
await flushPromises()
|
||||
await setupState(wrapper).handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(materialsStore.updateMaterial).toHaveBeenCalled()
|
||||
expect(setupState(wrapper).searchKeyword).toBe('螺丝')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -375,6 +375,9 @@ async function handleSave() {
|
|||
} else {
|
||||
await materialsStore.createMaterial(formData)
|
||||
ElMessage.success('物料创建成功')
|
||||
// store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得
|
||||
// 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
dialogVisible.value = false
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage } from 'element-plus'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import Users from './Users.vue'
|
||||
import { organizationsApi } from '../api/organizations'
|
||||
|
|
@ -36,6 +36,15 @@ vi.mock('../api/organizations', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
function mountUsers() {
|
||||
return mount(Users, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('用户管理角色', () => {
|
||||
beforeEach(() => {
|
||||
usersStore.users = [{
|
||||
|
|
@ -91,3 +100,58 @@ describe('用户管理角色', () => {
|
|||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('新增用户后列表与搜索框保持一致', () => {
|
||||
beforeEach(() => {
|
||||
usersStore.fetchUsers.mockReset()
|
||||
usersStore.createUser.mockReset()
|
||||
usersStore.updateUser.mockReset()
|
||||
usersStore.createUser.mockResolvedValue({ data: {} })
|
||||
usersStore.updateUser.mockResolvedValue({ data: {} })
|
||||
organizationsApi.listRoles.mockResolvedValue({ data: [] })
|
||||
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
// store 里新建后的那次刷新是刻意清空筛选的(好让刚建的用户出现在列表里),页面这份
|
||||
// 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着用户名、列表却是全量的
|
||||
// 表里不一,用户会把全量列表当成筛选结果。
|
||||
test('新增用户成功后搜索框要清空', async () => {
|
||||
const wrapper = mountUsers()
|
||||
await flushPromises()
|
||||
const state = wrapper.vm.$.setupState
|
||||
|
||||
await wrapper.find('.search-bar input').setValue('zhangsan')
|
||||
await flushPromises()
|
||||
expect(state.searchKeyword).toBe('zhangsan')
|
||||
|
||||
state.form.username = 'lisi'
|
||||
state.form.password = '123456'
|
||||
state.form.role_ids = [2]
|
||||
await state.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(usersStore.createUser).toHaveBeenCalled()
|
||||
expect(state.searchKeyword).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。
|
||||
test('编辑用户成功后搜索框要保留', async () => {
|
||||
const wrapper = mountUsers()
|
||||
await flushPromises()
|
||||
const state = wrapper.vm.$.setupState
|
||||
|
||||
await wrapper.find('.search-bar input').setValue('zhangsan')
|
||||
await flushPromises()
|
||||
|
||||
state.editingId = 7
|
||||
state.form.username = 'zhangsan'
|
||||
state.form.role_ids = [2]
|
||||
await state.handleSave()
|
||||
await flushPromises()
|
||||
|
||||
expect(usersStore.updateUser).toHaveBeenCalled()
|
||||
expect(state.searchKeyword).toBe('zhangsan')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ async function handleSave() {
|
|||
await usersStore.updateUser(editingId.value, data)
|
||||
} else {
|
||||
await usersStore.createUser(data)
|
||||
// store 里新建后的那次刷新会清空筛选(好让刚建的用户出现在列表里),搜索框得
|
||||
// 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user