From f41e4dce254d83f185d3ebce44d796d06b010c4e Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 8 Aug 2024 15:01:43 +0530 Subject: [PATCH] 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 --- apiserver/plane/app/views/issue/base.py | 1 + apiserver/plane/ee/urls/app/issue_property.py | 7 +- .../ee/utils/issue_property_validators.py | 96 +++++++------ .../ee/views/app/issue_property/value.py | 87 ++++++++++++ .../issues/issue-details/issue-identifier.tsx | 6 +- .../issue-layouts/calendar/issue-block.tsx | 16 ++- .../issues/issue-layouts/gantt/blocks.tsx | 24 ++-- .../issues/issue-layouts/kanban/block.tsx | 16 ++- .../issues/issue-layouts/list/block.tsx | 16 ++- .../issue-layouts/spreadsheet/issue-row.tsx | 11 +- .../issue-modal/components/title-input.tsx | 2 +- .../layouts/auth-layout/project-wrapper.tsx | 4 +- .../issue-types/dropdowns/issue-type.tsx | 44 ++++-- .../issue-types/issue-type-list-item.tsx | 40 ++++-- .../issue-types/issue-types-list.tsx | 11 +- .../properties/attributes/dropdown.tsx | 12 +- .../properties/attributes/member-picker.tsx | 2 +- .../properties/attributes/number.tsx | 2 +- .../attributes/options/create-option-item.tsx | 2 +- .../properties/dropdowns/property-title.tsx | 32 ++--- .../properties/dropdowns/property-type.tsx | 2 - .../issue-types/properties/empty-state.tsx | 2 +- .../properties/property-list-item.tsx | 54 ++++++-- .../issue-types/properties/property-list.tsx | 8 +- .../values/components/date-select.tsx | 69 +++++++--- .../values/components/member-select.tsx | 40 ++++-- .../values/components/number-input.tsx | 73 +++++----- .../values/components/option-select.tsx | 29 +++- .../values/components/text-input.tsx | 119 +++++++++------- .../components/issue-types/values/create.tsx | 129 +++++++++--------- web/ee/components/issue-types/values/root.tsx | 10 +- .../components/issue-types/values/update.tsx | 27 ++-- .../issue-types/values/value-select.tsx | 80 +++++++---- .../issues/issue-details/issue-identifier.tsx | 56 ++++++-- .../issue-modal/additional-properties.tsx | 25 +++- web/ee/components/issues/issue-modal/form.tsx | 44 +++++- .../issues/issue-modal/issue-type-select.tsx | 10 +- .../components/issues/issue-modal/modal.tsx | 10 +- web/ee/components/issues/quick-add/root.tsx | 64 ++++++++- web/ee/constants/issue-properties.ts | 26 ++-- web/ee/helpers/issue-properties.helper.ts | 2 +- .../issue-property-values.service.ts | 26 +++- .../issue-types/issue-property-option.ts | 3 +- web/ee/store/issue-types/issue-types.store.ts | 31 ++++- .../issue-property-configurations.d.ts | 2 + .../issue-types/issue-property-values.d.ts | 11 ++ 46 files changed, 954 insertions(+), 429 deletions(-) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 4d434273a2..d86ff675f9 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -402,6 +402,7 @@ class IssueViewSet(BaseViewSet): "is_draft", "archived_at", "deleted_at", + "type_id", ) .first() ) diff --git a/apiserver/plane/ee/urls/app/issue_property.py b/apiserver/plane/ee/urls/app/issue_property.py index f47a119fb3..446242730d 100644 --- a/apiserver/plane/ee/urls/app/issue_property.py +++ b/apiserver/plane/ee/urls/app/issue_property.py @@ -78,7 +78,12 @@ urlpatterns = [ IssuePropertyValueEndpoint.as_view(), name="issue-property-values", ), - ## Issue property value + path( + "workspaces//projects//issues//issue-properties//values/", + IssuePropertyValueEndpoint.as_view(), + name="issue-property-values", + ), + ## Issue property activity path( "workspaces//projects//issues//property-activity/", IssuePropertyActivityEndpoint.as_view(), diff --git a/apiserver/plane/ee/utils/issue_property_validators.py b/apiserver/plane/ee/utils/issue_property_validators.py index 15f7f9c7fa..6fec40cfa4 100644 --- a/apiserver/plane/ee/utils/issue_property_validators.py +++ b/apiserver/plane/ee/utils/issue_property_validators.py @@ -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 diff --git a/apiserver/plane/ee/views/app/issue_property/value.py b/apiserver/plane/ee/views/app/issue_property/value.py index 8baa0396bc..9a2a9fc493 100644 --- a/apiserver/plane/ee/views/app/issue_property/value.py +++ b/apiserver/plane/ee/views/app/issue_property/value.py @@ -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, + ) diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx index 8b32a8d321..29bdc2a471 100644 --- a/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -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 = 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 = observer((props) return (
- + {projectDetails?.identifier}-{issue?.sequence_id}
diff --git a/web/core/components/issues/issue-layouts/calendar/issue-block.tsx b/web/core/components/issues/issue-layouts/calendar/issue-block.tsx index 747425d41d..9904372acc 100644 --- a/web/core/components/issues/issue-layouts/calendar/issue-block.tsx +++ b/web/core/components/issues/issue-layouts/calendar/issue-block.tsx @@ -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(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, }} /> -
- {getProjectIdentifierById(issue?.project_id)}-{issue.sequence_id} -
+ {issue.project_id && ( + + )}
{issue.name}
diff --git a/web/core/components/issues/issue-layouts/gantt/blocks.tsx b/web/core/components/issues/issue-layouts/gantt/blocks.tsx index 69be0d2a2d..5d9d2748dc 100644 --- a/web/core/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/core/components/issues/issue-layouts/gantt/blocks.tsx @@ -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 = 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 = observer((props) => { disabled={!!issueDetails?.tempId} >
- {stateDetails && } -
+ {issueDetails?.project_id && ( + + )} + {/* {stateDetails && } */} + {/*
{projectIdentifier} {issueDetails?.sequence_id} -
+
*/} {issueDetails?.name} diff --git a/web/core/components/issues/issue-layouts/kanban/block.tsx b/web/core/components/issues/issue-layouts/kanban/block.tsx index 0d9ea8a13f..c8bce19532 100644 --- a/web/core/components/issues/issue-layouts/kanban/block.tsx +++ b/web/core/components/issues/issue-layouts/kanban/block.tsx @@ -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 = 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 = observer((prop <>
-
- {getProjectIdentifierById(issue.project_id)}-{issue.sequence_id} -
+ {issue.project_id && ( + + )}
{ )} {displayProperties && displayProperties?.key && ( -
- {projectIdentifier}-{issue.sequence_id} +
+ {issue.project_id && ( + + )}
)} diff --git a/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 189fdb9065..fe563f77e1 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -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) => {

- {getProjectIdentifierById(issueDetail.project_id)}-{issueDetail.sequence_id} + {issueDetail.project_id && ( + + )}

diff --git a/web/core/components/issues/issue-modal/components/title-input.tsx b/web/core/components/issues/issue-modal/components/title-input.tsx index d625826720..99f6cbf4aa 100644 --- a/web/core/components/issues/issue-modal/components/title-input.tsx +++ b/web/core/components/issues/issue-modal/components/title-input.tsx @@ -51,7 +51,7 @@ export const IssueTitleInput: React.FC = observer((props) /> )} /> - {errors?.name?.message} + {errors?.name?.message} ); }); diff --git a/web/core/layouts/auth-layout/project-wrapper.tsx b/web/core/layouts/auth-layout/project-wrapper.tsx index d4c765cff0..e7d51f46db 100644 --- a/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/web/core/layouts/auth-layout/project-wrapper.tsx @@ -46,7 +46,7 @@ export const ProjectAuthWrapper: FC = 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 = 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 } ); diff --git a/web/ee/components/issue-types/dropdowns/issue-type.tsx b/web/ee/components/issue-types/dropdowns/issue-type.tsx index a6351e1cce..02e9bdb18b 100644 --- a/web/ee/components/issue-types/dropdowns/issue-type.tsx +++ b/web/ee/components/issue-types/dropdowns/issue-type.tsx @@ -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: (
-
- {issueTypeDetail?.logo_props?.in_use ? ( - + <> + {issueTypeDetail?.is_default ? ( +
+ +
) : ( - +
+ {issueTypeDetail?.logo_props?.in_use ? ( + + ) : ( + + )} +
)} -
+
{issueTypeDetail.name}
), @@ -47,18 +55,24 @@ export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => { -
- {issueTypes[issueTypeId]?.logo_props?.in_use ? ( - - ) : ( - - )} -
+
+ {issueTypes[issueTypeId]?.is_default ? ( +
+ +
+ ) : ( +
+ {issueTypes[issueTypeId]?.logo_props?.in_use ? ( + + ) : ( + + )} +
+ )}
{issueTypes[issueTypeId]?.name}
} - options={issuePropertyTypeOptions} + options={issueTypeOptions} onChange={handleIssueTypeChange} className="w-full h-full flex" optionsClassName="w-48" diff --git a/web/ee/components/issue-types/issue-type-list-item.tsx b/web/ee/components/issue-types/issue-type-list-item.tsx index 0c5ac5700c..17e9cf4d08 100644 --- a/web/ee/components/issue-types/issue-type-list-item.tsx +++ b/web/ee/components/issue-types/issue-type-list-item.tsx @@ -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) => { })} />
-
- {issueTypeDetail?.logo_props?.in_use ? ( - - ) : ( - - )} -
+ {issueTypeDetail?.is_default ? ( +
+ +
+ ) : ( +
+ {issueTypeDetail?.logo_props?.in_use ? ( + + ) : ( + + )} +
+ )}
{issueTypeDetail?.name}
diff --git a/web/ee/components/issue-types/issue-types-list.tsx b/web/ee/components/issue-types/issue-types-list.tsx index c36349d3ee..2ea05e903b 100644 --- a/web/ee/components/issue-types/issue-types-list.tsx +++ b/web/ee/components/issue-types/issue-types-list.tsx @@ -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(() => {
{currentProjectIssueTypeIds && currentProjectIssueTypeIds.map((issueTypeId) => ( - + ))}
); diff --git a/web/ee/components/issue-types/properties/attributes/dropdown.tsx b/web/ee/components/issue-types/properties/attributes/dropdown.tsx index f03757fb5b..289c92e5ab 100644 --- a/web/ee/components/issue-types/properties/attributes/dropdown.tsx +++ b/web/ee/components/issue-types/properties/attributes/dropdown.tsx @@ -54,7 +54,15 @@ export const DropdownAttributes = observer((props: TDropdownAttributesProps) => 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} />
@@ -83,12 +91,12 @@ export const DropdownAttributes = observer((props: TDropdownAttributesProps) =>
Default • Optional
{dropdownPropertyDetail.id ? ( onDropdownDetailChange("default_value", value)} /> diff --git a/web/ee/components/issue-types/properties/attributes/member-picker.tsx b/web/ee/components/issue-types/properties/attributes/member-picker.tsx index 6cac6bb5cf..2a47151add 100644 --- a/web/ee/components/issue-types/properties/attributes/member-picker.tsx +++ b/web/ee/components/issue-types/properties/attributes/member-picker.tsx @@ -71,11 +71,11 @@ export const MemberPickerAttributes = observer((props: TMemberPickerAttributesPr
Default • Optional
onMemberPickerDetailChange("default_value", value)} /> diff --git a/web/ee/components/issue-types/properties/attributes/number.tsx b/web/ee/components/issue-types/properties/attributes/number.tsx index d0fc91d0f5..0db69add85 100644 --- a/web/ee/components/issue-types/properties/attributes/number.tsx +++ b/web/ee/components/issue-types/properties/attributes/number.tsx @@ -45,7 +45,7 @@ export const NumberAttributes = observer((props: TNumberAttributesProps) => { )}
Default • Optional
onNumberDetailChange("default_value", value)} variant="create" diff --git a/web/ee/components/issue-types/properties/attributes/options/create-option-item.tsx b/web/ee/components/issue-types/properties/attributes/options/create-option-item.tsx index 1fc25260b9..516c7040aa 100644 --- a/web/ee/components/issue-types/properties/attributes/options/create-option-item.tsx +++ b/web/ee/components/issue-types/properties/attributes/options/create-option-item.tsx @@ -20,7 +20,7 @@ export const IssuePropertyCreateOptionItem = observer( const { issueTypeId, issuePropertyId, propertyOptionCreateListData, updateCreateListData } = props; return ( -
+
Name your property
-
- 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) && ( - - - - )} -
+ 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) && {error}}
Describe your property
diff --git a/web/ee/components/issue-types/properties/dropdowns/property-type.tsx b/web/ee/components/issue-types/properties/dropdowns/property-type.tsx index 38c5fb0922..dc59f1240b 100644 --- a/web/ee/components/issue-types/properties/dropdowns/property-type.tsx +++ b/web/ee/components/issue-types/properties/dropdowns/property-type.tsx @@ -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, }); }; diff --git a/web/ee/components/issue-types/properties/empty-state.tsx b/web/ee/components/issue-types/properties/empty-state.tsx index 93c570c31d..c7d9304a14 100644 --- a/web/ee/components/issue-types/properties/empty-state.tsx +++ b/web/ee/components/issue-types/properties/empty-state.tsx @@ -6,7 +6,7 @@ export const IssueTypePropertiesEmptyState = () => (
-
Create new properties for this issue type.
+
New properties you add for this issue type will show here.
); diff --git a/web/ee/components/issue-types/properties/property-list-item.tsx b/web/ee/components/issue-types/properties/property-list-item.tsx index d1d168992d..93edd9f846 100644 --- a/web/ee/components/issue-types/properties/property-list-item.tsx +++ b/web/ee/components/issue-types/properties/property-list-item.tsx @@ -66,17 +66,39 @@ export const IssuePropertyListItem = observer((props: TIssuePropertyListItem) => const [issuePropertyOptionCreateList, setIssuePropertyOptionCreateList] = useState( [] ); + // 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 (
{ - // 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} />
diff --git a/web/ee/components/issue-types/properties/property-list.tsx b/web/ee/components/issue-types/properties/property-list.tsx index 18a70a112b..625a8b2649 100644 --- a/web/ee/components/issue-types/properties/property-list.tsx +++ b/web/ee/components/issue-types/properties/property-list.tsx @@ -32,12 +32,8 @@ export const IssuePropertyList: FC = observer((props) => {
-
- Property title -
-
- Property type -
+
Name
+
Type
Attributes
Mandatory
diff --git a/web/ee/components/issue-types/values/components/date-select.tsx b/web/ee/components/issue-types/values/components/date-select.tsx index 83a3319d2b..3956a80728 100644 --- a/web/ee/components/issue-types/values/components/date-select.tsx +++ b/web/ee/components/issue-types/values/components/date-select.tsx @@ -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>; 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; }; 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([]); @@ -34,21 +51,33 @@ export const DateValueSelect = observer((props: TDateValueSelectProps) => { }; return ( - + <> + + {Boolean(error) && ( + + {error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error} + + )} + ); }); diff --git a/web/ee/components/issue-types/values/components/member-select.tsx b/web/ee/components/issue-types/values/components/member-select.tsx index 44f646ac31..6d82c633f3 100644 --- a/web/ee/components/issue-types/values/components/member-select.tsx +++ b/web/ee/components/issue-types/values/components/member-select.tsx @@ -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>; 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; }; 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([]); @@ -29,10 +41,15 @@ export const MemberValueSelect = observer((props: TMemberValueSelectProps) => { }, [value]); const memberPickerProps: Partial = { - 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) && ( + + {error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error} + + )} ); }); diff --git a/web/ee/components/issue-types/values/components/number-input.tsx b/web/ee/components/issue-types/values/components/number-input.tsx index 32fd36f3f0..aa041ecf9b 100644 --- a/web/ee/components/issue-types/values/components/number-input.tsx +++ b/web/ee/components/issue-types/values/components/number-input.tsx @@ -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>; value: string[]; variant: TPropertyValueVariant; numberInputSize?: "xs" | "sm" | "md"; - isRequired?: boolean; + error?: EIssuePropertyValueError; isDisabled?: boolean; className?: string; onNumberValueChange: (value: string[]) => Promise; @@ -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 ( - + { + // 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) && ( + + {error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error} + )} - 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} - /> + ); }); diff --git a/web/ee/components/issue-types/values/components/option-select.tsx b/web/ee/components/issue-types/values/components/option-select.tsx index f6d25beb0b..942606f185 100644 --- a/web/ee/components/issue-types/values/components/option-select.tsx +++ b/web/ee/components/issue-types/values/components/option-select.tsx @@ -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>; 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; }; 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) && ( + + {error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error} + + )} ); }); diff --git a/web/ee/components/issue-types/values/components/text-input.tsx b/web/ee/components/issue-types/values/components/text-input.tsx index 3444cd2ab6..e0a3aea77d 100644 --- a/web/ee/components/issue-types/values/components/text-input.tsx +++ b/web/ee/components/issue-types/values/components/text-input.tsx @@ -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>; 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 ( - { - // 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} - /> + <> + { + // 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) && ( + + {error === "REQUIRED" ? `${propertyDetail.display_name} is required` : error} + + )} + ); case "multi-line": return ( -