mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 13:29:56 +02:00
chore: issue property create options endpoint revamp (#803)
* dev: update the issue property option endpoint * chore: optimize create options API call and some bug fixes. * fix: issue type switch on create more toggle. * chore: add loader for issue properties. * chore; fix issue type name and description overflow. * chore: delete property confirmation modal. --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db import IntegrityError, models
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -8,10 +8,11 @@ from rest_framework.response import Response
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
from plane.ee.views.base import BaseAPIView
|
||||
from plane.ee.models import IssueProperty
|
||||
from plane.ee.models import IssueProperty, IssuePropertyOption
|
||||
from plane.ee.permissions import ProjectEntityPermission
|
||||
from plane.ee.serializers import (
|
||||
IssuePropertySerializer,
|
||||
IssuePropertyOptionSerializer,
|
||||
)
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
@@ -58,8 +59,9 @@ class IssuePropertyEndpoint(BaseAPIView):
|
||||
issue_type_id,
|
||||
):
|
||||
try:
|
||||
# Create a new issue properties
|
||||
serializer = IssuePropertySerializer(data=request.data)
|
||||
|
||||
# Get the options
|
||||
options = request.data.pop("options", [])
|
||||
|
||||
# Check is_active
|
||||
if not request.data.get("is_active"):
|
||||
@@ -81,11 +83,94 @@ class IssuePropertyEndpoint(BaseAPIView):
|
||||
if request.data.get("is_required") is True:
|
||||
request.data["default_value"] = []
|
||||
|
||||
# Create a new issue properties
|
||||
serializer = IssuePropertySerializer(data=request.data)
|
||||
# Validate the data
|
||||
serializer.is_valid(raise_exception=True)
|
||||
# Save the data
|
||||
serializer.save(project_id=project_id, issue_type_id=issue_type_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
issue_property = IssueProperty.objects.get(
|
||||
project_id=project_id,
|
||||
issue_type_id=issue_type_id,
|
||||
pk=serializer.data["id"],
|
||||
)
|
||||
|
||||
# Check if the property type is option and create the options
|
||||
if issue_property.property_type == "OPTION":
|
||||
workspace_id = issue_property.workspace_id
|
||||
issue_property_id = issue_property.id
|
||||
|
||||
# Bulk create the options
|
||||
bulk_create_options = []
|
||||
last_id = IssuePropertyOption.objects.filter(
|
||||
project=project_id, property_id=issue_property_id
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
|
||||
if last_id:
|
||||
sort_order = last_id + 10000
|
||||
else:
|
||||
sort_order = 10000
|
||||
|
||||
for option in options:
|
||||
if not option.get("name"):
|
||||
return Response(
|
||||
{"error": "Name of option is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
bulk_create_options.append(
|
||||
IssuePropertyOption(
|
||||
name=option.get("name"),
|
||||
sort_order=sort_order,
|
||||
property_id=issue_property_id,
|
||||
description=option.get("description", ""),
|
||||
logo_props=option.get("logo_props", {}),
|
||||
is_active=option.get("is_active", True),
|
||||
is_default=option.get("is_default", False),
|
||||
parent_id=option.get("parent_id"),
|
||||
workspace_id=issue_property.workspace_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
|
||||
sort_order += 10000
|
||||
|
||||
# Create the options
|
||||
IssuePropertyOption.objects.bulk_create(
|
||||
bulk_create_options,
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Fetch all the default options
|
||||
issue_property_options = IssuePropertyOption.objects.filter(
|
||||
property_id=issue_property_id,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
is_default=True,
|
||||
).values_list("id", flat=True)
|
||||
|
||||
# Save the default value
|
||||
issue_property.default_value = [
|
||||
str(option) for option in issue_property_options
|
||||
]
|
||||
issue_property.save()
|
||||
|
||||
serializer = IssuePropertySerializer(issue_property)
|
||||
options = IssuePropertyOption.objects.filter(
|
||||
property_id=issue_property.id,
|
||||
workspace_id=issue_property.workspace_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
options_serializer = IssuePropertyOptionSerializer(
|
||||
options, many=True
|
||||
)
|
||||
# generate the response with the new data and options
|
||||
response = {
|
||||
"property_detail": serializer.data,
|
||||
"options": options_serializer.data,
|
||||
}
|
||||
return Response(response, status=status.HTTP_201_CREATED)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{
|
||||
@@ -114,6 +199,22 @@ class IssuePropertyEndpoint(BaseAPIView):
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# if property type is being changed, reset the defaults
|
||||
if (
|
||||
request.data.get("property_type")
|
||||
and request.data.get("property_type")
|
||||
!= issue_property.property_type
|
||||
):
|
||||
defaults = {
|
||||
"relation_type": None,
|
||||
"default_value": [],
|
||||
"settings": {},
|
||||
"is_multi": False,
|
||||
"validation_rules": {},
|
||||
}
|
||||
# Update request data with defaults for missing fields
|
||||
for field, default_value in defaults.items():
|
||||
request.data.setdefault(field, default_value)
|
||||
|
||||
# Check defaults
|
||||
if (
|
||||
|
||||
@@ -78,37 +78,28 @@ class IssuePropertyOptionEndpoint(BaseAPIView):
|
||||
project=project_id, property_id=issue_property_id
|
||||
).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
|
||||
# Set the sort order for the new option
|
||||
if last_id:
|
||||
sort_order = last_id + 10000
|
||||
else:
|
||||
sort_order = 10000
|
||||
|
||||
bulk_property_options = []
|
||||
for option in request.data.get("options", []):
|
||||
bulk_property_options.append(
|
||||
IssuePropertyOption(
|
||||
name=option.get("name"),
|
||||
sort_order=sort_order,
|
||||
property_id=issue_property_id,
|
||||
description=option.get("description", ""),
|
||||
logo_props=option.get("logo_props", {}),
|
||||
is_active=option.get("is_active", True),
|
||||
is_default=option.get("is_default", False),
|
||||
parent_id=option.get("parent_id"),
|
||||
workspace_id=issue_property.workspace_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
sort_order += 10000
|
||||
|
||||
# Bulk create the options
|
||||
issue_property_options = IssuePropertyOption.objects.bulk_create(
|
||||
bulk_property_options, batch_size=100
|
||||
# Create the issue property option
|
||||
issue_property_option = IssuePropertyOption.objects.create(
|
||||
name=request.data.get("name"),
|
||||
sort_order=sort_order,
|
||||
property_id=issue_property_id,
|
||||
description=request.data.get("description", ""),
|
||||
logo_props=request.data.get("logo_props", {}),
|
||||
is_active=request.data.get("is_active", True),
|
||||
is_default=request.data.get("is_default", False),
|
||||
parent_id=request.data.get("parent_id"),
|
||||
workspace_id=issue_property.workspace_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
serializer = IssuePropertyOptionSerializer(
|
||||
issue_property_options, many=True
|
||||
)
|
||||
# Serialize the data
|
||||
serializer = IssuePropertyOptionSerializer(issue_property_option)
|
||||
|
||||
# Save the default value
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -53,9 +53,9 @@ export const IssueTypeListItem = observer((props: TIssueTypeListItem) => {
|
||||
isDefault={issueTypeDetail?.is_default}
|
||||
containerClassName={cn(!issueTypeDetail?.is_active && "opacity-60")}
|
||||
/>
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex flex-col items-start justify-start whitespace-normal">
|
||||
<div className="flex gap-4 text-left">
|
||||
<div className="text-sm text-custom-text-100 font-medium">{issueTypeDetail?.name}</div>
|
||||
<div className="text-sm text-custom-text-100 font-medium line-clamp-1">{issueTypeDetail?.name}</div>
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-300 text-left line-clamp-1">
|
||||
{issueTypeDetail?.description}
|
||||
|
||||
@@ -71,7 +71,7 @@ export const IssuePropertyOptionsRoot: FC<TIssuePropertyOptionsRoot> = observer(
|
||||
const { key, ...payload } = value;
|
||||
|
||||
await issueProperty
|
||||
.createPropertyOptions([payload])
|
||||
.createPropertyOption(payload)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useState } from "react";
|
||||
|
||||
type TProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDisable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const DeleteConfirmationModal: React.FC<TProps> = observer((props) => {
|
||||
const { isOpen, onClose, onDisable, onDelete } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const handleDisable = async () => {
|
||||
setIsSubmitting(true);
|
||||
await onDisable().finally(() => {
|
||||
onClose();
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsSubmitting(true);
|
||||
await onDelete().finally(() => {
|
||||
onClose();
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={onClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.XXL}
|
||||
className="py-5 px-6"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<span className={cn("flex-shrink-0 grid place-items-center rounded-full size-10 bg-red-500/10 text-red-500")}>
|
||||
<AlertTriangle className="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium">Delete this property</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-4 text-center sm:text-left text-sm text-custom-text-200">
|
||||
<p>Deletion of properties may lead to loss of existing data.</p>
|
||||
<p>Do you want to disable the property instead?</p>
|
||||
</div>
|
||||
<div className="px-1 pt-4 flex flex-col-reverse sm:flex-row sm:justify-between gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-center sm:justify-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
onClick={handleDisable}
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Yes, disable it
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
tabIndex={1}
|
||||
onClick={handleDelete}
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
No, delete it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type TPropertyTypeDropdownProps = {
|
||||
issueTypeId: string;
|
||||
propertyType: EIssuePropertyType | undefined;
|
||||
propertyRelationType: EIssuePropertyRelationType | undefined;
|
||||
propertyRelationType: EIssuePropertyRelationType | null | undefined;
|
||||
currentOperationMode: TOperationMode | null;
|
||||
handlePropertyObjectChange: (value: Partial<TIssueProperty<EIssuePropertyType>>) => void;
|
||||
error?: string;
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "./dropdowns";
|
||||
export * from "./attributes";
|
||||
export * from "./mandatory-field-toggle";
|
||||
export * from "./quick-actions";
|
||||
export * from "./delete-confirmation-modal";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import omitBy from "lodash/omitBy";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
TCreationListModes,
|
||||
TOperationMode,
|
||||
TIssuePropertyOptionCreateList,
|
||||
TIssuePropertyOption,
|
||||
} from "@/plane-web/types";
|
||||
|
||||
type TIssuePropertyListItem = {
|
||||
@@ -136,65 +139,25 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePropertyOptions = async (propertyId: string | undefined) => {
|
||||
if (!propertyId || !issuePropertyOptionCreateList || !issuePropertyOptionCreateList.length) return;
|
||||
|
||||
// get issue property details from the store
|
||||
const issueProperty = issueType?.getPropertyById(propertyId);
|
||||
if (!issueProperty) return;
|
||||
|
||||
const payload = issuePropertyOptionCreateList
|
||||
.map((item) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { key, ...rest } = item;
|
||||
return rest;
|
||||
})
|
||||
.filter((item) => !!item.name);
|
||||
|
||||
await issueProperty
|
||||
.createPropertyOptions(payload)
|
||||
.then(async (response) => {
|
||||
// get the response and set the default value for the property
|
||||
if (response && response.length) {
|
||||
const defaultOptionIds = response
|
||||
.filter((option) => option.id && option.is_default)
|
||||
.map((option) => option.id as string);
|
||||
// update the default value for the property
|
||||
handlePropertyDataChange("default_value", defaultOptionIds);
|
||||
// sync the property options
|
||||
await issueProperty.updateProperty(issueTypeId, {
|
||||
default_value: defaultOptionIds,
|
||||
});
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `Property options created successfully.`,
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error ?? `Failed to create issue property options. Please try again!`,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIssuePropertyOptionCreateList([]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateProperty = async () => {
|
||||
if (!issuePropertyData) return;
|
||||
|
||||
// create property options payload (required for option type)
|
||||
let optionsPayload: Partial<TIssuePropertyOption>[] = [];
|
||||
if (issuePropertyData.property_type === EIssuePropertyType.OPTION) {
|
||||
optionsPayload = issuePropertyOptionCreateList
|
||||
.map((item) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { key, ...rest } = item;
|
||||
return rest;
|
||||
})
|
||||
.filter((item) => !!item.name);
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
await issueType
|
||||
?.createProperty(issuePropertyData)
|
||||
?.createProperty(issuePropertyData, optionsPayload)
|
||||
.then(async (response) => {
|
||||
if (issuePropertyData.property_type === EIssuePropertyType.OPTION) {
|
||||
await handleCreatePropertyOptions(response?.id);
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
@@ -210,6 +173,7 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIssuePropertyOptionCreateList([]);
|
||||
key && handleIssuePropertyCreateList("remove", { key, ...issuePropertyData });
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
@@ -218,7 +182,9 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
const handleUpdateProperty = async (data: Partial<TIssueProperty<EIssuePropertyType>>) => {
|
||||
if (!data) return;
|
||||
// Construct the payload by filtering out unchanged properties
|
||||
const payload = omitBy(data, (value, key) => isEqual(value, issuePropertyDetail[key]));
|
||||
const originalData = cloneDeep(issuePropertyDetail);
|
||||
const payload = originalData && omitBy(data, (value, key) => isEqual(value, originalData[key]));
|
||||
if (isEmpty(payload)) return;
|
||||
setIsSubmitting(true);
|
||||
await issueProperty
|
||||
?.updateProperty(issueTypeId, payload)
|
||||
@@ -395,10 +361,11 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
<IssuePropertyQuickActions
|
||||
currentOperationMode={issuePropertyOperationMode}
|
||||
isSubmitting={isSubmitting}
|
||||
handleCreateUpdate={handleCreateUpdate}
|
||||
handleDiscard={handleDiscard}
|
||||
handleDelete={handleDelete}
|
||||
handleIssuePropertyOperationMode={(mode) => setIssuePropertyOperationMode(mode)}
|
||||
onCreateUpdate={handleCreateUpdate}
|
||||
onDiscard={handleDiscard}
|
||||
onDelete={handleDelete}
|
||||
onDisable={async () => handlePropertyDataChange("is_active", false, true)}
|
||||
onIssuePropertyOperationMode={(mode) => setIssuePropertyOperationMode(mode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,55 +2,44 @@ import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check, Pencil, Trash2, X } from "lucide-react";
|
||||
// ui
|
||||
import { AlertModalCore, Spinner, Tooltip } from "@plane/ui";
|
||||
import { Spinner, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web components
|
||||
import { DeleteConfirmationModal } from "@/plane-web/components/issue-types";
|
||||
// plane web types
|
||||
import { TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TIssuePropertyQuickActions = {
|
||||
currentOperationMode: TOperationMode | null;
|
||||
isSubmitting: boolean;
|
||||
handleCreateUpdate: () => Promise<void>;
|
||||
handleDiscard: () => void;
|
||||
handleDelete: () => Promise<void>;
|
||||
handleIssuePropertyOperationMode: (mode: TOperationMode) => void;
|
||||
onCreateUpdate: () => Promise<void>;
|
||||
onDiscard: () => void;
|
||||
onDisable: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onIssuePropertyOperationMode: (mode: TOperationMode) => void;
|
||||
};
|
||||
|
||||
export const IssuePropertyQuickActions = observer((props: TIssuePropertyQuickActions) => {
|
||||
const {
|
||||
currentOperationMode,
|
||||
isSubmitting,
|
||||
handleCreateUpdate,
|
||||
handleDiscard,
|
||||
handleDelete,
|
||||
handleIssuePropertyOperationMode,
|
||||
onCreateUpdate,
|
||||
onDiscard,
|
||||
onDisable,
|
||||
onDelete,
|
||||
onIssuePropertyOperationMode,
|
||||
} = props;
|
||||
// states
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const handleDeleteIssueProperty = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
await handleDelete().finally(() => {
|
||||
setIsDeleteLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertModalCore
|
||||
<DeleteConfirmationModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
handleClose={() => setIsDeleteModalOpen(false)}
|
||||
handleSubmit={handleDeleteIssueProperty}
|
||||
isSubmitting={isDeleteLoading}
|
||||
title="Delete this property"
|
||||
content={
|
||||
<>
|
||||
<p>Deletion of properties may lead to loss of existing data or weird behavior.</p>
|
||||
<p>Do you want to disable the property instead?</p>
|
||||
</>
|
||||
}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onDisable={onDisable}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<div
|
||||
className={cn("items-center justify-end gap-1.5 pr-2", {
|
||||
@@ -65,7 +54,7 @@ export const IssuePropertyQuickActions = observer((props: TIssuePropertyQuickAct
|
||||
className={cn(
|
||||
"p-1 border-[0.5px] border-custom-border-300 rounded bg-custom-background-100 hover:bg-custom-background-90"
|
||||
)}
|
||||
onClick={handleCreateUpdate}
|
||||
onClick={onCreateUpdate}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? <Spinner width="12px" height="12px" /> : <Check size={12} className="text-green-600" />}
|
||||
@@ -79,7 +68,7 @@ export const IssuePropertyQuickActions = observer((props: TIssuePropertyQuickAct
|
||||
"bg-custom-background-80": isSubmitting,
|
||||
}
|
||||
)}
|
||||
onClick={handleDiscard}
|
||||
onClick={onDiscard}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X size={12} className="text-red-500" />
|
||||
@@ -91,7 +80,7 @@ export const IssuePropertyQuickActions = observer((props: TIssuePropertyQuickAct
|
||||
<Tooltip className="w-full shadow" tooltipContent="Edit" position="bottom">
|
||||
<button
|
||||
className="p-1 border-[0.5px] border-custom-border-300 rounded bg-custom-background-100 hover:bg-custom-background-90"
|
||||
onClick={() => handleIssuePropertyOperationMode("update")}
|
||||
onClick={() => onIssuePropertyOperationMode("update")}
|
||||
>
|
||||
<Pencil size={12} className="text-custom-text-300" />
|
||||
</button>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { v4 } from "uuid";
|
||||
import { Plus } from "lucide-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
import { Button, Loader } from "@plane/ui";
|
||||
// plane web components
|
||||
import { IssuePropertyList, IssueTypePropertiesEmptyState } from "@/plane-web/components/issue-types";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
import { useIssueType, useIssueTypes } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty, TCreationListModes } from "@/plane-web/types";
|
||||
|
||||
@@ -31,11 +32,15 @@ const defaultIssueProperty: Partial<TIssueProperty<EIssuePropertyType>> = {
|
||||
|
||||
export const IssuePropertiesRoot = observer((props: TIssuePropertiesRoot) => {
|
||||
const { issueTypeId } = props;
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// states
|
||||
const [issuePropertyCreateList, setIssuePropertyCreateList] = useState<TIssuePropertyCreateList[]>([]);
|
||||
// store hooks
|
||||
const { getProjectIssuePropertiesLoader } = useIssueTypes();
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const issuePropertiesLoader = getProjectIssuePropertiesLoader(projectId?.toString());
|
||||
const properties = issueType?.properties;
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -73,7 +78,14 @@ export const IssuePropertiesRoot = observer((props: TIssuePropertiesRoot) => {
|
||||
<div className="w-full flex gap-2 items-center px-6">
|
||||
<div className="text-base font-semibold">Properties</div>
|
||||
</div>
|
||||
{(properties && properties?.length > 0) || issuePropertyCreateList.length > 0 ? (
|
||||
{issuePropertiesLoader === "init-loader" ? (
|
||||
<Loader className="w-full space-y-4 p-6">
|
||||
<Loader.Item height="30px" width="100%" />
|
||||
<Loader.Item height="30px" width="100%" />
|
||||
<Loader.Item height="30px" width="100%" />
|
||||
<Loader.Item height="30px" width="100%" />
|
||||
</Loader>
|
||||
) : (properties && properties?.length > 0) || issuePropertyCreateList.length > 0 ? (
|
||||
<IssuePropertyList
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyCreateList={issuePropertyCreateList}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPrope
|
||||
const projectDetails = getProjectById(projectId);
|
||||
const issueTypeDetails = issueType?.asJSON;
|
||||
const activeProperties = issueType?.activeProperties;
|
||||
const issueProperties = getProjectIssuePropertiesLoader(projectId);
|
||||
const issuePropertiesLoader = getProjectIssuePropertiesLoader(projectId);
|
||||
const isIssueTypeEnabled = isIssueTypeDisplayEnabled && projectDetails?.is_issue_type_enabled;
|
||||
|
||||
// fetch issue custom property values
|
||||
@@ -93,7 +93,7 @@ export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPrope
|
||||
// if issue types are not enabled, return null
|
||||
if (!isIssueTypeEnabled) return null;
|
||||
|
||||
if (issueProperties === "init-loader") {
|
||||
if (issuePropertiesLoader === "init-loader") {
|
||||
return (
|
||||
<Loader className="space-y-4 py-4">
|
||||
<Loader.Item height="30px" />
|
||||
|
||||
@@ -301,7 +301,12 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
handleFormChange={handleFormChange}
|
||||
/>
|
||||
{projectId && (
|
||||
<IssueTypeSelect control={control} projectId={projectId} disabled={!!data?.sourceIssueId} />
|
||||
<IssueTypeSelect
|
||||
control={control}
|
||||
projectId={projectId}
|
||||
disabled={!!data?.sourceIssueId}
|
||||
handleFormChange={handleFormChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,12 +16,13 @@ import { useFlag } from "@/plane-web/hooks/store";
|
||||
|
||||
type TIssueTypeSelectProps = {
|
||||
control: Control<TIssue>;
|
||||
projectId: string;
|
||||
projectId: string | null;
|
||||
disabled?: boolean;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = observer((props) => {
|
||||
const { control, projectId, disabled = false } = props;
|
||||
const { control, projectId, disabled = false, handleFormChange } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
@@ -52,6 +53,7 @@ export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = observer((props)
|
||||
disabled={disabled}
|
||||
handleIssueTypeChange={(issueTypeId) => {
|
||||
onChange(issueTypeId);
|
||||
handleFormChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -61,10 +61,10 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
},
|
||||
},
|
||||
property_type: EIssuePropertyType.TEXT,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
relation_type: null,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
default_value: [],
|
||||
settings: {
|
||||
display_format: "single-line",
|
||||
} as TIssuePropertySettingsMap[EIssuePropertyType.TEXT],
|
||||
@@ -82,10 +82,10 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
},
|
||||
},
|
||||
property_type: EIssuePropertyType.DECIMAL,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
relation_type: null,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
default_value: [],
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
@@ -101,10 +101,10 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
},
|
||||
},
|
||||
property_type: EIssuePropertyType.OPTION,
|
||||
relation_type: undefined,
|
||||
relation_type: null,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
default_value: [],
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
@@ -120,10 +120,10 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
},
|
||||
},
|
||||
property_type: EIssuePropertyType.BOOLEAN,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
relation_type: null,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
default_value: [],
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
@@ -139,10 +139,10 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
},
|
||||
},
|
||||
property_type: EIssuePropertyType.DATETIME,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
relation_type: null,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
default_value: [],
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
@@ -161,7 +161,7 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
relation_type: EIssuePropertyRelationType.USER,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
default_value: [],
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,14 +18,14 @@ import {
|
||||
// Get the key for the issue property type based on the property type and relation type
|
||||
export const getIssuePropertyTypeKey = (
|
||||
issuePropertyType: EIssuePropertyType | undefined,
|
||||
issuePropertyRelationType: EIssuePropertyRelationType | undefined
|
||||
issuePropertyRelationType: EIssuePropertyRelationType | null | undefined
|
||||
) =>
|
||||
`${issuePropertyType}${issuePropertyRelationType ? `_${issuePropertyRelationType}` : ""}` as TIssuePropertyTypeKeys;
|
||||
|
||||
// Get the display name for the issue property type based on the property type and relation type
|
||||
export const getIssuePropertyTypeDisplayName = (
|
||||
issuePropertyType: EIssuePropertyType | undefined,
|
||||
issuePropertyRelationType: EIssuePropertyRelationType | undefined
|
||||
issuePropertyRelationType: EIssuePropertyRelationType | null | undefined
|
||||
) => {
|
||||
const propertyTypeKey = getIssuePropertyTypeKey(issuePropertyType, issuePropertyRelationType);
|
||||
return ISSUE_PROPERTY_TYPE_DETAILS[propertyTypeKey]?.displayName || "--";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty } from "@/plane-web/types";
|
||||
import { EIssuePropertyType, TIssueProperty, TIssuePropertyOption, TIssuePropertyResponse } from "@/plane-web/types";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
@@ -22,11 +22,15 @@ export class IssuePropertiesService extends APIService {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueTypeId: string,
|
||||
data: Partial<TIssueProperty<EIssuePropertyType>>
|
||||
): Promise<TIssueProperty<EIssuePropertyType>> {
|
||||
data: Partial<TIssueProperty<EIssuePropertyType>>,
|
||||
options?: Partial<TIssuePropertyOption>[] | undefined
|
||||
): Promise<TIssuePropertyResponse> {
|
||||
return this.post(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${issueTypeId}/issue-properties/`,
|
||||
data
|
||||
{
|
||||
...data,
|
||||
options: options ?? [],
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
|
||||
@@ -22,13 +22,11 @@ export class IssuePropertyOptionsService extends APIService {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issuePropertyId: string,
|
||||
data: Partial<TIssuePropertyOption>[]
|
||||
): Promise<TIssuePropertyOption[]> {
|
||||
data: Partial<TIssuePropertyOption>
|
||||
): Promise<TIssuePropertyOption> {
|
||||
return this.post(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/${issuePropertyId}/options/`,
|
||||
{
|
||||
options: data,
|
||||
}
|
||||
data
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
|
||||
@@ -30,9 +30,7 @@ export interface IIssueProperty<T extends EIssuePropertyType> extends TIssueProp
|
||||
// actions
|
||||
updateProperty: (issueTypeId: string, propertyData: Partial<TIssueProperty<T>>) => Promise<void>;
|
||||
addPropertyOption: (propertyOptionData: TIssuePropertyOption) => void;
|
||||
createPropertyOptions: (
|
||||
propertyOptions: Partial<TIssuePropertyOption>[]
|
||||
) => Promise<TIssuePropertyOption[] | undefined>;
|
||||
createPropertyOption: (propertyOption: Partial<TIssuePropertyOption>) => Promise<TIssuePropertyOption | undefined>;
|
||||
deletePropertyOption: (propertyOptionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -45,7 +43,7 @@ export class IssueProperty<T extends EIssuePropertyType> implements IIssueProper
|
||||
logo_props: TLogoProps | undefined = undefined;
|
||||
sort_order: number | undefined = undefined;
|
||||
property_type: T | undefined = undefined;
|
||||
relation_type: EIssuePropertyRelationType | undefined = undefined;
|
||||
relation_type: EIssuePropertyRelationType | null | undefined = undefined;
|
||||
is_required: boolean | undefined = undefined;
|
||||
default_value: string[] | undefined = undefined;
|
||||
settings: TIssuePropertySettingsMap[T] | undefined = undefined;
|
||||
@@ -92,7 +90,7 @@ export class IssueProperty<T extends EIssuePropertyType> implements IIssueProper
|
||||
// actions
|
||||
updateProperty: action,
|
||||
addPropertyOption: action,
|
||||
createPropertyOptions: action,
|
||||
createPropertyOption: action,
|
||||
deletePropertyOption: action,
|
||||
});
|
||||
|
||||
@@ -211,26 +209,24 @@ export class IssueProperty<T extends EIssuePropertyType> implements IIssueProper
|
||||
|
||||
/**
|
||||
* @description Create a new property option
|
||||
* @param {Partial<TIssuePropertyOption>[]} propertyOptions
|
||||
* @param {Partial<TIssuePropertyOption>} propertyOption
|
||||
*/
|
||||
createPropertyOptions = async (propertyOptions: Partial<TIssuePropertyOption>[]) => {
|
||||
createPropertyOption = async (propertyOption: Partial<TIssuePropertyOption>) => {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||
|
||||
try {
|
||||
const issuePropertyOptions = await this.propertyOptionService.create(
|
||||
const issuePropertyOption = await this.propertyOptionService.create(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
this.id,
|
||||
propertyOptions
|
||||
propertyOption
|
||||
);
|
||||
runInAction(() => {
|
||||
issuePropertyOptions.forEach((option) => {
|
||||
this.addPropertyOption(option);
|
||||
});
|
||||
this.addPropertyOption(issuePropertyOption);
|
||||
});
|
||||
|
||||
return issuePropertyOptions;
|
||||
return issuePropertyOption;
|
||||
} catch (error) {
|
||||
console.error("IssueProperty -> createPropertyOption -> error", error);
|
||||
throw error;
|
||||
|
||||
@@ -25,7 +25,8 @@ export interface IIssueType extends TIssueType {
|
||||
updateType: (issueTypeData: Partial<TIssueType>) => Promise<TIssueType | undefined>;
|
||||
addProperty: (propertyData: TIssueProperty<EIssuePropertyType>, propertyOptions?: TIssuePropertyOption[]) => void;
|
||||
createProperty: (
|
||||
propertyData: Partial<TIssueProperty<EIssuePropertyType>>
|
||||
propertyData: Partial<TIssueProperty<EIssuePropertyType>>,
|
||||
propertyOptions?: Partial<TIssuePropertyOption>[]
|
||||
) => Promise<TIssueProperty<EIssuePropertyType> | undefined>;
|
||||
deleteProperty: (propertyId: string) => Promise<void>;
|
||||
}
|
||||
@@ -196,16 +197,25 @@ export class IssueType implements IIssueType {
|
||||
* @description Create an issue property
|
||||
* @param propertyData Issue property data
|
||||
*/
|
||||
createProperty = async (propertyData: Partial<TIssueProperty<EIssuePropertyType>>) => {
|
||||
createProperty = async (
|
||||
propertyData: Partial<TIssueProperty<EIssuePropertyType>>,
|
||||
propertyOptions?: Partial<TIssuePropertyOption>[]
|
||||
) => {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !this.id) return;
|
||||
|
||||
try {
|
||||
const issueProperty = await this.issuePropertyService.create(workspaceSlug, projectId, this.id, propertyData);
|
||||
const issueProperty = await this.issuePropertyService.create(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
this.id,
|
||||
propertyData,
|
||||
propertyOptions
|
||||
);
|
||||
runInAction(() => {
|
||||
this.addProperty(issueProperty);
|
||||
this.addProperty(issueProperty.property_detail, issueProperty.options);
|
||||
});
|
||||
return issueProperty;
|
||||
return issueProperty.property_detail;
|
||||
} catch (error) {
|
||||
console.error("IssueType.createProperty -> error", error);
|
||||
throw error;
|
||||
|
||||
10
web/ee/types/issue-types/issue-properties.d.ts
vendored
10
web/ee/types/issue-types/issue-properties.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
// types
|
||||
import { TLogoProps } from "@plane/types";
|
||||
// plane web types
|
||||
import { TIssuePropertySettingsMap } from "@/plane-web/types/issue-types";
|
||||
import { TIssuePropertyOption, TIssuePropertySettingsMap } from "@/plane-web/types/issue-types";
|
||||
|
||||
// Issue property types
|
||||
export enum EIssuePropertyType {
|
||||
@@ -27,7 +27,7 @@ type TBaseIssueProperty = {
|
||||
description: string | undefined;
|
||||
logo_props: TLogoProps | undefined;
|
||||
sort_order: number | undefined;
|
||||
relation_type: EIssuePropertyRelationType | undefined;
|
||||
relation_type: EIssuePropertyRelationType | null | undefined;
|
||||
is_required: boolean | undefined;
|
||||
default_value: string[] | undefined;
|
||||
is_active: boolean | undefined;
|
||||
@@ -44,3 +44,9 @@ export interface TIssueProperty<T extends EIssuePropertyType> extends TBaseIssue
|
||||
property_type: T | undefined;
|
||||
settings: TIssuePropertySettingsMap[T] | undefined;
|
||||
}
|
||||
|
||||
// Issue property response
|
||||
export interface TIssuePropertyResponse {
|
||||
property_detail: TIssueProperty<EIssuePropertyType>;
|
||||
options: TIssuePropertyOption[];
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ export type TissuePropertyTypeDetails<T extends EIssuePropertyType> = {
|
||||
dataToUpdate: {
|
||||
logo_props: TLogoProps;
|
||||
property_type: EIssuePropertyType;
|
||||
relation_type: EIssuePropertyRelationType | undefined;
|
||||
is_multi: boolean | undefined;
|
||||
is_required: boolean | undefined;
|
||||
default_value: string[] | undefined;
|
||||
settings: TIssuePropertySettingsMap[T] | undefined;
|
||||
relation_type: EIssuePropertyRelationType | null;
|
||||
is_multi: boolean;
|
||||
is_required: boolean;
|
||||
default_value: string[];
|
||||
settings: TIssuePropertySettingsMap[T];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user