Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 프로젝트 생성 기능 구현 #94

Merged
merged 2 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
Expand Down
70 changes: 70 additions & 0 deletions apps/client/src/components/dialog/CreateProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CreateProjectRequestDTO } from '@/types/project';

const formSchema = z.object({
title: z
.string()
.min(1, 'Title is required.')
.regex(/^[a-zA-Z0-9 ]*$/, 'Only English letters and numbers are allowed.'),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

영어만 가능! 적용하셨군요!

});

interface CreateProjectDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onSubmit: (data: CreateProjectRequestDTO) => void;
isPending: boolean;
}

function CreateProjectDialog({
isOpen,
onOpenChange,
onSubmit,
isPending,
}: CreateProjectDialogProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateProjectRequestDTO>({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢
Dto 가 약자임을 알지만, 저는 보통 Dto 라고 마무리합니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅋㅋㅋ고민하다가 이렇게 했는데, 뭐로 맞출까용

resolver: zodResolver(formSchema),
});

return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="bg-white">
<DialogHeader>
<DialogTitle>Create a New Project</DialogTitle>
<DialogDescription>Enter a title to create a new project.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<label htmlFor="title">
<Input id="title" type="text" placeholder="Project Title" {...register('title')} />
{errors.title && <span className="text-sm text-red-500">{errors.title.message}</span>}
</label>
<Button
type="submit"
className="bg-black text-white hover:bg-black/80"
disabled={isPending}
>
{isPending ? 'Creating...' : 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

export default CreateProjectDialog;
108 changes: 108 additions & 0 deletions apps/client/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cn } from '@/lib/utils';

const Dialog = DialogPrimitive.Root;

const DialogTrigger = DialogPrimitive.Trigger;

const DialogPortal = DialogPrimitive.Portal;

const DialogClose = DialogPrimitive.Close;

const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/30',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
);
}
DialogHeader.displayName = 'DialogHeader';

function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
}
DialogFooter.displayName = 'DialogFooter';

const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;

const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
83 changes: 83 additions & 0 deletions apps/client/src/pages/AccountOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import axios from 'axios';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

라우트에서 쿼리 동작을 분리하신 이유가 궁금해요~

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

뭔가 걸려서 다시보니, 양쪽다 있네요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#a-more-realistic-example-using-tanstack-query
TanStack/router#1563

요것들을 참고했습니다.
메인테이너인 도도씨가 loader는 캐시를 심기 위한 용도라고 하네요..

import { useState } from 'react';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/authContext';
import TabView from '@/components/TabView';
import ProjectCard from '@/components/ProjectCard';
import CreateProjectDialog from '@/components/dialog/CreateProjectDialog';
import { CreateProjectRequestDTO, GetProjectsResponseDTO } from '@/types/project';

function AccountOverview() {
const auth = useAuth();
const queryClient = useQueryClient();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: projects } = useSuspenseQuery({
queryKey: ['projects'],
queryFn: async () => {
try {
const projects = await axios.get<GetProjectsResponseDTO>('/api/projects', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.accessToken}`,
},
});
return projects.data.result;
} catch {
throw new Error('Failed to fetch projects');
}
},
});
const { isPending, mutate } = useMutation({
mutationFn: async (data: CreateProjectRequestDTO) => {
await axios.post('/api/project', data, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.accessToken}`,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
setIsDialogOpen(false);
},
onError: (error) => {
console.log('Failed to create project', error);
},
});

return (
<TabView>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


여기에는 로 감싸지 않아도 되는 것인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰가 누락되어있는 것 같은데요!?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그러네요

서스펜스에 대해 언급했었습니다.
useSuspenseQuery 가 적용되어 있는데, 해당 부분을 제가 못찾은 건지...

<TabView.Title>Account Overview</TabView.Title>
<TabView.Content>
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-medium">Projects</h2>
<p className="mt-1 text-sm text-gray-500">
The Projects that are associated with your Harmony account.
</p>
</div>
<Button
className="bg-black text-white hover:bg-black/80"
onClick={() => setIsDialogOpen(true)}
>
Create a Project
</Button>
<CreateProjectDialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
onSubmit={mutate}
isPending={isPending}
/>
</div>
<div className="flex flex-col gap-2">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</TabView.Content>
</TabView>
);
}

export default AccountOverview;
67 changes: 5 additions & 62 deletions apps/client/src/routes/_auth.account.index.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,27 @@
import axios from 'axios';
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/authContext';
import TabView from '@/components/TabView';
import ProjectCard from '@/components/ProjectCard';

type Project = {
id: number;
title: string;
createdAt: string;
role: string;
};

type ProjectResponse = {
status: number;
message: string;
result: Project[];
};
import { GetProjectsResponseDTO } from '@/types/project';
import AccountOverview from '@/pages/AccountOverview';

export const Route = createFileRoute('/_auth/account/')({
loader: ({ context: { auth, queryClient } }) => {
queryClient.ensureQueryData({
queryKey: ['projects'],
queryFn: async () => {
try {
const projects = await axios.get<ProjectResponse>('/api/projects', {
const projects = await axios.get<GetProjectsResponseDTO>('/api/projects', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.accessToken}`,
},
});
return projects.data.result;
} catch (error) {
} catch {
throw new Error('Failed to fetch projects');
}
},
});
},
errorComponent: () => <div>Failed to load projects</div>,
component: AccountIndex,
component: AccountOverview,
});

function AccountIndex() {
const auth = useAuth();
const { data: projects } = useSuspenseQuery({
queryKey: ['projects'],
queryFn: async () => {
try {
const projects = await axios.get<ProjectResponse>('/api/projects', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.accessToken}`,
},
});
return projects.data.result;
} catch (error) {
throw new Error('Failed to fetch projects');
}
},
});
return (
<TabView>
<TabView.Title>Account Overview</TabView.Title>
<TabView.Content>
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-medium">Projects</h2>
<p className="mt-1 text-sm text-gray-500">
The Projects that are associated with your Harmony account.
</p>
</div>
<Button className="bg-black text-white hover:bg-black/80">Create a Project</Button>
</div>
<div className="flex flex-col gap-2">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</TabView.Content>
</TabView>
);
}
4 changes: 2 additions & 2 deletions apps/client/src/routes/_auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const Route = createFileRoute('/_auth')({
},
});
return projects.data.result;
} catch (error) {
} catch {
throw new Error('Failed to fetch projects');
}
},
Expand All @@ -79,7 +79,7 @@ function AuthLayout() {
},
});
return projects.data.result;
} catch (error) {
} catch {
throw new Error('Failed to fetch projects');
}
},
Expand Down
16 changes: 16 additions & 0 deletions apps/client/src/types/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface Project {
id: number;
title: string;
createdAt: string;
role: string;
}

export interface GetProjectsResponseDTO {
status: number;
message: string;
result: Project[];
}

export interface CreateProjectRequestDTO {
title: string;
}
Loading