Skip to content
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 docs/team-space-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Base URL은 `/api`이며, 아래 경로는 모두 인증 토큰을 사용한다.
| Method | Path | 용도 |
| --- | --- | --- |
| `GET` | `/project-groups/me` | 내 활성 팀 스페이스 조회 |
| `PATCH` | `/project-groups/{projectGroupId}/name` | 팀 스페이스 이름 수정 |
| `PATCH` | `/project-groups/{projectGroupId}/admins/{targetUserId}` | 팀 스페이스 관리자 권한 부여 |
| `DELETE` | `/project-groups/{projectGroupId}/admins/{targetUserId}` | 팀 스페이스 관리자 권한 회수 |
| `GET` | `/project-groups/{projectGroupId}/checklists` | 체크리스트 목록 조회 |
Expand Down
34 changes: 34 additions & 0 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,31 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
/project-groups/{projectGroupId}/name:
patch:
tags: [ProjectGroups]
security:
- bearerAuth: []
summary: Update a project group's display name
parameters:
- $ref: '#/components/parameters/ProjectGroupId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateProjectGroupNameRequest'
responses:
'200':
description: Project group name updated
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/project-groups/{projectGroupId}/admins/{targetUserId}:
patch:
tags: [ProjectGroups]
Expand Down Expand Up @@ -1462,6 +1487,15 @@ components:
type: array
items:
$ref: '#/components/schemas/ProjectGroupMember'
UpdateProjectGroupNameRequest:
type: object
additionalProperties: false
required: [projectName]
properties:
projectName:
type: string
minLength: 1
maxLength: 255
ProjectGroupMember:
type: object
additionalProperties: false
Expand Down
25 changes: 25 additions & 0 deletions src/features/project-groups/hooks/use-project-group-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import {
getMyProjectGroup,
grantProjectGroupAdminPermission,
revokeProjectGroupAdminPermission,
updateProjectGroupName,
} from "@/lib/api/project-groups";
import type {
MyProjectGroup,
ProjectGroupAdminPermissionRequest,
ProjectGroupFinishRequest,
UpdateProjectGroupNameRequest,
} from "@/lib/types/project-group";
import { isProjectGroupNotFoundError } from "@/features/project-groups/lib/errors";

Expand Down Expand Up @@ -64,6 +67,28 @@ export function useRevokeProjectGroupAdminPermissionMutation() {
});
}

export function useUpdateProjectGroupNameMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (payload: UpdateProjectGroupNameRequest) =>
updateProjectGroupName(payload),
onSuccess: (_, variables) => {
queryClient.setQueryData<MyProjectGroup | null>(
projectGroupQueryKeys.me,
(current) =>
current
? {
...current,
projectName: variables.projectName.trim(),
}
: current,
);
queryClient.invalidateQueries({ queryKey: projectGroupQueryKeys.all });
},
});
}

