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
16 changes: 16 additions & 0 deletions apps/web/ce/types/issue-types/issue-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
115 changes: 110 additions & 5 deletions apps/web/core/components/work-item-types/work-item-type-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string[]>([]);
const [projectLinkMap, setProjectLinkMap] = useState<Record<string, string>>({});
// 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,
Expand All @@ -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<string, string> = {};
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();
};

Expand All @@ -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!",
Expand Down Expand Up @@ -160,12 +234,43 @@ export const WorkItemTypeModal = observer(function WorkItemTypeModal(props: TWor
</div>
)}
/>
<div className="flex flex-col gap-2">
<span className="text-body-sm-regular text-secondary">Available in projects</span>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{workspaceProjectIds && workspaceProjectIds.length > 0 ? (
workspaceProjectIds.map((projectId) => {
const project = getProjectById(projectId);
if (!project) return null;
return (
<label
key={projectId}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-layer-1"
>
<Checkbox
checked={selectedProjectIds.includes(projectId)}
onChange={() => toggleProject(projectId)}
/>
<span className="text-body-sm-regular text-primary">{project.name}</span>
</label>
);
})
) : (
<span className="text-body-sm-regular text-tertiary">No projects available.</span>
)}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose} type="button">
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
<Button
variant="primary"
size="sm"
type="submit"
loading={isSubmitting}
disabled={isLoadingProjectLinks}
>
{isEditing ? "Update" : "Create"}
</Button>
</div>
Expand Down
48 changes: 47 additions & 1 deletion apps/web/core/services/issue-type.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -181,6 +186,47 @@ export class IssueTypeService extends APIService {
throw error;
}
}

// ------------------------------------------------ project ↔ type links

async fetchProjectIssueTypes(
workspaceSlug: string,
projectId: string
): Promise<TProjectIssueType[] | undefined> {
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<TProjectIssueType | undefined> {
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<void> {
try {
await this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${projectIssueTypeId}/`);
} catch (error) {
throw error;
}
}
}

const issueTypeService = new IssueTypeService();
Expand Down
79 changes: 78 additions & 1 deletion apps/web/core/store/issue-types/work-item-type.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,6 +90,21 @@ export interface IWorkItemTypeStore {
propertyId: string,
optionId: string
) => Promise<void>;
// project link actions (which types a project may use)
fetchProjectIssueTypes: (
workspaceSlug: string,
projectId: string
) => Promise<TProjectIssueType[] | undefined>;
linkProjectIssueType: (
workspaceSlug: string,
projectId: string,
workItemTypeId: string
) => Promise<TProjectIssueType | undefined>;
unlinkProjectIssueType: (
workspaceSlug: string,
projectId: string,
projectIssueTypeId: string
) => Promise<void>;
}

export class WorkItemTypeStore implements IWorkItemTypeStore {
Expand Down Expand Up @@ -116,6 +136,10 @@ export class WorkItemTypeStore implements IWorkItemTypeStore {
createOption: action,
updateOption: action,
deleteOption: action,
// project link actions
fetchProjectIssueTypes: action,
linkProjectIssueType: action,
unlinkProjectIssueType: action,
});
}

Expand Down Expand Up @@ -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<TProjectIssueType[] | undefined> => {
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<TProjectIssueType | undefined> => {
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<void> => {
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;
}
};
}