mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 04:33:42 +08:00
[resumes][feat] Add pagination on browse page (#388)
* [resumes][feat] Add pagination on browse page * [resume][fix] Remove unused type
This commit is contained in:
@ -1,12 +1,22 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Props = Readonly<{
|
||||
isSelected: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
export default function ResumeFilterPill({ title, onClick }: Props) {
|
||||
export default function ResumeFilterPill({
|
||||
title,
|
||||
onClick,
|
||||
isSelected,
|
||||
}: Props) {
|
||||
return (
|
||||
<button
|
||||
className="rounded-xl border border-indigo-500 border-transparent bg-white px-2 py-1 text-xs font-medium text-indigo-500 focus:bg-indigo-500 focus:text-white"
|
||||
className={clsx(
|
||||
'rounded-xl border border-indigo-500 border-transparent px-2 py-1 text-xs font-medium focus:bg-indigo-500 focus:text-white',
|
||||
isSelected ? 'bg-indigo-500 text-white' : 'bg-white text-indigo-500',
|
||||
)}
|
||||
type="button"
|
||||
onClick={onClick}>
|
||||
{title}
|
||||
|
@ -41,11 +41,6 @@ export type FilterState = Partial<CustomFilter> &
|
||||
|
||||
export type SortOrder = 'latest' | 'popular' | 'topComments';
|
||||
|
||||
type SortOption = {
|
||||
name: string;
|
||||
value: SortOrder;
|
||||
};
|
||||
|
||||
export type Shortcut = {
|
||||
customFilters?: CustomFilter;
|
||||
filters: FilterState;
|
||||
@ -59,11 +54,11 @@ export const BROWSE_TABS_VALUES = {
|
||||
STARRED: 'starred',
|
||||
};
|
||||
|
||||
export const SORT_OPTIONS: Array<SortOption> = [
|
||||
{ name: 'Latest', value: 'latest' },
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Top Comments', value: 'topComments' },
|
||||
];
|
||||
export const SORT_OPTIONS: Record<string, string> = {
|
||||
latest: 'Latest',
|
||||
popular: 'Popular',
|
||||
topComments: 'Top Comments',
|
||||
};
|
||||
|
||||
export const ROLE: Array<FilterOption<RoleFilter>> = [
|
||||
{
|
||||
|
@ -1,8 +1,7 @@
|
||||
import compareAsc from 'date-fns/compareAsc';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
@ -10,6 +9,7 @@ import {
|
||||
CheckboxInput,
|
||||
CheckboxList,
|
||||
DropdownMenu,
|
||||
Pagination,
|
||||
Tabs,
|
||||
TextInput,
|
||||
} from '@tih/ui';
|
||||
@ -18,10 +18,7 @@ import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
|
||||
import type {
|
||||
Filter,
|
||||
FilterId,
|
||||
FilterState,
|
||||
FilterValue,
|
||||
Shortcut,
|
||||
SortOrder,
|
||||
} from '~/components/resumes/browse/resumeFilters';
|
||||
import {
|
||||
BROWSE_TABS_VALUES,
|
||||
@ -58,59 +55,64 @@ const filters: Array<Filter> = [
|
||||
},
|
||||
];
|
||||
|
||||
const filterResumes = (
|
||||
resumes: Array<Resume>,
|
||||
searchValue: string,
|
||||
userFilters: FilterState,
|
||||
) =>
|
||||
resumes
|
||||
.filter((resume) =>
|
||||
resume.title.toLowerCase().includes(searchValue.toLocaleLowerCase()),
|
||||
)
|
||||
.filter(
|
||||
({ experience, location, role }) =>
|
||||
userFilters.role.includes(role as FilterValue) &&
|
||||
userFilters.experience.includes(experience as FilterValue) &&
|
||||
userFilters.location.includes(location as FilterValue),
|
||||
)
|
||||
.filter(
|
||||
({ numComments }) =>
|
||||
userFilters.numComments === undefined ||
|
||||
numComments === userFilters.numComments,
|
||||
);
|
||||
|
||||
const sortComparators: Record<
|
||||
SortOrder,
|
||||
(resume1: Resume, resume2: Resume) => number
|
||||
> = {
|
||||
latest: (resume1, resume2) =>
|
||||
compareAsc(resume2.createdAt, resume1.createdAt),
|
||||
popular: (resume1, resume2) => resume2.numStars - resume1.numStars,
|
||||
topComments: (resume1, resume2) => resume2.numComments - resume1.numComments,
|
||||
};
|
||||
const sortResumes = (resumes: Array<Resume>, sortOrder: SortOrder) =>
|
||||
resumes.sort(sortComparators[sortOrder]);
|
||||
|
||||
export default function ResumeHomePage() {
|
||||
const { data: sessionData } = useSession();
|
||||
const router = useRouter();
|
||||
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
|
||||
const [sortOrder, setSortOrder] = useState(SORT_OPTIONS[0].value);
|
||||
const [sortOrder, setSortOrder] = useState('latest');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
|
||||
const [shortcutSelected, setShortcutSelected] = useState('All');
|
||||
const [resumes, setResumes] = useState<Array<Resume>>([]);
|
||||
const [renderSignInButton, setRenderSignInButton] = useState(false);
|
||||
const [signInButtonText, setSignInButtonText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const allResumesQuery = trpc.useQuery(['resumes.resume.findAll'], {
|
||||
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
|
||||
onSuccess: (data) => {
|
||||
setResumes(data);
|
||||
setRenderSignInButton(false);
|
||||
const PAGE_LIMIT = 10;
|
||||
const skip = (currentPage - 1) * PAGE_LIMIT;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [userFilters, sortOrder]);
|
||||
|
||||
const allResumesQuery = trpc.useQuery(
|
||||
[
|
||||
'resumes.resume.findAll',
|
||||
{
|
||||
experienceFilters: userFilters.experience,
|
||||
locationFilters: userFilters.location,
|
||||
numComments: userFilters.numComments,
|
||||
roleFilters: userFilters.role,
|
||||
skip,
|
||||
sortOrder,
|
||||
},
|
||||
],
|
||||
{
|
||||
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
|
||||
onSuccess: (data) => {
|
||||
setTotalPages(
|
||||
data.totalRecords % PAGE_LIMIT === 0
|
||||
? data.totalRecords / PAGE_LIMIT
|
||||
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
|
||||
);
|
||||
setResumes(data.mappedResumeData);
|
||||
setRenderSignInButton(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
const starredResumesQuery = trpc.useQuery(
|
||||
['resumes.resume.user.findUserStarred'],
|
||||
[
|
||||
'resumes.resume.user.findUserStarred',
|
||||
{
|
||||
experienceFilters: userFilters.experience,
|
||||
locationFilters: userFilters.location,
|
||||
numComments: userFilters.numComments,
|
||||
roleFilters: userFilters.role,
|
||||
skip,
|
||||
sortOrder,
|
||||
},
|
||||
],
|
||||
{
|
||||
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
|
||||
onError: () => {
|
||||
@ -119,13 +121,28 @@ export default function ResumeHomePage() {
|
||||
setSignInButtonText('to view starred resumes');
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResumes(data);
|
||||
setTotalPages(
|
||||
data.totalRecords % PAGE_LIMIT === 0
|
||||
? data.totalRecords / PAGE_LIMIT
|
||||
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
|
||||
);
|
||||
setResumes(data.mappedResumeData);
|
||||
},
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const myResumesQuery = trpc.useQuery(
|
||||
['resumes.resume.user.findUserCreated'],
|
||||
[
|
||||
'resumes.resume.user.findUserCreated',
|
||||
{
|
||||
experienceFilters: userFilters.experience,
|
||||
locationFilters: userFilters.location,
|
||||
numComments: userFilters.numComments,
|
||||
roleFilters: userFilters.role,
|
||||
skip,
|
||||
sortOrder,
|
||||
},
|
||||
],
|
||||
{
|
||||
enabled: tabsValue === BROWSE_TABS_VALUES.MY,
|
||||
onError: () => {
|
||||
@ -134,7 +151,12 @@ export default function ResumeHomePage() {
|
||||
setSignInButtonText('to view your submitted resumes');
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResumes(data);
|
||||
setTotalPages(
|
||||
data.totalRecords % PAGE_LIMIT === 0
|
||||
? data.totalRecords / PAGE_LIMIT
|
||||
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
|
||||
);
|
||||
setResumes(data.mappedResumeData);
|
||||
},
|
||||
retry: false,
|
||||
},
|
||||
@ -171,11 +193,18 @@ export default function ResumeHomePage() {
|
||||
const onShortcutChange = ({
|
||||
sortOrder: shortcutSortOrder,
|
||||
filters: shortcutFilters,
|
||||
name: shortcutName,
|
||||
}: Shortcut) => {
|
||||
setShortcutSelected(shortcutName);
|
||||
setSortOrder(shortcutSortOrder);
|
||||
setUserFilters(shortcutFilters);
|
||||
};
|
||||
|
||||
const onTabChange = (tab: string) => {
|
||||
setTabsValue(tab);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@ -195,7 +224,7 @@ export default function ResumeHomePage() {
|
||||
</div>
|
||||
<div className="col-span-10">
|
||||
<div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2">
|
||||
<div className="col-span-7">
|
||||
<div className="col-span-5">
|
||||
<Tabs
|
||||
label="Resume Browse Tabs"
|
||||
tabs={[
|
||||
@ -213,42 +242,44 @@ export default function ResumeHomePage() {
|
||||
},
|
||||
]}
|
||||
value={tabsValue}
|
||||
onChange={setTabsValue}
|
||||
onChange={onTabChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 self-end">
|
||||
<form>
|
||||
<TextInput
|
||||
label=""
|
||||
placeholder="Search Resumes"
|
||||
startAddOn={MagnifyingGlassIcon}
|
||||
startAddOnType="icon"
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div className="col-span-1 justify-self-center">
|
||||
<DropdownMenu align="end" label="Sort">
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.name}
|
||||
isSelected={sortOrder === option.value}
|
||||
label={option.name}
|
||||
onClick={() =>
|
||||
setSortOrder(option.value)
|
||||
}></DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<button
|
||||
className="rounded-md bg-indigo-500 py-1 px-3 text-sm font-medium text-white"
|
||||
type="button"
|
||||
onClick={onSubmitResume}>
|
||||
Submit Resume
|
||||
</button>
|
||||
<div className="col-span-7 flex items-center justify-evenly">
|
||||
<div className="w-64">
|
||||
<form>
|
||||
<TextInput
|
||||
label=""
|
||||
placeholder="Search Resumes"
|
||||
startAddOn={MagnifyingGlassIcon}
|
||||
startAddOnType="icon"
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
|
||||
{Object.entries(SORT_OPTIONS).map(([key, value]) => (
|
||||
<DropdownMenu.Item
|
||||
key={key}
|
||||
isSelected={sortOrder === key}
|
||||
label={value}
|
||||
onClick={() =>
|
||||
setSortOrder(key)
|
||||
}></DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="rounded-md bg-indigo-500 py-1 px-3 text-sm font-medium text-white"
|
||||
type="button"
|
||||
onClick={onSubmitResume}>
|
||||
Submit Resume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -265,6 +296,7 @@ export default function ResumeHomePage() {
|
||||
{SHORTCUTS.map((shortcut) => (
|
||||
<li key={shortcut.name}>
|
||||
<ResumeFilterPill
|
||||
isSelected={shortcutSelected === shortcut.name}
|
||||
title={shortcut.name}
|
||||
onClick={() => onShortcutChange(shortcut)}
|
||||
/>
|
||||
@ -339,17 +371,26 @@ export default function ResumeHomePage() {
|
||||
{renderSignInButton && (
|
||||
<ResumeSignInButton text={signInButtonText} />
|
||||
)}
|
||||
{totalPages === 0 && (
|
||||
<div className="mt-4">Nothing to see here.</div>
|
||||
)}
|
||||
<ResumeListItems
|
||||
isLoading={
|
||||
allResumesQuery.isFetching ||
|
||||
starredResumesQuery.isFetching ||
|
||||
myResumesQuery.isFetching
|
||||
}
|
||||
resumes={sortResumes(
|
||||
filterResumes(resumes, searchValue, userFilters),
|
||||
sortOrder,
|
||||
)}
|
||||
resumes={resumes}
|
||||
/>
|
||||
<div className="my-4 flex justify-center">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
end={totalPages}
|
||||
label="pagination"
|
||||
start={1}
|
||||
onSelect={(page) => setCurrentPage(page)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,8 +6,36 @@ import type { Resume } from '~/types/resume';
|
||||
|
||||
export const resumesRouter = createRouter()
|
||||
.query('findAll', {
|
||||
async resolve({ ctx }) {
|
||||
input: z.object({
|
||||
experienceFilters: z.string().array(),
|
||||
locationFilters: z.string().array(),
|
||||
numComments: z.number().optional(),
|
||||
roleFilters: z.string().array(),
|
||||
skip: z.number(),
|
||||
sortOrder: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const {
|
||||
roleFilters,
|
||||
locationFilters,
|
||||
experienceFilters,
|
||||
sortOrder,
|
||||
numComments,
|
||||
skip,
|
||||
} = input;
|
||||
const userId = ctx.session?.user?.id;
|
||||
const totalRecords = await ctx.prisma.resumesResume.count({
|
||||
where: {
|
||||
...(numComments === 0 && {
|
||||
comments: {
|
||||
none: {},
|
||||
},
|
||||
}),
|
||||
experience: { in: experienceFilters },
|
||||
location: { in: locationFilters },
|
||||
role: { in: roleFilters },
|
||||
},
|
||||
});
|
||||
const resumesData = await ctx.prisma.resumesResume.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
@ -16,6 +44,7 @@ export const resumesRouter = createRouter()
|
||||
stars: true,
|
||||
},
|
||||
},
|
||||
comments: true,
|
||||
stars: {
|
||||
where: {
|
||||
OR: {
|
||||
@ -29,11 +58,32 @@ export const resumesRouter = createRouter()
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
orderBy:
|
||||
sortOrder === 'latest'
|
||||
? {
|
||||
createdAt: 'desc',
|
||||
}
|
||||
: sortOrder === 'popular'
|
||||
? {
|
||||
stars: {
|
||||
_count: 'desc',
|
||||
},
|
||||
}
|
||||
: { comments: { _count: 'desc' } },
|
||||
skip,
|
||||
take: 10,
|
||||
where: {
|
||||
...(numComments === 0 && {
|
||||
comments: {
|
||||
none: {},
|
||||
},
|
||||
}),
|
||||
experience: { in: experienceFilters },
|
||||
location: { in: locationFilters },
|
||||
role: { in: roleFilters },
|
||||
},
|
||||
});
|
||||
return resumesData.map((r) => {
|
||||
const mappedResumeData = resumesData.map((r) => {
|
||||
const resume: Resume = {
|
||||
additionalInfo: r.additionalInfo,
|
||||
createdAt: r.createdAt,
|
||||
@ -50,6 +100,7 @@ export const resumesRouter = createRouter()
|
||||
};
|
||||
return resume;
|
||||
});
|
||||
return { mappedResumeData, totalRecords };
|
||||
},
|
||||
})
|
||||
.query('findOne', {
|
||||
|
@ -45,8 +45,39 @@ export const resumesResumeUserRouter = createProtectedRouter()
|
||||
},
|
||||
})
|
||||
.query('findUserStarred', {
|
||||
async resolve({ ctx }) {
|
||||
input: z.object({
|
||||
experienceFilters: z.string().array(),
|
||||
locationFilters: z.string().array(),
|
||||
numComments: z.number().optional(),
|
||||
roleFilters: z.string().array(),
|
||||
skip: z.number(),
|
||||
sortOrder: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session.user.id;
|
||||
const {
|
||||
roleFilters,
|
||||
locationFilters,
|
||||
experienceFilters,
|
||||
sortOrder,
|
||||
numComments,
|
||||
skip,
|
||||
} = input;
|
||||
const totalRecords = await ctx.prisma.resumesStar.count({
|
||||
where: {
|
||||
resume: {
|
||||
...(numComments === 0 && {
|
||||
comments: {
|
||||
none: {},
|
||||
},
|
||||
}),
|
||||
experience: { in: experienceFilters },
|
||||
location: { in: locationFilters },
|
||||
role: { in: roleFilters },
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
const resumeStarsData = await ctx.prisma.resumesStar.findMany({
|
||||
include: {
|
||||
resume: {
|
||||
@ -65,14 +96,46 @@ export const resumesResumeUserRouter = createProtectedRouter()
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
orderBy:
|
||||
sortOrder === 'latest'
|
||||
? {
|
||||
resume: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}
|
||||
: sortOrder === 'popular'
|
||||
? {
|
||||
resume: {
|
||||
stars: {
|
||||
_count: 'desc',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
resume: {
|
||||
comments: {
|
||||
_count: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
skip,
|
||||
take: 10,
|
||||
where: {
|
||||
resume: {
|
||||
...(numComments === 0 && {
|
||||
comments: {
|
||||
none: {},
|
||||
},
|
||||
}),
|
||||
experience: { in: experienceFilters },
|
||||
location: { in: locationFilters },
|
||||
role: { in: roleFilters },
|
||||
},
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return resumeStarsData.map((rs) => {
|
||||
|
||||
const mappedResumeData = resumeStarsData.map((rs) => {
|
||||
const resume: Resume = {
|
||||
additionalInfo: rs.resume.additionalInfo,
|
||||
createdAt: rs.resume.createdAt,
|
||||
@ -89,11 +152,41 @@ export const resumesResumeUserRouter = createProtectedRouter()
|
||||
};
|
||||
return resume;
|
||||
});
|
||||
return { mappedResumeData, totalRecords };
|
||||
},
|
||||
})
|
||||
.query('findUserCreated', {
|
||||
async resolve({ ctx }) {
|
||||
input: z.object({
|
||||
experienceFilters: z.string().array(),
|
||||
locationFilters: z.string().array(),
|
||||
numComments: z.number().optional(),
|
||||
roleFilters: z.string().array(),
|
||||
skip: z.number(),
|
||||
sortOrder: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session.user.id;
|
||||
const {
|
||||
roleFilters,
|
||||
locationFilters,
|
||||
experienceFilters,
|
||||
sortOrder,
|
||||
numComments,
|
||||
skip,
|
||||
} = input;
|
||||
const totalRecords = await ctx.prisma.resumesResume.count({
|
||||
where: {
|
||||
...(numComments === 0 && {
|
||||
comments: {
|
||||
none: {},
|
||||
},
|
||||
}),
|
||||
experience: { in: experienceFilters },
|
||||
location: { in: locationFilters },
|
||||
role: { in: roleFilters },
|
||||
userId,
|
||||
},
|
||||
});
|
||||
const resumesData = await ctx.prisma.resumesResume.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
@ -113,14 +206,33 @@ export const resumesResumeUserRouter = createProtectedRouter()
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
orderBy:
|
||||
sortOrder === 'latest'
|
||||
? {
|
||||
createdAt: 'desc',
|
||||
}
|
||||
: sortOrder === 'popular'
|
||||
? {
|
||||
stars: {
|
||||
_count: 'desc',
|
||||
},
|
||||
}
|
||||
: { comments: { _count: 'desc' } },
|
||||
skip,
|
||||
take: 10,
|
||||
where: {
|
||||
...(numComments === 0 && {
|
||||
comments: {
|
||||
none: {},
|
||||
},
|
||||
}),
|
||||
experience: { in: experienceFilters },
|
||||
location: { in: locationFilters },
|
||||
role: { in: roleFilters },
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return resumesData.map((r) => {
|
||||
const mappedResumeData = resumesData.map((r) => {
|
||||
const resume: Resume = {
|
||||
additionalInfo: r.additionalInfo,
|
||||
createdAt: r.createdAt,
|
||||
@ -137,5 +249,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
|
||||
};
|
||||
return resume;
|
||||
});
|
||||
return { mappedResumeData, totalRecords };
|
||||
},
|
||||
});
|
||||
|
Reference in New Issue
Block a user