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:
Nikhil
2024-08-08 15:01:43 +05:30
committed by GitHub
parent 7db4da187a
commit f41e4dce25
46 changed files with 954 additions and 429 deletions

View File

@@ -402,6 +402,7 @@ class IssueViewSet(BaseViewSet):
"is_draft",
"archived_at",
"deleted_at",
"type_id",
)
.first()
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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