mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-06-20 22:03:59 +02:00
Refactor UserTable and getPaginatedUsers query
This commit is contained in:
parent
c4c46fc39d
commit
a1e751407f
@ -12,25 +12,28 @@ const AdminSwitch = ({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UsersTable = () => {
|
const UsersTable = () => {
|
||||||
const [skip, setskip] = useState(0);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [page, setPage] = useState(1);
|
const [emailFilter, setEmailFilter] = useState<string>('');
|
||||||
const [email, setEmail] = useState<string | undefined>(undefined);
|
|
||||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
||||||
const [statusOptions, setStatusOptions] = useState<SubscriptionStatus[]>([]);
|
const [subscriptionStatusFilter, setSubcriptionStatusFilter] = useState<SubscriptionStatus[]>([]);
|
||||||
|
|
||||||
|
const skipPages = currentPage - 1;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery(getPaginatedUsers, {
|
const { data, isLoading } = useQuery(getPaginatedUsers, {
|
||||||
skip,
|
skipPages,
|
||||||
emailContains: email,
|
filter: {
|
||||||
isAdmin: isAdminFilter,
|
...(emailFilter && { emailContains: emailFilter }),
|
||||||
subscriptionStatus: statusOptions?.length > 0 ? statusOptions : undefined,
|
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
||||||
|
...(subscriptionStatusFilter?.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(
|
||||||
setPage(1);
|
function backToPageOne() {
|
||||||
}, [email, statusOptions]);
|
setCurrentPage(1);
|
||||||
|
},
|
||||||
useEffect(() => {
|
[emailFilter, subscriptionStatusFilter, isAdminFilter]
|
||||||
setskip((page - 1) * 10);
|
);
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
@ -47,7 +50,7 @@ const UsersTable = () => {
|
|||||||
id='email-filter'
|
id='email-filter'
|
||||||
placeholder='dude@example.com'
|
placeholder='dude@example.com'
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setEmail(e.currentTarget.value);
|
setEmailFilter(e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
className='rounded border border-stroke py-2 px-5 bg-white outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
className='rounded border border-stroke py-2 px-5 bg-white outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||||
/>
|
/>
|
||||||
@ -56,8 +59,8 @@ const UsersTable = () => {
|
|||||||
</label>
|
</label>
|
||||||
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none bg-white transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none bg-white transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
{!!statusOptions && statusOptions.length > 0 ? (
|
{!!subscriptionStatusFilter && subscriptionStatusFilter.length > 0 ? (
|
||||||
statusOptions.map((opt) => (
|
subscriptionStatusFilter.map((opt) => (
|
||||||
<span
|
<span
|
||||||
key={opt}
|
key={opt}
|
||||||
className='z-30 flex items-center my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
className='z-30 flex items-center my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||||
@ -66,26 +69,13 @@ const UsersTable = () => {
|
|||||||
<span
|
<span
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setStatusOptions((prevValue) => {
|
setSubcriptionStatusFilter((prevValue) => {
|
||||||
return prevValue?.filter((val) => val !== opt);
|
return prevValue?.filter((val) => val !== opt);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className='z-30 cursor-pointer pl-2 hover:text-danger'
|
className='z-30 cursor-pointer pl-2 hover:text-danger'
|
||||||
>
|
>
|
||||||
<svg
|
<XIcon />
|
||||||
width='14'
|
|
||||||
height='14'
|
|
||||||
viewBox='0 0 12 12'
|
|
||||||
fill='none'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
clipRule='evenodd'
|
|
||||||
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
|
|
||||||
fill='currentColor'
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
@ -98,7 +88,7 @@ const UsersTable = () => {
|
|||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const targetValue = e.target.value === '' ? null : e.target.value;
|
const targetValue = e.target.value === '' ? null : e.target.value;
|
||||||
setStatusOptions((prevValue) => {
|
setSubcriptionStatusFilter((prevValue) => {
|
||||||
if (prevValue?.includes(targetValue as SubscriptionStatus)) {
|
if (prevValue?.includes(targetValue as SubscriptionStatus)) {
|
||||||
return prevValue?.filter((val) => val !== targetValue);
|
return prevValue?.filter((val) => val !== targetValue);
|
||||||
} else if (!!prevValue) {
|
} else if (!!prevValue) {
|
||||||
@ -113,8 +103,8 @@ const UsersTable = () => {
|
|||||||
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
|
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
|
||||||
>
|
>
|
||||||
<option value=''>Select filters</option>
|
<option value=''>Select filters</option>
|
||||||
{['past_due', 'canceled', 'active', 'deleted', null].map((status) => {
|
{['past_due', 'cancel_at_period_end', 'active', 'deleted', null].map((status) => {
|
||||||
if (!statusOptions.includes(status as SubscriptionStatus)) {
|
if (!subscriptionStatusFilter.includes(status as SubscriptionStatus)) {
|
||||||
return (
|
return (
|
||||||
<option key={status} value={status || ''}>
|
<option key={status} value={status || ''}>
|
||||||
{status ? status : 'has not subscribed'}
|
{status ? status : 'has not subscribed'}
|
||||||
@ -124,22 +114,7 @@ const UsersTable = () => {
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||||
<svg
|
<ChevronDownIcon />
|
||||||
width='24'
|
|
||||||
height='24'
|
|
||||||
viewBox='0 0 24 24'
|
|
||||||
fill='none'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
>
|
|
||||||
<g opacity='0.8'>
|
|
||||||
<path
|
|
||||||
fillRule='evenodd'
|
|
||||||
clipRule='evenodd'
|
|
||||||
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
|
|
||||||
fill='#637381'
|
|
||||||
></path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
@ -157,6 +132,7 @@ const UsersTable = () => {
|
|||||||
}}
|
}}
|
||||||
className='relative z-20 w-full appearance-none rounded border border-stroke bg-white p-2 pl-4 pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'
|
className='relative z-20 w-full appearance-none rounded border border-stroke bg-white p-2 pl-4 pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'
|
||||||
>
|
>
|
||||||
|
{/*Why no svg here*/}
|
||||||
<option value='both'>both</option>
|
<option value='both'>both</option>
|
||||||
<option value='true'>true</option>
|
<option value='true'>true</option>
|
||||||
<option value='false'>false</option>
|
<option value='false'>false</option>
|
||||||
@ -168,11 +144,14 @@ const UsersTable = () => {
|
|||||||
<span className='text-md mr-2 text-black dark:text-white'>page</span>
|
<span className='text-md mr-2 text-black dark:text-white'>page</span>
|
||||||
<input
|
<input
|
||||||
type='number'
|
type='number'
|
||||||
value={page}
|
|
||||||
min={1}
|
min={1}
|
||||||
|
defaultValue={currentPage}
|
||||||
max={data?.totalPages}
|
max={data?.totalPages}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPage(parseInt(e.currentTarget.value));
|
const value = parseInt(e.currentTarget.value);
|
||||||
|
if (data?.totalPages && value <= data?.totalPages && value > 0) {
|
||||||
|
setCurrentPage(value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||||
/>
|
/>
|
||||||
@ -238,4 +217,32 @@ const UsersTable = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ChevronDownIcon() {
|
||||||
|
return (
|
||||||
|
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||||
|
<g opacity='0.8'>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
clipRule='evenodd'
|
||||||
|
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
|
||||||
|
fill='#637381'
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function XIcon() {
|
||||||
|
return (
|
||||||
|
<svg width='14' height='14' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
clipRule='evenodd'
|
||||||
|
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
|
||||||
|
fill='currentColor'
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default UsersTable;
|
export default UsersTable;
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
|
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
|
||||||
import { type User } from 'wasp/entities';
|
import { type User } from 'wasp/entities';
|
||||||
import { HttpError } from 'wasp/server';
|
import { HttpError, prisma } from 'wasp/server';
|
||||||
import { type SubscriptionStatus } from '../payment/plans';
|
import { type SubscriptionStatus } from '../payment/plans';
|
||||||
|
import { type Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isAdmin'>, User> = async (
|
export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isAdmin'>, User> = async (
|
||||||
{ id, isAdmin },
|
{ id, isAdmin },
|
||||||
@ -22,11 +23,12 @@ export const updateIsUserAdminById: UpdateIsUserAdminById<Pick<User, 'id' | 'isA
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GetPaginatedUsersInput = {
|
type GetPaginatedUsersInput = {
|
||||||
skip: number;
|
skipPages: number;
|
||||||
cursor?: number | undefined;
|
filter: {
|
||||||
emailContains?: string;
|
emailContains?: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
subscriptionStatus?: SubscriptionStatus[];
|
subscriptionStatusIn?: SubscriptionStatus[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetPaginatedUsersOutput = {
|
type GetPaginatedUsersOutput = {
|
||||||
@ -41,39 +43,44 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
|||||||
args,
|
args,
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
if (!context.user?.isAdmin) {
|
if (!context.user) {
|
||||||
throw new HttpError(401);
|
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSubscriptionStatusOptions = args.subscriptionStatus as Array<string | null> | undefined;
|
if (!context.user.isAdmin) {
|
||||||
const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null);
|
throw new HttpError(403, 'Only admins are allowed to perform this operation');
|
||||||
const subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as
|
}
|
||||||
| string[]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const queryResults = await context.entities.User.findMany({
|
const {
|
||||||
skip: args.skip,
|
skipPages,
|
||||||
take: 10,
|
filter: { subscriptionStatusIn: subscriptionStatus, emailContains, isAdmin },
|
||||||
|
} = args;
|
||||||
|
const includeUnsubscribedUsers = !!subscriptionStatus?.some((status) => status === null);
|
||||||
|
const desiredSubscriptionStatuses = subscriptionStatus?.filter((status) => status !== null);
|
||||||
|
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
const userPageQuery: Prisma.UserFindManyArgs = {
|
||||||
|
skip: skipPages * pageSize,
|
||||||
|
take: pageSize,
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
email: {
|
email: {
|
||||||
contains: args.emailContains || undefined,
|
contains: emailContains,
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
isAdmin: args.isAdmin,
|
isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
subscriptionStatus: {
|
subscriptionStatus: {
|
||||||
in: subscriptionStatusStrings,
|
in: desiredSubscriptionStatuses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
subscriptionStatus: {
|
subscriptionStatus: includeUnsubscribedUsers ? null : undefined,
|
||||||
equals: hasNotSubscribed,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -88,41 +95,18 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
|||||||
paymentProcessorUserId: true,
|
paymentProcessorUserId: true,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'desc',
|
username: 'asc',
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
const totalUserCount = await context.entities.User.count({
|
const [pageOfUsers, totalUsers] = await prisma.$transaction([
|
||||||
where: {
|
context.entities.User.findMany(userPageQuery),
|
||||||
AND: [
|
context.entities.User.count({ where: userPageQuery.where }),
|
||||||
{
|
]);
|
||||||
email: {
|
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||||
contains: args.emailContains || undefined,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
isAdmin: args.isAdmin,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
subscriptionStatus: {
|
|
||||||
in: subscriptionStatusStrings,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subscriptionStatus: {
|
|
||||||
equals: hasNotSubscribed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const totalPages = Math.ceil(totalUserCount / 10);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users: queryResults,
|
users: pageOfUsers,
|
||||||
totalPages,
|
totalPages,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user