mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 13:29:56 +02:00
[WEB-2580] improvement: enable bulk operations of issue types. (#1364)
* [WEB-2580] improvement: enable bulk operations of issue types. * chore: update error codes. * chore: minor improvement. * fix: disable estimates bulk ops if estimates are disabled for the project. * chore: minor improvement. * chore: issue type dropdown icon for unselected state * chore: update custom search select props names. * chore: show tooltip for issue type options with mandatory fields. * chore: update issue type with mandatory property infoi content. * chore: minor improvements.
This commit is contained in:
@@ -31,6 +31,7 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
)
|
||||
from plane.ee.models import IssueProperty
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.ee.bgtasks import bulk_issue_activity
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
@@ -238,6 +239,54 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
)
|
||||
issue.target_date = properties.get("target_date")
|
||||
|
||||
# Estimate Point
|
||||
if properties.get("estimate_point", False):
|
||||
issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{
|
||||
"estimate_point": properties.get(
|
||||
"estimate_point"
|
||||
)
|
||||
}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"estimate_point": (
|
||||
str(issue.estimate_point_id)
|
||||
if issue.estimate_point_id
|
||||
else issue.estimate_point_id
|
||||
)
|
||||
}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
issue.estimate_point_id = properties.get("estimate_point")
|
||||
|
||||
# Issue Type
|
||||
if properties.get("type_id", False):
|
||||
issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{"type_id": properties.get("type_id")}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{"type_id": str(issue.type_id)}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
issue.type_id = properties.get("type_id")
|
||||
|
||||
bulk_update_issues.append(issue)
|
||||
|
||||
# Labels
|
||||
@@ -381,35 +430,6 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
}
|
||||
)
|
||||
|
||||
# Estimate Point
|
||||
if properties.get("estimate_point", False):
|
||||
issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{
|
||||
"estimate_point": properties.get(
|
||||
"estimate_point"
|
||||
)
|
||||
}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"estimate_point": (
|
||||
str(issue.estimate_point_id)
|
||||
if issue.estimate_point_id
|
||||
else issue.estimate_point_id
|
||||
)
|
||||
}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
issue.estimate_point_id = properties.get("estimate_point")
|
||||
|
||||
# Bulk update all the objects
|
||||
Issue.objects.bulk_update(
|
||||
bulk_update_issues,
|
||||
@@ -420,6 +440,7 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
"state_id",
|
||||
"completed_at",
|
||||
"estimate_point_id",
|
||||
"type_id",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { CustomSearchSelect, Loader } from "@plane/ui";
|
||||
import { CustomSearchSelect, LayersIcon, Loader } from "@plane/ui";
|
||||
// ce imports
|
||||
import { TIssueTypeDropdownVariant } from "@/ce/components/issues";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web types
|
||||
import { IssueTypeLogo } from "@/plane-web/components/issue-types";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
|
||||
export type TIssueTypeOptionTooltip = {
|
||||
[issueTypeId: string]: string; // issue type id --> tooltip content
|
||||
};
|
||||
|
||||
type TIssueTypeDropdownProps = {
|
||||
issueTypeId: string | null;
|
||||
projectId: string;
|
||||
disabled?: boolean;
|
||||
variant?: TIssueTypeDropdownVariant;
|
||||
placeholder?: string;
|
||||
optionTooltip?: TIssueTypeOptionTooltip;
|
||||
handleIssueTypeChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
const { issueTypeId, projectId, disabled = false, handleIssueTypeChange } = props;
|
||||
const {
|
||||
issueTypeId,
|
||||
projectId,
|
||||
disabled = false,
|
||||
variant = "sm",
|
||||
placeholder = "Issue type",
|
||||
handleIssueTypeChange,
|
||||
optionTooltip,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { loader: issueTypesLoader, getProjectIssueTypes } = useIssueTypes();
|
||||
// derived values
|
||||
@@ -28,12 +47,20 @@ export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
content: (
|
||||
<div className="flex w-full gap-2 items-center">
|
||||
<IssueTypeLogo icon_props={issueTypeDetail?.logo_props?.icon} isDefault={issueTypeDetail?.is_default} />
|
||||
<div className="text-sm font-medium text-custom-text-200 truncate">{issueTypeDetail.name}</div>
|
||||
<div
|
||||
className={cn("text-custom-text-200 truncate", {
|
||||
"text-xs": variant === "xs",
|
||||
"text-sm font-medium": variant === "sm",
|
||||
})}
|
||||
>
|
||||
{issueTypeDetail.name}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
tooltip: optionTooltip?.[issueTypeId] ?? undefined,
|
||||
}));
|
||||
|
||||
if (!issueTypeId || issueTypesLoader === "init-loader") {
|
||||
if (issueTypesLoader === "init-loader") {
|
||||
return (
|
||||
<Loader className="w-16 h-full">
|
||||
<Loader.Item height="100%" />
|
||||
@@ -45,12 +72,35 @@ export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
<CustomSearchSelect
|
||||
value={issueTypeId}
|
||||
label={
|
||||
<div className="flex w-full gap-2 items-center max-w-44">
|
||||
<IssueTypeLogo
|
||||
icon_props={allIssueTypes[issueTypeId]?.logo_props?.icon}
|
||||
isDefault={allIssueTypes[issueTypeId]?.is_default}
|
||||
/>
|
||||
<div className="text-sm font-medium text-custom-text-200 truncate">{allIssueTypes[issueTypeId]?.name}</div>
|
||||
<div
|
||||
className={cn("flex w-full items-center max-w-44", {
|
||||
"gap-1": variant === "xs",
|
||||
"gap-2": variant === "sm",
|
||||
})}
|
||||
>
|
||||
{!issueTypeId && (
|
||||
<LayersIcon
|
||||
className={cn("flex-shrink-0 text-custom-text-300", {
|
||||
"size-3": variant === "xs",
|
||||
"size-4": variant === "sm",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{issueTypeId && (
|
||||
<IssueTypeLogo
|
||||
icon_props={allIssueTypes[issueTypeId]?.logo_props?.icon}
|
||||
isDefault={allIssueTypes[issueTypeId]?.is_default}
|
||||
size={variant}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn("truncate", issueTypeId ? "text-custom-text-200" : "text-custom-text-300", {
|
||||
"text-xs": variant === "xs",
|
||||
"text-sm font-medium": variant === "sm",
|
||||
})}
|
||||
>
|
||||
{issueTypeId ? allIssueTypes[issueTypeId]?.name : placeholder}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
options={issueTypeOptions}
|
||||
|
||||
@@ -176,7 +176,7 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
})
|
||||
.finally(() => {
|
||||
resetOptions();
|
||||
key && handleIssuePropertyCreateList("remove", { key, ...issuePropertyData });
|
||||
if (key) handleIssuePropertyCreateList("remove", { key, ...issuePropertyData });
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
@@ -271,7 +271,7 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
key && handleIssuePropertyCreateList("remove", { key, ...issuePropertyData });
|
||||
if (key) handleIssuePropertyCreateList("remove", { key, ...issuePropertyData });
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
|
||||
import { useProjectEstimates } from "@/hooks/store";
|
||||
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
|
||||
import { TSelectionHelper, TSelectionSnapshot } from "@/hooks/use-multiple-select";
|
||||
// plane web components
|
||||
import { IssueTypeSelect } from "@/plane-web/components/issues/issue-modal";
|
||||
// plane web hooks
|
||||
import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
|
||||
type Props = {
|
||||
@@ -47,6 +50,7 @@ const defaultValues: TBulkIssueProperties = {
|
||||
cycle_id: "",
|
||||
module_ids: [],
|
||||
estimate_point: null,
|
||||
type_id: null,
|
||||
};
|
||||
|
||||
export const IssueBulkOperationsProperties: React.FC<Props> = observer((props) => {
|
||||
@@ -59,7 +63,7 @@ export const IssueBulkOperationsProperties: React.FC<Props> = observer((props) =
|
||||
const {
|
||||
issues: { bulkUpdateProperties },
|
||||
} = useIssuesStore();
|
||||
const { currentActiveEstimateId } = useProjectEstimates();
|
||||
const { currentActiveEstimateId, areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const isAdvancedBulkOpsEnabled = useFlag(workspaceSlug?.toString(), "BULK_OPS_ADVANCED");
|
||||
|
||||
// form info
|
||||
@@ -260,7 +264,7 @@ export const IssueBulkOperationsProperties: React.FC<Props> = observer((props) =
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectId && currentActiveEstimateId && (
|
||||
{projectId && currentActiveEstimateId && areEstimateEnabledByProjectId(projectId?.toString()) && (
|
||||
<Controller
|
||||
name="estimate_point"
|
||||
control={control}
|
||||
@@ -301,6 +305,17 @@ export const IssueBulkOperationsProperties: React.FC<Props> = observer((props) =
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectId && (
|
||||
<IssueTypeSelect
|
||||
variant="xs"
|
||||
control={control}
|
||||
projectId={projectId.toString()}
|
||||
disabled={isUpdateDisabled}
|
||||
isRequired={false}
|
||||
showMandatoryFieldInfo
|
||||
dropDownContainerClassName="h-6"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps
|
||||
<>
|
||||
{isIssueTypeDisplayEnabled && (
|
||||
<>
|
||||
{!issueTypeId || issuePropertiesLoader === "init-loader" ? (
|
||||
{issuePropertiesLoader === "init-loader" ? (
|
||||
<Loader className="space-y-4 py-2">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
@@ -49,12 +49,16 @@ export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps
|
||||
<Loader.Item height="30px" width="50%" />
|
||||
</Loader>
|
||||
) : (
|
||||
<IssueAdditionalPropertyValuesCreate
|
||||
issueId={issueId}
|
||||
issueTypeId={issueTypeId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
<>
|
||||
{issueTypeId && (
|
||||
<IssueAdditionalPropertyValuesCreate
|
||||
issueId={issueId}
|
||||
issueTypeId={issueTypeId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,58 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import { Controller, FieldPath } from "react-hook-form";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// helpers
|
||||
import { TIssueFields, TIssueTypeSelectProps } from "@/ce/components/issues";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web components
|
||||
import { IssueTypeDropdown } from "@/plane-web/components/issue-types/dropdowns";
|
||||
import { IssueTypeDropdown, TIssueTypeOptionTooltip } from "@/plane-web/components/issue-types/dropdowns";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
|
||||
type TIssueTypeSelectProps = {
|
||||
control: Control<TIssue>;
|
||||
projectId: string | null;
|
||||
disabled?: boolean;
|
||||
renderChevron?: boolean;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = observer((props) => {
|
||||
const { control, projectId, disabled = false, handleFormChange } = props;
|
||||
export const IssueTypeSelect = observer(<T extends Partial<TIssueFields>>(props: TIssueTypeSelectProps<T>) => {
|
||||
const {
|
||||
control,
|
||||
projectId,
|
||||
disabled = false,
|
||||
variant = "sm",
|
||||
placeholder,
|
||||
isRequired = true,
|
||||
renderChevron = false,
|
||||
dropDownContainerClassName,
|
||||
showMandatoryFieldInfo = false, // Show info about mandatory fields
|
||||
handleFormChange,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// plane web store hooks
|
||||
const { isIssueTypeEnabledForProject } = useIssueTypes();
|
||||
const { isIssueTypeEnabledForProject, getIssueTypeIdsWithMandatoryProperties } = useIssueTypes();
|
||||
// derived values
|
||||
const isIssueTypeDisplayEnabled =
|
||||
!!projectId && isIssueTypeEnabledForProject(workspaceSlug?.toString(), projectId, "ISSUE_TYPE_DISPLAY");
|
||||
// Information for issue types with mandatory fields
|
||||
let optionTooltip: TIssueTypeOptionTooltip = {};
|
||||
if (showMandatoryFieldInfo) {
|
||||
// Get issue types with mandatory properties
|
||||
const issueTypeIdsWithMandatoryProperties = useMemo(() => {
|
||||
if (!projectId) return [];
|
||||
return getIssueTypeIdsWithMandatoryProperties(projectId);
|
||||
}, [getIssueTypeIdsWithMandatoryProperties, projectId]);
|
||||
// Create a map of information for issue types with mandatory field
|
||||
optionTooltip = useMemo(() => {
|
||||
if (issueTypeIdsWithMandatoryProperties.length === 0) return {};
|
||||
return issueTypeIdsWithMandatoryProperties.reduce((acc, issueTypeId) => {
|
||||
acc[issueTypeId] =
|
||||
"This issue type includes mandatory properties that will initially be blank when an issue is converted to this type.";
|
||||
return acc;
|
||||
}, {} as TIssueTypeOptionTooltip);
|
||||
}, [issueTypeIdsWithMandatoryProperties]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isIssueTypeDisplayEnabled && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" aria-hidden="true" />
|
||||
</div>
|
||||
<Controller
|
||||
{renderChevron && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
<Controller<T>
|
||||
control={control}
|
||||
name="type_id"
|
||||
name={"type_id" as FieldPath<T>}
|
||||
rules={{
|
||||
required: true,
|
||||
required: isRequired,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<div className={cn("h-7", dropDownContainerClassName)}>
|
||||
{projectId && (
|
||||
<IssueTypeDropdown
|
||||
issueTypeId={value}
|
||||
projectId={projectId}
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
placeholder={placeholder}
|
||||
optionTooltip={optionTooltip}
|
||||
handleIssueTypeChange={(issueTypeId) => {
|
||||
onChange(issueTypeId);
|
||||
handleFormChange();
|
||||
// If it's not set as required, then allow issue type to be null (unset issue type)
|
||||
const newValue = !isRequired && value === issueTypeId ? null : issueTypeId;
|
||||
onChange(newValue);
|
||||
handleFormChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface IIssueTypesStore {
|
||||
getProjectIssueTypes: (projectId: string, activeOnly: boolean) => Record<string, IIssueType>; // issue type id -> issue type
|
||||
getProjectDefaultIssueType: (projectId: string) => IIssueType | undefined;
|
||||
getIssueTypeProperties: (issueTypeId: string) => IIssueProperty<EIssuePropertyType>[];
|
||||
getIssueTypeIdsWithMandatoryProperties: (projectId: string) => string[];
|
||||
isIssueTypeEnabledForProject: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
@@ -170,6 +171,19 @@ export class IssueTypes implements IIssueTypesStore {
|
||||
return issueType.properties;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description Get issue type ids with mandatory properties
|
||||
* @param projectId
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getIssueTypeIdsWithMandatoryProperties = computedFn((projectId: string) => {
|
||||
const projectIssueTypes = this.getProjectIssueTypes(projectId, false);
|
||||
return Object.keys(projectIssueTypes).filter((issueTypeId) => {
|
||||
const issueType = projectIssueTypes[issueTypeId];
|
||||
return issueType.activeProperties.some((property) => property.is_required);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @description Check if issue type is enabled for the project
|
||||
* @param workspaceSlug
|
||||
|
||||
Reference in New Issue
Block a user