export function useFinishProjectGroupMutation() {
const queryClient = useQueryClient();

Expand Down
87 changes: 74 additions & 13 deletions src/features/team/components/real-team-manage-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { CheckCircle2, LoaderCircle, ShieldCheck } from "lucide-react";
import { CheckCircle2, LoaderCircle, Save, ShieldCheck } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";

import { AppPanel, AppPanelHeader } from "@/components/app-shell";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
type ActionFeedback,
RealActionFeedback,
Expand All @@ -23,10 +25,13 @@ export function RealManagePanel({
hasCurrentUserAgreedFinish,
isAdminPermissionPending,
isFinishPending,
isProjectNamePending,
onAdminPermissionChange,
onFinishProjectGroup,
onProjectNameUpdate,
pendingAdminPermissionTargetId,
projectGroup,
projectNameFeedback,
}: {
canManageAdminPermissions: boolean;
currentUserId: number;
Expand All @@ -35,31 +40,86 @@ export function RealManagePanel({
hasCurrentUserAgreedFinish: boolean;
isAdminPermissionPending: boolean;
isFinishPending: boolean;
isProjectNamePending: boolean;
onAdminPermissionChange: (member: ProjectGroupMember) => void;
onFinishProjectGroup: () => void;
onProjectNameUpdate: (projectName: string) => void;
pendingAdminPermissionTargetId: number | null;
projectGroup: MyProjectGroup;
projectNameFeedback: ActionFeedback | null;
}) {
const [projectNameInput, setProjectNameInput] = useState(
projectGroup.projectName,
);
const trimmedProjectName = projectNameInput.trim();
const isProjectNameChanged = trimmedProjectName !== projectGroup.projectName;
const isProjectNameInvalid =
trimmedProjectName.length === 0 || trimmedProjectName.length > 255;
const canSubmitProjectName =
canManageAdminPermissions &&
isProjectNameChanged &&
!isProjectNameInvalid &&
!isProjectNamePending;

useEffect(() => {
setProjectNameInput(projectGroup.projectName);
}, [projectGroup.projectName]);

function handleProjectNameSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();

if (!canSubmitProjectName) {
return;
}

onProjectNameUpdate(trimmedProjectName);
}

return (
<div className="grid gap-5">
<AppPanel>
<AppPanelHeader
action={<Badge variant="brand">active</Badge>}
description="멤버 관리자 권한과 팀 종료 동의를 관리해요."
description="팀 이름, 멤버 관리자 권한, 팀 종료 동의를 관리해요."
eyebrow="Manage"
title="팀 관리"
/>
<div className="grid gap-5 p-5">
<div className="grid gap-4 lg:grid-cols-2">
<label className="grid gap-2 text-sm font-semibold text-brand-ink">
팀 이름
<input
className="h-11 rounded-lg border border-input bg-secondary/40 px-3 text-sm font-normal text-muted-foreground outline-none"
defaultValue={projectGroup.projectName}
disabled
readOnly
<form
className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto]"
onSubmit={handleProjectNameSubmit}
>
<div className="grid gap-2">
<label
className="text-sm font-semibold text-brand-ink"
htmlFor="project-group-name"
>
팀 이름
</label>
<Input
disabled={!canManageAdminPermissions || isProjectNamePending}
id="project-group-name"
maxLength={255}
onChange={(event) => setProjectNameInput(event.target.value)}
value={projectNameInput}
/>
</label>
</div>
<div className="flex items-end">
<Button disabled={!canSubmitProjectName} type="submit">
{isProjectNamePending ? (
<LoaderCircle
className="animate-spin"
data-icon="inline-start"
/>
) : (
<Save data-icon="inline-start" />
)}
{isProjectNamePending ? "저장 중" : "이름 저장"}
</Button>
</div>
</form>
<RealActionFeedback feedback={projectNameFeedback} />
<div className="grid gap-4 lg:grid-cols-2">
<label className="grid gap-2 text-sm font-semibold text-brand-ink">
팀 상태
<select
Expand All @@ -72,8 +132,9 @@ export function RealManagePanel({
</label>
</div>
<div className="rounded-lg border border-dashed border-border bg-secondary/30 p-4 text-sm leading-6 text-muted-foreground">
팀 이름 편집은 준비 중이에요. 팀 종료는 모든 팀원의 동의가 모이면
완료돼요.
{canManageAdminPermissions
? "팀 이름 변경은 모든 팀원에게 같은 팀 스페이스 이름으로 보여요."
: "팀 이름은 방장만 수정할 수 있어요."}
</div>
</div>
</AppPanel>
Expand Down
38 changes: 38 additions & 0 deletions src/features/team/components/team-space-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
useGrantProjectGroupAdminPermissionMutation,
useMyProjectGroupQuery,
useRevokeProjectGroupAdminPermissionMutation,
useUpdateProjectGroupNameMutation,
} from "@/features/project-groups/hooks/use-project-group-queries";
import {
hasStoredProjectGroupFinishAgreement,
Expand Down Expand Up @@ -84,13 +85,16 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) {
useGrantProjectGroupAdminPermissionMutation();
const revokeAdminPermissionMutation =
useRevokeProjectGroupAdminPermissionMutation();
const updateProjectGroupNameMutation = useUpdateProjectGroupNameMutation();
const finishProjectGroupMutation = useFinishProjectGroupMutation();
const {
isPending: isCompletingGithubInstallation,
mutate: completeGithubAppInstallation,
} = useCompleteGithubAppInstallationMutation();
const [adminPermissionFeedback, setAdminPermissionFeedback] =
useState<ActionFeedback | null>(null);
const [projectNameFeedback, setProjectNameFeedback] =
useState<ActionFeedback | null>(null);
const [finishState, setFinishState] = useState<ProjectGroupFinishState>({
agreedProjectGroupId: null,
feedback: null,
Expand Down Expand Up @@ -288,6 +292,35 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) {
);
}

function handleUpdateProjectGroupName(projectName: string) {
if (!projectGroup) {
return;
}

const trimmedProjectName = projectName.trim();
setProjectNameFeedback(null);
updateProjectGroupNameMutation.mutate(
{
projectGroupId: projectGroup.projectGroupId,
projectName: trimmedProjectName,
},
{
onError: (error: unknown) => {
setProjectNameFeedback({
message: getApiErrorMessage(error),
tone: "error",
});
},
onSuccess: () => {
setProjectNameFeedback({
message: "팀 이름을 저장했어요.",
tone: "success",
});
},
},
);
}

return (
<AppShell
actions={
Expand Down Expand Up @@ -447,12 +480,17 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) {
hasCurrentUserAgreedFinish={hasCurrentUserAgreedFinish}
isAdminPermissionPending={isAdminPermissionPending}
isFinishPending={finishProjectGroupMutation.isPending}
isProjectNamePending={
updateProjectGroupNameMutation.isPending
}
onAdminPermissionChange={handleAdminPermissionChange}
onFinishProjectGroup={handleFinishProjectGroup}
onProjectNameUpdate={handleUpdateProjectGroupName}
pendingAdminPermissionTargetId={
pendingAdminPermissionTargetId
}
projectGroup={projectGroup}
projectNameFeedback={projectNameFeedback}
/>
) : null}
</section>
Expand Down
58 changes: 57 additions & 1 deletion src/lib/api/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ import type {
ProjectChecklistStatus,
UpdateProjectChecklistRequest,
} from "@/lib/types/project-checklist";
import type { MyProjectGroup } from "@/lib/types/project-group";
import type {
MyProjectGroup,
UpdateProjectGroupNameRequest,
} from "@/lib/types/project-group";
import type { ChatMessage } from "@/lib/types/chat";
import type {
DevGuideContent,
Expand Down Expand Up @@ -1758,6 +1761,59 @@ export const handlers = [
return HttpResponse.json(activeProjectGroup);
}),

http.patch(
getPath("/project-groups/:projectGroupId/name"),
async ({ params, request }) => {
await delay(250);
syncSessionFromRequest(request);

const projectGroupId = Number(params.projectGroupId);
const accessError = assertProjectGroupAccess(projectGroupId);

if (accessError) {
return accessError;
}

const currentMember = findProjectGroupMember(currentUserId);
if (currentMember?.groupRole !== "HOST") {
return buildErrorResponse(
403,
"방장만 팀 이름을 수정할 수 있습니다.",
"PROJECT_GROUP_PERMISSION_DENIED",
);
}

const body = (await request
.json()
.catch(() => null)) as Partial<UpdateProjectGroupNameRequest> | null;
const projectName =
typeof body?.projectName === "string" ? body.projectName.trim() : "";

if (!projectName) {
return buildErrorResponse(
400,
"팀 이름은 비어 있을 수 없습니다.",
"INVALID_PROJECT_GROUP_REQUEST",
);
}

if (projectName.length > 255) {
return buildErrorResponse(
400,
"팀 이름은 255자 이하여야 합니다.",
"INVALID_PROJECT_GROUP_REQUEST",
);
}

activeProjectGroup = activeProjectGroup
? { ...activeProjectGroup, projectName }
: activeProjectGroup;
activeDevGuide = createMockDevGuide(activeProjectGroup);

return new HttpResponse(null, { status: 200 });
},
),

http.patch(
getPath("/project-groups/:projectGroupId/admins/:targetUserId"),
({ params }) => {
Expand Down
Loading
Loading