[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:
Prateek Shourya
2024-10-08 18:35:27 +05:30
committed by GitHub
parent 1cc210216c
commit 5ee59ea913
7 changed files with 208 additions and 75 deletions

View File

@@ -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,
)

View File

@@ -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}

View File

@@ -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);
});
};

View File

@@ -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>

View File

@@ -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}
/>
)}
</>
)}
</>
)}

View File

@@ -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?.();
}}
/>
)}

View File

@@ -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