From acb18f54465ca939f9c5a72f728985f470144144 Mon Sep 17 00:00:00 2001 From: hwnagjokim Date: Wed, 17 Jun 2026 10:16:56 +0900 Subject: [PATCH] feat(team): connect team rule api --- openapi/openapi.yaml | 90 ++++- .../team/components/real-team-rules-panel.tsx | 332 ++++++++++++++++++ .../team/components/team-space-view.tsx | 47 +-- .../team/hooks/use-team-space-queries.ts | 38 ++ src/lib/api/mocks/handlers.ts | 192 ++++++++++ src/lib/api/team-space.ts | 25 ++ src/lib/types/team-space.ts | 15 + 7 files changed, 696 insertions(+), 43 deletions(-) create mode 100644 src/features/team/components/real-team-rules-panel.tsx diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 9e7e332..60ef03a 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Team-po Server API - version: 0.5.0 + version: 0.6.0 summary: Client-facing contract mirrored from the current Spring controllers. servers: - url: /api @@ -863,6 +863,55 @@ paths: $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' + /team-space/{projectGroupId}/team-rule: + get: + tags: [TeamSpace] + security: + - bearerAuth: [] + summary: Get team rule for a team space + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + responses: + '200': + description: Team rule + content: + application/json: + schema: + $ref: '#/components/schemas/TeamRuleResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: [TeamSpace] + security: + - bearerAuth: [] + summary: Update team rule for a team space + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTeamRuleRequest' + responses: + '200': + description: Team rule updated + content: + application/json: + schema: + $ref: '#/components/schemas/TeamRuleResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' /team-space/{projectGroupId}/github/status: get: tags: [TeamSpace] @@ -1714,6 +1763,45 @@ components: updatedAt: type: string format: date-time + TeamRuleResponse: + type: object + additionalProperties: false + required: + - id + - projectGroupId + - content + - version + - updatedAt + - updatedByNickname + properties: + id: + type: integer + format: int64 + projectGroupId: + type: integer + format: int64 + content: + type: string + version: + type: integer + format: int64 + updatedAt: + type: string + format: date-time + updatedByNickname: + type: string + UpdateTeamRuleRequest: + type: object + additionalProperties: false + required: [content, version] + properties: + content: + type: string + minLength: 1 + maxLength: 10000 + version: + type: integer + format: int64 GithubInstallationStatus: type: object additionalProperties: false diff --git a/src/features/team/components/real-team-rules-panel.tsx b/src/features/team/components/real-team-rules-panel.tsx new file mode 100644 index 0000000..51c2ff8 --- /dev/null +++ b/src/features/team/components/real-team-rules-panel.tsx @@ -0,0 +1,332 @@ +import { LoaderCircle, RefreshCw, RotateCcw, Save } 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 { + type ActionFeedback, + RealActionFeedback, + RealInlineStatus, +} from "@/features/team/components/real-team-shared"; +import { + useTeamRuleQuery, + useUpdateTeamRuleMutation, +} from "@/features/team/hooks/use-team-space-queries"; +import { ApiError, getApiErrorMessage } from "@/lib/api/client"; +import type { MyProjectGroup } from "@/lib/types/project-group"; +import type { TeamRuleResponse } from "@/lib/types/team-space"; +import { cn } from "@/lib/utils"; +import { formatDateTime } from "@/lib/utils/date"; + +const maxTeamRuleContentLength = 10_000; + +export function RealTeamRulesPanel({ + projectGroup, +}: { + projectGroup: MyProjectGroup; +}) { + const teamRuleQuery = useTeamRuleQuery(projectGroup.projectGroupId); + const updateTeamRuleMutation = useUpdateTeamRuleMutation(); + const teamRule = teamRuleQuery.data ?? null; + const [draftContent, setDraftContent] = useState(""); + const [syncedRuleKey, setSyncedRuleKey] = useState(null); + const [feedback, setFeedback] = useState(null); + const teamRuleKey = teamRule ? getTeamRuleKey(teamRule) : null; + const isBlank = draftContent.trim().length === 0; + const isTooLong = draftContent.length > maxTeamRuleContentLength; + const isDirty = Boolean(teamRule && draftContent !== teamRule.content); + const canSave = + Boolean(teamRule) && + isDirty && + !isBlank && + !isTooLong && + !updateTeamRuleMutation.isPending; + + useEffect(() => { + if (!teamRule || !teamRuleKey || teamRuleKey === syncedRuleKey) { + return; + } + + setDraftContent(teamRule.content); + setSyncedRuleKey(teamRuleKey); + }, [teamRule, teamRuleKey, syncedRuleKey]); + + async function handleRefresh() { + setFeedback(null); + const result = await teamRuleQuery.refetch(); + + if (result.data) { + setDraftContent(result.data.content); + setSyncedRuleKey(getTeamRuleKey(result.data)); + } + } + + function handleResetDraft() { + if (!teamRule) { + return; + } + + setDraftContent(teamRule.content); + setFeedback(null); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + if (!teamRule) { + return; + } + + if (isBlank) { + setFeedback({ + message: "팀 룰 내용을 입력해 주세요.", + tone: "error", + }); + return; + } + + if (isTooLong) { + setFeedback({ + message: "팀 룰 내용은 10000자 이하로 입력해 주세요.", + tone: "error", + }); + return; + } + + setFeedback(null); + + try { + await updateTeamRuleMutation.mutateAsync({ + content: draftContent, + projectGroupId: projectGroup.projectGroupId, + version: teamRule.version, + }); + setFeedback({ + message: "팀 룰을 저장했어요.", + tone: "success", + }); + } catch (error: unknown) { + setFeedback({ + message: getTeamRuleSaveErrorMessage(error), + tone: "error", + }); + } + } + + if (teamRuleQuery.isLoading) { + return ( + + 조회 중} + description="팀이 함께 지킬 협업 규칙을 불러오고 있어요." + eyebrow="Rulebook" + title="팀 룰" + /> +
+ + } + message="팀 룰을 불러오고 있어요." + /> +
+
+ ); + } + + if (teamRuleQuery.error) { + return ( + + 오류} + description="팀 룰을 불러오지 못했어요." + eyebrow="Rulebook" + title="팀 룰" + /> +
+ +
+ +
+
+
+ ); + } + + if (!teamRule) { + return null; + } + + return ( + + + {getTeamRuleStatusLabel({ + isDirty, + isFetching: teamRuleQuery.isFetching, + isSaving: updateTeamRuleMutation.isPending, + })} + + } + description="브랜치, 커밋, 리뷰, 공유 방식을 Markdown으로 정리해요." + eyebrow="Rulebook" + title="팀 룰" + /> +
+ +
+
+
+ + + {draftContent.length.toLocaleString()} /{" "} + {maxTeamRuleContentLength.toLocaleString()} + +
+