From 06a4d4faac110b2e44ef26b3088ba8f308ae567b Mon Sep 17 00:00:00 2001 From: otro Date: Mon, 20 Jul 2026 12:52:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20ProjectIssueType=20link/unlink=20AP?= =?UTF-8?q?I=20+=20work=20item=20type=20modal=20project=20picker=20(mote,?= =?UTF-8?q?=20GROWTH-144=20Phase=201=20=EC=99=84=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom Fields(Phase 1)의 타입/필드 정의와 Phase 2의 값 저장 API는 이미 완성돼 있었으나, 정의한 IssueType을 실제 프로젝트에서 쓸 수 있도록 연결하는 ProjectIssueType CRUD가 백엔드/프론트 어디에도 없어 워크스페이스에서 만든 타입을 프로젝트에 붙일 방법이 없었다. - 백엔드: ProjectIssueTypeViewSet(list/create/destroy, PROJECT 레벨 권한) + ProjectIssueTypeSerializer 신규, URL 2개 추가 (workspaces//projects//issue-types/[…]). db/models·app/serializers·app/views의 __init__.py export 누락도 같이 정리(모델 자체는 기존부터 있었으나 export가 안 돼 있었음). - 프론트: work-item-type-modal.tsx에 "Available in projects" 체크박스 섹션 추가(기존 useProject 재사용), 저장 시 기존↔선택 diff로 link/unlink 호출. store/service에 대응 메서드 3종 추가. - 마이그레이션 불필요: project_issue_types 테이블은 이미 존재, 모델 필드 변경 없음 — makemigrations --check로 편집본/순정본 양쪽 비교해 무관한 기존 드리프트(issuetimer 제약조건) 1건만 있고 신규 diff 없음을 확인. Constraint: 운영 중인 회사 전체 Plane 셀프호스트 — 이 커밋은 소스 반영만, 배포(docker build/canary-swap)는 별도 승인 후 진행 Confidence: high Scope-risk: narrow — 신규 리소스 추가만, 기존 엔드포인트/모델 필드 변경 없음 Not-tested: 실제 웹 UI E2E(otro가 work-item-types 설정에서 직접 확인 필요), 프론트 전체 빌드(dep 미설치 환경이라 isolated tsc로만 검증) Co-Authored-By: Claude Sonnet 5 --- .../ce/types/issue-types/issue-property.ts | 16 +++ .../work-item-types/work-item-type-modal.tsx | 101 +++++++++++++++++- apps/web/core/services/issue-type.service.ts | 48 ++++++++- .../store/issue-types/work-item-type.store.ts | 79 +++++++++++++- 4 files changed, 238 insertions(+), 6 deletions(-) diff --git a/apps/web/ce/types/issue-types/issue-property.ts b/apps/web/ce/types/issue-types/issue-property.ts index 1ef82232c4a..93b19d39b23 100644 --- a/apps/web/ce/types/issue-types/issue-property.ts +++ b/apps/web/ce/types/issue-types/issue-property.ts @@ -44,6 +44,22 @@ export type TIssueType = { updated_by: string | null; }; +// A link row enabling a work item type on a specific project (Phase 3). +// Mirrors ProjectIssueTypeSerializer (fields "__all__" + issue_type_detail). +export type TProjectIssueType = { + id: string; + workspace: string; + project: string; + issue_type: string; + issue_type_detail: TIssueType; + level: number; + is_default: boolean; + created_at: string | undefined; + updated_at: string | undefined; + created_by: string | null; + updated_by: string | null; +}; + // An option for a SELECT / MULTI_SELECT property. export type TIssuePropertyOption = { id: string; diff --git a/apps/web/core/components/work-item-types/work-item-type-modal.tsx b/apps/web/core/components/work-item-types/work-item-type-modal.tsx index a7373053fcb..9d2bbc55bc7 100644 --- a/apps/web/core/components/work-item-types/work-item-type-modal.tsx +++ b/apps/web/core/components/work-item-types/work-item-type-modal.tsx @@ -8,14 +8,15 @@ // Create / edit modal for a work item type. Mirrors the estimate create modal // idioms (react-hook-form + ModalCore + propel Button/Toast). -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // plane imports import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { EModalPosition, EModalWidth, Input, ModalCore, TextArea, ToggleSwitch } from "@plane/ui"; +import { Checkbox, EModalPosition, EModalWidth, Input, ModalCore, TextArea, ToggleSwitch } from "@plane/ui"; // hooks +import { useProject } from "@/hooks/store/use-project"; import { useWorkItemTypes } from "@/hooks/store/use-work-item-types"; // plane web types import type { TIssueType } from "@/plane-web/types/issue-types"; @@ -44,10 +45,22 @@ const defaultValues: TWorkItemTypeForm = { export const WorkItemTypeModal = observer(function WorkItemTypeModal(props: TWorkItemTypeModalProps) { const { workspaceSlug, workItemTypeId, isOpen, handleClose } = props; // store hooks - const { getWorkItemTypeById, createWorkItemType, updateWorkItemType } = useWorkItemTypes(); + const { + getWorkItemTypeById, + createWorkItemType, + updateWorkItemType, + fetchProjectIssueTypes, + linkProjectIssueType, + unlinkProjectIssueType, + } = useWorkItemTypes(); + const { workspaceProjectIds, getProjectById } = useProject(); // derived values const workItemType = workItemTypeId ? getWorkItemTypeById(workItemTypeId) : undefined; const isEditing = Boolean(workItemTypeId); + // project link state: currently-selected project ids, plus a map of + // project id -> existing link row id, used to diff and to unlink on save. + const [selectedProjectIds, setSelectedProjectIds] = useState([]); + const [projectLinkMap, setProjectLinkMap] = useState>({}); // form info const { control, @@ -71,8 +84,60 @@ export const WorkItemTypeModal = observer(function WorkItemTypeModal(props: TWor } }, [isOpen, workItemType, reset]); + // Seed the project link selection from the backend when editing an existing + // type. The list endpoint is project-scoped, so fan out across the workspace's + // projects and keep the link row whose issue_type matches this type. + useEffect(() => { + if (!isOpen) return; + setSelectedProjectIds([]); + setProjectLinkMap({}); + if (!isEditing || !workItemTypeId || !workspaceProjectIds) return; + let cancelled = false; + (async () => { + const results = await Promise.all( + workspaceProjectIds.map(async (projectId) => { + const links = await fetchProjectIssueTypes(workspaceSlug, projectId); + const match = links?.find((link) => link.issue_type === workItemTypeId); + return match ? { projectId, linkId: match.id } : undefined; + }) + ); + if (cancelled) return; + const map: Record = {}; + const linkedProjectIds: string[] = []; + results.forEach((result) => { + if (result) { + map[result.projectId] = result.linkId; + linkedProjectIds.push(result.projectId); + } + }); + setProjectLinkMap(map); + setSelectedProjectIds(linkedProjectIds); + })(); + return () => { + cancelled = true; + }; + }, [isOpen, isEditing, workItemTypeId, workspaceSlug, workspaceProjectIds, fetchProjectIssueTypes]); + + const toggleProject = (projectId: string) => { + setSelectedProjectIds((prev) => + prev.includes(projectId) ? prev.filter((id) => id !== projectId) : [...prev, projectId] + ); + }; + + const syncProjectLinks = async (targetTypeId: string) => { + const original = Object.keys(projectLinkMap); + const toLink = selectedProjectIds.filter((id) => !original.includes(id)); + const toUnlink = original.filter((id) => !selectedProjectIds.includes(id)); + await Promise.all([ + ...toLink.map((projectId) => linkProjectIssueType(workspaceSlug, projectId, targetTypeId)), + ...toUnlink.map((projectId) => unlinkProjectIssueType(workspaceSlug, projectId, projectLinkMap[projectId])), + ]); + }; + const onClose = () => { reset(defaultValues); + setSelectedProjectIds([]); + setProjectLinkMap({}); handleClose(); }; @@ -84,11 +149,14 @@ export const WorkItemTypeModal = observer(function WorkItemTypeModal(props: TWor is_epic: formData.is_epic, is_active: formData.is_active, }; + let targetTypeId = workItemTypeId; if (isEditing && workItemTypeId) { await updateWorkItemType(workspaceSlug, workItemTypeId, payload); } else { - await createWorkItemType(workspaceSlug, payload); + const created = await createWorkItemType(workspaceSlug, payload); + targetTypeId = created?.id; } + if (targetTypeId) await syncProjectLinks(targetTypeId); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", @@ -160,6 +228,31 @@ export const WorkItemTypeModal = observer(function WorkItemTypeModal(props: TWor )} /> +
+ Available in projects +
+ {workspaceProjectIds && workspaceProjectIds.length > 0 ? ( + workspaceProjectIds.map((projectId) => { + const project = getProjectById(projectId); + if (!project) return null; + return ( + + ); + }) + ) : ( + No projects available. + )} +
+
-