mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 05:21:14 +02:00
chore: property validations (#784)
* chore: add required property validations on create issue modal. * chore: fix issue type list item default open state. * chore: fix boolean field default value and mandatory field validations. * fix: default value and mandatory field validations for dropdown property. * fix: unused code removed. * chore: add `type_id` to create issue endpoint response. * chore: minor height fix for readonly text property. * chore: improve additional properties loader in create/ update issue modal. * chore: update `getProjectDefaultIssueTypeId` function to get the entire issue type instance instead of just Id. * chore: remove logic to expand issue type list. * chore: update logic to expand default issue type. * chore: minor UI fixes. * fix: typo * chore: open issue modal if default issue type has mandatory properties on quick add. * dev: update issue property validation * chore: fix values validation. * fix: create issue form validation. * chore: issue type icon for all layouts. --------- Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
This commit is contained in:
@@ -402,6 +402,7 @@ class IssueViewSet(BaseViewSet):
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"deleted_at",
|
||||
"type_id",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -78,7 +78,12 @@ urlpatterns = [
|
||||
IssuePropertyValueEndpoint.as_view(),
|
||||
name="issue-property-values",
|
||||
),
|
||||
## Issue property value
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-properties/<uuid:property_id>/values/",
|
||||
IssuePropertyValueEndpoint.as_view(),
|
||||
name="issue-property-values",
|
||||
),
|
||||
## Issue property activity
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/property-activity/",
|
||||
IssuePropertyActivityEndpoint.as_view(),
|
||||
|
||||
@@ -34,11 +34,12 @@ def validate_uuid(issue_property, value):
|
||||
|
||||
def validate_datetime(issue_property, value):
|
||||
try:
|
||||
# Validate the date
|
||||
datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
# Raise a validation error
|
||||
raise ValidationError(f"{value} is not a valid date")
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
raise ValidationError(f"{value} is not a valid datetime")
|
||||
|
||||
|
||||
def validate_decimal(issue_property, value):
|
||||
@@ -292,25 +293,25 @@ def save_file(
|
||||
return bulk_issue_prop_values
|
||||
|
||||
|
||||
# Map the property type to the validator
|
||||
VALIDATOR_MAPPER = {
|
||||
PropertyTypeEnum.TEXT: validate_text,
|
||||
PropertyTypeEnum.DATETIME: validate_datetime,
|
||||
PropertyTypeEnum.DECIMAL: validate_decimal,
|
||||
PropertyTypeEnum.BOOLEAN: validate_boolean,
|
||||
PropertyTypeEnum.OPTION: validate_option,
|
||||
PropertyTypeEnum.RELATION: validate_relation,
|
||||
PropertyTypeEnum.URL: validate_url,
|
||||
PropertyTypeEnum.EMAIL: validate_email_value,
|
||||
PropertyTypeEnum.FILE: validate_file,
|
||||
}
|
||||
|
||||
|
||||
def property_validators(
|
||||
properties,
|
||||
property_values,
|
||||
existing_prop_values,
|
||||
):
|
||||
|
||||
# Map the property type to the validator
|
||||
VALIDATOR_MAPPER = {
|
||||
PropertyTypeEnum.TEXT: validate_text,
|
||||
PropertyTypeEnum.DATETIME: validate_datetime,
|
||||
PropertyTypeEnum.DECIMAL: validate_decimal,
|
||||
PropertyTypeEnum.BOOLEAN: validate_boolean,
|
||||
PropertyTypeEnum.OPTION: validate_option,
|
||||
PropertyTypeEnum.RELATION: validate_relation,
|
||||
PropertyTypeEnum.URL: validate_url,
|
||||
PropertyTypeEnum.EMAIL: validate_email_value,
|
||||
PropertyTypeEnum.FILE: validate_file,
|
||||
}
|
||||
|
||||
# Validate the property values
|
||||
for property in properties:
|
||||
# Fetch the validator
|
||||
@@ -325,23 +326,31 @@ def property_validators(
|
||||
# Fetch the value
|
||||
values = property_values.get(str(property.id), [])
|
||||
|
||||
# Get the existing values
|
||||
existing_values = [
|
||||
prop_value.get("values")
|
||||
for prop_value in existing_prop_values
|
||||
if str(prop_value.get("property_id")) == str(property.id)
|
||||
]
|
||||
|
||||
# Validate the value
|
||||
if property.is_required and not values and not existing_values:
|
||||
raise ValidationError(f"{property.display_name} is required")
|
||||
if property.is_required and not values:
|
||||
raise ValidationError(
|
||||
f"{property.display_name} is a required property"
|
||||
)
|
||||
|
||||
# Validate the value
|
||||
for value in values:
|
||||
# Validate the value
|
||||
validator(issue_property=property, value=value)
|
||||
|
||||
return
|
||||
return
|
||||
|
||||
|
||||
SAVE_MAPPER = {
|
||||
PropertyTypeEnum.TEXT: save_text,
|
||||
PropertyTypeEnum.DATETIME: save_datetime,
|
||||
PropertyTypeEnum.DECIMAL: save_decimal,
|
||||
PropertyTypeEnum.BOOLEAN: save_boolean,
|
||||
PropertyTypeEnum.OPTION: save_option,
|
||||
PropertyTypeEnum.RELATION: save_relation,
|
||||
PropertyTypeEnum.URL: save_url,
|
||||
PropertyTypeEnum.EMAIL: save_email,
|
||||
PropertyTypeEnum.FILE: save_file,
|
||||
}
|
||||
|
||||
|
||||
def property_savers(
|
||||
@@ -353,18 +362,6 @@ def property_savers(
|
||||
existing_prop_values,
|
||||
):
|
||||
# Save the property values
|
||||
SAVE_MAPPER = {
|
||||
PropertyTypeEnum.TEXT: save_text,
|
||||
PropertyTypeEnum.DATETIME: save_datetime,
|
||||
PropertyTypeEnum.DECIMAL: save_decimal,
|
||||
PropertyTypeEnum.BOOLEAN: save_boolean,
|
||||
PropertyTypeEnum.OPTION: save_option,
|
||||
PropertyTypeEnum.RELATION: save_relation,
|
||||
PropertyTypeEnum.URL: save_url,
|
||||
PropertyTypeEnum.EMAIL: save_email,
|
||||
PropertyTypeEnum.FILE: save_file,
|
||||
}
|
||||
|
||||
bulk_issue_properties = []
|
||||
for property in properties:
|
||||
# Fetch the saver
|
||||
@@ -388,16 +385,15 @@ def property_savers(
|
||||
|
||||
# Save the value
|
||||
if values:
|
||||
# Save the value
|
||||
bulk_issue_properties.extend(
|
||||
saver(
|
||||
issue_property=property,
|
||||
values=values,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
existing_values=existing_values,
|
||||
)
|
||||
saver_value = saver(
|
||||
issue_property=property,
|
||||
values=values,
|
||||
existing_values=existing_values,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
# Save the value
|
||||
bulk_issue_properties.extend(saver_value)
|
||||
|
||||
return bulk_issue_properties # Return the bulk issue properties
|
||||
|
||||
@@ -22,6 +22,8 @@ from plane.ee.permissions import ProjectEntityPermission
|
||||
from plane.ee.utils.issue_property_validators import (
|
||||
property_validators,
|
||||
property_savers,
|
||||
SAVE_MAPPER,
|
||||
VALIDATOR_MAPPER,
|
||||
)
|
||||
from plane.ee.bgtasks.issue_property_activity_task import (
|
||||
issue_property_activity,
|
||||
@@ -201,3 +203,88 @@ class IssuePropertyValueEndpoint(BaseAPIView):
|
||||
{"error": str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, project_id, issue_id, property_id):
|
||||
try:
|
||||
# Get the issue property
|
||||
issue_property = IssueProperty.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=property_id,
|
||||
)
|
||||
|
||||
existing_prop_queryset = IssuePropertyValue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
property_id=property_id,
|
||||
)
|
||||
|
||||
# Get all issue property values
|
||||
existing_prop_values = self.query_annotator(
|
||||
existing_prop_queryset
|
||||
).values("property_id", "values")
|
||||
|
||||
# Get the value
|
||||
values = request.data.get("values", [])
|
||||
|
||||
if issue_property.is_required and (
|
||||
not values or not [v for v in values if v]
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": issue_property.display_name
|
||||
+ " is a required property"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate the values
|
||||
validator = VALIDATOR_MAPPER.get(issue_property.property_type)
|
||||
|
||||
if validator:
|
||||
for value in values:
|
||||
validator(issue_property=issue_property, value=value)
|
||||
else:
|
||||
raise ValidationError("Invalid property type")
|
||||
|
||||
# Save the values
|
||||
saver = SAVE_MAPPER.get(issue_property.property_type)
|
||||
if saver:
|
||||
# Save the data
|
||||
property_values = saver(
|
||||
values=values,
|
||||
issue_property=issue_property,
|
||||
issue_id=issue_id,
|
||||
existing_values=[],
|
||||
workspace_id=issue_property.workspace_id,
|
||||
project_id=issue_property.project_id,
|
||||
)
|
||||
# Delete the old values
|
||||
existing_prop_queryset.filter(property_id=property_id).delete()
|
||||
# Bulk create the issue property values
|
||||
IssuePropertyValue.objects.bulk_create(
|
||||
property_values, batch_size=10
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValidationError("Invalid property type")
|
||||
|
||||
# Log the activity
|
||||
issue_property_activity.delay(
|
||||
existing_values={
|
||||
str(prop["property_id"]): prop["values"]
|
||||
for prop in existing_prop_values
|
||||
},
|
||||
requested_values={str(property_id): values},
|
||||
issue_id=issue_id,
|
||||
user_id=str(request.user.id),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except (ValidationError, ValueError) as e:
|
||||
return Response(
|
||||
{"error": str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type TIssueIdentifierProps = {
|
||||
issueId: string;
|
||||
projectId: string;
|
||||
textContainerClassName?: string;
|
||||
};
|
||||
|
||||
export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props) => {
|
||||
const { issueId, projectId } = props;
|
||||
const { issueId, projectId, textContainerClassName } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
@@ -20,7 +22,7 @@ export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props)
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-base font-medium text-custom-text-300">
|
||||
<span className={cn("text-xs font-medium text-custom-text-300", textContainerClassName)}>
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,12 @@ import { TIssue } from "@plane/types";
|
||||
import { Tooltip, ControlLink } from "@plane/ui";
|
||||
// hooks
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
|
||||
import { useIssueDetail, useProjectState } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
// helpers
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
|
||||
type Props = {
|
||||
@@ -33,7 +34,6 @@ export const CalendarIssueBlock = observer(
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
|
||||
const { isMobile } = usePlatformOS();
|
||||
@@ -99,9 +99,15 @@ export const CalendarIssueBlock = observer(
|
||||
backgroundColor: stateColor,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-sm md:text-xs text-custom-text-300">
|
||||
{getProjectIdentifierById(issue?.project_id)}-{issue.sequence_id}
|
||||
</div>
|
||||
{issue.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={issue.project_id}
|
||||
iconContainerClassName="size-[18px]"
|
||||
iconSize={11}
|
||||
textContainerClassName="text-sm md:text-xs text-custom-text-300"
|
||||
/>
|
||||
)}
|
||||
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
|
||||
<div className="truncate text-sm font-medium md:font-normal md:text-xs">{issue.name}</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
// ui
|
||||
import { Tooltip, StateGroupIcon, ControlLink } from "@plane/ui";
|
||||
import { Tooltip, ControlLink } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
|
||||
import { useIssueDetail, useProjectState } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
@@ -78,16 +79,12 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug: routerWorkspaceSlug } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug?.toString();
|
||||
// store hooks
|
||||
const { getStateById } = useProjectState();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
setPeekIssue,
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
const projectIdentifier = issueDetails && getProjectIdentifierById(issueDetails?.project_id);
|
||||
const stateDetails = issueDetails && getStateById(issueDetails?.state_id);
|
||||
|
||||
const handleIssuePeekOverview = () =>
|
||||
workspaceSlug &&
|
||||
@@ -104,10 +101,19 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
disabled={!!issueDetails?.tempId}
|
||||
>
|
||||
<div className="relative flex h-full w-full cursor-pointer items-center gap-2">
|
||||
{stateDetails && <StateGroupIcon stateGroup={stateDetails?.group} color={stateDetails?.color} />}
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{issueDetails?.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={issueDetails.project_id}
|
||||
iconContainerClassName="size-[18px]"
|
||||
iconSize={11}
|
||||
textContainerClassName="text-xs text-custom-text-300"
|
||||
/>
|
||||
)}
|
||||
{/* {stateDetails && <StateGroupIcon stateGroup={stateDetails?.group} color={stateDetails?.color} />} */}
|
||||
{/* <div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{projectIdentifier} {issueDetails?.sequence_id}
|
||||
</div>
|
||||
</div> */}
|
||||
<Tooltip tooltipContent={issueDetails?.name} isMobile={isMobile}>
|
||||
<span className="flex-grow truncate text-sm font-medium">{issueDetails?.name}</span>
|
||||
</Tooltip>
|
||||
|
||||
@@ -12,10 +12,11 @@ import RenderIfVisible from "@/components/core/render-if-visible-HOC";
|
||||
import { HIGHLIGHT_CLASS } from "@/components/issues/issue-layouts/utils";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject, useKanbanView } from "@/hooks/store";
|
||||
import { useIssueDetail, useKanbanView } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
@@ -51,7 +52,6 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
const { cardRef, issue, updateIssue, quickActions, isReadOnly, displayProperties } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
|
||||
const handleEventPropagation = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -62,9 +62,15 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
<>
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
|
||||
<div className="relative">
|
||||
<div className="line-clamp-1 text-xs text-custom-text-300">
|
||||
{getProjectIdentifierById(issue.project_id)}-{issue.sequence_id}
|
||||
</div>
|
||||
{issue.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={issue.project_id}
|
||||
iconContainerClassName="size-[18px]"
|
||||
iconSize={11}
|
||||
textContainerClassName="line-clamp-1 text-xs text-custom-text-300"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn("absolute -top-1 right-0", {
|
||||
"hidden group-hover/kanban-block:block": !isMobile,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
|
||||
interface IssueBlockProps {
|
||||
@@ -187,11 +188,16 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div
|
||||
className="flex-shrink-0 text-xs font-medium text-custom-text-300 pl-2"
|
||||
style={{ minWidth: `${keyMinWidth}px` }}
|
||||
>
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
<div className="flex-shrink-0 pl-2" style={{ minWidth: `${keyMinWidth}px` }}>
|
||||
{issue.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issueId}
|
||||
projectId={issue.project_id}
|
||||
iconContainerClassName="size-[18px]"
|
||||
iconSize={11}
|
||||
textContainerClassName="text-xs font-medium text-custom-text-300"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
import { TRenderQuickActions } from "../list/list-view-types";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { IssueColumn } from "./issue-column";
|
||||
@@ -287,7 +288,15 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
||||
<p className={`flex font-medium leading-7`} style={{ minWidth: `${keyMinWidth}px` }}>
|
||||
{getProjectIdentifierById(issueDetail.project_id)}-{issueDetail.sequence_id}
|
||||
{issueDetail.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetail.id}
|
||||
projectId={issueDetail.project_id}
|
||||
iconContainerClassName="size-[18px]"
|
||||
iconSize={11}
|
||||
textContainerClassName="text-sm md:text-xs text-custom-text-300"
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
<span className="text-xs font-medium text-red-500">{errors?.name?.message}</span>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
const { fetchProjectStates } = useProjectState();
|
||||
const { fetchProjectLabels } = useLabel();
|
||||
const { getProjectEstimates } = useProjectEstimates();
|
||||
const { getAllTypesPropertiesOptions } = useIssueTypes();
|
||||
const { fetchAllTypesPropertiesOptions } = useIssueTypes();
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
||||
@@ -106,7 +106,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `ISSUE_TYPES_AND_PROPERTIES_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId
|
||||
? () => getAllTypesPropertiesOptions(workspaceSlug.toString(), projectId.toString())
|
||||
? () => fetchAllTypesPropertiesOptions(workspaceSlug.toString(), projectId.toString())
|
||||
: null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
@@ -18,18 +18,26 @@ export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
const issueTypeLoader = getProjectIssueTypeLoader(projectId);
|
||||
const issueTypes = getProjectActiveIssueTypes(projectId);
|
||||
|
||||
const issuePropertyTypeOptions = Object.entries(issueTypes).map(([issueTypeId, issueTypeDetail]) => ({
|
||||
const issueTypeOptions = Object.entries(issueTypes).map(([issueTypeId, issueTypeDetail]) => ({
|
||||
value: issueTypeId,
|
||||
query: issueTypeDetail.name ?? "",
|
||||
content: (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-custom-background-80">
|
||||
{issueTypeDetail?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypeDetail.logo_props} size={12} type="lucide" />
|
||||
<>
|
||||
{issueTypeDetail?.is_default ? (
|
||||
<div className="flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-[#6695FF]">
|
||||
<LayersIcon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<LayersIcon className="h-3 w-3 text-custom-text-300" />
|
||||
<div className="flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-custom-background-80">
|
||||
{issueTypeDetail?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypeDetail.logo_props} size={12} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-3 w-3 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="text-sm font-medium text-custom-text-200">{issueTypeDetail.name}</div>
|
||||
</div>
|
||||
),
|
||||
@@ -47,18 +55,24 @@ export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
<CustomSearchSelect
|
||||
value={issueTypeId}
|
||||
label={
|
||||
<div className="flex gap-1 items-center">
|
||||
<div className="flex-shrink-0 grid h-4 w-4 place-items-center">
|
||||
{issueTypes[issueTypeId]?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypes[issueTypeId].logo_props} size={12} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-3 w-3 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{issueTypes[issueTypeId]?.is_default ? (
|
||||
<div className="flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-[#6695FF]">
|
||||
<LayersIcon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-custom-background-80">
|
||||
{issueTypes[issueTypeId]?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypes[issueTypeId].logo_props} size={12} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-3 w-3 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-custom-text-200">{issueTypes[issueTypeId]?.name}</div>
|
||||
</div>
|
||||
}
|
||||
options={issuePropertyTypeOptions}
|
||||
options={issueTypeOptions}
|
||||
onChange={handleIssueTypeChange}
|
||||
className="w-full h-full flex"
|
||||
optionsClassName="w-48"
|
||||
|
||||
@@ -12,16 +12,17 @@ import { useIssueType } from "@/plane-web/hooks/store";
|
||||
|
||||
type TIssueTypeListItem = {
|
||||
issueTypeId: string;
|
||||
isDefaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export const IssueTypeListItem = observer((props: TIssueTypeListItem) => {
|
||||
const { issueTypeId } = props;
|
||||
const { issueTypeId, isDefaultOpen = false } = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const issueTypeDetail = issueType?.asJSON;
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState(issueTypeDetail?.is_default ?? false);
|
||||
const [isOpen, setIsOpen] = useState(isDefaultOpen);
|
||||
|
||||
if (!issueTypeDetail) return null;
|
||||
|
||||
@@ -47,18 +48,29 @@ export const IssueTypeListItem = observer((props: TIssueTypeListItem) => {
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 grid h-10 w-10 place-items-center rounded-md bg-custom-background-80/70",
|
||||
!issueTypeDetail?.is_active && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{issueTypeDetail?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypeDetail.logo_props} size={20} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-5 w-5 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
{issueTypeDetail?.is_default ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 grid h-10 w-10 place-items-center rounded-md bg-[#6695FF]",
|
||||
!issueTypeDetail?.is_active && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<LayersIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 grid h-10 w-10 place-items-center rounded-md bg-custom-background-80/70",
|
||||
!issueTypeDetail?.is_active && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{issueTypeDetail?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypeDetail.logo_props} size={20} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-5 w-5 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col w-full items-start justify-start">
|
||||
<div className="flex gap-4 text-left">
|
||||
<div className="text-sm text-custom-text-100 font-medium line-clamp-1">{issueTypeDetail?.name}</div>
|
||||
|
||||
@@ -11,10 +11,11 @@ export const IssueTypesList = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectIssueTypeLoader, getProjectIssueTypeIds } = useIssueTypes();
|
||||
const { getProjectIssueTypeLoader, getProjectIssueTypeIds, getProjectDefaultIssueType } = useIssueTypes();
|
||||
// derived states
|
||||
const issueTypeLoader = getProjectIssueTypeLoader(projectId?.toString());
|
||||
const currentProjectIssueTypeIds = getProjectIssueTypeIds(projectId?.toString());
|
||||
const currentProjectDefaultIssueType = getProjectDefaultIssueType(projectId?.toString());
|
||||
|
||||
if (issueTypeLoader === "init-loader") {
|
||||
return (
|
||||
@@ -33,7 +34,13 @@ export const IssueTypesList = observer(() => {
|
||||
<div>
|
||||
{currentProjectIssueTypeIds &&
|
||||
currentProjectIssueTypeIds.map((issueTypeId) => (
|
||||
<IssueTypeListItem key={issueTypeId} issueTypeId={issueTypeId} />
|
||||
<IssueTypeListItem
|
||||
key={issueTypeId}
|
||||
issueTypeId={issueTypeId}
|
||||
isDefaultOpen={
|
||||
issueTypeId === currentProjectDefaultIssueType?.id && currentProjectIssueTypeIds.length === 1
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,15 @@ export const DropdownAttributes = observer((props: TDropdownAttributesProps) =>
|
||||
<PropertyMultiSelect
|
||||
value={dropdownPropertyDetail.is_multi}
|
||||
variant="OPTION"
|
||||
onChange={(value) => onDropdownDetailChange("is_multi", value)}
|
||||
onChange={(value) => {
|
||||
onDropdownDetailChange("is_multi", value);
|
||||
if (!value) {
|
||||
onDropdownDetailChange(
|
||||
"default_value",
|
||||
dropdownPropertyDetail.default_value?.[0] ? [dropdownPropertyDetail.default_value?.[0]] : []
|
||||
);
|
||||
}
|
||||
}}
|
||||
isDisabled={currentOperationMode === "update" && isAnyIssueAttached}
|
||||
/>
|
||||
</div>
|
||||
@@ -83,12 +91,12 @@ export const DropdownAttributes = observer((props: TDropdownAttributesProps) =>
|
||||
<div className="text-xs font-medium text-custom-text-300">Default • Optional</div>
|
||||
{dropdownPropertyDetail.id ? (
|
||||
<OptionValueSelect
|
||||
propertyDetail={dropdownPropertyDetail}
|
||||
value={dropdownPropertyDetail.default_value ?? []}
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyId={dropdownPropertyDetail.id}
|
||||
variant="create"
|
||||
isMultiSelect={dropdownPropertyDetail.is_multi}
|
||||
isRequired={false}
|
||||
isDisabled={isOptionDefaultDisabled}
|
||||
onOptionValueChange={async (value) => onDropdownDetailChange("default_value", value)}
|
||||
/>
|
||||
|
||||
@@ -71,11 +71,11 @@ export const MemberPickerAttributes = observer((props: TMemberPickerAttributesPr
|
||||
<div className="pt-4">
|
||||
<div className="text-xs font-medium text-custom-text-300">Default • Optional</div>
|
||||
<MemberValueSelect
|
||||
propertyDetail={memberPickerPropertyDetail}
|
||||
value={memberPickerPropertyDetail.default_value ?? []}
|
||||
projectId={projectId?.toString()}
|
||||
variant="create"
|
||||
isMultiSelect={memberPickerPropertyDetail.is_multi}
|
||||
isRequired={false}
|
||||
isDisabled={isMemberDropdownDisabled}
|
||||
onMemberValueChange={async (value) => onMemberPickerDetailChange("default_value", value)}
|
||||
/>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const NumberAttributes = observer((props: TNumberAttributesProps) => {
|
||||
)}
|
||||
<div className="text-xs font-medium text-custom-text-300">Default • Optional</div>
|
||||
<NumberValueInput
|
||||
propertyId={numberPropertyDetail.id}
|
||||
propertyDetail={numberPropertyDetail}
|
||||
value={numberPropertyDetail.default_value ?? []}
|
||||
onNumberValueChange={async (value) => onNumberDetailChange("default_value", value)}
|
||||
variant="create"
|
||||
|
||||
@@ -20,7 +20,7 @@ export const IssuePropertyCreateOptionItem = observer(
|
||||
const { issueTypeId, issuePropertyId, propertyOptionCreateListData, updateCreateListData } = props;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full px-1">
|
||||
<div ref={ref} className="w-full px-1 pr-2">
|
||||
<IssuePropertyOptionItem
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyId={issuePropertyId}
|
||||
|
||||
@@ -65,25 +65,19 @@ export const PropertyTitleDropdown = observer((props: TPropertyTitleDropdownProp
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-custom-text-300">Name your property</div>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
id="display_name"
|
||||
type="text"
|
||||
value={propertyDetail.display_name}
|
||||
onChange={(e) => onPropertyDetailChange("display_name", e.target.value)}
|
||||
className={cn("w-full resize-none text-sm bg-custom-background-100 border-[0.5px] rounded")}
|
||||
tabIndex={1}
|
||||
hasError={Boolean(error)}
|
||||
inputSize="xs"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{Boolean(error) && (
|
||||
<Tooltip tooltipContent={error} className="text-xs" position="left">
|
||||
<Info className="absolute right-1.5 h-3 w-3 stroke-red-600 hover:cursor-pointer" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="display_name"
|
||||
type="text"
|
||||
value={propertyDetail.display_name}
|
||||
onChange={(e) => onPropertyDetailChange("display_name", e.target.value)}
|
||||
className={cn("w-full resize-none text-sm bg-custom-background-100 border-[0.5px] rounded")}
|
||||
tabIndex={1}
|
||||
hasError={Boolean(error)}
|
||||
inputSize="xs"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{Boolean(error) && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-custom-text-300">Describe your property</div>
|
||||
|
||||
@@ -57,8 +57,6 @@ export const PropertyTypeDropdown = (props: TPropertyTypeDropdownProps) => {
|
||||
const onPropertyTypeChange = (key: TIssuePropertyTypeKeys) => {
|
||||
handlePropertyObjectChange({
|
||||
...ISSUE_PROPERTY_TYPE_DETAILS[key]?.dataToUpdate,
|
||||
// reset other properties
|
||||
default_value: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const IssueTypePropertiesEmptyState = () => (
|
||||
<div className="flex-shrink-0 grid h-24 w-24 place-items-center rounded-full bg-custom-background-90">
|
||||
<LayersIcon className="h-14 w-14 text-custom-text-400" strokeWidth="1.5" />
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-300">Create new properties for this issue type.</div>
|
||||
<div className="text-sm text-custom-text-300">New properties you add for this issue type will show here.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -66,17 +66,39 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
const [issuePropertyOptionCreateList, setIssuePropertyOptionCreateList] = useState<TIssuePropertyOptionCreateList[]>(
|
||||
[]
|
||||
);
|
||||
// derived values
|
||||
// check if mandatory field is disabled for the property
|
||||
const isMandatoryFieldDisabled =
|
||||
issuePropertyData?.property_type === EIssuePropertyType.BOOLEAN ||
|
||||
(issuePropertyData?.property_type === EIssuePropertyType.TEXT &&
|
||||
issuePropertyData?.settings?.display_format === "readonly");
|
||||
|
||||
// get property default values
|
||||
const getDefaultValues = () => {
|
||||
// if property is option type and operation mode is create, return default values from issuePropertyOptionCreateList
|
||||
if (issuePropertyData?.property_type === EIssuePropertyType.OPTION && issuePropertyOperationMode === "create") {
|
||||
return (
|
||||
issuePropertyOptionCreateList.filter((option) => option.is_default).map((option) => option.id as string) ?? []
|
||||
);
|
||||
}
|
||||
// else return default values from issuePropertyData
|
||||
return issuePropertyData?.default_value ?? [];
|
||||
};
|
||||
|
||||
// validators
|
||||
const validateIssueProperty = () => {
|
||||
let hasError = false;
|
||||
const error = { ...defaultIssuePropertyError };
|
||||
if (!issuePropertyData.display_name) {
|
||||
error.display_name = "Property name is required";
|
||||
error.display_name = "You must name your property.";
|
||||
hasError = true;
|
||||
}
|
||||
if (issuePropertyData.display_name && issuePropertyData.display_name?.length > 255) {
|
||||
error.display_name = "Property name should not exceed 255 characters.";
|
||||
hasError = true;
|
||||
}
|
||||
if (!issuePropertyData.property_type) {
|
||||
error.property_type = "Property type is required";
|
||||
error.property_type = "You must select a property type.";
|
||||
hasError = true;
|
||||
}
|
||||
setIssuePropertyError(error);
|
||||
@@ -287,6 +309,21 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
setIssuePropertyError(defaultIssuePropertyError);
|
||||
};
|
||||
|
||||
const handleMandatoryFieldChange = (value: boolean) => {
|
||||
// if mandatory field is enabled, remove default value
|
||||
if (value) {
|
||||
// if property is option type, set is_default to false for all options
|
||||
if (issuePropertyData.property_type === EIssuePropertyType.OPTION) {
|
||||
setIssuePropertyOptionCreateList((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return prevValue.map((item) => ({ ...item, is_default: false }));
|
||||
});
|
||||
}
|
||||
handlePropertyDataChange("default_value", []);
|
||||
}
|
||||
handlePropertyDataChange("is_required", value, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -335,16 +372,9 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) =>
|
||||
<div className="w-20 text-center">
|
||||
<PropertyMandatoryFieldToggle
|
||||
value={!!issuePropertyData.is_required}
|
||||
defaultValue={issuePropertyData?.default_value ?? []}
|
||||
onMandatoryFieldChange={(value) => {
|
||||
// if mandatory field is enabled, set default value to empty array
|
||||
if (value) handlePropertyDataChange("default_value", []);
|
||||
handlePropertyDataChange("is_required", value, true);
|
||||
}}
|
||||
isDisabled={
|
||||
issuePropertyData?.property_type === EIssuePropertyType.TEXT &&
|
||||
issuePropertyData?.settings?.display_format === "readonly"
|
||||
}
|
||||
defaultValue={getDefaultValues()}
|
||||
onMandatoryFieldChange={handleMandatoryFieldChange}
|
||||
isDisabled={isMandatoryFieldDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-14 text-center whitespace-nowrap">
|
||||
|
||||
@@ -32,12 +32,8 @@ export const IssuePropertyList: FC<TIssuePropertyList> = observer((props) => {
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center mx-7 gap-2 border-b-[0.5px] border-custom-border-200">
|
||||
<div className="w-48 grow py-1.5 text-left text-sm font-medium text-custom-text-300 truncate">
|
||||
Property title
|
||||
</div>
|
||||
<div className="w-36 py-1.5 text-left text-sm font-medium text-custom-text-300 truncate">
|
||||
Property type
|
||||
</div>
|
||||
<div className="w-48 grow py-1.5 text-left text-sm font-medium text-custom-text-300 truncate">Name</div>
|
||||
<div className="w-36 py-1.5 text-left text-sm font-medium text-custom-text-300 truncate">Type</div>
|
||||
<div className="w-36 py-1.5 text-left text-sm font-medium text-custom-text-300 truncate">Attributes</div>
|
||||
<div className="w-20 py-1.5 text-center text-sm font-medium text-custom-text-300 truncate">Mandatory</div>
|
||||
<div className="w-14 py-1.5">
|
||||
|
||||
@@ -6,19 +6,36 @@ import { DateDropdown } from "@/components/dropdowns";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
// plane web types
|
||||
import { TDateAttributeDisplayOptions, TPropertyValueVariant } from "@/plane-web/types/issue-types";
|
||||
import {
|
||||
EIssuePropertyType,
|
||||
EIssuePropertyValueError,
|
||||
TDateAttributeDisplayOptions,
|
||||
TIssueProperty,
|
||||
TPropertyValueVariant,
|
||||
} from "@/plane-web/types/issue-types";
|
||||
|
||||
type TDateValueSelectProps = {
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType.DATETIME>>;
|
||||
value: string[];
|
||||
variant: TPropertyValueVariant
|
||||
variant: TPropertyValueVariant;
|
||||
displayFormat: TDateAttributeDisplayOptions;
|
||||
isRequired?: boolean; // TODO: remove if not required.
|
||||
error?: EIssuePropertyValueError;
|
||||
isDisabled?: boolean;
|
||||
buttonClassName?: string;
|
||||
onDateValueChange: (value: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const DateValueSelect = observer((props: TDateValueSelectProps) => {
|
||||
const { value, variant, displayFormat, isDisabled = false, onDateValueChange } = props;
|
||||
const {
|
||||
propertyDetail,
|
||||
value,
|
||||
variant,
|
||||
displayFormat,
|
||||
error,
|
||||
isDisabled = false,
|
||||
buttonClassName,
|
||||
onDateValueChange,
|
||||
} = props;
|
||||
// states
|
||||
const [data, setData] = useState<string[]>([]);
|
||||
|
||||
@@ -34,21 +51,33 @@ export const DateValueSelect = observer((props: TDateValueSelectProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<DateDropdown
|
||||
value={data?.[0]}
|
||||
onChange={handleDateChange}
|
||||
placeholder="Add date"
|
||||
buttonVariant={variant === "update" ? "transparent-with-text" : "border-with-text"}
|
||||
disabled={isDisabled}
|
||||
className="w-full flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={cn("text-sm", {
|
||||
"text-custom-text-400": !data?.length,
|
||||
"border-custom-border-200": variant === "create",
|
||||
})}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline"
|
||||
formatToken={displayFormat}
|
||||
/>
|
||||
<>
|
||||
<DateDropdown
|
||||
value={data?.[0]}
|
||||
onChange={handleDateChange}
|
||||
placeholder="Add date"
|
||||
buttonVariant={variant === "update" && !Boolean(error) ? "transparent-with-text" : "border-with-text"}
|
||||
disabled={isDisabled}
|
||||
className="w-full flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={cn(
|
||||
"text-sm",
|
||||
{
|
||||
"text-custom-text-400": !data?.length,
|
||||
"border-custom-border-200": variant === "create",
|
||||
"border-red-500": Boolean(error),
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline"
|
||||
formatToken={displayFormat}
|
||||
/>
|
||||
{Boolean(error) && (
|
||||
<span className="text-xs font-medium text-red-500">
|
||||
{error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,20 +7,32 @@ import { MemberDropdownProps } from "@/components/dropdowns/member/types";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web types
|
||||
import { TPropertyValueVariant } from "@/plane-web/types";
|
||||
import { EIssuePropertyType, EIssuePropertyValueError, TIssueProperty, TPropertyValueVariant } from "@/plane-web/types";
|
||||
|
||||
type TMemberValueSelectProps = {
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType.RELATION>>;
|
||||
value: string[];
|
||||
projectId: string;
|
||||
variant: TPropertyValueVariant;
|
||||
error?: EIssuePropertyValueError;
|
||||
isMultiSelect?: boolean;
|
||||
isRequired?: boolean; // TODO: remove if not required.
|
||||
isDisabled?: boolean;
|
||||
buttonClassName?: string;
|
||||
onMemberValueChange: (value: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const MemberValueSelect = observer((props: TMemberValueSelectProps) => {
|
||||
const { value, projectId, variant, isMultiSelect = false, isDisabled = false, onMemberValueChange } = props;
|
||||
const {
|
||||
propertyDetail,
|
||||
value,
|
||||
projectId,
|
||||
variant,
|
||||
error,
|
||||
isMultiSelect = false,
|
||||
isDisabled = false,
|
||||
buttonClassName,
|
||||
onMemberValueChange,
|
||||
} = props;
|
||||
// states
|
||||
const [data, setData] = useState<string[]>([]);
|
||||
|
||||
@@ -29,10 +41,15 @@ export const MemberValueSelect = observer((props: TMemberValueSelectProps) => {
|
||||
}, [value]);
|
||||
|
||||
const memberPickerProps: Partial<MemberDropdownProps> = {
|
||||
buttonClassName: cn("py-1 text-sm justify-between", {
|
||||
"text-custom-text-400": !data?.length,
|
||||
"border-custom-border-200": variant === "create",
|
||||
}),
|
||||
buttonClassName: cn(
|
||||
"h-full py-1 text-sm justify-between",
|
||||
{
|
||||
"text-custom-text-400": !data?.length,
|
||||
"border-custom-border-200": variant === "create",
|
||||
"border-red-500": Boolean(error),
|
||||
},
|
||||
buttonClassName
|
||||
),
|
||||
buttonContainerClassName: cn("w-full text-left", {
|
||||
"bg-custom-background-90": isDisabled,
|
||||
}),
|
||||
@@ -64,7 +81,7 @@ export const MemberValueSelect = observer((props: TMemberValueSelectProps) => {
|
||||
setData(memberIds);
|
||||
}}
|
||||
buttonVariant={
|
||||
variant === "update"
|
||||
variant === "update" && !Boolean(error)
|
||||
? data.length > 1
|
||||
? "transparent-without-text"
|
||||
: "transparent-with-text"
|
||||
@@ -84,7 +101,7 @@ export const MemberValueSelect = observer((props: TMemberValueSelectProps) => {
|
||||
setData(memberId && !data?.includes(memberId) ? [memberId] : []);
|
||||
}}
|
||||
buttonVariant={
|
||||
variant === "update"
|
||||
variant === "update" && !Boolean(error)
|
||||
? data.length > 1
|
||||
? "transparent-without-text"
|
||||
: "transparent-with-text"
|
||||
@@ -94,6 +111,11 @@ export const MemberValueSelect = observer((props: TMemberValueSelectProps) => {
|
||||
multiple={false}
|
||||
/>
|
||||
)}
|
||||
{Boolean(error) && (
|
||||
<span className="text-xs font-medium text-red-500">
|
||||
{error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Input } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web types
|
||||
import { TPropertyValueVariant } from "@/plane-web/types";
|
||||
import { EIssuePropertyType, EIssuePropertyValueError, TIssueProperty, TPropertyValueVariant } from "@/plane-web/types";
|
||||
|
||||
type TNumberValueInputProps = {
|
||||
propertyId: string | undefined;
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType.DECIMAL>>;
|
||||
value: string[];
|
||||
variant: TPropertyValueVariant;
|
||||
numberInputSize?: "xs" | "sm" | "md";
|
||||
isRequired?: boolean;
|
||||
error?: EIssuePropertyValueError;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
onNumberValueChange: (value: string[]) => Promise<void>;
|
||||
@@ -21,11 +21,11 @@ type TNumberValueInputProps = {
|
||||
|
||||
export const NumberValueInput = observer((props: TNumberValueInputProps) => {
|
||||
const {
|
||||
propertyId,
|
||||
propertyDetail,
|
||||
value,
|
||||
variant,
|
||||
numberInputSize = "sm",
|
||||
isRequired = false,
|
||||
error,
|
||||
isDisabled = false,
|
||||
className = "",
|
||||
onNumberValueChange,
|
||||
@@ -43,34 +43,41 @@ export const NumberValueInput = observer((props: TNumberValueInputProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={`number_input_${propertyId}`}
|
||||
type="number"
|
||||
value={data?.[0]}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
"w-full px-2 resize-none text-sm bg-custom-background-100 rounded border-0 border-custom-border-200",
|
||||
{
|
||||
"border-[0.5px]": variant === "create",
|
||||
"cursor-not-allowed": isDisabled,
|
||||
},
|
||||
className
|
||||
<>
|
||||
<Input
|
||||
id={`number_input_${propertyDetail.id}`}
|
||||
type="number"
|
||||
value={data?.[0]}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
"w-full px-2 resize-none text-sm bg-custom-background-100 rounded border-0",
|
||||
{
|
||||
"border-[0.5px]": variant === "create" || Boolean(error),
|
||||
"cursor-not-allowed": isDisabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
// add data-delay-outside-click to delay the dropdown from closing so that data can be synced
|
||||
document.body?.setAttribute("data-delay-outside-click", "true");
|
||||
}}
|
||||
onWheel={(e) => e.currentTarget.blur()}
|
||||
onBlur={() => {
|
||||
if (!isEqual(value, data)) {
|
||||
onNumberValueChange(data);
|
||||
}
|
||||
document.body?.removeAttribute("data-delay-outside-click");
|
||||
}}
|
||||
placeholder="Enter a number"
|
||||
inputSize={numberInputSize}
|
||||
disabled={isDisabled}
|
||||
hasError={Boolean(error)}
|
||||
/>
|
||||
{Boolean(error) && (
|
||||
<span className="text-xs font-medium text-red-500">
|
||||
{error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error}
|
||||
</span>
|
||||
)}
|
||||
onClick={() => {
|
||||
// add data-delay-outside-click to delay the dropdown from closing so that data can be synced
|
||||
document.body?.setAttribute("data-delay-outside-click", "true");
|
||||
}}
|
||||
onWheel={(e) => e.currentTarget.blur()}
|
||||
onBlur={() => {
|
||||
if (!isEqual(value, data)) {
|
||||
onNumberValueChange(data);
|
||||
}
|
||||
document.body?.removeAttribute("data-delay-outside-click");
|
||||
}}
|
||||
placeholder="Enter a number"
|
||||
inputSize={numberInputSize}
|
||||
required={isRequired}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,27 +8,32 @@ import { cn } from "@/helpers/common.helper";
|
||||
// plane web hooks
|
||||
import { useIssueProperty } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { TPropertyValueVariant } from "@/plane-web/types";
|
||||
import { EIssuePropertyType, EIssuePropertyValueError, TIssueProperty, TPropertyValueVariant } from "@/plane-web/types";
|
||||
|
||||
type TOptionValueSelectProps = {
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType.OPTION>>;
|
||||
value: string[];
|
||||
issueTypeId: string;
|
||||
issuePropertyId: string;
|
||||
variant: TPropertyValueVariant;
|
||||
error?: EIssuePropertyValueError;
|
||||
isMultiSelect?: boolean;
|
||||
isRequired?: boolean; // TODO: remove if not required.
|
||||
isDisabled?: boolean;
|
||||
buttonClassName?: string;
|
||||
onOptionValueChange: (value: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const OptionValueSelect = observer((props: TOptionValueSelectProps) => {
|
||||
const {
|
||||
propertyDetail,
|
||||
value,
|
||||
issueTypeId,
|
||||
issuePropertyId,
|
||||
variant,
|
||||
error,
|
||||
isMultiSelect = false,
|
||||
isDisabled = false,
|
||||
buttonClassName = "",
|
||||
onOptionValueChange,
|
||||
} = props;
|
||||
// states
|
||||
@@ -72,11 +77,16 @@ export const OptionValueSelect = observer((props: TOptionValueSelectProps) => {
|
||||
className: "group w-full h-full flex",
|
||||
optionsClassName: "w-48",
|
||||
chevronClassName: "h-3.5 w-3.5 hidden group-hover:inline",
|
||||
buttonClassName: cn("rounded text-sm bg-custom-background-100 border-custom-border-200", {
|
||||
"border-[0.5px]": variant === "create",
|
||||
"border-0": variant === "update",
|
||||
"text-custom-text-400": !data.length,
|
||||
}),
|
||||
buttonClassName: cn(
|
||||
"rounded text-sm bg-custom-background-100 border-custom-border-200",
|
||||
{
|
||||
"border-[0.5px]": variant === "create" || Boolean(error),
|
||||
"border-0": variant === "update",
|
||||
"text-custom-text-400": !data.length,
|
||||
"border-red-500": Boolean(error),
|
||||
},
|
||||
buttonClassName
|
||||
),
|
||||
disabled: isDisabled,
|
||||
};
|
||||
|
||||
@@ -113,6 +123,11 @@ export const OptionValueSelect = observer((props: TOptionValueSelectProps) => {
|
||||
multiple={false}
|
||||
/>
|
||||
)}
|
||||
{Boolean(error) && (
|
||||
<span className="text-xs font-medium text-red-500">
|
||||
{error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Input, TextArea } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web types
|
||||
import { TPropertyValueVariant, TTextAttributeDisplayOptions } from "@/plane-web/types";
|
||||
import {
|
||||
EIssuePropertyType,
|
||||
EIssuePropertyValueError,
|
||||
TIssueProperty,
|
||||
TPropertyValueVariant,
|
||||
TTextAttributeDisplayOptions,
|
||||
} from "@/plane-web/types";
|
||||
|
||||
type TTextValueInputProps = {
|
||||
propertyId: string | undefined;
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType.TEXT>>;
|
||||
value: string[];
|
||||
variant: TPropertyValueVariant;
|
||||
display_format: TTextAttributeDisplayOptions;
|
||||
readOnlyData?: string;
|
||||
isRequired?: boolean;
|
||||
error?: EIssuePropertyValueError;
|
||||
className?: string;
|
||||
onTextValueChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export const TextValueInput = observer((props: TTextValueInputProps) => {
|
||||
const {
|
||||
propertyId,
|
||||
propertyDetail,
|
||||
value,
|
||||
variant,
|
||||
display_format = "single-line",
|
||||
readOnlyData,
|
||||
isRequired = false,
|
||||
error,
|
||||
className = "",
|
||||
onTextValueChange,
|
||||
} = props;
|
||||
@@ -48,9 +54,9 @@ export const TextValueInput = observer((props: TTextValueInputProps) => {
|
||||
};
|
||||
|
||||
const commonClassNames = cn(
|
||||
"w-full px-2 resize-none text-sm bg-custom-background-100 rounded border-0 border-custom-border-200",
|
||||
"w-full px-2 resize-none text-sm bg-custom-background-100 rounded border-0",
|
||||
{
|
||||
"border-[0.5px]": variant === "create",
|
||||
"border-[0.5px]": variant === "create" || Boolean(error),
|
||||
},
|
||||
className
|
||||
);
|
||||
@@ -58,61 +64,74 @@ export const TextValueInput = observer((props: TTextValueInputProps) => {
|
||||
switch (display_format) {
|
||||
case "single-line":
|
||||
return (
|
||||
<Input
|
||||
id={`single_line_text_${propertyId}`}
|
||||
type="text"
|
||||
value={data?.[0]}
|
||||
onChange={handleInputChange}
|
||||
className={commonClassNames}
|
||||
onClick={() => {
|
||||
// add data-delay-outside-click to delay the dropdown from closing so that data can be synced
|
||||
document.body?.setAttribute("data-delay-outside-click", "true");
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isEqual(value, data)) {
|
||||
onTextValueChange(data);
|
||||
}
|
||||
document.body?.removeAttribute("data-delay-outside-click");
|
||||
}}
|
||||
placeholder="Enter some text"
|
||||
required={isRequired}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
id={`single_line_text_${propertyDetail.id}`}
|
||||
type="text"
|
||||
value={data?.[0]}
|
||||
onChange={handleInputChange}
|
||||
className={commonClassNames}
|
||||
onClick={() => {
|
||||
// add data-delay-outside-click to delay the dropdown from closing so that data can be synced
|
||||
document.body?.setAttribute("data-delay-outside-click", "true");
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isEqual(value, data)) {
|
||||
onTextValueChange(data);
|
||||
}
|
||||
document.body?.removeAttribute("data-delay-outside-click");
|
||||
}}
|
||||
placeholder="Enter some text"
|
||||
hasError={Boolean(error)}
|
||||
/>
|
||||
{Boolean(error) && (
|
||||
<span className="text-xs font-medium text-red-500">
|
||||
{error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "multi-line":
|
||||
return (
|
||||
<TextArea
|
||||
id={`multi_line_text_${propertyId}`}
|
||||
value={data?.[0]}
|
||||
onChange={handleTextAreaChange}
|
||||
className={cn(
|
||||
commonClassNames,
|
||||
"min-h-10 max-h-52 vertical-scrollbar scrollbar-xs",
|
||||
variant === "create" && "min-h-28"
|
||||
<>
|
||||
<TextArea
|
||||
id={`multi_line_text_${propertyDetail.id}`}
|
||||
value={data?.[0]}
|
||||
onChange={handleTextAreaChange}
|
||||
className={cn(
|
||||
commonClassNames,
|
||||
"min-h-10 max-h-52 vertical-scrollbar scrollbar-xs",
|
||||
variant === "create" && "min-h-28"
|
||||
)}
|
||||
onClick={() => {
|
||||
// add data-delay-outside-click to delay the dropdown from closing so that data can be synced
|
||||
document.body?.setAttribute("data-delay-outside-click", "true");
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isEqual(value, data)) {
|
||||
onTextValueChange(data);
|
||||
}
|
||||
document.body?.removeAttribute("data-delay-outside-click");
|
||||
}}
|
||||
placeholder="Describe..."
|
||||
hasError={Boolean(error)}
|
||||
/>
|
||||
{Boolean(error) && (
|
||||
<span className="text-xs font-medium text-red-500">
|
||||
{error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error}
|
||||
</span>
|
||||
)}
|
||||
onClick={() => {
|
||||
// add data-delay-outside-click to delay the dropdown from closing so that data can be synced
|
||||
document.body?.setAttribute("data-delay-outside-click", "true");
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isEqual(value, data)) {
|
||||
onTextValueChange(data);
|
||||
}
|
||||
document.body?.removeAttribute("data-delay-outside-click");
|
||||
}}
|
||||
placeholder="Describe..."
|
||||
required={isRequired}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "readonly":
|
||||
return (
|
||||
<TextArea
|
||||
id={`readonly_text_${propertyId}`}
|
||||
id={`readonly_text_${propertyDetail.id}`}
|
||||
value={readOnlyData ?? "--"}
|
||||
className={cn(
|
||||
commonClassNames,
|
||||
"bg-custom-background-80 text-custom-text-100 border-custom-border-400 cursor-default"
|
||||
)}
|
||||
required={isRequired}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IssuePropertyValuesService } from "@/plane-web/services/issue-types";
|
||||
// plane web store
|
||||
import { IIssueProperty } from "@/plane-web/store/issue-types";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssuePropertyValues } from "@/plane-web/types";
|
||||
import { EIssuePropertyType, TIssuePropertyValueErrors, TIssuePropertyValues } from "@/plane-web/types";
|
||||
// local components
|
||||
import { IssueAdditionalPropertyValues } from "./root";
|
||||
|
||||
@@ -19,6 +19,7 @@ type TIssueAdditionalPropertyValuesCreateProps = {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
issuePropertyDefaultValues: TIssuePropertyValues;
|
||||
issuePropertyValueErrors?: TIssuePropertyValueErrors;
|
||||
setIssuePropertyValues: React.Dispatch<React.SetStateAction<TIssuePropertyValues>>;
|
||||
};
|
||||
|
||||
@@ -33,69 +34,73 @@ const getPropertiesDefaultValues = (properties: IIssueProperty<EIssuePropertyTyp
|
||||
|
||||
const issuePropertyValuesService = new IssuePropertyValuesService();
|
||||
|
||||
export const IssueAdditionalPropertyValuesCreate: React.FC<TIssueAdditionalPropertyValuesCreateProps> = observer((props) => {
|
||||
const {
|
||||
issueId,
|
||||
issueTypeId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
issuePropertyDefaultValues,
|
||||
setIssuePropertyValues: handleIssuePropertyValueUpdate,
|
||||
} = props;
|
||||
// states
|
||||
const [issuePropertyValues, setIssuePropertyValues] = React.useState({});
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
// store hooks
|
||||
const { getAllTypesPropertiesOptions } = useIssueTypes();
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const issueTypeDetail = issueType?.asJSON;
|
||||
const activeProperties = issueType?.activeProperties;
|
||||
export const IssueAdditionalPropertyValuesCreate: React.FC<TIssueAdditionalPropertyValuesCreateProps> = observer(
|
||||
(props) => {
|
||||
const {
|
||||
issueId,
|
||||
issueTypeId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
issuePropertyDefaultValues,
|
||||
issuePropertyValueErrors,
|
||||
setIssuePropertyValues: handleIssuePropertyValueUpdate,
|
||||
} = props;
|
||||
// states
|
||||
const [issuePropertyValues, setIssuePropertyValues] = React.useState({});
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
// store hooks
|
||||
const { fetchAllTypesPropertiesOptions } = useIssueTypes();
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const issueTypeDetail = issueType?.asJSON;
|
||||
const activeProperties = issueType?.activeProperties;
|
||||
|
||||
// fetch issue custom property values
|
||||
useEffect(() => {
|
||||
async function fetchIssuePropertyValues(issueId: string) {
|
||||
setIsLoading(true);
|
||||
await issuePropertyValuesService
|
||||
.fetchAll(workspaceSlug, projectId, issueId)
|
||||
.then((data) => {
|
||||
setIssuePropertyValues(data);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
// fetch issue custom property values
|
||||
useEffect(() => {
|
||||
async function fetchIssuePropertyValues(issueId: string) {
|
||||
setIsLoading(true);
|
||||
await issuePropertyValuesService
|
||||
.fetchAll(workspaceSlug, projectId, issueId)
|
||||
.then((data) => {
|
||||
setIssuePropertyValues(data);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
if (issueId) fetchIssuePropertyValues(issueId);
|
||||
}, [fetchAllTypesPropertiesOptions, issueId, projectId, workspaceSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProperties?.length) {
|
||||
handleIssuePropertyValueUpdate({
|
||||
...getPropertiesDefaultValues(activeProperties),
|
||||
...issuePropertyValues,
|
||||
});
|
||||
}
|
||||
if (issueId) fetchIssuePropertyValues(issueId);
|
||||
}, [getAllTypesPropertiesOptions, issueId, projectId, workspaceSlug]);
|
||||
}
|
||||
}, [activeProperties, handleIssuePropertyValueUpdate, issuePropertyValues]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProperties?.length) {
|
||||
handleIssuePropertyValueUpdate({
|
||||
...getPropertiesDefaultValues(activeProperties),
|
||||
...issuePropertyValues,
|
||||
});
|
||||
}
|
||||
}, [activeProperties, handleIssuePropertyValueUpdate, issuePropertyValues]);
|
||||
const handlePropertyValueChange = (propertyId: string, value: string[]) => {
|
||||
handleIssuePropertyValueUpdate((prev) => ({
|
||||
...prev,
|
||||
[propertyId]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePropertyValueChange = (propertyId: string, value: string[]) => {
|
||||
handleIssuePropertyValueUpdate((prev) => ({
|
||||
...prev,
|
||||
[propertyId]: value,
|
||||
}));
|
||||
};
|
||||
if (!issueTypeDetail || !activeProperties?.length) return null;
|
||||
|
||||
if (!issueTypeDetail || !activeProperties?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<IssueAdditionalPropertyValues
|
||||
issueTypeId={issueTypeId}
|
||||
projectId={projectId}
|
||||
issuePropertyValues={issuePropertyDefaultValues}
|
||||
variant="create"
|
||||
isPropertyValuesLoading={isLoading}
|
||||
handlePropertyValueChange={handlePropertyValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<IssueAdditionalPropertyValues
|
||||
issueTypeId={issueTypeId}
|
||||
projectId={projectId}
|
||||
issuePropertyValues={issuePropertyDefaultValues}
|
||||
issuePropertyValueErrors={issuePropertyValueErrors}
|
||||
variant="create"
|
||||
isPropertyValuesLoading={isLoading}
|
||||
handlePropertyValueChange={handlePropertyValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,11 +5,17 @@ import { PropertyValueSelect } from "@/plane-web/components/issue-types/values";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssuePropertyValues, TPropertyValueVariant } from "@/plane-web/types";
|
||||
import {
|
||||
EIssuePropertyType,
|
||||
TIssuePropertyValueErrors,
|
||||
TIssuePropertyValues,
|
||||
TPropertyValueVariant,
|
||||
} from "@/plane-web/types";
|
||||
|
||||
type TIssueAdditionalPropertyValuesProps = {
|
||||
issueTypeId: string;
|
||||
issuePropertyValues: TIssuePropertyValues;
|
||||
issuePropertyValueErrors?: TIssuePropertyValueErrors;
|
||||
projectId: string;
|
||||
variant: TPropertyValueVariant;
|
||||
isPropertyValuesLoading?: boolean;
|
||||
@@ -20,6 +26,7 @@ export const IssueAdditionalPropertyValues: React.FC<TIssueAdditionalPropertyVal
|
||||
const {
|
||||
issueTypeId,
|
||||
issuePropertyValues,
|
||||
issuePropertyValueErrors,
|
||||
projectId,
|
||||
variant,
|
||||
isPropertyValuesLoading = false,
|
||||
@@ -65,6 +72,7 @@ export const IssueAdditionalPropertyValues: React.FC<TIssueAdditionalPropertyVal
|
||||
<PropertyValueSelect
|
||||
propertyDetail={property}
|
||||
propertyValue={issuePropertyValues[property.id] ?? []}
|
||||
propertyValueError={issuePropertyValueErrors?.[property.id] ?? undefined}
|
||||
projectId={projectId}
|
||||
variant={variant}
|
||||
isPropertyValuesLoading={isPropertyValuesLoading}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { IssueAdditionalPropertyValues } from "@/plane-web/components/issue-type
|
||||
import { useIssueType, useIssueTypes } from "@/plane-web/hooks/store";
|
||||
// plane web services
|
||||
import { IssuePropertyValuesService } from "@/plane-web/services/issue-types";
|
||||
// plane web types
|
||||
import { TIssuePropertyValues } from "@/plane-web/types";
|
||||
|
||||
type TIssueAdditionalPropertyValuesUpdateProps = {
|
||||
issueId: string;
|
||||
@@ -23,10 +25,10 @@ export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPrope
|
||||
(props) => {
|
||||
const { issueId, issueTypeId, projectId, workspaceSlug } = props;
|
||||
// states
|
||||
const [issuePropertyValues, setIssuePropertyValues] = React.useState({});
|
||||
const [issuePropertyValues, setIssuePropertyValues] = React.useState<TIssuePropertyValues>({});
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
// store hooks
|
||||
const { getProjectIssueTypeLoader, getAllTypesPropertiesOptions } = useIssueTypes();
|
||||
const { getProjectIssueTypeLoader, fetchAllTypesPropertiesOptions } = useIssueTypes();
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const issueTypeDetails = issueType?.asJSON;
|
||||
@@ -38,7 +40,7 @@ export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPrope
|
||||
async function fetchIssuePropertyValues() {
|
||||
setIsLoading(true);
|
||||
// This is required when accessing the peek overview from workspace level.
|
||||
await getAllTypesPropertiesOptions(workspaceSlug, projectId);
|
||||
await fetchAllTypesPropertiesOptions(workspaceSlug, projectId);
|
||||
await issuePropertyValuesService
|
||||
.fetchAll(workspaceSlug, projectId, issueId)
|
||||
.then((data) => {
|
||||
@@ -49,17 +51,17 @@ export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPrope
|
||||
});
|
||||
}
|
||||
fetchIssuePropertyValues();
|
||||
}, [getAllTypesPropertiesOptions, issueId, projectId, workspaceSlug]);
|
||||
}, [fetchAllTypesPropertiesOptions, issueId, projectId, workspaceSlug]);
|
||||
|
||||
const handlePropertyValueChange = async (propertyId: string, value: string[]) => {
|
||||
const beforeUpdateValue = issuePropertyValues[propertyId];
|
||||
setIssuePropertyValues((prev) => ({
|
||||
...prev,
|
||||
[propertyId]: value,
|
||||
}));
|
||||
// update the property value
|
||||
await issuePropertyValuesService
|
||||
.createUpdate(workspaceSlug, projectId, issueId, {
|
||||
[propertyId]: value,
|
||||
})
|
||||
.update(workspaceSlug, projectId, issueId, propertyId, value)
|
||||
.then(() => {
|
||||
// TODO: remove
|
||||
setToast({
|
||||
@@ -68,11 +70,16 @@ export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPrope
|
||||
message: "Property update successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
// revert the value if update fails
|
||||
setIssuePropertyValues((prev) => ({
|
||||
...prev,
|
||||
[propertyId]: beforeUpdateValue,
|
||||
}));
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Property could not be update. Please try again.",
|
||||
message: error?.error ?? "Property could not be update. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -94,7 +101,7 @@ export const IssueAdditionalPropertyValuesUpdate: React.FC<TIssueAdditionalPrope
|
||||
<IssueAdditionalPropertyValues
|
||||
issueTypeId={issueTypeId}
|
||||
projectId={projectId}
|
||||
issuePropertyValues={issuePropertyValues ?? {}}
|
||||
issuePropertyValues={issuePropertyValues}
|
||||
variant="update"
|
||||
isPropertyValuesLoading={isLoading}
|
||||
handlePropertyValueChange={handlePropertyValueChange}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getIssuePropertyTypeKey } from "@/plane-web/helpers/issue-properties.he
|
||||
// plane web types
|
||||
import {
|
||||
EIssuePropertyType,
|
||||
EIssuePropertyValueError,
|
||||
TDateAttributeDisplayOptions,
|
||||
TIssueProperty,
|
||||
TIssuePropertyTypeKeys,
|
||||
@@ -30,14 +31,23 @@ import {
|
||||
type TPropertyValueSelectProps = {
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType>>;
|
||||
propertyValue: string[];
|
||||
propertyValueError?: EIssuePropertyValueError;
|
||||
projectId: string;
|
||||
variant: TPropertyValueVariant
|
||||
variant: TPropertyValueVariant;
|
||||
isPropertyValuesLoading: boolean;
|
||||
onPropertyValueChange: (value: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PropertyValueSelect = observer((props: TPropertyValueSelectProps) => {
|
||||
const { propertyDetail, propertyValue, projectId, variant, isPropertyValuesLoading, onPropertyValueChange } = props;
|
||||
const {
|
||||
propertyDetail,
|
||||
propertyValue,
|
||||
propertyValueError,
|
||||
projectId,
|
||||
variant,
|
||||
isPropertyValuesLoading,
|
||||
onPropertyValueChange,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { peekIssue } = useIssueDetail();
|
||||
// derived values
|
||||
@@ -55,6 +65,7 @@ export const PropertyValueSelect = observer((props: TPropertyValueSelectProps) =
|
||||
<Tooltip tooltipContent={propertyDetail?.description} position="top-left" disabled={!propertyDetail?.description}>
|
||||
<span className={cn("w-full cursor-default truncate", variant === "create" && "text-sm text-custom-text-200")}>
|
||||
{propertyDetail?.display_name}
|
||||
{propertyDetail?.is_required && <span className="px-0.5 text-red-500">*</span>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -62,71 +73,79 @@ export const PropertyValueSelect = observer((props: TPropertyValueSelectProps) =
|
||||
|
||||
const ISSUE_PROPERTY_ATTRIBUTE_DETAILS: Partial<Record<TIssuePropertyTypeKeys, JSX.Element>> = {
|
||||
TEXT: (
|
||||
<div className="w-full min-h-8">
|
||||
<>
|
||||
<TextValueInput
|
||||
propertyId={propertyDetail?.id}
|
||||
propertyDetail={propertyDetail as TIssueProperty<EIssuePropertyType.TEXT>}
|
||||
value={propertyValue}
|
||||
error={propertyValueError}
|
||||
variant={variant}
|
||||
display_format={propertyDetail?.settings?.display_format as TTextAttributeDisplayOptions}
|
||||
readOnlyData={propertyDetail?.default_value?.[0]}
|
||||
isRequired={propertyDetail?.is_required}
|
||||
className="min-h-8"
|
||||
onTextValueChange={onPropertyValueChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
DECIMAL: (
|
||||
<div className="w-full h-8">
|
||||
<>
|
||||
<NumberValueInput
|
||||
propertyId={propertyDetail?.id}
|
||||
propertyDetail={propertyDetail as TIssueProperty<EIssuePropertyType.DECIMAL>}
|
||||
value={propertyValue}
|
||||
error={propertyValueError}
|
||||
variant={variant}
|
||||
isRequired={propertyDetail?.is_required}
|
||||
className="h-8"
|
||||
onNumberValueChange={onPropertyValueChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
OPTION: (
|
||||
<div className="w-full h-8">
|
||||
<>
|
||||
{propertyDetail?.id && propertyDetail?.issue_type && (
|
||||
<OptionValueSelect
|
||||
propertyDetail={propertyDetail as TIssueProperty<EIssuePropertyType.OPTION>}
|
||||
value={propertyValue}
|
||||
error={propertyValueError}
|
||||
issueTypeId={propertyDetail.issue_type}
|
||||
issuePropertyId={propertyDetail.id}
|
||||
variant={variant}
|
||||
isMultiSelect={propertyDetail.is_multi}
|
||||
isRequired={propertyDetail.is_required}
|
||||
buttonClassName="h-8"
|
||||
onOptionValueChange={onPropertyValueChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
BOOLEAN: (
|
||||
<div className={cn("w-full flex items-center h-8", variant === "update" && "px-1.5")}>
|
||||
<div className={cn("w-full h-8 flex items-center", variant === "update" && "px-1.5")}>
|
||||
<BooleanInput value={propertyValue} onBooleanValueChange={onPropertyValueChange} />
|
||||
</div>
|
||||
),
|
||||
DATETIME: (
|
||||
<div className="w-full h-8">
|
||||
<>
|
||||
<DateValueSelect
|
||||
propertyDetail={propertyDetail as TIssueProperty<EIssuePropertyType.DATETIME>}
|
||||
value={propertyValue}
|
||||
error={propertyValueError}
|
||||
variant={variant}
|
||||
displayFormat={propertyDetail?.settings?.display_format as TDateAttributeDisplayOptions}
|
||||
isRequired={propertyDetail?.is_required}
|
||||
buttonClassName="h-8"
|
||||
onDateValueChange={onPropertyValueChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
RELATION_USER: (
|
||||
<div className="w-full h-8">
|
||||
<>
|
||||
<MemberValueSelect
|
||||
propertyDetail={propertyDetail as TIssueProperty<EIssuePropertyType.RELATION>}
|
||||
value={propertyValue}
|
||||
error={propertyValueError}
|
||||
projectId={projectId}
|
||||
variant={variant}
|
||||
isMultiSelect={propertyDetail?.is_multi}
|
||||
isRequired={propertyDetail?.is_required}
|
||||
buttonClassName="h-8"
|
||||
onMemberValueChange={onPropertyValueChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -135,7 +154,7 @@ export const PropertyValueSelect = observer((props: TPropertyValueSelectProps) =
|
||||
propertyTypeKey === "TEXT" && propertyDetail?.settings?.display_format === "multi-line";
|
||||
|
||||
const CurrentPropertyAttribute = isPropertyValuesLoading ? (
|
||||
<Loader className="w-full h-8">
|
||||
<Loader className="w-full min-h-8">
|
||||
<Loader.Item height="32px" />
|
||||
</Loader>
|
||||
) : (
|
||||
@@ -148,31 +167,38 @@ export const PropertyValueSelect = observer((props: TPropertyValueSelectProps) =
|
||||
<>
|
||||
{variant === "create" && (
|
||||
<div
|
||||
className={cn("w-full flex items-center justify-center gap-1.5 py-1", isPropertyMultiLineText && "flex-col")}
|
||||
className={cn("w-full flex items-start justify-center gap-1.5 py-1", isPropertyMultiLineText && "flex-col")}
|
||||
>
|
||||
<div className={cn("w-1/2 flex flex-shrink-0 gap-1.5 items-center", isPropertyMultiLineText && "w-full")}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-1/3 md:w-1/2 h-8 flex flex-shrink-0 gap-1.5 items-center",
|
||||
isPropertyMultiLineText && "w-full"
|
||||
)}
|
||||
>
|
||||
<IssuePropertyDetail />
|
||||
</div>
|
||||
<div className="w-full h-full flex flex-col items-center">{CurrentPropertyAttribute}</div>
|
||||
<div className="w-full h-full min-h-8 flex flex-col gap-0.5">{CurrentPropertyAttribute}</div>
|
||||
</div>
|
||||
)}
|
||||
{variant === "update" && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-x-3 gap-y-1 min-h-8",
|
||||
"flex w-full items-start gap-x-3 gap-y-1 min-h-8",
|
||||
isPropertyMultiLineText && "flex-col items-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 flex-shrink-0 text-sm text-custom-text-300",
|
||||
"flex items-center h-8 gap-1 flex-shrink-0 text-sm text-custom-text-300",
|
||||
isPeekOverview ? "w-1/4" : "w-2/5",
|
||||
isPropertyMultiLineText && "w-full"
|
||||
)}
|
||||
>
|
||||
<IssuePropertyDetail />
|
||||
</div>
|
||||
<div className="relative h-full min-h-8 w-full flex-grow flex items-center">{CurrentPropertyAttribute}</div>
|
||||
<div className="relative h-full min-h-8 w-full flex-grow flex flex-col gap-0.5">
|
||||
{CurrentPropertyAttribute}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { IssueIdentifier as BaseIssueIdentifier } from "ce/components/issues/issue-details/issue-identifier";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { LayersIcon, Logo } from "@plane/ui";
|
||||
import { LayersIcon, Loader, Logo } from "@plane/ui";
|
||||
// hooks
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
@@ -9,12 +12,15 @@ import { useIssueType } from "@/plane-web/hooks/store";
|
||||
type TIssueIdentifierProps = {
|
||||
issueId: string;
|
||||
projectId: string;
|
||||
iconContainerClassName?: string;
|
||||
iconSize?: number;
|
||||
textContainerClassName?: string;
|
||||
};
|
||||
|
||||
export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props) => {
|
||||
const { issueId, projectId } = props;
|
||||
const { issueId, projectId, iconContainerClassName = "", iconSize = 12, textContainerClassName = "" } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectById, getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
@@ -24,17 +30,49 @@ export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props)
|
||||
// plane web hooks
|
||||
const issueType = useIssueType(issue?.type_id);
|
||||
|
||||
if (!projectDetails?.is_issue_type_enabled) {
|
||||
return <BaseIssueIdentifier issueId={issueId} projectId={projectId} />;
|
||||
}
|
||||
|
||||
if (!issueType) {
|
||||
return (
|
||||
<Loader className="flex w-full h-5">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
if (issueType?.is_default) {
|
||||
return (
|
||||
<div className="flex flex-shrink-0 items-center space-x-2">
|
||||
<div
|
||||
className={cn("flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-[#6695FF]", iconContainerClassName)}
|
||||
>
|
||||
<LayersIcon width={iconSize} height={iconSize} className="text-white" />
|
||||
</div>
|
||||
<span className={cn("text-base font-medium text-custom-text-300", textContainerClassName)}>
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-custom-background-80">
|
||||
<div className="flex flex-shrink-0 items-center space-x-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-custom-background-80",
|
||||
iconContainerClassName
|
||||
)}
|
||||
>
|
||||
{issueType?.logo_props?.in_use ? (
|
||||
<Logo logo={issueType.logo_props} size={12} type="lucide" />
|
||||
<Logo logo={issueType.logo_props} size={iconSize} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-3 w-3 text-custom-text-300" />
|
||||
<LayersIcon width={iconSize} height={iconSize} className="text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-base font-medium text-custom-text-300">
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
<span className={cn("text-base font-medium text-custom-text-300", textContainerClassName)}>
|
||||
{getProjectIdentifierById(issue?.project_id)}-{issue?.sequence_id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Loader } from "@plane/ui";
|
||||
import { useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueAdditionalPropertyValuesCreate } from "@/plane-web/components/issue-types/";
|
||||
// plane web types
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
import { TIssuePropertyValues } from "@/plane-web/types";
|
||||
// plane web types
|
||||
import { TIssuePropertyValueErrors, TIssuePropertyValues } from "@/plane-web/types";
|
||||
|
||||
type TIssueAdditionalPropertiesProps = {
|
||||
issueId: string | undefined;
|
||||
@@ -17,14 +18,23 @@ type TIssueAdditionalPropertiesProps = {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
issuePropertyValues?: TIssuePropertyValues;
|
||||
issuePropertyValueErrors?: TIssuePropertyValueErrors;
|
||||
setIssuePropertyValues?: React.Dispatch<React.SetStateAction<TIssuePropertyValues>>;
|
||||
};
|
||||
|
||||
export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps> = observer((props) => {
|
||||
const { issueId, issueTypeId, projectId, workspaceSlug, issuePropertyValues, setIssuePropertyValues } = props;
|
||||
const {
|
||||
issueId,
|
||||
issueTypeId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
issuePropertyValues,
|
||||
issuePropertyValueErrors,
|
||||
setIssuePropertyValues,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectIssueTypeLoader, getAllTypesPropertiesOptions } = useIssueTypes();
|
||||
const { getProjectIssueTypeLoader, fetchAllTypesPropertiesOptions } = useIssueTypes();
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
const issueTypeLoader = getProjectIssueTypeLoader(projectId);
|
||||
@@ -32,7 +42,7 @@ export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps
|
||||
// This has to be on root level because of global level issue update, where we haven't fetch the details yet.
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
getAllTypesPropertiesOptions(workspaceSlug?.toString(), projectId);
|
||||
fetchAllTypesPropertiesOptions(workspaceSlug?.toString(), projectId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
@@ -45,10 +55,10 @@ export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps
|
||||
<>
|
||||
{!issueTypeId || issueTypeLoader === "init-loader" ? (
|
||||
<Loader className="space-y-4 py-2">
|
||||
<Loader.Item height="30px" width="50%" />
|
||||
<Loader.Item height="30px" width="50%" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" width="50%" />
|
||||
<Loader.Item height="30px" width="50%" />
|
||||
</Loader>
|
||||
) : (
|
||||
<IssueAdditionalPropertyValuesCreate
|
||||
@@ -57,6 +67,7 @@ export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
issuePropertyDefaultValues={issuePropertyValues}
|
||||
issuePropertyValueErrors={issuePropertyValueErrors}
|
||||
setIssuePropertyValues={setIssuePropertyValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EditorRefApi } from "@plane/editor";
|
||||
import type { TIssue, ISearchIssueResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
// components
|
||||
import {
|
||||
IssueDefaultProperties,
|
||||
IssueDescriptionEditor,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IssueTitleInput,
|
||||
} from "@/components/issues/issue-modal/components";
|
||||
import { CreateLabelModal } from "@/components/labels";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/issue-modal.helper";
|
||||
import { getChangedIssuefields } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
@@ -26,9 +27,10 @@ import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
// plane web components
|
||||
import { IssueAdditionalProperties, IssueTypeSelect } from "@/plane-web/components/issues/issue-modal";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
// services
|
||||
import { TIssuePropertyValues } from "@/plane-web/types";
|
||||
// local components
|
||||
import { TIssuePropertyValueErrors, TIssuePropertyValues } from "@/plane-web/types";
|
||||
|
||||
const defaultValues: Partial<TIssue> = {
|
||||
project_id: "",
|
||||
@@ -79,6 +81,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
const [issuePropertyValueErrors, setIssuePropertyValueErrors] = useState<TIssuePropertyValueErrors>({});
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -86,6 +89,8 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId: routeProjectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// plane web hooks
|
||||
const { getIssueTypeProperties } = useIssueTypes();
|
||||
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
@@ -107,6 +112,35 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
|
||||
const projectId = watch("project_id");
|
||||
|
||||
const handlePropertyValuesValidation = () => {
|
||||
const issueTypeId = watch("type_id");
|
||||
// if no issue type id or no issue property values, return
|
||||
if (!issueTypeId || !issuePropertyValues || Object.keys(issuePropertyValues).length === 0) return true;
|
||||
// all properties for the issue type
|
||||
const properties = getIssueTypeProperties(issueTypeId);
|
||||
// filter all active & required propertyIds
|
||||
const activeRequiredPropertyIds = properties
|
||||
?.filter((property) => property.is_active && property.is_required)
|
||||
.map((property) => property.id);
|
||||
// filter missing required property based on property values
|
||||
const missingRequiredPropertyIds = activeRequiredPropertyIds?.filter(
|
||||
(propertyId) =>
|
||||
propertyId &&
|
||||
(!issuePropertyValues[propertyId] ||
|
||||
!issuePropertyValues[propertyId].length ||
|
||||
issuePropertyValues[propertyId][0].trim() === "")
|
||||
);
|
||||
// set error state
|
||||
setIssuePropertyValueErrors(
|
||||
missingRequiredPropertyIds?.reduce((acc, propertyId) => {
|
||||
if (propertyId) acc[propertyId] = "REQUIRED";
|
||||
return acc;
|
||||
}, {} as TIssuePropertyValueErrors)
|
||||
);
|
||||
// return true if no missing required properties values
|
||||
return missingRequiredPropertyIds.length === 0;
|
||||
};
|
||||
|
||||
//reset few fields on projectId change
|
||||
useEffect(() => {
|
||||
if (isDirty) {
|
||||
@@ -139,6 +173,9 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// check for required properties validation
|
||||
if (!handlePropertyValuesValidation()) return;
|
||||
|
||||
const submitData = !data?.id
|
||||
? formData
|
||||
: {
|
||||
@@ -281,6 +318,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
issuePropertyValues={issuePropertyValues}
|
||||
issuePropertyValueErrors={issuePropertyValueErrors}
|
||||
setIssuePropertyValues={setIssuePropertyValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -24,11 +24,11 @@ export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = observer((props)
|
||||
const { control, setValue, data, issueTypeId, projectId } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectActiveIssueTypes, getProjectDefaultIssueTypeId } = useIssueTypes();
|
||||
const { getProjectActiveIssueTypes, getProjectDefaultIssueType } = useIssueTypes();
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
const projectIssueTypes = getProjectActiveIssueTypes(projectId);
|
||||
const defaultIssueTypeId = getProjectDefaultIssueTypeId(projectId);
|
||||
const defaultIssueType = getProjectDefaultIssueType(projectId);
|
||||
|
||||
// Update the issue type id when the project id changes
|
||||
useEffect(() => {
|
||||
@@ -43,15 +43,15 @@ export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = observer((props)
|
||||
|
||||
// if data is not present, set active type id to the default type id of the project
|
||||
if (projectId && projectIssueTypes) {
|
||||
if (defaultIssueTypeId) {
|
||||
setValue("type_id", defaultIssueTypeId, { shouldValidate: true });
|
||||
if (defaultIssueType?.id) {
|
||||
setValue("type_id", defaultIssueType.id, { shouldValidate: true });
|
||||
} else {
|
||||
const issueTypeId = Object.keys(projectIssueTypes)[0];
|
||||
if (issueTypeId) setValue("type_id", issueTypeId, { shouldValidate: true });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, issueTypeId, projectId, projectIssueTypes, defaultIssueTypeId]);
|
||||
}, [data, issueTypeId, projectId, projectIssueTypes, defaultIssueType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,9 +16,11 @@ import { useEventTracker, useCycle, useIssues, useModule, useProject, useIssueDe
|
||||
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// components
|
||||
// plane web services
|
||||
import { IssuePropertyValuesService } from "@/plane-web/services/issue-types";
|
||||
// plane web types
|
||||
import { TIssuePropertyValues } from "@/plane-web/types";
|
||||
// components
|
||||
import { DraftIssueLayout } from "./draft-issue-layout";
|
||||
import { IssueFormRoot } from "./form";
|
||||
|
||||
@@ -283,20 +285,20 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
if (!projectDetails?.is_issue_type_enabled) return;
|
||||
|
||||
await issuePropertyValuesService
|
||||
.createUpdate(workspaceSlug.toString(), projectId, issueId, issuePropertyValues)
|
||||
.create(workspaceSlug.toString(), projectId, issueId, issuePropertyValues)
|
||||
.then(() => {
|
||||
// TODO: remove
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Additional properties created successfully.",
|
||||
message: "Custom properties created successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Additional properties could not be created. Please try again.",
|
||||
message: "Custom properties could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1 +1,63 @@
|
||||
export * from "ce/components/issues/quick-add/root";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { UseFormRegister, UseFormSetFocus } from "react-hook-form";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { QuickAddIssueFormRoot as BaseQuickAddIssueFormRoot } from "@/ce/components/issues/quick-add/";
|
||||
// components
|
||||
import { CreateUpdateIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssueLayoutTypes } from "@/constants/issue";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
|
||||
export type TQuickAddIssueFormRoot = {
|
||||
isOpen: boolean;
|
||||
layout: EIssueLayoutTypes;
|
||||
prePopulatedData?: Partial<TIssue>;
|
||||
projectId: string;
|
||||
hasError?: boolean;
|
||||
setFocus: UseFormSetFocus<TIssue>;
|
||||
register: UseFormRegister<TIssue>;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const QuickAddIssueFormRoot: FC<TQuickAddIssueFormRoot> = observer((props) => {
|
||||
const {
|
||||
isOpen,
|
||||
layout,
|
||||
prePopulatedData,
|
||||
projectId,
|
||||
hasError = false,
|
||||
setFocus,
|
||||
register,
|
||||
onSubmit,
|
||||
onClose,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { getProjectDefaultIssueType } = useIssueTypes();
|
||||
// derived values
|
||||
const defaultIssueType = getProjectDefaultIssueType(projectId);
|
||||
const mandatoryFields = defaultIssueType?.activeProperties.filter((property) => property.is_required) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{mandatoryFields.length > 0 ? (
|
||||
<CreateUpdateIssueModal isOpen={isOpen} onClose={onClose} data={prePopulatedData} />
|
||||
) : (
|
||||
<BaseQuickAddIssueFormRoot
|
||||
isOpen={isOpen}
|
||||
layout={layout}
|
||||
projectId={projectId}
|
||||
hasError={hasError}
|
||||
setFocus={setFocus}
|
||||
register={register}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,6 +60,8 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
property_type: EIssuePropertyType.TEXT,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
settings: {
|
||||
display_format: "single-line",
|
||||
} as TIssuePropertySettingsMap[EIssuePropertyType.TEXT],
|
||||
@@ -79,6 +81,8 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
property_type: EIssuePropertyType.DECIMAL,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
@@ -96,6 +100,8 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
property_type: EIssuePropertyType.OPTION,
|
||||
relation_type: undefined,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
@@ -113,6 +119,8 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
property_type: EIssuePropertyType.BOOLEAN,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
is_required: false,
|
||||
default_value: ["false"],
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
@@ -130,6 +138,8 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
property_type: EIssuePropertyType.DATETIME,
|
||||
relation_type: undefined,
|
||||
is_multi: undefined,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
settings: {
|
||||
display_format: "MMM dd, yyyy",
|
||||
} as TIssuePropertySettingsMap[EIssuePropertyType.DATETIME],
|
||||
@@ -149,25 +159,13 @@ export const ISSUE_PROPERTY_TYPE_DETAILS: Partial<
|
||||
property_type: EIssuePropertyType.RELATION,
|
||||
relation_type: EIssuePropertyRelationType.USER,
|
||||
is_multi: false,
|
||||
is_required: false,
|
||||
default_value: undefined,
|
||||
settings: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// List of mandatory fields for each issue property type
|
||||
// type TIssuePropertyMandatoryFields = {
|
||||
// [key in TIssuePropertyTypeKeys]: string[][];
|
||||
// };
|
||||
|
||||
// export const ISSUE_PROPERTY_MANDATORY_FIELDS: Partial<TIssuePropertyMandatoryFields> = {
|
||||
// TEXT: [["display_name"], ["settings", "display_format"]],
|
||||
// DECIMAL: [["display_name"]],
|
||||
// OPTION: [["display_name"], ["is_multi"]],
|
||||
// BOOLEAN: [["display_name"]],
|
||||
// DATETIME: [["display_name"], ["settings", "display_format"]],
|
||||
// RELATION_USER: [["display_name"], ["is_multi"]],
|
||||
// };
|
||||
|
||||
export const DROPDOWN_ATTRIBUTES: Partial<{
|
||||
[key in TIssuePropertyTypeKeys]: {
|
||||
key: TDropdownAttributeOptions;
|
||||
|
||||
@@ -50,7 +50,7 @@ export const getMultiSelectAttributeDisplayName = (
|
||||
|
||||
// Get the display name for the boolean attribute based on the default value
|
||||
export const getBooleanAttributeDisplayName = (default_value: string | undefined) =>
|
||||
default_value !== undefined ? `${default_value === "true" ? "True" : "False"}` : "True / False";
|
||||
default_value !== undefined ? `${default_value === "true" ? "True" : "False"}` : "True | False";
|
||||
|
||||
// Get the display name for the issue property attribute based on the property type
|
||||
export const getIssuePropertyAttributeDisplayName = (
|
||||
|
||||
@@ -18,7 +18,12 @@ export class IssuePropertyValuesService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async createUpdate(workspaceSlug: string, projectId: string, issueId: string, data: TIssuePropertyValues): Promise<TIssuePropertyValues> {
|
||||
async create(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssuePropertyValues
|
||||
): Promise<TIssuePropertyValues> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/values/`, {
|
||||
property_values: data,
|
||||
})
|
||||
@@ -27,4 +32,23 @@ export class IssuePropertyValuesService extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
propertyId: string,
|
||||
data: string[]
|
||||
): Promise<void> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-properties/${propertyId}/values/`,
|
||||
{
|
||||
values: data,
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ export class IssuePropertyOption implements IIssuePropertyOption {
|
||||
// computed
|
||||
/**
|
||||
* @description Get issue property option as JSON
|
||||
* @returns {TIssuePropertyOption */
|
||||
* @returns {TIssuePropertyOption}
|
||||
*/
|
||||
get asJSON(): TIssuePropertyOption {
|
||||
return {
|
||||
id: this.id,
|
||||
|
||||
@@ -30,12 +30,13 @@ export interface IIssueTypesStore {
|
||||
getProjectIssueTypeLoader: (projectId: string) => TLoader;
|
||||
getProjectIssueTypeIds: (projectId: string) => string[];
|
||||
getProjectActiveIssueTypes: (projectId: string) => Record<string, IIssueType>; // issue type id -> issue type
|
||||
getProjectDefaultIssueTypeId: (projectId: string) => string | undefined;
|
||||
getProjectDefaultIssueType: (projectId: string) => IIssueType | undefined;
|
||||
getIssueTypeProperties: (issueTypeId: string) => TIssueProperty<EIssuePropertyType>[];
|
||||
// helper actions
|
||||
fetchAllData: (workspaceSlug: string, projectId: string) => Promise<TIssueTypesPropertiesOptions>;
|
||||
// actions
|
||||
enableIssueTypes: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
getAllTypesPropertiesOptions: (workspaceSlug: string, projectId: string) => Promise<TIssueType[] | undefined>;
|
||||
fetchAllTypesPropertiesOptions: (workspaceSlug: string, projectId: string) => Promise<TIssueType[] | undefined>;
|
||||
createType: (typeData: Partial<TIssueType>) => Promise<TIssueType | undefined>;
|
||||
deleteType: (typeId: string) => Promise<void>;
|
||||
}
|
||||
@@ -60,7 +61,7 @@ export class IssueTypes implements IIssueTypesStore {
|
||||
fetchAllData: action,
|
||||
// actions
|
||||
enableIssueTypes: action,
|
||||
getAllTypesPropertiesOptions: action,
|
||||
fetchAllTypesPropertiesOptions: action,
|
||||
createType: action,
|
||||
deleteType: action,
|
||||
});
|
||||
@@ -107,11 +108,27 @@ export class IssueTypes implements IIssueTypesStore {
|
||||
return projectIssueTypes;
|
||||
});
|
||||
|
||||
getProjectDefaultIssueTypeId = computedFn((projectId: string) => {
|
||||
/**
|
||||
* @description Get project default issue type id of the project
|
||||
* @param projectId
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
getProjectDefaultIssueType = computedFn((projectId: string) => {
|
||||
const projectIssueTypes = this.getProjectActiveIssueTypes(projectId);
|
||||
|
||||
const defaultIssueType = Object.values(projectIssueTypes).find((issueType) => issueType.is_default);
|
||||
return defaultIssueType?.id ?? undefined;
|
||||
return defaultIssueType ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description Get issue type properties by issue type id
|
||||
* @param issueTypeId
|
||||
* @returns {TIssueProperty<EIssuePropertyType>[]}
|
||||
*/
|
||||
getIssueTypeProperties = computedFn((issueTypeId: string) => {
|
||||
const issueType = this.data[issueTypeId];
|
||||
if (!issueType) return [];
|
||||
return issueType.properties;
|
||||
});
|
||||
|
||||
// helper actions
|
||||
@@ -144,7 +161,7 @@ export class IssueTypes implements IIssueTypesStore {
|
||||
this.loader[projectId] = "init-loader";
|
||||
await this.service.enableIssueTypes(workspaceSlug, projectId);
|
||||
await this.store.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId);
|
||||
await this.getAllTypesPropertiesOptions(workspaceSlug, projectId);
|
||||
await this.fetchAllTypesPropertiesOptions(workspaceSlug, projectId);
|
||||
} catch (error) {
|
||||
this.loader[projectId] = "loaded";
|
||||
throw error;
|
||||
@@ -156,7 +173,7 @@ export class IssueTypes implements IIssueTypesStore {
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
getAllTypesPropertiesOptions = async (workspaceSlug: string, projectId: string) => {
|
||||
fetchAllTypesPropertiesOptions = async (workspaceSlug: string, projectId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,8 @@ export type TissuePropertyTypeDetails<T extends EIssuePropertyType> = {
|
||||
property_type: EIssuePropertyType;
|
||||
relation_type: EIssuePropertyRelationType | undefined;
|
||||
is_multi: boolean | undefined;
|
||||
is_required: boolean | undefined;
|
||||
default_value: string[] | undefined;
|
||||
settings: TIssuePropertySettingsMap[T] | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,3 +4,14 @@ export type TPropertyValueVariant = "create" | "update";
|
||||
export type TIssuePropertyValues = {
|
||||
[property_id: string]: string[];
|
||||
};
|
||||
|
||||
const IssuePropertyValueError = {
|
||||
REQUIRED: "REQUIRED",
|
||||
INVALID: "INVALID",
|
||||
} as const;
|
||||
|
||||
export type EIssuePropertyValueError = keyof typeof IssuePropertyValueError;
|
||||
|
||||
export type TIssuePropertyValueErrors = {
|
||||
[property_id: string]: EIssuePropertyValueError;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user