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..0f828130a57 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,25 @@ 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>({}); + // guards submit until the project-link seed fetch below resolves, so saving + // mid-fetch can't wipe out existing links the form hasn't loaded yet. + const [isLoadingProjectLinks, setIsLoadingProjectLinks] = useState(false); // form info const { control, @@ -71,8 +87,63 @@ 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; + setIsLoadingProjectLinks(true); + (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); + setIsLoadingProjectLinks(false); + })(); + 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({}); + setIsLoadingProjectLinks(false); handleClose(); }; @@ -84,11 +155,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,12 +234,43 @@ 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. + )} +
+
-
diff --git a/apps/web/core/services/issue-type.service.ts b/apps/web/core/services/issue-type.service.ts index 470a5b66225..e5f69e5a96e 100644 --- a/apps/web/core/services/issue-type.service.ts +++ b/apps/web/core/services/issue-type.service.ts @@ -13,7 +13,12 @@ // plane imports import { API_BASE_URL } from "@plane/constants"; -import type { TIssueType, TIssueProperty, TIssuePropertyOption } from "@/plane-web/types/issue-types"; +import type { + TIssueType, + TIssueProperty, + TIssuePropertyOption, + TProjectIssueType, +} from "@/plane-web/types/issue-types"; // services import { APIService } from "@/services/api.service"; @@ -181,6 +186,47 @@ export class IssueTypeService extends APIService { throw error; } } + + // ------------------------------------------------ project ↔ type links + + async fetchProjectIssueTypes( + workspaceSlug: string, + projectId: string + ): Promise { + try { + const { data } = await this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/`); + return data || undefined; + } catch (error) { + throw error; + } + } + + async linkProjectIssueType( + workspaceSlug: string, + projectId: string, + issueTypeId: string + ): Promise { + try { + const { data } = await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/`, { + issue_type_id: issueTypeId, + }); + return data || undefined; + } catch (error) { + throw error; + } + } + + async unlinkProjectIssueType( + workspaceSlug: string, + projectId: string, + projectIssueTypeId: string + ): Promise { + try { + await this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${projectIssueTypeId}/`); + } catch (error) { + throw error; + } + } } const issueTypeService = new IssueTypeService(); diff --git a/apps/web/core/store/issue-types/work-item-type.store.ts b/apps/web/core/store/issue-types/work-item-type.store.ts index 88571e7f30b..cfc566b9b4b 100644 --- a/apps/web/core/store/issue-types/work-item-type.store.ts +++ b/apps/web/core/store/issue-types/work-item-type.store.ts @@ -13,7 +13,12 @@ import { orderBy, set, unset } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types -import type { TIssueProperty, TIssuePropertyOption, TIssueType } from "@/plane-web/types/issue-types"; +import type { + TIssueProperty, + TIssuePropertyOption, + TIssueType, + TProjectIssueType, +} from "@/plane-web/types/issue-types"; // plane web services import issueTypeService from "@/services/issue-type.service"; // plane web store @@ -85,6 +90,21 @@ export interface IWorkItemTypeStore { propertyId: string, optionId: string ) => Promise; + // project link actions (which types a project may use) + fetchProjectIssueTypes: ( + workspaceSlug: string, + projectId: string + ) => Promise; + linkProjectIssueType: ( + workspaceSlug: string, + projectId: string, + workItemTypeId: string + ) => Promise; + unlinkProjectIssueType: ( + workspaceSlug: string, + projectId: string, + projectIssueTypeId: string + ) => Promise; } export class WorkItemTypeStore implements IWorkItemTypeStore { @@ -116,6 +136,10 @@ export class WorkItemTypeStore implements IWorkItemTypeStore { createOption: action, updateOption: action, deleteOption: action, + // project link actions + fetchProjectIssueTypes: action, + linkProjectIssueType: action, + unlinkProjectIssueType: action, }); } @@ -394,4 +418,57 @@ export class WorkItemTypeStore implements IWorkItemTypeStore { throw error; } }; + + // ----------------------------------------------------- project link actions + // These rows are project-scoped, not workspace-scoped, so they are not held + // in the workItemTypes map; the modal owns the transient link state. + /** + * @description fetch the work item types enabled on a project + */ + fetchProjectIssueTypes = async ( + workspaceSlug: string, + projectId: string + ): Promise => { + try { + this.error = undefined; + return await issueTypeService.fetchProjectIssueTypes(workspaceSlug, projectId); + } catch (error) { + this.error = { status: "error", message: "Error fetching project work item types" }; + throw error; + } + }; + + /** + * @description enable a work item type on a project + */ + linkProjectIssueType = async ( + workspaceSlug: string, + projectId: string, + workItemTypeId: string + ): Promise => { + try { + this.error = undefined; + return await issueTypeService.linkProjectIssueType(workspaceSlug, projectId, workItemTypeId); + } catch (error) { + this.error = { status: "error", message: "Error linking work item type to project" }; + throw error; + } + }; + + /** + * @description disable a work item type on a project + */ + unlinkProjectIssueType = async ( + workspaceSlug: string, + projectId: string, + projectIssueTypeId: string + ): Promise => { + try { + this.error = undefined; + await issueTypeService.unlinkProjectIssueType(workspaceSlug, projectId, projectIssueTypeId); + } catch (error) { + this.error = { status: "error", message: "Error unlinking work item type from project" }; + throw error; + } + }; }