mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-01 00:18:19 +02:00
Merge pull request #381 from wasp-lang/filip-refactor-users-table
Refactor users table and pagination
This commit is contained in:
commit
b9c1321d1e
@ -1,14 +1,6 @@
|
||||
--- template/app/src/admin/dashboards/users/UsersTable.tsx
|
||||
+++ opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
|
||||
@@ -17,6 +17,7 @@
|
||||
const [skip, setskip] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [email, setEmail] = useState<string | undefined>(undefined);
|
||||
+
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
||||
const [statusOptions, setStatusOptions] = useState<SubscriptionStatus[]>([]);
|
||||
const { data, isLoading } = useQuery(getPaginatedUsers, {
|
||||
@@ -223,7 +224,7 @@
|
||||
@@ -202,7 +202,7 @@
|
||||
<p className='text-sm text-black dark:text-white'>{user.subscriptionStatus}</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
|
@ -1,15 +1,6 @@
|
||||
--- template/app/src/user/operations.ts
|
||||
+++ opensaas-sh/app/src/user/operations.ts
|
||||
@@ -2,7 +2,7 @@
|
||||
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
|
||||
import { type User } from 'wasp/entities';
|
||||
import { HttpError } from 'wasp/server';
|
||||
-import { subscriptionStatusSchema, type SubscriptionStatus } from '../payment/plans';
|
||||
+import { subscriptionStatusSchema } from '../payment/plans';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||
|
||||
const updateUserAdminByIdInputSchema = z.object({
|
||||
@@ -33,10 +33,7 @@
|
||||
@@ -34,10 +34,7 @@
|
||||
};
|
||||
|
||||
type GetPaginatedUsersOutput = {
|
||||
@ -21,7 +12,7 @@
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
@@ -80,6 +77,7 @@
|
||||
@@ -85,6 +82,7 @@
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin,
|
||||
@ -29,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
@@ -103,7 +101,7 @@
|
||||
@@ -106,7 +104,7 @@
|
||||
username: true,
|
||||
isAdmin: true,
|
||||
subscriptionStatus: true,
|
||||
@ -37,12 +28,4 @@
|
||||
+ stripeId: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
@@ -119,6 +117,7 @@
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin,
|
||||
+ isMockUser: true,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
username: 'asc',
|
||||
|
@ -110,4 +110,4 @@ model ContactFormMessage {
|
||||
content String
|
||||
isRead Boolean @default(false)
|
||||
repliedAt DateTime?
|
||||
}
|
||||
}
|
||||
|
@ -13,26 +13,29 @@ function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
||||
);
|
||||
}
|
||||
|
||||
function UsersTable() {
|
||||
const [skip, setskip] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [email, setEmail] = useState<string | undefined>(undefined);
|
||||
const UsersTable = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [emailFilter, setEmailFilter] = useState<string | 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, {
|
||||
skip,
|
||||
emailContains: email,
|
||||
isAdmin: isAdminFilter,
|
||||
subscriptionStatus: statusOptions?.length > 0 ? statusOptions : undefined,
|
||||
skipPages,
|
||||
filter: {
|
||||
...(emailFilter && { emailContains: emailFilter }),
|
||||
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
||||
...(subscriptionStatusFilter.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [email, statusOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setskip((page - 1) * 10);
|
||||
}, [page]);
|
||||
useEffect(
|
||||
function backToPageOne() {
|
||||
setCurrentPage(1);
|
||||
},
|
||||
[emailFilter, subscriptionStatusFilter, isAdminFilter]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
@ -49,7 +52,8 @@ function UsersTable() {
|
||||
id='email-filter'
|
||||
placeholder='dude@example.com'
|
||||
onChange={(e) => {
|
||||
setEmail(e.currentTarget.value);
|
||||
const value = e.currentTarget.value;
|
||||
setEmailFilter(value === '' ? undefined : 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'
|
||||
/>
|
||||
@ -58,8 +62,8 @@ function UsersTable() {
|
||||
</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 items-center'>
|
||||
{!!statusOptions && statusOptions.length > 0 ? (
|
||||
statusOptions.map((opt) => (
|
||||
{subscriptionStatusFilter.length > 0 ? (
|
||||
subscriptionStatusFilter.map((opt) => (
|
||||
<span
|
||||
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'
|
||||
@ -68,26 +72,13 @@ function UsersTable() {
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStatusOptions((prevValue) => {
|
||||
setSubcriptionStatusFilter((prevValue) => {
|
||||
return prevValue?.filter((val) => val !== opt);
|
||||
});
|
||||
}}
|
||||
className='z-30 cursor-pointer pl-2 hover:text-danger'
|
||||
>
|
||||
<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>
|
||||
<XIcon />
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
@ -100,7 +91,7 @@ function UsersTable() {
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const targetValue = e.target.value === '' ? null : e.target.value;
|
||||
setStatusOptions((prevValue) => {
|
||||
setSubcriptionStatusFilter((prevValue) => {
|
||||
if (prevValue?.includes(targetValue as SubscriptionStatus)) {
|
||||
return prevValue?.filter((val) => val !== targetValue);
|
||||
} else if (!!prevValue) {
|
||||
@ -115,8 +106,8 @@ function UsersTable() {
|
||||
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
|
||||
>
|
||||
<option value=''>Select filters</option>
|
||||
{['past_due', 'canceled', 'active', 'deleted', null].map((status) => {
|
||||
if (!statusOptions.includes(status as SubscriptionStatus)) {
|
||||
{['past_due', 'cancel_at_period_end', 'active', 'deleted', null].map((status) => {
|
||||
if (!subscriptionStatusFilter.includes(status as SubscriptionStatus)) {
|
||||
return (
|
||||
<option key={status} value={status || ''}>
|
||||
{status ? status : 'has not subscribed'}
|
||||
@ -126,22 +117,7 @@ function UsersTable() {
|
||||
})}
|
||||
</select>
|
||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||
<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>
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
@ -170,11 +146,14 @@ function UsersTable() {
|
||||
<span className='text-md mr-2 text-black dark:text-white'>page</span>
|
||||
<input
|
||||
type='number'
|
||||
value={page}
|
||||
min={1}
|
||||
defaultValue={currentPage}
|
||||
max={data?.totalPages}
|
||||
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'
|
||||
/>
|
||||
@ -238,6 +217,34 @@ function UsersTable() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
@ -1,8 +1,9 @@
|
||||
import * as z from 'zod';
|
||||
import { type UpdateIsUserAdminById, type GetPaginatedUsers } from 'wasp/server/operations';
|
||||
import { type User } from 'wasp/entities';
|
||||
import { HttpError } from 'wasp/server';
|
||||
import { HttpError, prisma } from 'wasp/server';
|
||||
import { subscriptionStatusSchema, type SubscriptionStatus } from '../payment/plans';
|
||||
import { type Prisma } from '@prisma/client';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||
|
||||
const updateUserAdminByIdInputSchema = z.object({
|
||||
@ -41,11 +42,12 @@ type GetPaginatedUsersOutput = {
|
||||
};
|
||||
|
||||
const getPaginatorArgsSchema = z.object({
|
||||
skip: z.number(),
|
||||
cursor: z.number().optional(),
|
||||
emailContains: z.string().nonempty().optional(),
|
||||
isAdmin: z.boolean().optional(),
|
||||
subscriptionStatus: z.array(subscriptionStatusSchema).optional(),
|
||||
skipPages: z.number(),
|
||||
filter: z.object({
|
||||
emailContains: z.string().nonempty().optional(),
|
||||
isAdmin: z.boolean().optional(),
|
||||
subscriptionStatusIn: z.array(subscriptionStatusSchema.nullable()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type GetPaginatedUsersInput = z.infer<typeof getPaginatorArgsSchema>;
|
||||
@ -54,29 +56,32 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
||||
rawArgs,
|
||||
context
|
||||
) => {
|
||||
const { skip, emailContains, isAdmin, subscriptionStatus } = ensureArgsSchemaOrThrowHttpError(
|
||||
getPaginatorArgsSchema,
|
||||
rawArgs
|
||||
);
|
||||
|
||||
if (!context.user?.isAdmin) {
|
||||
throw new HttpError(401);
|
||||
if (!context.user) {
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
}
|
||||
|
||||
const allSubscriptionStatusOptions = subscriptionStatus;
|
||||
const hasNotSubscribed = allSubscriptionStatusOptions?.find((status) => status === null);
|
||||
const subscriptionStatusStrings = allSubscriptionStatusOptions?.filter((status) => status !== null) as
|
||||
| string[]
|
||||
| undefined;
|
||||
if (!context.user.isAdmin) {
|
||||
throw new HttpError(403, 'Only admins are allowed to perform this operation');
|
||||
}
|
||||
|
||||
const queryResults = await context.entities.User.findMany({
|
||||
skip,
|
||||
take: 10,
|
||||
const {
|
||||
skipPages,
|
||||
filter: { subscriptionStatusIn: subscriptionStatus, emailContains, isAdmin },
|
||||
} = ensureArgsSchemaOrThrowHttpError(getPaginatorArgsSchema, rawArgs);
|
||||
|
||||
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: {
|
||||
AND: [
|
||||
{
|
||||
email: {
|
||||
contains: emailContains || undefined,
|
||||
contains: emailContains,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin,
|
||||
@ -85,13 +90,11 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
||||
OR: [
|
||||
{
|
||||
subscriptionStatus: {
|
||||
in: subscriptionStatusStrings,
|
||||
in: desiredSubscriptionStatuses,
|
||||
},
|
||||
},
|
||||
{
|
||||
subscriptionStatus: {
|
||||
equals: hasNotSubscribed,
|
||||
},
|
||||
subscriptionStatus: includeUnsubscribedUsers ? null : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -106,41 +109,18 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
||||
paymentProcessorUserId: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
username: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const totalUserCount = await context.entities.User.count({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: {
|
||||
contains: emailContains || undefined,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
isAdmin,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
subscriptionStatus: {
|
||||
in: subscriptionStatusStrings,
|
||||
},
|
||||
},
|
||||
{
|
||||
subscriptionStatus: {
|
||||
equals: hasNotSubscribed,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const totalPages = Math.ceil(totalUserCount / 10);
|
||||
const [pageOfUsers, totalUsers] = await prisma.$transaction([
|
||||
context.entities.User.findMany(userPageQuery),
|
||||
context.entities.User.count({ where: userPageQuery.where }),
|
||||
]);
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
return {
|
||||
users: queryResults,
|
||||
users: pageOfUsers,
|
||||
totalPages,
|
||||
};
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user