>(({ className, ...props }, ref) => (
[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
TableCaption.displayName = 'TableCaption';
export {
TableElement,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
type TableColumn = {
title: string;
field: keyof Entry;
Cell?({ entry }: { entry: Entry }): React.ReactElement;
};
export type TableProps = {
data: Entry[];
columns: TableColumn[];
pagination?: TablePaginationProps;
};
export const Table = ({
data,
columns,
pagination,
}: TableProps) => {
if (!data?.length) {
return (
);
}
return (
<>
{columns.map((column, index) => (
{column.title}
))}
{data.map((entry, entryIndex) => (
{columns.map(({ Cell, field, title }, columnIndex) => (
{Cell ? | : `${entry[field]}`}
))}
))}
{pagination && }
>
);
};
================================================
FILE: apps/nextjs-app/src/config/env.ts
================================================
import * as z from 'zod';
import 'dotenv/config';
const createEnv = () => {
const EnvSchema = z.object({
API_URL: z.string(),
ENABLE_API_MOCKING: z
.string()
.refine((s) => s === 'true' || s === 'false')
.transform((s) => s === 'true')
.optional(),
APP_URL: z.string().optional().default('http://localhost:3000'),
APP_MOCK_API_PORT: z.string().optional().default('8080'),
});
const envVars = {
API_URL: process.env.NEXT_PUBLIC_API_URL,
ENABLE_API_MOCKING: process.env.NEXT_PUBLIC_ENABLE_API_MOCKING,
APP_URL: process.env.NEXT_PUBLIC_URL,
APP_MOCK_API_PORT: process.env.NEXT_PUBLIC_MOCK_API_PORT,
};
const parsedEnv = EnvSchema.safeParse(envVars);
if (!parsedEnv.success) {
throw new Error(
`Invalid env provided.
The following variables are missing or invalid:
${Object.entries(parsedEnv.error.flatten().fieldErrors)
.map(([k, v]) => `- ${k}: ${v}`)
.join('\n')}
`,
);
}
return parsedEnv.data ?? {};
};
export const env = createEnv();
================================================
FILE: apps/nextjs-app/src/config/paths.ts
================================================
export const paths = {
home: {
getHref: () => '/',
},
auth: {
register: {
getHref: (redirectTo?: string | null | undefined) =>
`/auth/register${redirectTo ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}`,
},
login: {
getHref: (redirectTo?: string | null | undefined) =>
`/auth/login${redirectTo ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}`,
},
},
app: {
root: {
getHref: () => '/app',
},
dashboard: {
getHref: () => '/app',
},
discussions: {
getHref: () => '/app/discussions',
},
discussion: {
getHref: (id: string) => `/app/discussions/${id}`,
},
users: {
getHref: () => '/app/users',
},
profile: {
getHref: () => '/app/profile',
},
},
public: {
discussion: {
getHref: (id: string) => `/public/discussions/${id}`,
},
},
} as const;
================================================
FILE: apps/nextjs-app/src/features/auth/components/__tests__/login-form.test.tsx
================================================
import {
createUser,
renderApp,
screen,
userEvent,
waitFor,
} from '@/testing/test-utils';
import { LoginForm } from '../login-form';
test('should login new user and call onSuccess cb which should navigate the user to the app', async () => {
const newUser = await createUser({ teamId: undefined });
const onSuccess = vi.fn();
await renderApp( , { user: null });
await userEvent.type(screen.getByLabelText(/email address/i), newUser.email);
await userEvent.type(screen.getByLabelText(/password/i), newUser.password);
await userEvent.click(screen.getByRole('button', { name: /log in/i }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
================================================
FILE: apps/nextjs-app/src/features/auth/components/__tests__/register-form.test.tsx
================================================
import { createUser } from '@/testing/data-generators';
import { renderApp, screen, userEvent, waitFor } from '@/testing/test-utils';
import { RegisterForm } from '../register-form';
test('should register new user and call onSuccess cb which should navigate the user to the app', async () => {
const newUser = createUser({});
const onSuccess = vi.fn();
await renderApp(
{}}
teams={[]}
/>,
{ user: null },
);
await userEvent.type(screen.getByLabelText(/first name/i), newUser.firstName);
await userEvent.type(screen.getByLabelText(/last name/i), newUser.lastName);
await userEvent.type(screen.getByLabelText(/email address/i), newUser.email);
await userEvent.type(screen.getByLabelText(/password/i), newUser.password);
await userEvent.type(screen.getByLabelText(/team name/i), newUser.teamName);
await userEvent.click(screen.getByRole('button', { name: /register/i }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
================================================
FILE: apps/nextjs-app/src/features/auth/components/login-form.tsx
================================================
'use client';
import NextLink from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Form, Input } from '@/components/ui/form';
import { paths } from '@/config/paths';
import { useLogin, loginInputSchema } from '@/lib/auth';
type LoginFormProps = {
onSuccess: () => void;
};
export const LoginForm = ({ onSuccess }: LoginFormProps) => {
const login = useLogin({
onSuccess,
});
const searchParams = useSearchParams();
const redirectTo = searchParams?.get('redirectTo');
return (
);
};
================================================
FILE: apps/nextjs-app/src/features/auth/components/register-form.tsx
================================================
'use client';
import NextLink from 'next/link';
import { useSearchParams } from 'next/navigation';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Form, Input, Select, Label, Switch } from '@/components/ui/form';
import { paths } from '@/config/paths';
import { useRegister, registerInputSchema } from '@/lib/auth';
import { Team } from '@/types/api';
type RegisterFormProps = {
onSuccess: () => void;
chooseTeam: boolean;
setChooseTeam: () => void;
teams?: Team[];
};
export const RegisterForm = ({
onSuccess,
chooseTeam,
setChooseTeam,
teams,
}: RegisterFormProps) => {
const registering = useRegister({ onSuccess });
const searchParams = useSearchParams();
const redirectTo = searchParams?.get('redirectTo');
return (
);
};
================================================
FILE: apps/nextjs-app/src/features/comments/api/create-comment.ts
================================================
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { api } from '@/lib/api-client';
import { MutationConfig } from '@/lib/react-query';
import { Comment } from '@/types/api';
import { getInfiniteCommentsQueryOptions } from './get-comments';
export const createCommentInputSchema = z.object({
discussionId: z.string().min(1, 'Required'),
body: z.string().min(1, 'Required'),
});
export type CreateCommentInput = z.infer;
export const createComment = ({
data,
}: {
data: CreateCommentInput;
}): Promise => {
return api.post('/comments', data);
};
type UseCreateCommentOptions = {
discussionId: string;
mutationConfig?: MutationConfig;
};
export const useCreateComment = ({
mutationConfig,
discussionId,
}: UseCreateCommentOptions) => {
const queryClient = useQueryClient();
const { onSuccess, ...restConfig } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getInfiniteCommentsQueryOptions(discussionId).queryKey,
});
onSuccess?.(...args);
},
...restConfig,
mutationFn: createComment,
});
};
================================================
FILE: apps/nextjs-app/src/features/comments/api/delete-comment.ts
================================================
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { MutationConfig } from '@/lib/react-query';
import { getInfiniteCommentsQueryOptions } from './get-comments';
export const deleteComment = ({ commentId }: { commentId: string }) => {
return api.delete(`/comments/${commentId}`);
};
type UseDeleteCommentOptions = {
discussionId: string;
mutationConfig?: MutationConfig;
};
export const useDeleteComment = ({
mutationConfig,
discussionId,
}: UseDeleteCommentOptions) => {
const queryClient = useQueryClient();
const { onSuccess, ...restConfig } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getInfiniteCommentsQueryOptions(discussionId).queryKey,
});
onSuccess?.(...args);
},
...restConfig,
mutationFn: deleteComment,
});
};
================================================
FILE: apps/nextjs-app/src/features/comments/api/get-comments.ts
================================================
import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { QueryConfig } from '@/lib/react-query';
import { Comment, Meta } from '@/types/api';
export const getComments = ({
discussionId,
page = 1,
}: {
discussionId: string;
page?: number;
}): Promise<{ data: Comment[]; meta: Meta }> => {
return api.get(`/comments`, {
params: {
discussionId,
page,
},
});
};
export const getInfiniteCommentsQueryOptions = (discussionId: string) => {
return infiniteQueryOptions({
queryKey: ['comments', discussionId],
queryFn: ({ pageParam = 1 }) => {
return getComments({ discussionId, page: pageParam as number });
},
getNextPageParam: (lastPage) => {
if (lastPage?.meta?.page === lastPage?.meta?.totalPages) return undefined;
const nextPage = lastPage.meta.page + 1;
return nextPage;
},
initialPageParam: 1,
});
};
type UseCommentsOptions = {
discussionId: string;
page?: number;
queryConfig?: QueryConfig;
};
export const useInfiniteComments = ({ discussionId }: UseCommentsOptions) => {
return useInfiniteQuery({
...getInfiniteCommentsQueryOptions(discussionId),
});
};
================================================
FILE: apps/nextjs-app/src/features/comments/components/comments-list.tsx
================================================
'use client';
import { ArchiveX } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { MDPreview } from '@/components/ui/md-preview';
import { Spinner } from '@/components/ui/spinner';
import { useUser } from '@/lib/auth';
import { canDeleteComment } from '@/lib/authorization';
import { formatDate } from '@/utils/format';
import { useInfiniteComments } from '../api/get-comments';
import { DeleteComment } from './delete-comment';
type CommentsListProps = {
discussionId: string;
};
export const CommentsList = ({ discussionId }: CommentsListProps) => {
const user = useUser();
const commentsQuery = useInfiniteComments({ discussionId });
const pathname = usePathname();
const isPublicView = pathname?.startsWith?.('/public/');
if (commentsQuery.isLoading) {
return (
);
}
const comments = commentsQuery.data?.pages.flatMap((page) => page.data);
if (!comments?.length)
return (
);
return (
<>
{comments.map((comment, index) => (
{formatDate(comment.createdAt)}
{comment.author && (
{' '}
by {comment.author.firstName} {comment.author.lastName}
)}
{!isPublicView && canDeleteComment(user.data, comment) && (
)}
))}
{commentsQuery.hasNextPage && (
commentsQuery.fetchNextPage()}>
{commentsQuery.isFetchingNextPage ? (
) : (
'Load More Comments'
)}
)}
>
);
};
================================================
FILE: apps/nextjs-app/src/features/comments/components/comments.tsx
================================================
'use client';
import { usePathname } from 'next/navigation';
import { CommentsList } from './comments-list';
import { CreateComment } from './create-comment';
type CommentsProps = {
discussionId: string;
};
export const Comments = ({ discussionId }: CommentsProps) => {
const pathname = usePathname();
const isPublicView = pathname?.startsWith?.('/public/');
return (
Comments:
{!isPublicView && }
);
};
================================================
FILE: apps/nextjs-app/src/features/comments/components/create-comment.tsx
================================================
'use client';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Form, FormDrawer, Textarea } from '@/components/ui/form';
import { useNotifications } from '@/components/ui/notifications';
import {
useCreateComment,
createCommentInputSchema,
} from '../api/create-comment';
type CreateCommentProps = {
discussionId: string;
};
export const CreateComment = ({ discussionId }: CreateCommentProps) => {
const { addNotification } = useNotifications();
const createCommentMutation = useCreateComment({
discussionId,
mutationConfig: {
onSuccess: () => {
addNotification({
type: 'success',
title: 'Comment Created',
});
},
},
});
return (
}>
Create Comment
}
title="Create Comment"
submitButton={
Submit
}
>
);
};
================================================
FILE: apps/nextjs-app/src/features/comments/components/delete-comment.tsx
================================================
'use client';
import { Trash } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/ui/dialog';
import { useNotifications } from '@/components/ui/notifications';
import { useDeleteComment } from '../api/delete-comment';
type DeleteCommentProps = {
id: string;
discussionId: string;
};
export const DeleteComment = ({ id, discussionId }: DeleteCommentProps) => {
const { addNotification } = useNotifications();
const deleteCommentMutation = useDeleteComment({
discussionId,
mutationConfig: {
onSuccess: () => {
addNotification({
type: 'success',
title: 'Comment Deleted',
});
},
},
});
return (
}
>
Delete Comment
}
confirmButton={
deleteCommentMutation.mutate({ commentId: id })}
>
Delete Comment
}
/>
);
};
================================================
FILE: apps/nextjs-app/src/features/discussions/api/create-discussion.ts
================================================
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { api } from '@/lib/api-client';
import { MutationConfig } from '@/lib/react-query';
import { Discussion } from '@/types/api';
import { getDiscussionsQueryOptions } from './get-discussions';
export const createDiscussionInputSchema = z.object({
title: z.string().min(1, 'Required'),
body: z.string().min(1, 'Required'),
public: z.boolean(),
});
export type CreateDiscussionInput = z.infer;
export const createDiscussion = ({
data,
}: {
data: CreateDiscussionInput;
}): Promise => {
return api.post(`/discussions`, data);
};
type UseCreateDiscussionOptions = {
mutationConfig?: MutationConfig;
};
export const useCreateDiscussion = ({
mutationConfig,
}: UseCreateDiscussionOptions = {}) => {
const queryClient = useQueryClient();
const { onSuccess, ...restConfig } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getDiscussionsQueryOptions().queryKey,
});
onSuccess?.(...args);
},
...restConfig,
mutationFn: createDiscussion,
});
};
================================================
FILE: apps/nextjs-app/src/features/discussions/api/delete-discussion.ts
================================================
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { MutationConfig } from '@/lib/react-query';
import { getDiscussionsQueryOptions } from './get-discussions';
export const deleteDiscussion = ({
discussionId,
}: {
discussionId: string;
}) => {
return api.delete(`/discussions/${discussionId}`);
};
type UseDeleteDiscussionOptions = {
mutationConfig?: MutationConfig;
};
export const useDeleteDiscussion = ({
mutationConfig,
}: UseDeleteDiscussionOptions = {}) => {
const queryClient = useQueryClient();
const { onSuccess, ...restConfig } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getDiscussionsQueryOptions().queryKey,
});
onSuccess?.(...args);
},
...restConfig,
mutationFn: deleteDiscussion,
});
};
================================================
FILE: apps/nextjs-app/src/features/discussions/api/get-discussion.ts
================================================
import { useQuery, queryOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { QueryConfig } from '@/lib/react-query';
import { Discussion } from '@/types/api';
export const getDiscussion = ({
discussionId,
}: {
discussionId: string;
}): Promise<{ data: Discussion }> => {
return api.get(`/discussions/${discussionId}`);
};
export const getDiscussionQueryOptions = (discussionId: string) => {
return queryOptions({
queryKey: ['discussions', discussionId],
queryFn: () => getDiscussion({ discussionId }),
});
};
type UseDiscussionOptions = {
discussionId: string;
queryConfig?: QueryConfig;
};
export const useDiscussion = ({
discussionId,
queryConfig,
}: UseDiscussionOptions) => {
return useQuery({
...getDiscussionQueryOptions(discussionId),
...queryConfig,
});
};
================================================
FILE: apps/nextjs-app/src/features/discussions/api/get-discussions.ts
================================================
import { queryOptions, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { QueryConfig } from '@/lib/react-query';
import { Discussion, Meta } from '@/types/api';
export const getDiscussions = (
{ page }: { page?: number } = { page: 1 },
): Promise<{
data: Discussion[];
meta: Meta;
}> => {
return api.get(`/discussions`, {
params: {
page,
},
});
};
export const getDiscussionsQueryOptions = ({
page = 1,
}: { page?: number } = {}) => {
return queryOptions({
queryKey: ['discussions', { page }],
queryFn: () => getDiscussions({ page }),
});
};
type UseDiscussionsOptions = {
page?: number;
queryConfig?: QueryConfig;
};
export const useDiscussions = ({
queryConfig,
page,
}: UseDiscussionsOptions) => {
return useQuery({
...getDiscussionsQueryOptions({ page }),
...queryConfig,
});
};
================================================
FILE: apps/nextjs-app/src/features/discussions/api/update-discussion.ts
================================================
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { api } from '@/lib/api-client';
import { MutationConfig } from '@/lib/react-query';
import { Discussion } from '@/types/api';
import { getDiscussionQueryOptions } from './get-discussion';
export const updateDiscussionInputSchema = z.object({
title: z.string().min(1, 'Required'),
body: z.string().min(1, 'Required'),
public: z.boolean(),
});
export type UpdateDiscussionInput = z.infer;
export const updateDiscussion = ({
data,
discussionId,
}: {
data: UpdateDiscussionInput;
discussionId: string;
}): Promise => {
return api.patch(`/discussions/${discussionId}`, data);
};
type UseUpdateDiscussionOptions = {
mutationConfig?: MutationConfig;
};
export const useUpdateDiscussion = ({
mutationConfig,
}: UseUpdateDiscussionOptions = {}) => {
const queryClient = useQueryClient();
const { onSuccess, ...restConfig } = mutationConfig || {};
return useMutation({
onSuccess: (data, ...args) => {
queryClient.refetchQueries({
queryKey: getDiscussionQueryOptions(data.id).queryKey,
});
onSuccess?.(data, ...args);
},
...restConfig,
mutationFn: updateDiscussion,
});
};
================================================
FILE: apps/nextjs-app/src/features/discussions/components/create-discussion.tsx
================================================
'use client';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Form,
FormDrawer,
Input,
Label,
Switch,
Textarea,
} from '@/components/ui/form';
import { useNotifications } from '@/components/ui/notifications';
import { useUser } from '@/lib/auth';
import { canCreateDiscussion } from '@/lib/authorization';
import {
createDiscussionInputSchema,
useCreateDiscussion,
} from '../api/create-discussion';
export const CreateDiscussion = () => {
const { addNotification } = useNotifications();
const createDiscussionMutation = useCreateDiscussion({
mutationConfig: {
onSuccess: () => {
addNotification({
type: 'success',
title: 'Discussion Created',
});
},
},
});
const user = useUser();
if (!canCreateDiscussion(user?.data)) {
return null;
}
return (
}>
Create Discussion
}
title="Create Discussion"
submitButton={
Submit
}
>
);
};
================================================
FILE: apps/nextjs-app/src/features/discussions/components/delete-discussion.tsx
================================================
'use client';
import { Trash } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/ui/dialog';
import { useNotifications } from '@/components/ui/notifications';
import { useUser } from '@/lib/auth';
import { canDeleteDiscussion } from '@/lib/authorization';
import { useDeleteDiscussion } from '../api/delete-discussion';
type DeleteDiscussionProps = {
id: string;
};
export const DeleteDiscussion = ({ id }: DeleteDiscussionProps) => {
const user = useUser();
const { addNotification } = useNotifications();
const deleteDiscussionMutation = useDeleteDiscussion({
mutationConfig: {
onSuccess: () => {
addNotification({
type: 'success',
title: 'Discussion Deleted',
});
},
},
});
if (!canDeleteDiscussion(user?.data)) {
return null;
}
return (
}>
Delete Discussion
}
confirmButton={
deleteDiscussionMutation.mutate({ discussionId: id })}
>
Delete Discussion
}
/>
);
};
================================================
FILE: apps/nextjs-app/src/features/discussions/components/discussion-view.tsx
================================================
'use client';
import { Link as LinkIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { Link } from '@/components/ui/link';
import { MDPreview } from '@/components/ui/md-preview';
import { Spinner } from '@/components/ui/spinner';
import { paths } from '@/config/paths';
import { formatDate } from '@/utils/format';
import { useDiscussion } from '../api/get-discussion';
import { UpdateDiscussion } from '../components/update-discussion';
export const DiscussionView = ({ discussionId }: { discussionId: string }) => {
const pathname = usePathname();
const isPublicView = pathname?.startsWith?.('/public/');
const discussionQuery = useDiscussion({
discussionId,
});
if (discussionQuery.isLoading) {
return (
);
}
const discussion = discussionQuery?.data?.data;
if (!discussion) return null;
return (
{formatDate(discussion.createdAt)}
{discussion.author && (
by {discussion.author.firstName} {discussion.author.lastName}
)}
{!isPublicView && discussion.public && (
View Public Version
)}
);
};
================================================
FILE: apps/nextjs-app/src/features/discussions/components/discussions-list.tsx
================================================
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import { Link } from '@/components/ui/link';
import { Spinner } from '@/components/ui/spinner';
import { Table } from '@/components/ui/table';
import { paths } from '@/config/paths';
import { formatDate } from '@/utils/format';
import { getDiscussionQueryOptions } from '../api/get-discussion';
import { useDiscussions } from '../api/get-discussions';
import { DeleteDiscussion } from './delete-discussion';
export type DiscussionsListProps = {
onDiscussionPrefetch?: (id: string) => void;
};
export const DiscussionsList = ({
onDiscussionPrefetch,
}: DiscussionsListProps) => {
const searchParams = useSearchParams();
const page = searchParams?.get('page') ? Number(searchParams.get('page')) : 1;
const discussionsQuery = useDiscussions({
page: page,
});
const queryClient = useQueryClient();
if (discussionsQuery.isLoading) {
return (
);
}
const discussions = discussionsQuery.data?.data;
const meta = discussionsQuery.data?.meta;
if (!discussions) return null;
return (
{formatDate(createdAt)};
},
},
{
title: '',
field: 'id',
Cell({ entry: { id } }) {
return (
{
// Prefetch the discussion data when the user hovers over the link
queryClient.prefetchQuery(getDiscussionQueryOptions(id));
onDiscussionPrefetch?.(id);
}}
href={paths.app.discussion.getHref(id)}
>
View
);
},
},
{
title: '',
field: 'id',
Cell({ entry: { id } }) {
return ;
},
},
]}
pagination={
meta && {
totalPages: meta.totalPages,
currentPage: meta.page,
rootUrl: '',
}
}
/>
);
};
================================================
FILE: apps/nextjs-app/src/features/discussions/components/update-discussion.tsx
================================================
'use client';
import { Pen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Form,
FormDrawer,
Input,
Label,
Switch,
Textarea,
} from '@/components/ui/form';
import { useNotifications } from '@/components/ui/notifications';
import { useUser } from '@/lib/auth';
import { canUpdateDiscussion } from '@/lib/authorization';
import { useDiscussion } from '../api/get-discussion';
import {
updateDiscussionInputSchema,
useUpdateDiscussion,
} from '../api/update-discussion';
type UpdateDiscussionProps = {
discussionId: string;
};
export const UpdateDiscussion = ({ discussionId }: UpdateDiscussionProps) => {
const { addNotification } = useNotifications();
const discussionQuery = useDiscussion({ discussionId });
const updateDiscussionMutation = useUpdateDiscussion({
mutationConfig: {
onSuccess: () => {
addNotification({
type: 'success',
title: 'Discussion Updated',
});
},
},
});
const user = useUser();
if (!canUpdateDiscussion(user?.data)) {
return null;
}
const discussion = discussionQuery.data?.data;
return (
} size="sm">
Update Discussion
}
title="Update Discussion"
submitButton={
Submit
}
>
{
updateDiscussionMutation.mutate({
data: values,
discussionId,
});
}}
options={{
defaultValues: {
title: discussion?.title ?? '',
body: discussion?.body ?? '',
public: discussion?.public ?? false,
},
}}
schema={updateDiscussionInputSchema}
>
{({ register, formState, setValue, watch }) => (
<>
setValue('public', value)}
checked={watch('public')}
className={` relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2`}
id="public"
/>
Public
>
)}
);
};
================================================
FILE: apps/nextjs-app/src/features/teams/api/get-teams.ts
================================================
import { queryOptions, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { QueryConfig } from '@/lib/react-query';
import { Team } from '@/types/api';
export const getTeams = (): Promise<{ data: Team[] }> => {
return api.get('/teams');
};
export const getTeamsQueryOptions = () => {
return queryOptions({
queryKey: ['teams'],
queryFn: () => getTeams(),
});
};
type UseTeamsOptions = {
queryConfig?: QueryConfig;
};
export const useTeams = ({ queryConfig = {} }: UseTeamsOptions = {}) => {
return useQuery({
...getTeamsQueryOptions(),
...queryConfig,
});
};
================================================
FILE: apps/nextjs-app/src/features/users/api/delete-user.ts
================================================
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { MutationConfig } from '@/lib/react-query';
import { getUsersQueryOptions } from './get-users';
export type DeleteUserDTO = {
userId: string;
};
export const deleteUser = ({ userId }: DeleteUserDTO) => {
return api.delete(`/users/${userId}`);
};
type UseDeleteUserOptions = {
mutationConfig?: MutationConfig;
};
export const useDeleteUser = ({
mutationConfig,
}: UseDeleteUserOptions = {}) => {
const queryClient = useQueryClient();
const { onSuccess, ...restConfig } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getUsersQueryOptions().queryKey,
});
onSuccess?.(...args);
},
...restConfig,
mutationFn: deleteUser,
});
};
================================================
FILE: apps/nextjs-app/src/features/users/api/get-users.ts
================================================
import { queryOptions, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { QueryConfig } from '@/lib/react-query';
import { User } from '@/types/api';
export const getUsers = (): Promise<{ data: User[] }> => {
return api.get(`/users`);
};
export const getUsersQueryOptions = () => {
return queryOptions({
queryKey: ['users'],
queryFn: getUsers,
});
};
type UseUsersOptions = {
queryConfig?: QueryConfig;
};
export const useUsers = ({ queryConfig }: UseUsersOptions = {}) => {
return useQuery({
...getUsersQueryOptions(),
...queryConfig,
});
};
================================================
FILE: apps/nextjs-app/src/features/users/api/update-profile.ts
================================================
import { useMutation } from '@tanstack/react-query';
import { z } from 'zod';
import { api } from '@/lib/api-client';
import { useUser } from '@/lib/auth';
import { MutationConfig } from '@/lib/react-query';
export const updateProfileInputSchema = z.object({
email: z.string().min(1, 'Required').email('Invalid email'),
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
bio: z.string(),
});
export type UpdateProfileInput = z.infer;
export const updateProfile = ({ data }: { data: UpdateProfileInput }) => {
return api.patch(`/users/profile`, data);
};
type UseUpdateProfileOptions = {
mutationConfig?: MutationConfig;
};
export const useUpdateProfile = ({
mutationConfig,
}: UseUpdateProfileOptions = {}) => {
const { refetch: refetchUser } = useUser();
const { onSuccess, ...restConfig } = mutationConfig || {};
return useMutation({
onSuccess: (...args) => {
refetchUser();
onSuccess?.(...args);
},
...restConfig,
mutationFn: updateProfile,
});
};
================================================
FILE: apps/nextjs-app/src/features/users/components/delete-user.tsx
================================================
'use client';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/ui/dialog';
import { useNotifications } from '@/components/ui/notifications';
import { useUser } from '@/lib/auth';
import { useDeleteUser } from '../api/delete-user';
type DeleteUserProps = {
id: string;
};
export const DeleteUser = ({ id }: DeleteUserProps) => {
const user = useUser();
const { addNotification } = useNotifications();
const deleteUserMutation = useDeleteUser({
mutationConfig: {
onSuccess: () => {
addNotification({
type: 'success',
title: 'User Deleted',
});
},
},
});
if (user.data?.id === id) return null;
return (
Delete}
confirmButton={
deleteUserMutation.mutate({ userId: id })}
>
Delete User
}
/>
);
};
================================================
FILE: apps/nextjs-app/src/features/users/components/update-profile.tsx
================================================
'use client';
import { Pen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Form, FormDrawer, Input, Textarea } from '@/components/ui/form';
import { useNotifications } from '@/components/ui/notifications';
import { useUser } from '@/lib/auth';
import {
updateProfileInputSchema,
useUpdateProfile,
} from '../api/update-profile';
export const UpdateProfile = () => {
const user = useUser();
const { addNotification } = useNotifications();
const updateProfileMutation = useUpdateProfile({
mutationConfig: {
onSuccess: () => {
addNotification({
type: 'success',
title: 'Profile Updated',
});
},
},
});
return (
} size="sm">
Update Profile
}
title="Update Profile"
submitButton={
Submit
}
>
{
updateProfileMutation.mutate({ data: values });
}}
options={{
defaultValues: {
firstName: user.data?.firstName ?? '',
lastName: user.data?.lastName ?? '',
email: user.data?.email ?? '',
bio: user.data?.bio ?? '',
},
}}
schema={updateProfileInputSchema}
>
{({ register, formState }) => (
<>
>
)}
);
};
================================================
FILE: apps/nextjs-app/src/features/users/components/users-list.tsx
================================================
'use client';
import { Spinner } from '@/components/ui/spinner';
import { Table } from '@/components/ui/table';
import { formatDate } from '@/utils/format';
import { useUsers } from '../api/get-users';
import { DeleteUser } from './delete-user';
export const UsersList = () => {
const usersQuery = useUsers();
if (usersQuery.isLoading) {
return (
);
}
const users = usersQuery.data?.data;
if (!users) return null;
return (
{formatDate(createdAt)};
},
},
{
title: '',
field: 'id',
Cell({ entry: { id } }) {
return ;
},
},
]}
/>
);
};
================================================
FILE: apps/nextjs-app/src/hooks/__tests__/use-disclosure.test.ts
================================================
import { renderHook, act } from '@testing-library/react';
import { useDisclosure } from '../use-disclosure';
test('should open the state', () => {
const { result } = renderHook(() => useDisclosure());
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
});
test('should close the state', () => {
const { result } = renderHook(() => useDisclosure());
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.close();
});
expect(result.current.isOpen).toBe(false);
});
test('should toggle the state', () => {
const { result } = renderHook(() => useDisclosure());
expect(result.current.isOpen).toBe(false);
act(() => {
result.current.toggle();
});
expect(result.current.isOpen).toBe(true);
act(() => {
result.current.toggle();
});
expect(result.current.isOpen).toBe(false);
});
test('should define initial state', () => {
const { result } = renderHook(() => useDisclosure(true));
expect(result.current.isOpen).toBe(true);
act(() => {
result.current.toggle();
});
expect(result.current.isOpen).toBe(false);
});
================================================
FILE: apps/nextjs-app/src/hooks/use-disclosure.ts
================================================
import * as React from 'react';
export const useDisclosure = (initial = false) => {
const [isOpen, setIsOpen] = React.useState(initial);
const open = React.useCallback(() => setIsOpen(true), []);
const close = React.useCallback(() => setIsOpen(false), []);
const toggle = React.useCallback(() => setIsOpen((state) => !state), []);
return { isOpen, open, close, toggle };
};
================================================
FILE: apps/nextjs-app/src/lib/__tests__/authorization.test.tsx
================================================
import { Comment, User } from '@/types/api';
import {
canCreateDiscussion,
canDeleteDiscussion,
canUpdateDiscussion,
canViewUsers,
canDeleteComment,
} from '../authorization';
describe('Discussion Authorization', () => {
const adminUser: User = {
id: '1',
role: 'ADMIN',
} as User;
const regularUser: User = {
id: '2',
role: 'USER',
} as User;
test('should allow admin to create discussions', () => {
expect(canCreateDiscussion(adminUser)).toBe(true);
expect(canCreateDiscussion(regularUser)).toBe(false);
expect(canCreateDiscussion(null)).toBe(false);
expect(canCreateDiscussion(undefined)).toBe(false);
});
test('should allow admin to delete discussions', () => {
expect(canDeleteDiscussion(adminUser)).toBe(true);
expect(canDeleteDiscussion(regularUser)).toBe(false);
expect(canDeleteDiscussion(null)).toBe(false);
expect(canDeleteDiscussion(undefined)).toBe(false);
});
test('should allow admin to update discussions', () => {
expect(canUpdateDiscussion(adminUser)).toBe(true);
expect(canUpdateDiscussion(regularUser)).toBe(false);
expect(canUpdateDiscussion(null)).toBe(false);
expect(canUpdateDiscussion(undefined)).toBe(false);
});
test('should allow admin to view users', () => {
expect(canViewUsers(adminUser)).toBe(true);
expect(canViewUsers(regularUser)).toBe(false);
expect(canViewUsers(null)).toBe(false);
expect(canViewUsers(undefined)).toBe(false);
});
});
describe('Comment Authorization', () => {
const adminUser: User = {
id: '1',
role: 'ADMIN',
} as User;
const regularUser: User = {
id: '2',
role: 'USER',
} as User;
const anotherUser: User = {
id: '3',
role: 'USER',
} as User;
test('should allow admin to delete any comment', () => {
const comment: Comment = {
id: '1',
author: anotherUser,
} as Comment;
expect(canDeleteComment(adminUser, comment)).toBe(true);
});
test('should allow users to delete their own comments', () => {
const comment: Comment = {
id: '1',
author: regularUser,
} as Comment;
expect(canDeleteComment(regularUser, comment)).toBe(true);
});
test('should not allow users to delete others comments', () => {
const comment: Comment = {
id: '1',
author: anotherUser,
} as Comment;
expect(canDeleteComment(regularUser, comment)).toBe(false);
});
test('should not allow unauthorized users to delete comments', () => {
const comment: Comment = {
id: '1',
author: regularUser,
} as Comment;
expect(canDeleteComment(null, comment)).toBe(false);
expect(canDeleteComment(undefined, comment)).toBe(false);
});
});
================================================
FILE: apps/nextjs-app/src/lib/api-client.ts
================================================
import { useNotifications } from '@/components/ui/notifications';
import { env } from '@/config/env';
type RequestOptions = {
method?: string;
headers?: Record;
body?: any;
cookie?: string;
params?: Record;
cache?: RequestCache;
next?: NextFetchRequestConfig;
};
function buildUrlWithParams(
url: string,
params?: RequestOptions['params'],
): string {
if (!params) return url;
const filteredParams = Object.fromEntries(
Object.entries(params).filter(
([, value]) => value !== undefined && value !== null,
),
);
if (Object.keys(filteredParams).length === 0) return url;
const queryString = new URLSearchParams(
filteredParams as Record,
).toString();
return `${url}?${queryString}`;
}
// Create a separate function for getting server-side cookies that can be imported where needed
export function getServerCookies() {
if (typeof window !== 'undefined') return '';
// Dynamic import next/headers only on server-side
return import('next/headers').then(({ cookies }) => {
try {
const cookieStore = cookies();
return cookieStore
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join('; ');
} catch (error) {
console.error('Failed to access cookies:', error);
return '';
}
});
}
async function fetchApi(
url: string,
options: RequestOptions = {},
): Promise {
const {
method = 'GET',
headers = {},
body,
cookie,
params,
cache = 'no-store',
next,
} = options;
// Get cookies from the request when running on server
let cookieHeader = cookie;
if (typeof window === 'undefined' && !cookie) {
cookieHeader = await getServerCookies();
}
const fullUrl = buildUrlWithParams(`${env.API_URL}${url}`, params);
const response = await fetch(fullUrl, {
method,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...headers,
...(cookieHeader ? { Cookie: cookieHeader } : {}),
},
body: body ? JSON.stringify(body) : undefined,
credentials: 'include',
cache,
next,
});
if (!response.ok) {
const message = (await response.json()).message || response.statusText;
if (typeof window !== 'undefined') {
useNotifications.getState().addNotification({
type: 'error',
title: 'Error',
message,
});
}
throw new Error(message);
}
return response.json();
}
export const api = {
get(url: string, options?: RequestOptions): Promise {
return fetchApi(url, { ...options, method: 'GET' });
},
post(url: string, body?: any, options?: RequestOptions): Promise {
return fetchApi(url, { ...options, method: 'POST', body });
},
put(url: string, body?: any, options?: RequestOptions): Promise {
return fetchApi(url, { ...options, method: 'PUT', body });
},
patch(url: string, body?: any, options?: RequestOptions): Promise {
return fetchApi(url, { ...options, method: 'PATCH', body });
},
delete(url: string, options?: RequestOptions): Promise {
return fetchApi(url, { ...options, method: 'DELETE' });
},
};
================================================
FILE: apps/nextjs-app/src/lib/auth.tsx
================================================
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { z } from 'zod';
import { AuthResponse, User } from '@/types/api';
import { api } from './api-client';
// api call definitions for auth (types, schemas, requests):
// these are not part of features as this is a module shared across features
export const getUser = async (): Promise => {
const response = (await api.get('/auth/me')) as { data: User };
return response.data;
};
const userQueryKey = ['user'];
export const getUserQueryOptions = () => {
return queryOptions({
queryKey: userQueryKey,
queryFn: getUser,
});
};
export const useUser = () => useQuery(getUserQueryOptions());
export const useLogin = ({ onSuccess }: { onSuccess?: () => void }) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: loginWithEmailAndPassword,
onSuccess: (data) => {
queryClient.setQueryData(userQueryKey, data.user);
onSuccess?.();
},
});
};
export const useRegister = ({ onSuccess }: { onSuccess?: () => void }) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: registerWithEmailAndPassword,
onSuccess: (data) => {
queryClient.setQueryData(userQueryKey, data.user);
onSuccess?.();
},
});
};
export const useLogout = ({ onSuccess }: { onSuccess?: () => void }) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.removeQueries({ queryKey: userQueryKey });
onSuccess?.();
},
});
};
const logout = (): Promise => {
return api.post('/auth/logout');
};
export const loginInputSchema = z.object({
email: z.string().min(1, 'Required').email('Invalid email'),
password: z.string().min(5, 'Required'),
});
export type LoginInput = z.infer;
const loginWithEmailAndPassword = (data: LoginInput): Promise => {
return api.post('/auth/login', data);
};
export const registerInputSchema = z
.object({
email: z.string().min(1, 'Required'),
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
password: z.string().min(5, 'Required'),
})
.and(
z
.object({
teamId: z.string().min(1, 'Required'),
teamName: z.null().default(null),
})
.or(
z.object({
teamName: z.string().min(1, 'Required'),
teamId: z.null().default(null),
}),
),
);
export type RegisterInput = z.infer;
const registerWithEmailAndPassword = (
data: RegisterInput,
): Promise => {
return api.post('/auth/register', data);
};
================================================
FILE: apps/nextjs-app/src/lib/authorization.ts
================================================
import { Comment, User } from '@/types/api';
export const canCreateDiscussion = (user: User | null | undefined) => {
return user?.role === 'ADMIN';
};
export const canDeleteDiscussion = (user: User | null | undefined) => {
return user?.role === 'ADMIN';
};
export const canUpdateDiscussion = (user: User | null | undefined) => {
return user?.role === 'ADMIN';
};
export const canViewUsers = (user: User | null | undefined) => {
return user?.role === 'ADMIN';
};
export const canDeleteComment = (
user: User | null | undefined,
comment: Comment,
) => {
if (user?.role === 'ADMIN') {
return true;
}
if (user?.role === 'USER' && comment.author?.id === user.id) {
return true;
}
return false;
};
================================================
FILE: apps/nextjs-app/src/lib/react-query.ts
================================================
import { UseMutationOptions, DefaultOptions } from '@tanstack/react-query';
export const queryConfig = {
queries: {
// throwOnError: true,
refetchOnWindowFocus: false,
retry: false,
staleTime: 1000 * 60,
},
} satisfies DefaultOptions;
export type ApiFnReturnType Promise> =
Awaited>;
export type QueryConfig any> = Omit<
ReturnType,
'queryKey' | 'queryFn'
>;
export type MutationConfig<
MutationFnType extends (...args: any) => Promise,
> = UseMutationOptions<
ApiFnReturnType,
Error,
Parameters[0]
>;
================================================
FILE: apps/nextjs-app/src/styles/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
================================================
FILE: apps/nextjs-app/src/testing/data-generators.ts
================================================
import {
randCompanyName,
randUserName,
randEmail,
randParagraph,
randUuid,
randPassword,
randCatchPhrase,
} from '@ngneat/falso';
const generateUser = () => ({
id: randUuid() + Math.random(),
firstName: randUserName({ withAccents: false }),
lastName: randUserName({ withAccents: false }),
email: randEmail(),
password: randPassword(),
teamId: randUuid(),
teamName: randCompanyName(),
role: 'ADMIN',
bio: randParagraph(),
createdAt: Date.now(),
});
export const createUser = >>(
overrides?: T,
) => {
return { ...generateUser(), ...overrides };
};
const generateTeam = () => ({
id: randUuid(),
name: randCompanyName(),
description: randParagraph(),
createdAt: Date.now(),
});
export const createTeam = >>(
overrides?: T,
) => {
return { ...generateTeam(), ...overrides };
};
const generateDiscussion = () => ({
id: randUuid(),
title: randCatchPhrase(),
body: randParagraph(),
createdAt: Date.now(),
public: true,
});
export const createDiscussion = <
T extends Partial>,
>(
overrides?: T & {
authorId?: string;
teamId?: string;
},
) => {
return { ...generateDiscussion(), ...overrides };
};
const generateComment = () => ({
id: randUuid(),
body: randParagraph(),
createdAt: Date.now(),
});
export const createComment = <
T extends Partial>,
>(
overrides?: T & {
authorId?: string;
discussionId?: string;
},
) => {
return { ...generateComment(), ...overrides };
};
================================================
FILE: apps/nextjs-app/src/testing/mocks/browser.ts
================================================
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
================================================
FILE: apps/nextjs-app/src/testing/mocks/db.ts
================================================
import { factory, primaryKey } from '@mswjs/data';
import { nanoid } from 'nanoid';
const models = {
user: {
id: primaryKey(nanoid),
firstName: String,
lastName: String,
email: String,
password: String,
teamId: String,
role: String,
bio: String,
createdAt: Date.now,
},
team: {
id: primaryKey(nanoid),
name: String,
description: String,
createdAt: Date.now,
},
discussion: {
id: primaryKey(nanoid),
title: String,
body: String,
authorId: String,
teamId: String,
createdAt: Date.now,
public: Boolean,
},
comment: {
id: primaryKey(nanoid),
body: String,
authorId: String,
discussionId: String,
createdAt: Date.now,
},
};
export const db = factory(models);
export type Model = keyof typeof models;
const dbFilePath = 'mocked-db.json';
export const loadDb = async () => {
// If we are running in a Node.js environment
if (typeof window === 'undefined') {
const { readFile, writeFile } = await import('fs/promises');
try {
const data = await readFile(dbFilePath, 'utf8');
return JSON.parse(data);
} catch (error: any) {
if (error?.code === 'ENOENT') {
const emptyDB = {};
await writeFile(dbFilePath, JSON.stringify(emptyDB, null, 2));
return emptyDB;
} else {
console.error('Error loading mocked DB:', error);
return null;
}
}
}
// If we are running in a browser environment
return Object.assign(
JSON.parse(window.localStorage.getItem('msw-db') || '{}'),
);
};
export const storeDb = async (data: string) => {
// If we are running in a Node.js environment
if (typeof window === 'undefined') {
const { writeFile } = await import('fs/promises');
await writeFile(dbFilePath, data);
} else {
// If we are running in a browser environment
window.localStorage.setItem('msw-db', data);
}
};
export const persistDb = async (model: Model) => {
if (process.env.NODE_ENV === 'test') return;
const data = await loadDb();
data[model] = db[model].getAll();
await storeDb(JSON.stringify(data));
};
export const initializeDb = async () => {
const database = await loadDb();
Object.entries(db).forEach(([key, model]) => {
const dataEntres = database[key];
if (dataEntres) {
dataEntres?.forEach((entry: Record) => {
model.create(entry);
});
}
});
};
export const resetDb = () => {
window.localStorage.clear();
};
================================================
FILE: apps/nextjs-app/src/testing/mocks/handlers/auth.ts
================================================
import Cookies from 'js-cookie';
import { HttpResponse, http } from 'msw';
import { env } from '@/config/env';
import { db, persistDb } from '../db';
import {
authenticate,
hash,
requireAuth,
AUTH_COOKIE,
networkDelay,
} from '../utils';
type RegisterBody = {
firstName: string;
lastName: string;
email: string;
password: string;
teamId?: string;
teamName?: string;
};
type LoginBody = {
email: string;
password: string;
};
export const authHandlers = [
http.post(`${env.API_URL}/auth/register`, async ({ request }) => {
await networkDelay();
try {
const userObject = (await request.json()) as RegisterBody;
const existingUser = db.user.findFirst({
where: {
email: {
equals: userObject.email,
},
},
});
if (existingUser) {
return HttpResponse.json(
{ message: 'The user already exists' },
{ status: 400 },
);
}
let teamId;
let role;
if (!userObject.teamId) {
const team = db.team.create({
name: userObject.teamName ?? `${userObject.firstName} Team`,
});
await persistDb('team');
teamId = team.id;
role = 'ADMIN';
} else {
const existingTeam = db.team.findFirst({
where: {
id: {
equals: userObject.teamId,
},
},
});
if (!existingTeam) {
return HttpResponse.json(
{
message: 'The team you are trying to join does not exist!',
},
{ status: 400 },
);
}
teamId = userObject.teamId;
role = 'USER';
}
db.user.create({
...userObject,
role,
password: hash(userObject.password),
teamId,
});
await persistDb('user');
const result = authenticate({
email: userObject.email,
password: userObject.password,
});
// todo: remove once tests in Github Actions are fixed
Cookies.set(AUTH_COOKIE, result.jwt, { path: '/' });
return HttpResponse.json(result, {
headers: {
// with a real API servier, the token cookie should also be Secure and HttpOnly
'Set-Cookie': `${AUTH_COOKIE}=${result.jwt}; Path=/;`,
},
});
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.post(`${env.API_URL}/auth/login`, async ({ request }) => {
await networkDelay();
try {
const credentials = (await request.json()) as LoginBody;
const result = authenticate(credentials);
// todo: remove once tests in Github Actions are fixed
Cookies.set(AUTH_COOKIE, result.jwt, { path: '/' });
return HttpResponse.json(result, {
headers: {
// with a real API servier, the token cookie should also be Secure and HttpOnly
'Set-Cookie': `${AUTH_COOKIE}=${result.jwt}; Path=/;`,
},
});
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.post(`${env.API_URL}/auth/logout`, async () => {
await networkDelay();
// todo: remove once tests in Github Actions are fixed
Cookies.remove(AUTH_COOKIE);
return HttpResponse.json(
{ message: 'Logged out' },
{
headers: {
'Set-Cookie': `${AUTH_COOKIE}=; Path=/;`,
},
},
);
}),
http.get(`${env.API_URL}/auth/me`, async ({ cookies }) => {
await networkDelay();
try {
const { user } = requireAuth(cookies);
return HttpResponse.json({ data: user });
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
];
================================================
FILE: apps/nextjs-app/src/testing/mocks/handlers/comments.ts
================================================
import { HttpResponse, http } from 'msw';
import { env } from '@/config/env';
import { db, persistDb } from '../db';
import { networkDelay, requireAuth, sanitizeUser } from '../utils';
type CreateCommentBody = {
body: string;
discussionId: string;
};
export const commentsHandlers = [
http.get(`${env.API_URL}/comments`, async ({ request, cookies }) => {
await networkDelay();
try {
const url = new URL(request.url);
const discussionId = url.searchParams.get('discussionId') || '';
const page = Number(url.searchParams.get('page') || 1);
const discussion = db.discussion.findFirst({
where: {
id: {
equals: discussionId,
},
},
});
if (!discussion?.public) {
const { error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
}
const total = db.comment.count({
where: {
discussionId: {
equals: discussionId,
},
},
});
const totalPages = Math.ceil(total / 10);
const comments = db.comment
.findMany({
where: {
discussionId: {
equals: discussionId,
},
},
take: 10,
skip: 10 * (page - 1),
})
.map(({ authorId, ...comment }) => {
const author = db.user.findFirst({
where: {
id: {
equals: authorId,
},
},
});
return {
...comment,
author: author ? sanitizeUser(author) : {},
};
});
return HttpResponse.json({
data: comments,
meta: {
page,
total,
totalPages,
},
});
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.post(`${env.API_URL}/comments`, async ({ request, cookies }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const data = (await request.json()) as CreateCommentBody;
const result = db.comment.create({
authorId: user?.id,
...data,
});
await persistDb('comment');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.delete(
`${env.API_URL}/comments/:commentId`,
async ({ params, cookies }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const commentId = params.commentId as string;
const result = db.comment.delete({
where: {
id: {
equals: commentId,
},
...(user?.role === 'USER' && {
authorId: {
equals: user.id,
},
}),
},
});
await persistDb('comment');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
},
),
];
================================================
FILE: apps/nextjs-app/src/testing/mocks/handlers/discussions.ts
================================================
import { HttpResponse, http } from 'msw';
import { env } from '@/config/env';
import { db, persistDb } from '../db';
import {
requireAuth,
requireAdmin,
sanitizeUser,
networkDelay,
} from '../utils';
type DiscussionBody = {
title: string;
body: string;
public: boolean;
};
export const discussionsHandlers = [
http.get(`${env.API_URL}/discussions`, async ({ cookies, request }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') || 1);
const total = db.discussion.count({
where: {
teamId: {
equals: user?.teamId,
},
},
});
const totalPages = Math.ceil(total / 10);
const result = db.discussion
.findMany({
where: {
teamId: {
equals: user?.teamId,
},
},
take: 10,
skip: 10 * (page - 1),
})
.map(({ authorId, ...discussion }) => {
const author = db.user.findFirst({
where: {
id: {
equals: authorId,
},
},
});
return {
...discussion,
author: author ? sanitizeUser(author) : {},
};
});
return HttpResponse.json({
data: result,
meta: {
page,
total,
totalPages,
},
});
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.get(
`${env.API_URL}/discussions/:discussionId`,
async ({ params, cookies }) => {
await networkDelay();
const discussionId = params.discussionId as string;
const discussion = db.discussion.findFirst({
where: {
id: {
equals: discussionId,
},
},
});
if (discussion?.public) {
const author = db.user.findFirst({
where: {
id: {
equals: discussion.authorId,
},
},
});
const result = {
...discussion,
author: author ? sanitizeUser(author) : {},
};
return HttpResponse.json({ data: result });
}
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const discussion = db.discussion.findFirst({
where: {
id: {
equals: discussionId,
},
teamId: {
equals: user?.teamId,
},
},
});
if (!discussion) {
return HttpResponse.json(
{ message: 'Discussion not found' },
{ status: 404 },
);
}
const author = db.user.findFirst({
where: {
id: {
equals: discussion.authorId,
},
},
});
const result = {
...discussion,
author: author ? sanitizeUser(author) : {},
};
return HttpResponse.json({ data: result });
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
},
),
http.post(`${env.API_URL}/discussions`, async ({ request, cookies }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const data = (await request.json()) as DiscussionBody;
requireAdmin(user);
const result = db.discussion.create({
teamId: user?.teamId,
authorId: user?.id,
...data,
});
await persistDb('discussion');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.patch(
`${env.API_URL}/discussions/:discussionId`,
async ({ request, params, cookies }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const data = (await request.json()) as DiscussionBody;
const discussionId = params.discussionId as string;
requireAdmin(user);
const result = db.discussion.update({
where: {
teamId: {
equals: user?.teamId,
},
id: {
equals: discussionId,
},
},
data,
});
await persistDb('discussion');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
},
),
http.delete(
`${env.API_URL}/discussions/:discussionId`,
async ({ cookies, params }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const discussionId = params.discussionId as string;
requireAdmin(user);
const result = db.discussion.delete({
where: {
id: {
equals: discussionId,
},
},
});
await persistDb('discussion');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
},
),
];
================================================
FILE: apps/nextjs-app/src/testing/mocks/handlers/index.ts
================================================
import { HttpResponse, http } from 'msw';
import { env } from '@/config/env';
import { networkDelay } from '../utils';
import { authHandlers } from './auth';
import { commentsHandlers } from './comments';
import { discussionsHandlers } from './discussions';
import { teamsHandlers } from './teams';
import { usersHandlers } from './users';
export const handlers = [
...authHandlers,
...commentsHandlers,
...discussionsHandlers,
...teamsHandlers,
...usersHandlers,
http.get(`${env.API_URL}/healthcheck`, async () => {
await networkDelay();
return HttpResponse.json({ ok: true });
}),
];
================================================
FILE: apps/nextjs-app/src/testing/mocks/handlers/teams.ts
================================================
import { HttpResponse, http } from 'msw';
import { env } from '@/config/env';
import { db } from '../db';
import { networkDelay } from '../utils';
export const teamsHandlers = [
http.get(`${env.API_URL}/teams`, async () => {
await networkDelay();
try {
const result = db.team.getAll();
return HttpResponse.json({ data: result });
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
];
================================================
FILE: apps/nextjs-app/src/testing/mocks/handlers/users.ts
================================================
import { HttpResponse, http } from 'msw';
import { env } from '@/config/env';
import { db, persistDb } from '../db';
import {
requireAuth,
requireAdmin,
sanitizeUser,
networkDelay,
} from '../utils';
type ProfileBody = {
email: string;
firstName: string;
lastName: string;
bio: string;
};
export const usersHandlers = [
http.get(`${env.API_URL}/users`, async ({ cookies }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const result = db.user
.findMany({
where: {
teamId: {
equals: user?.teamId,
},
},
})
.map(sanitizeUser);
return HttpResponse.json({ data: result });
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.patch(`${env.API_URL}/users/profile`, async ({ request, cookies }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const data = (await request.json()) as ProfileBody;
const result = db.user.update({
where: {
id: {
equals: user?.id,
},
},
data,
});
await persistDb('user');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
http.delete(`${env.API_URL}/users/:userId`, async ({ cookies, params }) => {
await networkDelay();
try {
const { user, error } = requireAuth(cookies);
if (error) {
return HttpResponse.json({ message: error }, { status: 401 });
}
const userId = params.userId as string;
requireAdmin(user);
const result = db.user.delete({
where: {
id: {
equals: userId,
},
teamId: {
equals: user?.teamId,
},
},
});
await persistDb('user');
return HttpResponse.json(result);
} catch (error: any) {
return HttpResponse.json(
{ message: error?.message || 'Server Error' },
{ status: 500 },
);
}
}),
];
================================================
FILE: apps/nextjs-app/src/testing/mocks/index.ts
================================================
import { env } from '@/config/env';
export const enableMocking = async () => {
if (env.ENABLE_API_MOCKING) {
const { worker } = await import('./browser');
const { initializeDb } = await import('./db');
await initializeDb();
return worker.start();
}
};
================================================
FILE: apps/nextjs-app/src/testing/mocks/server.ts
================================================
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
================================================
FILE: apps/nextjs-app/src/testing/mocks/utils.ts
================================================
import Cookies from 'js-cookie';
import { delay } from 'msw';
import { db } from './db';
export const encode = (obj: any) => {
const btoa =
typeof window === 'undefined'
? (str: string) => Buffer.from(str, 'binary').toString('base64')
: window.btoa;
return btoa(JSON.stringify(obj));
};
export const decode = (str: string) => {
const atob =
typeof window === 'undefined'
? (str: string) => Buffer.from(str, 'base64').toString('binary')
: window.atob;
return JSON.parse(atob(str));
};
export const hash = (str: string) => {
let hash = 5381,
i = str.length;
while (i) {
hash = (hash * 33) ^ str.charCodeAt(--i);
}
return String(hash >>> 0);
};
export const networkDelay = () => {
const delayTime = process.env.TEST
? 200
: Math.floor(Math.random() * 700) + 300;
return delay(delayTime);
};
const omit = (obj: T, keys: string[]): T => {
const result = {} as T;
for (const key in obj) {
if (!keys.includes(key)) {
result[key] = obj[key];
}
}
return result;
};
export const sanitizeUser = (user: O) =>
omit(user, ['password', 'iat']);
export function authenticate({
email,
password,
}: {
email: string;
password: string;
}) {
const user = db.user.findFirst({
where: {
email: {
equals: email,
},
},
});
if (user?.password === hash(password)) {
const sanitizedUser = sanitizeUser(user);
const encodedToken = encode(sanitizedUser);
return { user: sanitizedUser, jwt: encodedToken };
}
const error = new Error('Invalid username or password');
throw error;
}
export const AUTH_COOKIE = `bulletproof_react_app_token`;
export function requireAuth(cookies: Record) {
try {
const encodedToken = cookies[AUTH_COOKIE] || Cookies.get(AUTH_COOKIE);
if (!encodedToken) {
return { error: 'Unauthorized', user: null };
}
const decodedToken = decode(encodedToken) as { id: string };
const user = db.user.findFirst({
where: {
id: {
equals: decodedToken.id,
},
},
});
if (!user) {
return { error: 'Unauthorized', user: null };
}
return { user: sanitizeUser(user) };
} catch (err: any) {
return { error: 'Unauthorized', user: null };
}
}
export function requireAdmin(user: any) {
if (user.role !== 'ADMIN') {
throw Error('Unauthorized');
}
}
================================================
FILE: apps/nextjs-app/src/testing/setup-tests.ts
================================================
import '@testing-library/jest-dom/vitest';
import { initializeDb, resetDb } from '@/testing/mocks/db';
import { server } from '@/testing/mocks/server';
vi.mock('zustand');
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
vi.mock('next/navigation', async () => {
const actual = await vi.importActual('next/navigation');
return {
...actual,
useRouter: () => {
return {
push: vi.fn(),
replace: vi.fn(),
};
},
usePathname: () => '/app',
useSearchParams: () => ({
get: vi.fn(),
}),
};
});
});
afterAll(() => server.close());
beforeEach(() => {
const ResizeObserverMock = vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
window.btoa = (str: string) => Buffer.from(str, 'binary').toString('base64');
window.atob = (str: string) => Buffer.from(str, 'base64').toString('binary');
initializeDb();
});
afterEach(() => {
server.resetHandlers();
resetDb();
});
================================================
FILE: apps/nextjs-app/src/testing/test-utils.tsx
================================================
import {
render as rtlRender,
waitForElementToBeRemoved,
screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Cookies from 'js-cookie';
import { AppProvider } from '@/app/provider';
import {
createDiscussion as generateDiscussion,
createUser as generateUser,
} from './data-generators';
import { db } from './mocks/db';
import { AUTH_COOKIE, authenticate, hash } from './mocks/utils';
export const waitForLoadingToFinish = () =>
waitForElementToBeRemoved(
() => [
...screen.queryAllByTestId(/loading/i),
...screen.queryAllByText(/loading/i),
],
{ timeout: 4000 },
);
export const createUser = async (userProperties?: any) => {
const user = generateUser(userProperties) as any;
await db.user.create({ ...user, password: hash(user.password) });
return user;
};
export const createDiscussion = async (discussionProperties?: any) => {
const discussion = generateDiscussion(discussionProperties);
const res = await db.discussion.create(discussion);
return res;
};
export const loginAsUser = async (user: any) => {
const authUser = await authenticate(user);
Cookies.set(AUTH_COOKIE, authUser.jwt);
return authUser;
};
const initializeUser = async (user: any) => {
if (typeof user === 'undefined') {
const newUser = await createUser();
return loginAsUser(newUser);
} else if (user) {
return loginAsUser(user);
} else {
return null;
}
};
export const renderApp = async (
ui: any,
{ user, ...renderOptions }: Record = {},
) => {
// if you want to render the app unauthenticated then pass "null" as the user
const initializedUser = await initializeUser(user);
const returnValue = {
...rtlRender(ui, {
wrapper: AppProvider,
...renderOptions,
}),
user: initializedUser,
};
return returnValue;
};
export * from '@testing-library/react';
export { userEvent, rtlRender };
================================================
FILE: apps/nextjs-app/src/types/api.ts
================================================
// let's imagine this file is autogenerated from the backend
// ideally, we want to keep these api related types in sync
// with the backend instead of manually writing them out
export type BaseEntity = {
id: string;
createdAt: number;
};
export type Entity = {
[K in keyof T]: T[K];
} & BaseEntity;
export type Meta = {
page: number;
total: number;
totalPages: number;
};
export type User = Entity<{
firstName: string;
lastName: string;
email: string;
role: 'ADMIN' | 'USER';
teamId: string;
bio: string;
}>;
export type AuthResponse = {
jwt: string;
user: User;
};
export type Team = Entity<{
name: string;
description: string;
}>;
export type Discussion = Entity<{
title: string;
body: string;
teamId: string;
author: User;
public: boolean;
}>;
export type Comment = Entity<{
body: string;
discussionId: string;
author: User;
}>;
================================================
FILE: apps/nextjs-app/src/utils/auth.ts
================================================
import { cookies } from 'next/headers';
export const AUTH_TOKEN_COOKIE_NAME = 'bulletproof_react_app_token';
export const getAuthTokenCookie = () => {
if (typeof window !== 'undefined') return '';
const cookieStore = cookies();
return cookieStore.get(AUTH_TOKEN_COOKIE_NAME)?.value;
};
export const checkLoggedIn = () => {
const cookieStore = cookies();
const isLoggedIn = !!cookieStore.get(AUTH_TOKEN_COOKIE_NAME);
return isLoggedIn;
};
================================================
FILE: apps/nextjs-app/src/utils/cn.ts
================================================
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
================================================
FILE: apps/nextjs-app/src/utils/format.ts
================================================
import { default as dayjs } from 'dayjs';
export const formatDate = (date: number) =>
dayjs(date).format('MMMM D, YYYY h:mm A');
================================================
FILE: apps/nextjs-app/tailwind.config.cjs
================================================
/** @type {import('tailwindcss').Config} */
const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
fontFamily: {
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
};
================================================
FILE: apps/nextjs-app/tsconfig.json
================================================
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"target": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
================================================
FILE: apps/nextjs-app/vitest.config.ts
================================================
///
import react from '@vitejs/plugin-react';
import viteTsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
base: './',
plugins: [react(), viteTsconfigPaths()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/testing/setup-tests.ts',
exclude: ['**/node_modules/**', '**/e2e/**'],
coverage: {
include: ['src/**'],
},
},
});
================================================
FILE: apps/nextjs-pages/.eslintrc.cjs
================================================
module.exports = {
root: true,
env: {
node: true,
es6: true,
},
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
ignorePatterns: [
'node_modules/*',
'public/mockServiceWorker.js',
'generators/*',
],
extends: ['eslint:recommended', 'next/core-web-vitals'],
plugins: ['check-file'],
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
parser: '@typescript-eslint/parser',
settings: {
react: { version: 'detect' },
'import/resolver': {
typescript: {},
},
},
env: {
browser: true,
node: true,
es6: true,
},
extends: [
'eslint:recommended',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'plugin:prettier/recommended',
'plugin:testing-library/react',
'plugin:jest-dom/recommended',
'plugin:tailwindcss/recommended',
'plugin:vitest/legacy-recommended',
],
rules: {
'@next/next/no-img-element': 'off',
'import/no-restricted-paths': [
'error',
{
zones: [
// disables cross-feature imports:
// eg. src/features/discussions should not import from src/features/comments, etc.
{
target: './src/features/auth',
from: './src/features',
except: ['./auth'],
},
{
target: './src/features/comments',
from: './src/features',
except: ['./comments'],
},
{
target: './src/features/discussions',
from: './src/features',
except: ['./discussions'],
},
{
target: './src/features/teams',
from: './src/features',
except: ['./teams'],
},
{
target: './src/features/users',
from: './src/features',
except: ['./users'],
},
// enforce unidirectional codebase:
// e.g. src/app can import from src/features but not the other way around
{
target: './src/features',
from: './src/app',
},
// e.g src/features and src/app can import from these shared modules but not the other way around
{
target: [
'./src/components',
'./src/hooks',
'./src/lib',
'./src/types',
'./src/utils',
],
from: ['./src/features', './src/app'],
},
],
},
],
'import/no-cycle': 'error',
'linebreak-style': ['error', 'unix'],
'react/prop-types': 'off',
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
'object',
],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
'import/default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-named-as-default': 'off',
'react/react-in-jsx-scope': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/explicit-function-return-type': ['off'],
'@typescript-eslint/explicit-module-boundary-types': ['off'],
'@typescript-eslint/no-empty-function': ['off'],
'@typescript-eslint/no-explicit-any': ['off'],
'prettier/prettier': ['error', {}, { usePrettierrc: true }],
'check-file/filename-naming-convention': [
'error',
{
'src/!(pages)/*.{ts,tsx}': 'KEBAB_CASE',
},
{
ignoreMiddleExtensions: true,
},
],
},
},
{
plugins: ['check-file'],
files: ['src/**/!(__tests__)/*'],
rules: {
'check-file/folder-naming-convention': [
'error',
{
'**/*': 'KEBAB_CASE',
},
],
},
},
],
};
================================================
FILE: apps/nextjs-pages/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/e2e/.auth/
# storybook
migration-storybook.log
storybook.log
storybook-static
# production
/dist
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local
mocked-db.json
/.next
/.vite
tsconfig.tsbuildinfo
================================================
FILE: apps/nextjs-pages/.prettierignore
================================================
*.hbs
================================================
FILE: apps/nextjs-pages/.prettierrc
================================================
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}
================================================
FILE: apps/nextjs-pages/.storybook/main.ts
================================================
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-actions',
'@storybook/addon-links',
'@storybook/node-logger',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-docs',
'@storybook/addon-a11y',
],
framework: '@storybook/nextjs',
docs: {
autodocs: 'tag',
},
typescript: {
reactDocgen: 'react-docgen-typescript',
},
};
================================================
FILE: apps/nextjs-pages/.storybook/preview.tsx
================================================
import React from 'react';
import '../src/styles/globals.css';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
};
export const decorators = [(Story) => ];
================================================
FILE: apps/nextjs-pages/.vscode/extensions.json
================================================
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"dsznajder.es7-react-js-snippets",
"mariusalchimavicius.json-to-ts",
"bradlc.vscode-tailwindcss"
]
}
================================================
FILE: apps/nextjs-pages/.vscode/settings.json
================================================
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
================================================
FILE: apps/nextjs-pages/README.md
================================================
# Next.js Pages Application
## Get Started
Prerequisites:
- Node 20+
- Yarn 1.22+
To set up the app execute the following commands.
```bash
git clone https://github.com/alan2207/bulletproof-react.git
cd bulletproof-react
cd apps/nextjs-pages
cp .env.example .env
yarn install
```
#### `yarn run-mock-server`
Make sure to start the mock server before running the app.
The mock server runs on [http://localhost:8080/api](http://localhost:8080/api).
##### `yarn dev`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
## Project Structure
Since the `pages` folder isn't very flexible and doesn't allow file collocation, we are keeping the `app` folder which is our application layer where we compose all the features, and then we just re-export Next.js page specific files (the pages and `getServerSideProps`) from the `pages` folder so Next.js can pick them up and serve as pages.
================================================
FILE: apps/nextjs-pages/__mocks__/vitest-env.d.ts
================================================
///
///
================================================
FILE: apps/nextjs-pages/__mocks__/zustand.ts
================================================
import { act } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
import * as zustand from 'zustand';
const { create: actualCreate, createStore: actualCreateStore } =
await vi.importActual('zustand');
// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>();
const createUncurried = (stateCreator: zustand.StateCreator) => {
const store = actualCreate(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = ((stateCreator: zustand.StateCreator) => {
// to support curried version of create
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried;
}) as typeof zustand.create;
const createStoreUncurried = (stateCreator: zustand.StateCreator) => {
const store = actualCreateStore(stateCreator);
const initialState = store.getInitialState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = ((stateCreator: zustand.StateCreator) => {
// to support curried version of createStore
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried;
}) as typeof zustand.createStore;
// reset all stores after each test run
afterEach(() => {
act(() => {
storeResetFns.forEach((resetFn) => {
resetFn();
});
});
});
================================================
FILE: apps/nextjs-pages/e2e/.eslintrc.cjs
================================================
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: 'plugin:playwright/recommended',
};
================================================
FILE: apps/nextjs-pages/e2e/tests/auth.setup.ts
================================================
import { test as setup, expect } from '@playwright/test';
import { createUser } from '../../src/testing/data-generators';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
const user = createUser();
await page.goto('/');
await page.getByRole('button', { name: 'Get started' }).click();
await page.waitForURL('/auth/login');
await page.getByRole('link', { name: 'Register' }).click();
// registration:
await page.getByLabel('First Name').click();
await page.getByLabel('First Name').fill(user.firstName);
await page.getByLabel('Last Name').click();
await page.getByLabel('Last Name').fill(user.lastName);
await page.getByLabel('Email Address').click();
await page.getByLabel('Email Address').fill(user.email);
await page.getByLabel('Password').click();
await page.getByLabel('Password').fill(user.password);
await page.getByLabel('Team Name').click();
await page.getByLabel('Team Name').fill(user.teamName);
await page.getByRole('button', { name: 'Register' }).click();
await page.waitForURL('/app');
// log out:
await page.getByRole('button', { name: 'Open user menu' }).click();
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
await page.waitForURL('/auth/login?redirectTo=%2Fapp');
// log in:
await page.getByLabel('Email Address').click();
await page.getByLabel('Email Address').fill(user.email);
await page.getByLabel('Password').click();
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('/app');
await page.context().storageState({ path: authFile });
});
================================================
FILE: apps/nextjs-pages/e2e/tests/profile.spec.ts
================================================
import { test, expect } from '@playwright/test';
test('profile', async ({ page }) => {
// update user:
await page.goto('/app');
await page.getByRole('button', { name: 'Open user menu' }).click();
await page.getByRole('menuitem', { name: 'Your Profile' }).click();
await page.getByRole('button', { name: 'Update Profile' }).click();
await page.getByLabel('Bio').click();
await page.getByLabel('Bio').fill('My bio');
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByLabel('Profile Updated')
.getByRole('button', { name: 'Close' })
.click();
await expect(page.getByText('My bio')).toBeVisible();
});
================================================
FILE: apps/nextjs-pages/e2e/tests/smoke.spec.ts
================================================
import { test, expect } from '@playwright/test';
import {
createDiscussion,
createComment,
} from '../../src/testing/data-generators';
test('smoke', async ({ page }) => {
const discussion = createDiscussion();
const comment = createComment();
await page.goto('/');
await page.getByRole('button', { name: 'Get started' }).click();
await page.waitForURL('/app');
// create discussion:
await page.getByRole('link', { name: 'Discussions' }).click();
await page.waitForURL('/app/discussions');
await page.getByRole('button', { name: 'Create Discussion' }).click();
await page.getByLabel('Title').click();
await page.getByLabel('Title').fill(discussion.title);
await page.getByLabel('Body').click();
await page.getByLabel('Body').fill(discussion.body);
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByLabel('Discussion Created')
.getByRole('button', { name: 'Close' })
.click();
// visit discussion page:
await page.getByRole('link', { name: 'View' }).click();
await expect(
page.getByRole('heading', { name: discussion.title }),
).toBeVisible();
await expect(page.getByText(discussion.body)).toBeVisible();
// update discussion:
await page.getByRole('button', { name: 'Update Discussion' }).click();
await page.getByLabel('Title').click();
await page.getByLabel('Title').fill(`${discussion.title} - updated`);
await page.getByLabel('Body').click();
await page.getByLabel('Body').fill(`${discussion.body} - updated`);
await page.getByRole('button', { name: 'Submit' }).click();
await page
.getByLabel('Discussion Updated')
.getByRole('button', { name: 'Close' })
.click();
await expect(
page.getByRole('heading', { name: `${discussion.title} - updated` }),
).toBeVisible();
await expect(page.getByText(`${discussion.body} - updated`)).toBeVisible();
// create comment:
await page.getByRole('button', { name: 'Create Comment' }).click();
await page.getByLabel('Body').click();
await page.getByLabel('Body').fill(comment.body);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText(comment.body)).toBeVisible();
await page
.getByLabel('Comment Created')
.getByRole('button', { name: 'Close' })
.click();
// delete comment:
await page.getByRole('button', { name: 'Delete Comment' }).click();
await expect(
page.getByText('Are you sure you want to delete this comment?'),
).toBeVisible();
await page.getByRole('button', { name: 'Delete Comment' }).click();
await page
.getByLabel('Comment Deleted')
.getByRole('button', { name: 'Close' })
.click();
await expect(
page.getByRole('heading', { name: 'No Comments Found' }),
).toBeVisible();
await expect(page.getByText(comment.body)).toBeHidden();
// go back to discussions:
await page.getByRole('link', { name: 'Discussions' }).click();
await page.waitForURL('/app/discussions');
// delete discussion:
await page.getByRole('button', { name: 'Delete Discussion' }).click();
await page.getByRole('button', { name: 'Delete Discussion' }).click();
await page
.getByLabel('Discussion Deleted')
.getByRole('button', { name: 'Close' })
.click();
await expect(
page.getByRole('heading', { name: 'No Entries Found' }),
).toBeVisible();
});
================================================
FILE: apps/nextjs-pages/generators/component/component.stories.tsx.hbs
================================================
import { Meta, StoryObj } from '@storybook/react';
import { {{ properCase name }} } from './{{ kebabCase name }}';
const meta: Meta = {
component: {{ properCase name }},
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {}
};
================================================
FILE: apps/nextjs-pages/generators/component/component.tsx.hbs
================================================
import * as React from "react";
export type {{properCase name}}Props = {};
export const {{properCase name}} = (props: {{properCase name}}Props) => {
return (
{{properCase name}}
);
};
================================================
FILE: apps/nextjs-pages/generators/component/index.cjs
================================================
const path = require('path');
const fs = require('fs');
const featuresDir = path.join(process.cwd(), 'src/features');
const features = fs.readdirSync(featuresDir);
/**
*
* @type {import('plop').PlopGenerator}
*/
module.exports = {
description: 'Component Generator',
prompts: [
{
type: 'input',
name: 'name',
message: 'component name',
},
{
type: 'list',
name: 'feature',
message: 'Which feature does this component belong to?',
choices: ['components', ...features],
when: () => features.length > 0,
},
{
type: 'input',
name: 'folder',
message: 'folder in components',
when: ({ feature }) => !feature || feature === 'components',
},
],
actions: (answers) => {
const componentGeneratePath =
!answers.feature || answers.feature === 'components'
? 'src/components/{{folder}}'
: 'src/features/{{feature}}/components';
return [
{
type: 'add',
path: componentGeneratePath + '/{{kebabCase name}}/index.ts',
templateFile: 'generators/component/index.ts.hbs',
},
{
type: 'add',
path: componentGeneratePath + '/{{kebabCase name}}/{{kebabCase name}}.tsx',
templateFile: 'generators/component/component.tsx.hbs',
},
{
type: 'add',
path: componentGeneratePath + '/{{kebabCase name}}/{{kebabCase name}}.stories.tsx',
templateFile: 'generators/component/component.stories.tsx.hbs',
},
];
},
};
================================================
FILE: apps/nextjs-pages/generators/component/index.ts.hbs
================================================
export * from './{{ kebabCase name }}';
================================================
FILE: apps/nextjs-pages/lint-staged.config.mjs
================================================
import path from 'path';
const buildEslintCommand = (filenames) => {
return `next lint --fix --file ${filenames
.filter((f) => f.includes('/src/'))
.map((f) => path.relative(process.cwd(), f))
.join(' --file ')}`;
};
const config = {
'*.{ts,tsx}': [buildEslintCommand, "bash -c 'yarn check-types'"],
};
export default config;
================================================
FILE: apps/nextjs-pages/mock-server.ts
================================================
import { createMiddleware } from '@mswjs/http-middleware';
import cors from 'cors';
import express from 'express';
import logger from 'pino-http';
import { initializeDb } from './src/testing/mocks/db';
import { handlers } from './src/testing/mocks/handlers';
const app = express();
app.use(
cors({
origin: process.env.NEXT_PUBLIC_URL,
credentials: true,
}),
);
app.use(express.json());
app.use(logger({ level: 'silent' }));
app.use(createMiddleware(...handlers));
initializeDb().then(() => {
console.log('Mock DB initialized');
app.listen(process.env.NEXT_PUBLIC_MOCK_API_PORT, () => {
console.log(
`Mock API server started at http://localhost:${process.env.NEXT_PUBLIC_MOCK_API_PORT}`,
);
});
});
================================================
FILE: apps/nextjs-pages/next-env.d.ts
================================================
///
///
///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
================================================
FILE: apps/nextjs-pages/next.config.mjs
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;
================================================
FILE: apps/nextjs-pages/package.json
================================================
{
"name": "bulletproof-react-nextjs-pages",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest",
"test-e2e": "pm2 start \"yarn run-mock-server\" --name server && yarn playwright test",
"prepare": "husky",
"check-types": "tsc --project tsconfig.json --pretty --noEmit",
"generate": "plop",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"run-mock-server": "tsx ./mock-server.ts"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@next/env": "^14.2.5",
"@ngneat/falso": "^7.2.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@tanstack/react-query": "^5.32.0",
"@tanstack/react-query-devtools": "^5.32.0",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"eslint-plugin-check-file": "^2.8.0",
"isomorphic-dompurify": "^2.14.0",
"lucide-react": "^0.378.0",
"marked": "^12.0.2",
"nanoid": "^5.0.7",
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-hook-form": "^7.51.3",
"react-query-auth": "^2.3.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.4",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.0.2",
"@mswjs/data": "^0.16.1",
"@mswjs/http-middleware": "^0.10.1",
"@playwright/test": "^1.43.1",
"@storybook/addon-a11y": "^8.0.10",
"@storybook/addon-actions": "^8.0.9",
"@storybook/addon-essentials": "^8.0.9",
"@storybook/addon-links": "^8.0.9",
"@storybook/nextjs": "^8.2.9",
"@storybook/node-logger": "^8.0.9",
"@storybook/react": "^8.0.9",
"@tailwindcss/typography": "^0.5.13",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^15.0.5",
"@testing-library/user-event": "^14.5.2",
"@types/cors": "^2.8.17",
"@types/dompurify": "^3.0.5",
"@types/js-cookie": "^3.0.6",
"@types/marked": "^6.0.0",
"@types/node": "^20.12.7",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"eslint": "8",
"eslint-config-next": "^14.2.5",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest-dom": "^5.4.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-playwright": "^1.6.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-tailwindcss": "^3.15.1",
"eslint-plugin-testing-library": "^6.2.2",
"eslint-plugin-vitest": "^0.5.4",
"express": "^4.19.2",
"husky": "^9.0.11",
"jest-environment-jsdom": "^29.7.0",
"js-cookie": "^3.0.5",
"jsdom": "^24.0.0",
"lint-staged": "^15.2.2",
"msw": "^2.2.14",
"next-router-mock": "^0.9.13",
"pino-http": "^10.1.0",
"pino-pretty": "^11.1.0",
"plop": "^4.0.1",
"pm2": "^5.4.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"storybook": "^8.0.9",
"tailwindcss": "^3.4.3",
"tsx": "^4.17.0",
"typescript": "^5.4.5",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.1.4"
},
"msw": {
"workerDirectory": "public"
}
}
================================================
FILE: apps/nextjs-pages/playwright.config.ts
================================================
import { defineConfig, devices } from '@playwright/test';
const PORT = 3000;
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
testMatch: /.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: `yarn dev --port ${PORT}`,
timeout: 10 * 1000,
port: PORT,
reuseExistingServer: !process.env.CI,
},
});
================================================
FILE: apps/nextjs-pages/plopfile.cjs
================================================
const componentGenerator = require('./generators/component/index');
/**
*
* @param {import('plop').NodePlopAPI} plop
*/
module.exports = function (plop) {
plop.setGenerator('component', componentGenerator);
};
================================================
FILE: apps/nextjs-pages/postcss.config.cjs
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
================================================
FILE: apps/nextjs-pages/public/_redirects
================================================
/* /index.html 200
================================================
FILE: apps/nextjs-pages/public/mockServiceWorker.js
================================================
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.3.5'
const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
[responseClone.body],
)
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone()
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries())
// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer()
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
},
},
[requestBuffer],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean)),
)
})
}
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
================================================
FILE: apps/nextjs-pages/public/robots.txt
================================================
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
================================================
FILE: apps/nextjs-pages/src/app/pages/app/dashboard.tsx
================================================
import { ReactElement } from 'react';
import { ContentLayout, DashboardLayout } from '@/components/layouts';
import { useUser } from '@/lib/auth';
import { ROLES } from '@/lib/authorization';
export const DashboardPage = () => {
const user = useUser();
if (!user.data) return null;
return (
<>
Welcome {`${user.data?.firstName} ${user.data?.lastName}`}
Your role is : {user.data?.role}
In this application you can:
{user.data?.role === ROLES.USER && (
Create comments in discussions
Delete own comments
)}
{user.data?.role === ROLES.ADMIN && (
Create discussions
Edit discussions
Delete discussions
Comment on discussions
Delete all comments
)}
>
);
};
DashboardPage.getLayout = (page: ReactElement) => {
return (
{page}
);
};
================================================
FILE: apps/nextjs-pages/src/app/pages/app/discussions/__tests__/discussion.test.tsx
================================================
import mockRouter from 'next-router-mock';
import {
renderApp,
screen,
userEvent,
waitFor,
createDiscussion,
createUser,
within,
waitForLoadingToFinish,
} from '@/testing/test-utils';
import { DiscussionPage } from '../discussion';
const renderDiscussion = async () => {
const fakeUser = await createUser();
const fakeDiscussion = await createDiscussion({ teamId: fakeUser.teamId });
mockRouter.query = { discussionId: fakeDiscussion.id };
const utils = await renderApp( , {
user: fakeUser,
path: `/app/discussions/:discussionId`,
url: `/app/discussions/${fakeDiscussion.id}`,
});
await waitForLoadingToFinish();
await screen.findByText(fakeDiscussion.title);
return {
...utils,
fakeUser,
fakeDiscussion,
};
};
test('should render discussion', async () => {
const { fakeDiscussion } = await renderDiscussion();
expect(screen.getByText(fakeDiscussion.body)).toBeInTheDocument();
});
test('should update discussion', async () => {
const { fakeDiscussion } = await renderDiscussion();
const titleUpdate = '-Updated';
const bodyUpdate = '-Updated';
await userEvent.click(
screen.getByRole('button', { name: /update discussion/i }),
);
const drawer = await screen.findByRole('dialog', {
name: /update discussion/i,
});
const titleField = within(drawer).getByText(/title/i);
const bodyField = within(drawer).getByText(/body/i);
const newTitle = `${fakeDiscussion.title}${titleUpdate}`;
const newBody = `${fakeDiscussion.body}${bodyUpdate}`;
// replacing the title with the new title
await userEvent.type(titleField, newTitle);
// appending updated to the body
await userEvent.type(bodyField, bodyUpdate);
const submitButton = within(drawer).getByRole('button', {
name: /submit/i,
});
await userEvent.click(submitButton);
await waitFor(() => expect(drawer).not.toBeInTheDocument());
expect(
await screen.findByRole('heading', { name: newTitle }),
).toBeInTheDocument();
expect(await screen.findByText(newBody)).toBeInTheDocument();
});
test(
'should create and delete a comment on the discussion',
async () => {
await renderDiscussion();
const comment = 'Hello World';
await userEvent.click(
screen.getByRole('button', { name: /create comment/i }),
);
const drawer = await screen.findByRole('dialog', {
name: /create comment/i,
});
const bodyField = await within(drawer).findByText(/body/i);
await userEvent.type(bodyField, comment);
const submitButton = await within(drawer).findByRole('button', {
name: /submit/i,
});
await userEvent.click(submitButton);
await waitFor(() => expect(drawer).not.toBeInTheDocument());
await screen.findByText(comment);
const commentsList = await screen.findByRole('list', {
name: 'comments',
});
const commentElements =
await within(commentsList).findAllByRole('listitem');
const commentElement = commentElements[0];
expect(commentElement).toBeInTheDocument();
const deleteCommentButton = within(commentElement).getByRole('button', {
name: /delete comment/i,
// exact: false,
});
await userEvent.click(deleteCommentButton);
const confirmationDialog = await screen.findByRole('dialog', {
name: /delete comment/i,
});
const confirmationDeleteButton = await within(
confirmationDialog,
).findByRole('button', {
name: /delete/i,
});
await userEvent.click(confirmationDeleteButton);
await screen.findByText(/comment deleted/i);
await waitFor(() => {
expect(within(commentsList).queryByText(comment)).not.toBeInTheDocument();
});
},
{
timeout: 20000,
},
);
================================================
FILE: apps/nextjs-pages/src/app/pages/app/discussions/__tests__/discussions.test.tsx
================================================
import type { Mock } from 'vitest';
import { createDiscussion } from '@/testing/data-generators';
import {
renderApp,
screen,
userEvent,
waitFor,
waitForLoadingToFinish,
within,
} from '@/testing/test-utils';
import { formatDate } from '@/utils/format';
import { DiscussionsPage } from '../discussions';
beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
(console.error as Mock).mockRestore();
});
test(
'should create, render and delete discussions',
{ timeout: 10000 },
async () => {
await renderApp( );
await waitForLoadingToFinish();
const newDiscussion = createDiscussion();
expect(await screen.findByText(/no entries/i)).toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: /create discussion/i }),
);
const drawer = await screen.findByRole('dialog', {
name: /create discussion/i,
});
const titleField = within(drawer).getByText(/title/i);
const bodyField = within(drawer).getByText(/body/i);
await userEvent.type(titleField, newDiscussion.title);
await userEvent.type(bodyField, newDiscussion.body);
const submitButton = within(drawer).getByRole('button', {
name: /submit/i,
});
await userEvent.click(submitButton);
await waitFor(() => expect(drawer).not.toBeInTheDocument());
const row = await screen.findByRole(
'row',
{
name: `${newDiscussion.title} ${formatDate(newDiscussion.createdAt)} View Delete Discussion`,
},
{ timeout: 5000 },
);
expect(
within(row).getByRole('cell', {
name: newDiscussion.title,
}),
).toBeInTheDocument();
await userEvent.click(
within(row).getByRole('button', {
name: /delete discussion/i,
}),
);
const confirmationDialog = await screen.findByRole('dialog', {
name: /delete discussion/i,
});
const confirmationDeleteButton = within(confirmationDialog).getByRole(
'button',
{
name: /delete discussion/i,
},
);
await userEvent.click(confirmationDeleteButton);
await screen.findByText(/discussion deleted/i);
expect(
within(row).queryByRole('cell', {
name: newDiscussion.title,
}),
).not.toBeInTheDocument();
},
);
================================================
FILE: apps/nextjs-pages/src/app/pages/app/discussions/discussion.tsx
================================================
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ContentLayout, DashboardLayout } from '@/components/layouts';
import { Spinner } from '@/components/ui/spinner';
import { getInfiniteCommentsQueryOptions } from '@/features/comments/api/get-comments';
import { Comments } from '@/features/comments/components/comments';
import {
useDiscussion,
getDiscussionQueryOptions,
} from '@/features/discussions/api/get-discussion';
import { DiscussionView } from '@/features/discussions/components/discussion-view';
type DiscussionPageProps = {
dehydratedState?: unknown;
};
export const getServerSideProps = (async ({ query, req }) => {
const queryClient = new QueryClient();
const discussionId = query.discussionId as string;
const cookie = req.headers.cookie;
await queryClient.prefetchQuery(
getDiscussionQueryOptions(discussionId, cookie),
);
await queryClient.prefetchInfiniteQuery(
getInfiniteCommentsQueryOptions(discussionId, cookie),
);
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}) satisfies GetServerSideProps;
export const DiscussionPage = () => {
const router = useRouter();
const discussionId = router.query.discussionId as string;
const discussionQuery = useDiscussion({
discussionId,
});
if (discussionQuery.isLoading) {
return (
);
}
const discussion = discussionQuery.data?.data;
if (!discussion) return null;
return (
Failed to load comments. Try to refresh the page.
}
>
);
};
DiscussionPage.getLayout = (page: ReactElement) => {
return {page} ;
};
export const PublicDiscussionPage = ({
dehydratedState,
}: InferGetServerSidePropsType) => {
return (
);
};
================================================
FILE: apps/nextjs-pages/src/app/pages/app/discussions/discussions.tsx
================================================
import { useQueryClient } from '@tanstack/react-query';
import { ReactElement } from 'react';
import { ContentLayout, DashboardLayout } from '@/components/layouts';
import { getInfiniteCommentsQueryOptions } from '@/features/comments/api/get-comments';
import { CreateDiscussion } from '@/features/discussions/components/create-discussion';
import { DiscussionsList } from '@/features/discussions/components/discussions-list';
export const DiscussionsPage = () => {
const queryClient = useQueryClient();
return (
<>
{
// Prefetch the comments data when the user hovers over the link in the list
queryClient.prefetchInfiniteQuery(
getInfiniteCommentsQueryOptions(id),
);
}}
/>
>
);
};
DiscussionsPage.getLayout = (page: ReactElement) => {
return (
{page}
);
};
================================================
FILE: apps/nextjs-pages/src/app/pages/app/profile.tsx
================================================
import { ReactElement } from 'react';
import { ContentLayout, DashboardLayout } from '@/components/layouts';
import { UpdateProfile } from '@/features/users/components/update-profile';
import { useUser } from '@/lib/auth';
type EntryProps = {
label: string;
value: string;
};
const Entry = ({ label, value }: EntryProps) => (
{label}
{value}
);
export const ProfilePage = () => {
const user = useUser();
if (!user.data) return null;
return (
User Information
Personal details of the user.
);
};
ProfilePage.getLayout = (page: ReactElement) => {
return (
{page}
);
};
================================================
FILE: apps/nextjs-pages/src/app/pages/app/users.tsx
================================================
import { ReactElement } from 'react';
import { ContentLayout, DashboardLayout } from '@/components/layouts';
import { UsersList } from '@/features/users/components/users-list';
import { Authorization, ROLES } from '@/lib/authorization';
export const UsersPage = () => {
return (
Only admin can view this.}
allowedRoles={[ROLES.ADMIN]}
>
);
};
UsersPage.getLayout = (page: ReactElement) => {
return (
{page}
);
};
================================================
FILE: apps/nextjs-pages/src/app/pages/auth/login.tsx
================================================
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { AuthLayout } from '@/components/layouts/auth-layout';
import { paths } from '@/config/paths';
import { LoginForm } from '@/features/auth/components/login-form';
export const LoginPage = () => {
const router = useRouter();
const { redirectTo } = router.query;
return (
router.replace(
`${redirectTo ? `${redirectTo}` : paths.app.dashboard.getHref()}`,
)
}
/>
);
};
LoginPage.getLayout = (page: ReactElement) => {
return {page} ;
};
================================================
FILE: apps/nextjs-pages/src/app/pages/auth/register.tsx
================================================
import { useRouter } from 'next/router';
import { ReactElement, useState } from 'react';
import { AuthLayout } from '@/components/layouts/auth-layout';
import { paths } from '@/config/paths';
import { RegisterForm } from '@/features/auth/components/register-form';
import { useTeams } from '@/features/teams/api/get-teams';
export const RegisterPage = () => {
const router = useRouter();
const { redirectTo } = router.query;
const [chooseTeam, setChooseTeam] = useState(false);
const teamsQuery = useTeams({
queryConfig: {
enabled: chooseTeam,
},
});
return (
router.replace(
`${redirectTo ? `${redirectTo}` : paths.app.dashboard.getHref()}`,
)
}
chooseTeam={chooseTeam}
setChooseTeam={() => setChooseTeam(!chooseTeam)}
teams={teamsQuery.data?.data}
/>
);
};
RegisterPage.getLayout = (page: ReactElement) => {
return {page} ;
};
================================================
FILE: apps/nextjs-pages/src/app/provider.tsx
================================================
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import * as React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { MainErrorFallback } from '@/components/errors/main';
import { Notifications } from '@/components/ui/notifications';
import { Spinner } from '@/components/ui/spinner';
import { queryConfig } from '@/lib/react-query';
type AppProviderProps = {
children: React.ReactNode;
};
export const AppProvider = ({ children }: AppProviderProps) => {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: queryConfig,
}),
);
return (
}
>
{process.env.DEV && }
{children}
);
};
================================================
FILE: apps/nextjs-pages/src/components/errors/main.tsx
================================================
import { Button } from '../ui/button';
export const MainErrorFallback = () => {
return (
Ooops, something went wrong :(
window.location.assign(window.location.origin)}
>
Refresh
);
};
================================================
FILE: apps/nextjs-pages/src/components/layouts/auth-layout.tsx
================================================
import { useRouter } from 'next/router';
import * as React from 'react';
import { useEffect } from 'react';
import { Head } from '@/components/seo';
import { Link } from '@/components/ui/link';
import { paths } from '@/config/paths';
import { useUser } from '@/lib/auth';
type LayoutProps = {
children: React.ReactNode;
title: string;
};
export const AuthLayout = ({ children, title }: LayoutProps) => {
const user = useUser();
const router = useRouter();
useEffect(() => {
if (user.data) {
router.replace(paths.app.dashboard.getHref());
}
}, [user.data, router]);
return (
<>
>
);
};
================================================
FILE: apps/nextjs-pages/src/components/layouts/content-layout.tsx
================================================
import * as React from 'react';
import { Head } from '../seo';
type ContentLayoutProps = {
children: React.ReactNode;
title: string;
};
export const ContentLayout = ({ children, title }: ContentLayoutProps) => {
return (
<>
>
);
};
================================================
FILE: apps/nextjs-pages/src/components/layouts/dashboard-layout.tsx
================================================
import { Home, PanelLeft, Folder, Users, User2 } from 'lucide-react';
import NextLink from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Button } from '@/components/ui/button';
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
import { Spinner } from '@/components/ui/spinner';
import { paths } from '@/config/paths';
import { AuthLoader, useLogout } from '@/lib/auth';
import { ROLES, useAuthorization } from '@/lib/authorization';
import { cn } from '@/utils/cn';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown';
import { Link } from '../ui/link';
type SideNavigationItem = {
name: string;
to: string;
icon: (props: React.SVGProps) => JSX.Element;
};
const Logo = () => {
return (
Bulletproof React
);
};
const Progress = () => {
const router = useRouter();
const [progress, setProgress] = useState(0);
useEffect(() => {
const handleRouteChangeStart = () => {
setProgress(0);
const timer = setInterval(() => {
setProgress((oldProgress) => {
if (oldProgress === 100) {
clearInterval(timer);
return 100;
}
const newProgress = oldProgress + 10;
return newProgress > 100 ? 100 : newProgress;
});
}, 300);
return () => {
clearInterval(timer);
};
};
const handleRouteChangeComplete = () => {
setProgress(100);
setTimeout(() => {
setProgress(0);
}, 500); // Adjust the delay as needed
};
router.events.on('routeChangeStart', handleRouteChangeStart);
router.events.on('routeChangeComplete', handleRouteChangeComplete);
router.events.on('routeChangeError', handleRouteChangeComplete);
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart);
router.events.off('routeChangeComplete', handleRouteChangeComplete);
router.events.off('routeChangeError', handleRouteChangeComplete);
};
}, [router.events]);
if (progress === 0) {
return null;
}
return (
);
};
const Layout = ({ children }: { children: React.ReactNode }) => {
const logout = useLogout();
const { checkAccess } = useAuthorization();
const router = useRouter();
const navigation = [
{ name: 'Dashboard', to: paths.app.dashboard.getHref(), icon: Home },
{ name: 'Discussions', to: paths.app.discussions.getHref(), icon: Folder },
checkAccess({ allowedRoles: [ROLES.ADMIN] }) && {
name: 'Users',
to: paths.app.users.getHref(),
icon: Users,
},
].filter(Boolean) as SideNavigationItem[];
return (
{navigation.map((item) => {
const isActive = router.pathname === item.to;
return (
{item.name}
);
})}
Toggle Menu
{navigation.map((item) => {
const isActive = router.pathname === item.to;
return (
{item.name}
);
})}
Open user menu
router.push(paths.app.profile.getHref())}
className={cn('block px-4 py-2 text-sm text-gray-700')}
>
Your Profile
logout.mutate({})}
>
Sign Out
{children}
);
};
export const DashboardLayout = ({
children,
}: {
children: React.ReactNode;
}) => {
const router = useRouter();
return (
}
>
Something went wrong!}
>
(
)}
>
{children}
);
};
================================================
FILE: apps/nextjs-pages/src/components/layouts/index.ts
================================================
export * from './content-layout';
export * from './dashboard-layout';
================================================
FILE: apps/nextjs-pages/src/components/seo/head.tsx
================================================
import NextHead from 'next/head';
type HeadProps = {
title?: string;
description?: string;
};
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
return (
{title}
);
};
================================================
FILE: apps/nextjs-pages/src/components/seo/index.ts
================================================
export * from './head';
================================================
FILE: apps/nextjs-pages/src/components/ui/button/button.stories.tsx
================================================
import { Meta, StoryObj } from '@storybook/react';
import { Button } from './button';
const meta: Meta = {
component: Button,
};
export default meta;
type Story = StoryObj;
export const Default: Story = {
args: {
children: 'Button',
variant: 'default',
},
};
================================================
FILE: apps/nextjs-pages/src/components/ui/button/button.tsx
================================================
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/utils/cn';
import { Spinner } from '../spinner';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export type ButtonProps = React.ButtonHTMLAttributes &
VariantProps & {
asChild?: boolean;
isLoading?: boolean;
icon?: React.ReactNode;
};
const Button = React.forwardRef(
(
{
className,
variant,
size,
asChild = false,
children,
isLoading,
icon,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
return (
{isLoading && }
{!isLoading && icon && {icon} }
{children}
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
================================================
FILE: apps/nextjs-pages/src/components/ui/button/index.ts
================================================
export * from './button';
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/__tests__/dialog.test.tsx
================================================
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { useDisclosure } from '@/hooks/use-disclosure';
import { rtlRender, screen, userEvent, waitFor } from '@/testing/test-utils';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../dialog';
const openButtonText = 'Open Modal';
const cancelButtonText = 'Cancel';
const titleText = 'Modal Title';
const TestDialog = () => {
const { close, open, isOpen } = useDisclosure();
const cancelButtonRef = React.useRef(null);
return (
{
if (!isOpen) {
close();
} else {
open();
}
}}
>
{openButtonText}
{titleText}
Submit
{cancelButtonText}
);
};
test('should handle basic dialog flow', async () => {
rtlRender( );
expect(screen.queryByText(titleText)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: openButtonText }));
expect(await screen.findByText(titleText)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: cancelButtonText }));
await waitFor(() =>
expect(screen.queryByText(titleText)).not.toBeInTheDocument(),
);
});
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/confirmation-dialog/__tests__/confirmation-dialog.test.tsx
================================================
import { Button } from '@/components/ui/button';
import { rtlRender, screen, userEvent, waitFor } from '@/testing/test-utils';
import { ConfirmationDialog } from '../confirmation-dialog';
test('should handle confirmation flow', async () => {
const titleText = 'Are you sure?';
const bodyText = 'Are you sure you want to delete this item?';
const confirmationButtonText = 'Confirm';
const openButtonText = 'Open';
await rtlRender(
{confirmationButtonText}}
triggerButton={{openButtonText} }
/>,
);
expect(screen.queryByText(titleText)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: openButtonText }));
expect(await screen.findByText(titleText)).toBeInTheDocument();
expect(screen.getByText(bodyText)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.queryByText(titleText)).not.toBeInTheDocument(),
);
expect(screen.queryByText(bodyText)).not.toBeInTheDocument();
});
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.stories.tsx
================================================
import { Meta, StoryObj } from '@storybook/react';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from './confirmation-dialog';
const meta: Meta = {
component: ConfirmationDialog,
};
export default meta;
type Story = StoryObj;
export const Danger: Story = {
args: {
icon: 'danger',
title: 'Confirmation',
body: 'Hello World',
confirmButton: Confirm ,
triggerButton: Open ,
},
};
export const Info: Story = {
args: {
icon: 'info',
title: 'Confirmation',
body: 'Hello World',
confirmButton: Confirm ,
triggerButton: Open ,
},
};
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/confirmation-dialog/confirmation-dialog.tsx
================================================
import { CircleAlert, Info } from 'lucide-react';
import * as React from 'react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useDisclosure } from '@/hooks/use-disclosure';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../dialog';
export type ConfirmationDialogProps = {
triggerButton: React.ReactElement;
confirmButton: React.ReactElement;
title: string;
body?: string;
cancelButtonText?: string;
icon?: 'danger' | 'info';
isDone?: boolean;
};
export const ConfirmationDialog = ({
triggerButton,
confirmButton,
title,
body = '',
cancelButtonText = 'Cancel',
icon = 'danger',
isDone = false,
}: ConfirmationDialogProps) => {
const { close, open, isOpen } = useDisclosure();
const cancelButtonRef = React.useRef(null);
useEffect(() => {
if (isDone) {
close();
}
}, [isDone, close]);
return (
{
if (!isOpen) {
close();
} else {
open();
}
}}
>
{triggerButton}
{' '}
{icon === 'danger' && (
)}
{icon === 'info' && (
)}
{title}
{confirmButton}
{cancelButtonText}
);
};
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/confirmation-dialog/index.ts
================================================
export * from './confirmation-dialog';
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/dialog.stories.tsx
================================================
import { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { useDisclosure } from '@/hooks/use-disclosure';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './dialog';
const DemoDialog = () => {
const { close, open, isOpen } = useDisclosure();
const cancelButtonRef = React.useRef(null);
return (
{
if (!isOpen) {
close();
} else {
open();
}
}}
>
Open Dialog
Edit profile
Lorem ipsum
Lorem ipsum
Save changes
Cancel
);
};
const meta: Meta = {
component: Dialog,
};
export default meta;
type Story = StoryObj;
export const Demo: Story = {
render: () => ,
};
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/dialog.tsx
================================================
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import * as React from 'react';
import { cn } from '@/utils/cn';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
{children}
Close
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes) => (
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes) => (
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
================================================
FILE: apps/nextjs-pages/src/components/ui/dialog/index.ts
================================================
export * from './dialog';
export * from './confirmation-dialog';
================================================
FILE: apps/nextjs-pages/src/components/ui/drawer/__tests__/drawer.test.tsx
================================================
import { Button } from '@/components/ui/button';
import { rtlRender, screen, userEvent, waitFor } from '@/testing/test-utils';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '../drawer';
const openButtonText = 'Open Drawer';
const titleText = 'Drawer Title';
const cancelButtonText = 'Cancel';
const drawerContentText = 'Hello From Drawer';
const TestDrawer = () => {
return (
{openButtonText}
{titleText}
{drawerContentText}
{cancelButtonText}
);
};
test('should handle basic drawer flow', async () => {
await rtlRender( );
expect(screen.queryByText(titleText)).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', {
name: openButtonText,
}),
);
expect(await screen.findByText(titleText)).toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', {
name: cancelButtonText,
}),
);
await waitFor(() =>
expect(screen.queryByText(titleText)).not.toBeInTheDocument(),
);
});
================================================
FILE: apps/nextjs-pages/src/components/ui/drawer/drawer.stories.tsx
================================================
import { Meta, StoryObj } from '@storybook/react';
import { Button } from '@/components/ui/button';
import { useDisclosure } from '@/hooks/use-disclosure';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from './drawer';
const meta: Meta = {
component: Drawer,
};
export default meta;
type Story = StoryObj;
const DemoDrawer = () => {
const { close, open, isOpen } = useDisclosure();
return (
{
if (!isOpen) {
close();
} else {
open();
}
}}
>
Open
Drawer Header
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Save changes
);
};
export const Default: Story = {
render: () => ,
};
================================================
FILE: apps/nextjs-pages/src/components/ui/drawer/drawer.tsx
================================================
import * as DrawerPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/utils/cn';
const Drawer = DrawerPrimitive.Root;
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerClose = DrawerPrimitive.Close;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const drawerVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
},
);
type DrawerContentProps = React.ComponentPropsWithoutRef<
typeof DrawerPrimitive.Content
> &
VariantProps;
const DrawerContent = React.forwardRef<
React.ElementRef,
DrawerContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
{children}
Close
));
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes) => (
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes) => (
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
================================================
FILE: apps/nextjs-pages/src/components/ui/drawer/index.ts
================================================
export * from './drawer';
================================================
FILE: apps/nextjs-pages/src/components/ui/dropdown/dropdown.stories.tsx
================================================
import type { Meta } from '@storybook/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuRadioGroup,
} from './dropdown';
const meta: Meta = {
component: DropdownMenu,
};
export default meta;
export const Default = () => (
Open Menu
Item One
Item Two
Item Three
);
export const WithCheckboxItems = () => {
const [checked, setChecked] = React.useState(true);
const [checked2, setChecked2] = React.useState(false);
return (
Open Menu
Option One
Option Two
);
};
export const WithRadioItems = () => {
const [value, setValue] = React.useState('one');
return (
Open Menu
Select an option
Option One
Option Two
Option Three
);
};
export const WithSubmenus = () => (
Open Menu
Item One
More Options
Sub Item One
Sub Item Two
Item Three
);
================================================
FILE: apps/nextjs-pages/src/components/ui/dropdown/dropdown.tsx
================================================
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from '@radix-ui/react-icons';
import * as React from 'react';
import { cn } from '@/utils/cn';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
{children}
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, sideOffset = 4, ...props }, ref) => (
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, checked, ...props }, ref) => (