diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 7a9cfb1b5c..6f66c373ec 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -262,7 +262,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Cycle with the same external id and external source already exists", - "cycle": str(cycle.id), + "id": str(cycle.id), }, status=status.HTTP_409_CONFLICT, ) @@ -325,7 +325,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Cycle with the same external id and external source already exists", - "cycle_id": str(cycle.id), + "id": str(cycle.id), }, status=status.HTTP_409_CONFLICT, ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 530eef5bf4..a759b15f6e 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -239,7 +239,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Issue with the same external id and external source already exists", - "issue_id": str(issue.id), + "id": str(issue.id), }, status=status.HTTP_409_CONFLICT, ) @@ -286,14 +286,16 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): and Issue.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get("external_source", issue.external_source), + external_source=request.data.get( + "external_source", issue.external_source + ), external_id=request.data.get("external_id"), ).exists() ): return Response( { "error": "Issue with the same external id and external source already exists", - "issue_id": str(issue.id), + "id": str(issue.id), }, status=status.HTTP_409_CONFLICT, ) @@ -362,6 +364,30 @@ class LabelAPIEndpoint(BaseAPIView): try: serializer = LabelSerializer(data=request.data) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Label.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -370,11 +396,17 @@ class LabelAPIEndpoint(BaseAPIView): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) except IntegrityError: + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() return Response( { - "error": "Label with the same name already exists in the project" + "error": "Label with the same name already exists in the project", + "id": str(label.id), }, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_409_CONFLICT, ) def get(self, request, slug, project_id, pk=None): @@ -401,6 +433,25 @@ class LabelAPIEndpoint(BaseAPIView): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, data=request.data, partial=True) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (label.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", label.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index e2d59e126f..d509a53c79 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -151,7 +151,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Module with the same external id and external source already exists", - "module_id": str(module.id), + "id": str(module.id), }, status=status.HTTP_409_CONFLICT, ) @@ -185,7 +185,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): return Response( { "error": "Module with the same external id and external source already exists", - "module_id": str(module.id), + "id": str(module.id), }, status=status.HTTP_409_CONFLICT, ) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 88fb083f05..0a262a071d 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -57,7 +57,7 @@ class StateAPIEndpoint(BaseAPIView): return Response( { "error": "State with the same external id and external source already exists", - "state_id": str(state.id), + "id": str(state.id), }, status=status.HTTP_409_CONFLICT, ) @@ -128,7 +128,7 @@ class StateAPIEndpoint(BaseAPIView): return Response( { "error": "State with the same external id and external source already exists", - "state_id": str(state.id), + "id": str(state.id), }, status=status.HTTP_409_CONFLICT, ) diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py index 47fae2c9ca..1366a2886a 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -145,6 +145,23 @@ def dashboard_assigned_issues(self, request, slug): ) ).order_by("priority_order") + if issue_type == "pending": + pending_issues_count = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + if issue_type == "completed": completed_issues_count = assigned_issues.filter( state__group__in=["completed"] @@ -257,6 +274,23 @@ def dashboard_created_issues(self, request, slug): ) ).order_by("priority_order") + if issue_type == "pending": + pending_issues_count = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + if issue_type == "completed": completed_issues_count = created_issues.filter( state__group__in=["completed"] diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b9f6bd4110..b86ab5e783 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -353,13 +353,18 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = set( - [str(asg) for asg in requested_data.get("assignee_ids", [])] + requested_assignees = ( + set([str(asg) for asg in requested_data.get("assignee_ids", [])]) + if requested_data is not None + else set() ) - current_assignees = set( - [str(asg) for asg in current_instance.get("assignee_ids", [])] + current_assignees = ( + set([str(asg) for asg in current_instance.get("assignee_ids", [])]) + if current_instance is not None + else set() ) + added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees @@ -547,6 +552,20 @@ def create_issue_activity( epoch=epoch, ) ) + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) + if requested_data.get("assignee_ids") is not None: + track_assignees( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, + ) def update_issue_activity( diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index a9f8856404..ca0e17d8da 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -176,4 +176,9 @@ def create_user_notification(sender, instance, created, **kwargs): UserNotificationPreference.objects.create( user=instance, + property_change=False, + state_change=False, + comment=False, + mention=False, + issue_completed=False, ) diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 3f306c559f..4e505cff9c 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -49,7 +49,7 @@ function buildLocalImage() { cd $PLANE_TEMP_CODE_DIR if [ "$BRANCH" == "master" ]; then - APP_RELEASE=latest + export APP_RELEASE=latest fi docker compose -f build.yml build --no-cache >&2 @@ -205,6 +205,11 @@ else PULL_POLICY=never fi +if [ "$BRANCH" == "master" ]; +then + export APP_RELEASE=latest +fi + # REMOVE SPECIAL CHARACTERS FROM BRANCH NAME if [ "$BRANCH" != "master" ]; then diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts index 31751c0d06..7cfa6aa856 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.d.ts @@ -13,9 +13,10 @@ export type TWidgetKeys = | "recent_projects" | "recent_collaborators"; -export type TIssuesListTypes = "upcoming" | "overdue" | "completed"; +export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed"; export type TDurationFilterOptions = + | "none" | "today" | "this_week" | "this_month" diff --git a/web/components/analytics/project-modal/modal.tsx b/web/components/analytics/project-modal/modal.tsx index a4b82c4b6d..df61411f20 100644 --- a/web/components/analytics/project-modal/modal.tsx +++ b/web/components/analytics/project-modal/modal.tsx @@ -38,14 +38,12 @@ export const ProjectAnalyticsModal: React.FC = observer((props) => { >
= ({ - + {distribution?.assignees.length > 0 ? ( distribution.assignees.map((assignee, index) => { if (assignee.assignee_id) diff --git a/web/components/cycles/cycle-mobile-header.tsx b/web/components/cycles/cycle-mobile-header.tsx new file mode 100644 index 0000000000..b893bf9930 --- /dev/null +++ b/web/components/cycles/cycle-mobile-header.tsx @@ -0,0 +1,168 @@ +import { useCallback, useState } from "react"; +import router from "next/router"; +//components +import { CustomMenu } from "@plane/ui"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +// hooks +import { useIssues, useCycle, useProjectState, useLabel, useMember } from "hooks/store"; +// constants +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue"; +import { ProjectAnalyticsModal } from "components/analytics"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues"; + +export const CycleMobileHeader = () => { + const [analyticsModal, setAnalyticsModal] = useState(false); + const { getCycleById } = useCycle(); + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + + const { workspaceSlug, projectId, cycleId } = router.query as { + workspaceSlug: string; + projectId: string; + cycleId: string; + }; + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ + Filters + + + } + > + + +
+
+ + Display + + + } + > + + +
+ + setAnalyticsModal(true)} + className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200" + > + Analytics + +
+ + ); +}; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 86de8d0c0c..a6d4670910 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -159,10 +159,10 @@ export const CyclesListItem: FC = (props) => { projectId={projectId} /> -
-
-
- +
+
+
+
{isCompleted ? ( progress === 100 ? ( @@ -176,95 +176,97 @@ export const CyclesListItem: FC = (props) => { {`${progress}%`} )} - +
-
- - - +
+ - {cycleDetails.name} + + {cycleDetails.name} +
-
- -
-
-
- {currentCycle && ( - - {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} - - )} +
- {renderDate && ( - - {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} - - )} - - -
- {cycleDetails.assignees.length > 0 ? ( - - {cycleDetails.assignees.map((assignee) => ( - - ))} - - ) : ( - - - - )} + {currentCycle && ( +
+ {currentCycle.value === "current" + ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` + : `${currentCycle.label}`}
- - {isEditingAllowed && - (cycleDetails.is_favorite ? ( - - ) : ( - - ))} + )} +
+
+
+ {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} +
- - {!isCompleted && isEditingAllowed && ( +
+ +
+ {cycleDetails.assignees.length > 0 ? ( + + {cycleDetails.assignees.map((assignee) => ( + + ))} + + ) : ( + + + + )} +
+
+ + {isEditingAllowed && ( <> - - - - Edit cycle - - - - - - Delete cycle - - + {cycleDetails.is_favorite ? ( + + ) : ( + + )} + + + {!isCompleted && isEditingAllowed && ( + <> + + + + Edit cycle + + + + + + Delete cycle + + + + )} + + + + Copy cycle link + + + )} - - - - Copy cycle link - - - +
diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index d4a27afc15..e2a54c05f6 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -17,7 +17,7 @@ import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper" // types import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types"; // constants -import { ISSUES_TABS_LIST } from "constants/dashboard"; +import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard"; const WIDGET_KEY = "assigned_issues"; @@ -30,6 +30,8 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const selectedTab = widgetDetails?.widget_filters.tab ?? "pending"; + const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none"; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -41,68 +43,79 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { filters, }); + const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming", - target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + issue_type: filters.tab ?? selectedTab, + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), expand: "issue_relation", }).finally(() => setFetching(false)); }; useEffect(() => { - const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"); + const filterDates = getCustomDates(selectedDurationFilter); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - issue_type: widgetDetails?.widget_filters.tab ?? "upcoming", - target_date: filterDates, + issue_type: selectedTab, + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), expand: "issue_relation", }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming"); + const filterParams = getRedirectionFilters(selectedTab); + const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; if (!widgetDetails || !widgetStats) return ; return (
-
-
- - Assigned to you - -

- Filtered by{" "} - Due date -

-
+
+ + Assigned to you + - handleUpdateFilters({ - target_date: val, - }) - } + value={selectedDurationFilter} + onChange={(val) => { + if (val === selectedDurationFilter) return; + + // switch to pending tab if target date is changed to none + if (val === "none" && selectedTab !== "completed") { + handleUpdateFilters({ target_date: val, tab: "pending" }); + return; + } + // switch to upcoming tab if target date is changed to other than none + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { + handleUpdateFilters({ + target_date: val, + tab: "upcoming", + }); + return; + } + + handleUpdateFilters({ target_date: val }); + }} />
t.key === widgetDetails.widget_filters.tab ?? "upcoming")} + selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)} onChange={(i) => { - const selectedTab = ISSUES_TABS_LIST[i]; - handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" }); + const selectedTab = tabsList[i]; + handleUpdateFilters({ tab: selectedTab?.key ?? "pending" }); }} className="h-full flex flex-col" >
- +
- {ISSUES_TABS_LIST.map((tab) => ( + {tabsList.map((tab) => ( = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const selectedTab = widgetDetails?.widget_filters.tab ?? "pending"; + const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none"; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -41,64 +43,76 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { filters, }); + const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming", - target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + issue_type: filters.tab ?? selectedTab, + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), }).finally(() => setFetching(false)); }; useEffect(() => { + const filterDates = getCustomDates(selectedDurationFilter); + fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - issue_type: widgetDetails?.widget_filters.tab ?? "upcoming", - target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"), + issue_type: selectedTab, + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming"); + const filterParams = getRedirectionFilters(selectedTab); + const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; if (!widgetDetails || !widgetStats) return ; return (
-
-
- - Created by you - -

- Filtered by{" "} - Due date -

-
+
+ + Created by you + - handleUpdateFilters({ - target_date: val, - }) - } + value={selectedDurationFilter} + onChange={(val) => { + if (val === selectedDurationFilter) return; + + // switch to pending tab if target date is changed to none + if (val === "none" && selectedTab !== "completed") { + handleUpdateFilters({ target_date: val, tab: "pending" }); + return; + } + // switch to upcoming tab if target date is changed to other than none + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { + handleUpdateFilters({ + target_date: val, + tab: "upcoming", + }); + return; + } + + handleUpdateFilters({ target_date: val }); + }} />
t.key === widgetDetails.widget_filters.tab ?? "upcoming")} + selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)} onChange={(i) => { - const selectedTab = ISSUES_TABS_LIST[i]; - handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" }); + const selectedTab = tabsList[i]; + handleUpdateFilters({ tab: selectedTab.key ?? "pending" }); }} className="h-full flex flex-col" >
- +
- {ISSUES_TABS_LIST.map((tab) => ( + {tabsList.map((tab) => ( = (props) => { const filterParams = getRedirectionFilters(tab); const ISSUE_LIST_ITEM: { - [key in string]: { + [key: string]: { [key in TIssuesListTypes]: React.FC; }; } = { assigned: { + pending: AssignedUpcomingIssueListItem, upcoming: AssignedUpcomingIssueListItem, overdue: AssignedOverdueIssueListItem, completed: AssignedCompletedIssueListItem, }, created: { + pending: CreatedUpcomingIssueListItem, upcoming: CreatedUpcomingIssueListItem, overdue: CreatedOverdueIssueListItem, completed: CreatedCompletedIssueListItem, @@ -61,12 +63,7 @@ export const WidgetIssuesList: React.FC = (props) => { <>
{isLoading ? ( - - - - - - + <> ) : issues.length > 0 ? ( <>
@@ -81,7 +78,7 @@ export const WidgetIssuesList: React.FC = (props) => { {totalIssues} - {tab === "upcoming" &&
Due date
} + {["upcoming", "pending"].includes(tab) &&
Due date
} {tab === "overdue" &&
Due by
} {type === "assigned" && tab !== "completed" &&
Blocked by
} {type === "created" &&
Assigned to
} diff --git a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx index 6ef6ec0eea..9ce00a03c5 100644 --- a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx @@ -1,26 +1,63 @@ +import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; // helpers import { cn } from "helpers/common.helper"; +// types +import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types"; // constants -import { ISSUES_TABS_LIST } from "constants/dashboard"; +import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard"; -export const TabsList = () => ( - - {ISSUES_TABS_LIST.map((tab) => ( - - cn("font-semibold text-xs rounded py-1.5 focus:outline-none", { - "bg-custom-background-100 text-custom-text-300 shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selected, - "text-custom-text-400": !selected, - }) - } - > - {tab.label} - - ))} - -); +type Props = { + durationFilter: TDurationFilterOptions; + selectedTab: TIssuesListTypes; +}; + +export const TabsList: React.FC = observer((props) => { + const { durationFilter, selectedTab } = props; + + const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === (selectedTab ?? "pending")); + + return ( + +
+ {tabsList.map((tab) => ( + + {tab.label} + + ))} + + ); +}); diff --git a/web/components/dashboard/widgets/issues-by-priority.tsx b/web/components/dashboard/widgets/issues-by-priority.tsx index 45b71466d1..97884bccc1 100644 --- a/web/components/dashboard/widgets/issues-by-priority.tsx +++ b/web/components/dashboard/widgets/issues-by-priority.tsx @@ -84,16 +84,18 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => filters, }); + const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), }); }; useEffect(() => { + const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"), + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -129,21 +131,15 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => return (
-
-
- - Assigned by priority - -

- Filtered by{" "} - Due date -

-
+
+ + Assigned by priority + handleUpdateFilters({ target_date: val, diff --git a/web/components/dashboard/widgets/issues-by-state-group.tsx b/web/components/dashboard/widgets/issues-by-state-group.tsx index bd4171cfa2..2f7f6ffae8 100644 --- a/web/components/dashboard/widgets/issues-by-state-group.tsx +++ b/web/components/dashboard/widgets/issues-by-state-group.tsx @@ -43,17 +43,19 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) filters, }); + const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), }); }; // fetch widget stats useEffect(() => { + const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, - target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"), + ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -72,14 +74,14 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) startedCount > 0 ? "started" : unStartedCount > 0 - ? "unstarted" - : backlogCount > 0 - ? "backlog" - : completedCount > 0 - ? "completed" - : canceledCount > 0 - ? "cancelled" - : null; + ? "unstarted" + : backlogCount > 0 + ? "backlog" + : completedCount > 0 + ? "completed" + : canceledCount > 0 + ? "cancelled" + : null; setActiveStateGroup(stateGroup); setDefaultStateGroup(stateGroup); @@ -128,21 +130,15 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) return (
-
-
- - Assigned by state - -

- Filtered by{" "} - Due date -

-
+
+ + Assigned by state + handleUpdateFilters({ target_date: val, diff --git a/web/components/dropdowns/date.tsx b/web/components/dropdowns/date.tsx index 1dba6f7803..3cd9ca16a7 100644 --- a/web/components/dropdowns/date.tsx +++ b/web/components/dropdowns/date.tsx @@ -105,6 +105,7 @@ export const DateDropdown: React.FC = (props) => { tabIndex={tabIndex} className={cn("h-full", className)} onKeyDown={handleKeyDown} + disabled={disabled} > - - - )} + {canUserCreateIssue && ( + <> + + + + )} + +
+
+ +
); }); + + diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index a9ad3a01f2..a4bf963ab2 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -1,22 +1,24 @@ -import { FC } from "react"; +import { FC, useCallback } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { Plus } from "lucide-react"; +import { List, Plus } from "lucide-react"; // hooks import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // ui -import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; +import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // helpers import { renderEmoji } from "helpers/emoji.helper"; import { EUserProjectRoles } from "constants/project"; // components import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; +import { TCycleLayout } from "@plane/types"; +import { CYCLE_VIEW_LAYOUTS } from "constants/cycle"; +import useLocalStorage from "hooks/use-local-storage"; export const CyclesHeader: FC = observer(() => { // router const router = useRouter(); - const { workspaceSlug } = router.query; // store hooks const { commandPalette: { toggleCreateCycleModal }, @@ -30,54 +32,96 @@ export const CyclesHeader: FC = observer(() => { const canUserCreateCycle = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const { workspaceSlug } = router.query as { + workspaceSlug: string; + }; + const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + + const handleCurrentLayout = useCallback( + (_layout: TCycleLayout) => { + setCycleLayout(_layout); + }, + [setCycleLayout] + ); + return ( -
-
- -
- - - {currentProjectDetails?.name.charAt(0)} - - ) - } - /> - } - /> - } />} - /> - +
+
+
+ +
+ + + {currentProjectDetails?.name.charAt(0)} + + ) + } + /> + } + /> + } />} + /> + +
+ {canUserCreateCycle && ( +
+ +
+ )} +
+
+ + + Layout + + } + customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm" + closeOnSelect + > + {CYCLE_VIEW_LAYOUTS.map((layout) => ( + { + // handleLayoutChange(ISSUE_LAYOUTS[index].key); + handleCurrentLayout(layout.key as TCycleLayout); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
- {canUserCreateCycle && ( -
- -
- )}
); }); diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 70e4dbeeaa..6287223b07 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -23,7 +23,7 @@ import { BreadcrumbLink } from "components/common"; // ui import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui"; // icons -import { ArrowRight, Plus } from "lucide-react"; +import { ArrowRight, PanelRight, Plus } from "lucide-react"; // helpers import { truncateText } from "helpers/string.helper"; import { renderEmoji } from "helpers/emoji.helper"; @@ -32,6 +32,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // constants import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; import { EUserProjectRoles } from "constants/project"; +import { cn } from "helpers/common.helper"; +import { ModuleMobileHeader } from "components/modules/module-mobile-header"; const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { // router @@ -150,116 +152,127 @@ export const ModuleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} /> -
-
- - - - {currentProjectDetails?.name.charAt(0)} - - ) +
+
+
+ + + + + + {currentProjectDetails?.name.charAt(0)} + + ) + } + /> + + ... + + } + /> + } + /> + } + /> + + + {moduleDetails?.name && truncateText(moduleDetails.name, 40)} + + } + className="ml-1.5 flex-shrink-0" + placement="bottom-start" + > + {projectModuleIds?.map((moduleId) => ( + + ))} + + } + /> + +
+
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + - } - /> - } - /> - } - /> - - - {moduleDetails?.name && truncateText(moduleDetails.name, 40)} - + + + - {projectModuleIds?.map((moduleId) => ( - - ))} - - } - /> - -
-
- handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> - - - - - - + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} + displayProperties={issueFilters?.displayProperties ?? {}} + handleDisplayPropertiesUpdate={handleDisplayProperties} + /> + +
- {canUserCreateIssue && ( - <> - - - - )} - + {canUserCreateIssue && ( + <> + + + + )} + +
+
); diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 769d6c945f..c819b25c34 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; +import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; // hooks import { useApplication, @@ -29,6 +29,7 @@ import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } f import { renderEmoji } from "helpers/emoji.helper"; import { EUserProjectRoles } from "constants/project"; import { useIssues } from "hooks/store/use-issues"; +import { IssuesMobileHeader } from "components/issues/issues-mobile-header"; export const ProjectIssuesHeader: React.FC = observer(() => { // states @@ -114,118 +115,111 @@ export const ProjectIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} projectDetails={currentProjectDetails ?? undefined} /> -
-
- -
- +
+
+
+ +
+ + + {renderEmoji(currentProjectDetails.emoji)} + + ) : currentProjectDetails?.icon_prop ? ( +
+ {renderEmoji(currentProjectDetails.icon_prop)} +
+ ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) + ) : ( + + + + ) + } + /> + } + /> + + } />} + /> +
+
+ {currentProjectDetails?.is_deployed && deployUrl && ( + + + Public + + + )}
-
- - - {renderEmoji(currentProjectDetails.emoji)} - - ) : currentProjectDetails?.icon_prop ? ( -
- {renderEmoji(currentProjectDetails.icon_prop)} -
- ) : ( - - {currentProjectDetails?.name.charAt(0)} - - ) - ) : ( - - - - ) - } - /> +
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + + + + + + + {currentProjectDetails?.inbox_view && inboxDetails && ( + + + + + + )} - } />} - /> -
- {currentProjectDetails?.is_deployed && deployUrl && ( - - - Public - - - )} -
-
- handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> - - - - - - - - {currentProjectDetails?.inbox_view && inboxDetails && ( - - - - - - )} - {canUserCreateIssue && ( <> -
+
+ +
); diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 34e8ffa08c..34b1a6ef8d 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -23,7 +23,7 @@ export const ProjectsHeader = observer(() => { return (
-
+
@@ -34,12 +34,12 @@ export const ProjectsHeader = observer(() => {
-
+
{workspaceProjectIds && workspaceProjectIds?.length > 0 && ( -
- +
+ setSearchQuery(e.target.value)} placeholder="Search" @@ -54,6 +54,7 @@ export const ProjectsHeader = observer(() => { setTrackElement("Projects page"); commandPaletteStore.toggleCreateProjectModal(true); }} + className="items-center" > Add Project diff --git a/web/components/headers/workspace-analytics.tsx b/web/components/headers/workspace-analytics.tsx index 8bb4c92510..4d54dd9659 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/components/headers/workspace-analytics.tsx @@ -16,15 +16,6 @@ export const WorkspaceAnalyticsHeader = () => { >
-
- -
{ return ( <> -
+
@@ -37,13 +37,13 @@ export const WorkspaceDashboardHeader = () => { href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer" - className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium" + className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" > - {"What's new?"} + {"What's new?"} { width={16} alt="GitHub Logo" /> - Star us on GitHub + Star us on GitHub
diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 7cb53ad393..472d680858 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -11,11 +11,12 @@ import { TGroupedIssues, TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; import { EIssueActions } from "../types"; import { handleDragDrop } from "./utils"; -import { useIssues } from "hooks/store"; +import { useIssues, useUser } from "hooks/store"; import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; +import { EUserProjectRoles } from "constants/project"; interface IBaseCalendarRoot { issueStore: IProjectIssues | IModuleIssues | ICycleIssues | IProjectViewIssues; @@ -27,10 +28,11 @@ interface IBaseCalendarRoot { [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; }; viewId?: string; + isCompletedCycle?: boolean; } export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { - const { issueStore, issuesFilterStore, QuickActions, issueActions, viewId } = props; + const { issueStore, issuesFilterStore, QuickActions, issueActions, viewId, isCompletedCycle = false } = props; // router const router = useRouter(); @@ -39,6 +41,11 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { // hooks const { setToastAlert } = useToast(); const { issueMap } = useIssues(); + const { + membership: { currentProjectRole }, + } = useUser(); + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const displayFilters = issuesFilterStore.issueFilters?.displayFilters; @@ -107,10 +114,12 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { ? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE) : undefined } + readOnly={!isEditingAllowed || isCompletedCycle} /> )} quickAddCallback={issueStore.quickAddIssue} viewId={viewId} + readOnly={!isEditingAllowed || isCompletedCycle} />
diff --git a/web/components/issues/issue-layouts/calendar/calendar.tsx b/web/components/issues/issue-layouts/calendar/calendar.tsx index 1652aa89bf..c48f23068b 100644 --- a/web/components/issues/issue-layouts/calendar/calendar.tsx +++ b/web/components/issues/issue-layouts/calendar/calendar.tsx @@ -31,11 +31,21 @@ type Props = { viewId?: string ) => Promise; viewId?: string; + readOnly?: boolean; }; export const CalendarChart: React.FC = observer((props) => { - const { issuesFilterStore, issues, groupedIssueIds, layout, showWeekends, quickActions, quickAddCallback, viewId } = - props; + const { + issuesFilterStore, + issues, + groupedIssueIds, + layout, + showWeekends, + quickActions, + quickAddCallback, + viewId, + readOnly = false, + } = props; // store hooks const { issues: { viewFlags }, @@ -80,6 +90,7 @@ export const CalendarChart: React.FC = observer((props) => { quickActions={quickActions} quickAddCallback={quickAddCallback} viewId={viewId} + readOnly={readOnly} /> ))}
@@ -95,6 +106,7 @@ export const CalendarChart: React.FC = observer((props) => { quickActions={quickActions} quickAddCallback={quickAddCallback} viewId={viewId} + readOnly={readOnly} /> )}
diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index 5b4885bf3e..f98dba93a4 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -28,6 +28,7 @@ type Props = { viewId?: string ) => Promise; viewId?: string; + readOnly?: boolean; }; export const CalendarDayTile: React.FC = observer((props) => { @@ -41,6 +42,7 @@ export const CalendarDayTile: React.FC = observer((props) => { disableIssueCreation, quickAddCallback, viewId, + readOnly = false, } = props; const [showAllIssues, setShowAllIssues] = useState(false); const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month"; @@ -73,7 +75,7 @@ export const CalendarDayTile: React.FC = observer((props) => { {/* content */}
- + {(provided, snapshot) => (
= observer((props) => { issueIdList={issueIdList} quickActions={quickActions} showAllIssues={showAllIssues} + isDragDisabled={readOnly} /> - {enableQuickIssueCreate && !disableIssueCreation && ( + {enableQuickIssueCreate && !disableIssueCreation && !readOnly && (
React.ReactNode; showAllIssues?: boolean; + isDragDisabled?: boolean; }; export const CalendarIssueBlocks: React.FC = observer((props) => { - const { issues, issueIdList, quickActions, showAllIssues = false } = props; + const { issues, issueIdList, quickActions, showAllIssues = false, isDragDisabled = false } = props; // hooks const { router: { workspaceSlug, projectId }, @@ -65,7 +66,7 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || ""; return ( - + {(provided, snapshot) => (
{ const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCompletedCycleIds } = useCycle(); const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; @@ -38,6 +39,9 @@ export const CycleCalendarLayout: React.FC = observer(() => { if (!cycleId) return null; + const isCompletedCycle = + cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; + return ( { QuickActions={CycleIssueQuickActions} issueActions={issueActions} viewId={cycleId.toString()} + isCompletedCycle={isCompletedCycle} /> ); }); diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index c34aaef972..5a640a5669 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -26,6 +26,7 @@ type Props = { viewId?: string ) => Promise; viewId?: string; + readOnly?: boolean; }; export const CalendarWeekDays: React.FC = observer((props) => { @@ -39,6 +40,7 @@ export const CalendarWeekDays: React.FC = observer((props) => { disableIssueCreation, quickAddCallback, viewId, + readOnly = false, } = props; const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month"; @@ -67,6 +69,7 @@ export const CalendarWeekDays: React.FC = observer((props) => { disableIssueCreation={disableIssueCreation} quickAddCallback={quickAddCallback} viewId={viewId} + readOnly={readOnly} /> ); })} diff --git a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx index 425f53b46a..33b86ada1f 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx @@ -13,10 +13,11 @@ type Props = { placement?: Placement; disabled?: boolean; tabIndex?: number; + menuButton?: React.ReactNode; }; export const FiltersDropdown: React.FC = (props) => { - const { children, title = "Dropdown", placement, disabled = false, tabIndex } = props; + const { children, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props; const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -33,7 +34,9 @@ export const FiltersDropdown: React.FC = (props) => { return ( <> - : + } Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; + isCompletedCycle?: boolean; } type KanbanDragState = { @@ -65,6 +66,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas storeType, addIssuesToView, canEditPropertiesBasedOnProject, + isCompletedCycle = false, } = props; // router const router = useRouter(); @@ -183,6 +185,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + readOnly={!isEditingAllowed || isCompletedCycle} /> ), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -282,7 +285,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas showEmptyGroup={userDisplayFilters?.show_empty_groups || true} quickAddCallback={issues?.quickAddIssue} viewId={viewId} - disableIssueCreation={!enableIssueCreation || !isEditingAllowed} + disableIssueCreation={!enableIssueCreation || !isEditingAllowed || isCompletedCycle} canEditProperties={canEditProperties} storeType={storeType} addIssuesToView={addIssuesToView} diff --git a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx index 0903355cee..2b311f6eb9 100644 --- a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useIssues } from "hooks/store"; +import { useCycle, useIssues } from "hooks/store"; // ui import { CycleIssueQuickActions } from "components/issues"; // types @@ -20,6 +20,7 @@ export const CycleKanBanLayout: React.FC = observer(() => { // store const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCompletedCycleIds } = useCycle(); const issueActions = useMemo( () => ({ @@ -42,6 +43,11 @@ export const CycleKanBanLayout: React.FC = observer(() => { [issues, workspaceSlug, cycleId] ); + const isCompletedCycle = + cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; + + const canEditIssueProperties = () => !isCompletedCycle; + return ( { if (!workspaceSlug || !projectId || !cycleId) throw new Error(); return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); }} + canEditPropertiesBasedOnProject={canEditIssueProperties} + isCompletedCycle={isCompletedCycle} /> ); }); diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 10f3582f14..8f661a9e61 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -51,6 +51,7 @@ interface IBaseListRoot { storeType: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; + isCompletedCycle?: boolean; } export const BaseListRoot = observer((props: IBaseListRoot) => { @@ -63,6 +64,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { storeType, addIssuesToView, canEditPropertiesBasedOnProject, + isCompletedCycle = false, } = props; // mobx store const { @@ -112,6 +114,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + readOnly={!isEditingAllowed || isCompletedCycle} /> ), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -136,6 +139,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { disableIssueCreation={!enableIssueCreation || !isEditingAllowed} storeType={storeType} addIssuesToView={addIssuesToView} + isCompletedCycle={isCompletedCycle} />
diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 95e31b758c..dd6c8da228 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -37,6 +37,7 @@ export interface IGroupByList { storeType: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; viewId?: string; + isCompletedCycle?: boolean; } const GroupByList: React.FC = (props) => { @@ -55,6 +56,7 @@ const GroupByList: React.FC = (props) => { disableIssueCreation, storeType, addIssuesToView, + isCompletedCycle = false, } = props; // store hooks const member = useMember(); @@ -115,7 +117,7 @@ const GroupByList: React.FC = (props) => { title={_list.name || ""} count={is_list ? issueIds?.length || 0 : issueIds?.[_list.id]?.length || 0} issuePayload={_list.payload} - disableIssueCreation={disableIssueCreation || isGroupByCreatedBy} + disableIssueCreation={disableIssueCreation || isGroupByCreatedBy || isCompletedCycle} storeType={storeType} addIssuesToView={addIssuesToView} /> @@ -132,7 +134,7 @@ const GroupByList: React.FC = (props) => { /> )} - {enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && ( + {enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && (
Promise; + isCompletedCycle?: boolean; } export const List: React.FC = (props) => { @@ -186,6 +189,7 @@ export const List: React.FC = (props) => { disableIssueCreation, storeType, addIssuesToView, + isCompletedCycle = false, } = props; return ( @@ -205,6 +209,7 @@ export const List: React.FC = (props) => { disableIssueCreation={disableIssueCreation} storeType={storeType} addIssuesToView={addIssuesToView} + isCompletedCycle={isCompletedCycle} />
); diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index 9e3bb8701f..1838316cb9 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -5,4 +5,5 @@ export interface IQuickActionProps { handleRemoveFromView?: () => Promise; customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; + readOnly?: boolean; } diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index 89da8dd54b..e30c207b64 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useIssues } from "hooks/store"; +import { useCycle, useIssues } from "hooks/store"; // components import { CycleIssueQuickActions } from "components/issues"; // types @@ -19,6 +19,7 @@ export const CycleListLayout: React.FC = observer(() => { const { workspaceSlug, projectId, cycleId } = router.query; // store const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCompletedCycleIds } = useCycle(); const issueActions = useMemo( () => ({ @@ -40,6 +41,10 @@ export const CycleListLayout: React.FC = observer(() => { }), [issues, workspaceSlug, cycleId] ); + const isCompletedCycle = + cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; + + const canEditIssueProperties = () => !isCompletedCycle; return ( { if (!workspaceSlug || !projectId || !cycleId) throw new Error(); return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); }} + canEditPropertiesBasedOnProject={canEditIssueProperties} + isCompletedCycle={isCompletedCycle} /> ); }); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index 764f5605a5..f97f1ea86c 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -16,7 +16,7 @@ import { IQuickActionProps } from "../list/list-view-types"; import { EIssuesStoreType } from "constants/issue"; export const AllIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton, portalElement } = props; + const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -82,40 +82,44 @@ export const AllIssueQuickActions: React.FC = (props) => { Copy link
- { - setTrackElement("Global issues"); + {!readOnly && ( + <> + { + setTrackElement("Global issues"); setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }} + > +
+ + Edit issue +
+
+ { + setTrackElement("Global issues"); setCreateUpdateIssueModal(true); - }} - > -
- - Edit issue -
-
- { - setTrackElement("Global issues"); - setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- { - setTrackElement("Global issues"); + }} + > +
+ + Make a copy +
+
+ { + setTrackElement("Global issues"); setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
+ }} + > +
+ + Delete issue +
+
+ + )} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 2926b64337..f962701769 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -4,18 +4,18 @@ import { CustomMenu } from "@plane/ui"; import { Link, Trash2 } from "lucide-react"; // hooks import useToast from "hooks/use-toast"; -import { useEventTracker, useIssues } from "hooks/store"; +import { useEventTracker, useIssues ,useUser} from "hooks/store"; // components import { DeleteArchivedIssueModal } from "components/issues"; // helpers import { copyUrlToClipboard } from "helpers/string.helper"; // types import { IQuickActionProps } from "../list/list-view-types"; -// constants +import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; export const ArchivedIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, customActionButton, portalElement } = props; + const { issue, handleDelete, customActionButton, portalElement, readOnly = false } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -23,6 +23,13 @@ export const ArchivedIssueQuickActions: React.FC = (props) => const [deleteIssueModal, setDeleteIssueModal] = useState(false); // toast alert const { setToastAlert } = useToast(); + + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; // store hooks const { setTrackElement } = useEventTracker(); const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); @@ -64,17 +71,19 @@ export const ArchivedIssueQuickActions: React.FC = (props) => Copy link
- { - setTrackElement(activeLayout); + {isEditingAllowed && !readOnly && ( + { + setTrackElement(activeLayout); setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
+ }} + > +
+ + Delete issue +
+
+ )} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 6e6c2e5182..8c3beb3d2c 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -4,7 +4,7 @@ import { CustomMenu } from "@plane/ui"; import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react"; // hooks import useToast from "hooks/use-toast"; -import { useEventTracker, useIssues } from "hooks/store"; +import { useEventTracker, useIssues,useUser } from "hooks/store"; // components import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; // helpers @@ -14,9 +14,18 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; // constants import { EIssuesStoreType } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; export const CycleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; + const { + issue, + handleDelete, + handleUpdate, + handleRemoveFromView, + customActionButton, + portalElement, + readOnly = false, + } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -30,6 +39,13 @@ export const CycleIssueQuickActions: React.FC = (props) => { // toast alert const { setToastAlert } = useToast(); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const handleCopyIssueLink = () => { @@ -85,53 +101,57 @@ export const CycleIssueQuickActions: React.FC = (props) => { Copy link
- { - setIssueToEdit({ - ...issue, - cycle: cycleId?.toString() ?? null, - }); - setTrackElement(activeLayout); + {isEditingAllowed && !readOnly && ( + <> + { + setIssueToEdit({ + ...issue, + cycle: cycleId?.toString() ?? null, + }); + setTrackElement(activeLayout); setCreateUpdateIssueModal(true); - }} - > -
- - Edit issue -
-
- { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from cycle -
-
- { - setTrackElement(activeLayout); + }} + > +
+ + Edit issue +
+
+ { + handleRemoveFromView && handleRemoveFromView(); + }} + > +
+ + Remove from cycle +
+
+ { + setTrackElement(activeLayout); setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- { - setTrackElement(activeLayout); + }} + > +
+ + Make a copy +
+
+ { + setTrackElement(activeLayout); setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
+ }} + > +
+ + Delete issue +
+
+ + )} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index ed52c9198b..a3ed73ec0c 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -4,7 +4,7 @@ import { CustomMenu } from "@plane/ui"; import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react"; // hooks import useToast from "hooks/use-toast"; -import { useIssues, useEventTracker } from "hooks/store"; +import { useIssues, useEventTracker ,useUser } from "hooks/store"; // components import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; // helpers @@ -14,9 +14,18 @@ import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; // constants import { EIssuesStoreType } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; export const ModuleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; + const { + issue, + handleDelete, + handleUpdate, + handleRemoveFromView, + customActionButton, + portalElement, + readOnly = false, + } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -30,6 +39,13 @@ export const ModuleIssueQuickActions: React.FC = (props) => { // toast alert const { setToastAlert } = useToast(); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const handleCopyIssueLink = () => { @@ -85,52 +101,56 @@ export const ModuleIssueQuickActions: React.FC = (props) => { Copy link
- { - setIssueToEdit({ ...issue, module: moduleId?.toString() ?? null }); - setTrackElement(activeLayout); + {isEditingAllowed && !readOnly && ( + <> + { + setIssueToEdit({ ...issue, module: moduleId?.toString() ?? null }); + setTrackElement(activeLayout); setCreateUpdateIssueModal(true); - }} - > -
- - Edit issue -
-
- { - handleRemoveFromView && handleRemoveFromView(); - }} - > -
- - Remove from module -
-
- { - setTrackElement(activeLayout); + }} + > +
+ + Edit issue +
+
+ { + handleRemoveFromView && handleRemoveFromView(); + }} + > +
+ + Remove from module +
+
+ { + setTrackElement(activeLayout); setCreateUpdateIssueModal(true); - }} - > -
- - Make a copy -
-
- { - e.preventDefault(); - e.stopPropagation(); - setTrackElement(activeLayout); + }} + > +
+ + Make a copy +
+
+ { + e.preventDefault(); + e.stopPropagation(); + setTrackElement(activeLayout); setDeleteIssueModal(true); - }} - > -
- - Delete issue -
-
+ }} + > +
+ + Delete issue +
+
+ + )} ); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index e28dd9a42a..13c7ac02ae 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -17,7 +17,7 @@ import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; export const ProjectIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton, portalElement } = props; + const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -91,7 +91,7 @@ export const ProjectIssueQuickActions: React.FC = (props) => Copy link
- {isEditingAllowed && ( + {isEditingAllowed && !readOnly && ( <> { diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 54df6ca244..e4efc51374 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -28,10 +28,19 @@ interface IBaseSpreadsheetRoot { [EIssueActions.REMOVE]?: (issue: TIssue) => void; }; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; + isCompletedCycle?: boolean; } export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { - const { issueFiltersStore, issueStore, viewId, QuickActions, issueActions, canEditPropertiesBasedOnProject } = props; + const { + issueFiltersStore, + issueStore, + viewId, + QuickActions, + issueActions, + canEditPropertiesBasedOnProject, + isCompletedCycle = false, + } = props; // router const router = useRouter(); const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; @@ -95,6 +104,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } portalElement={portalElement} + readOnly={!isEditingAllowed || isCompletedCycle} /> ), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -113,7 +123,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { quickAddCallback={issueStore.quickAddIssue} viewId={viewId} enableQuickCreateIssue={enableQuickAdd} - disableIssueCreation={!enableIssueCreation || !isEditingAllowed} + disableIssueCreation={!enableIssueCreation || !isEditingAllowed || isCompletedCycle} /> ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx index 40b9335579..7f92bd74ca 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // mobx store -import { useIssues } from "hooks/store"; +import { useCycle, useIssues } from "hooks/store"; // components import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; import { EIssueActions } from "../../types"; @@ -15,6 +15,7 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => { const { workspaceSlug, cycleId } = router.query as { workspaceSlug: string; cycleId: string }; const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCompletedCycleIds } = useCycle(); const issueActions = useMemo( () => ({ @@ -35,6 +36,11 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => { [issues, workspaceSlug, cycleId] ); + const isCompletedCycle = + cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; + + const canEditIssueProperties = () => !isCompletedCycle; + return ( { viewId={cycleId} issueActions={issueActions} QuickActions={CycleIssueQuickActions} + canEditPropertiesBasedOnProject={canEditIssueProperties} + isCompletedCycle={isCompletedCycle} /> ); }); diff --git a/web/components/issues/issues-mobile-header.tsx b/web/components/issues/issues-mobile-header.tsx new file mode 100644 index 0000000000..2338e18481 --- /dev/null +++ b/web/components/issues/issues-mobile-header.tsx @@ -0,0 +1,166 @@ +import { useCallback, useState } from "react"; +import router from "next/router"; +// components +import { CustomMenu } from "@plane/ui"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// constants +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue"; +// hooks +import { useIssues, useLabel, useMember, useProject, useProjectState } from "hooks/store"; +// layouts +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "./issue-layouts"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { ProjectAnalyticsModal } from "components/analytics"; + +export const IssuesMobileHeader = () => { + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + const [analyticsModal, setAnalyticsModal] = useState(false); + const { workspaceSlug, projectId } = router.query as { + workspaceSlug: string; + projectId: string; + }; + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + project: { projectMemberIds }, + } = useMember(); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); + }, + [workspaceSlug, projectId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [workspaceSlug, projectId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + projectDetails={currentProjectDetails ?? undefined} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ + Filters + + + } + > + + +
+
+ + Display + + + } + > + + +
+ + +
+ + ); +}; diff --git a/web/components/modules/form.tsx b/web/components/modules/form.tsx index 661d31ef55..be0792caad 100644 --- a/web/components/modules/form.tsx +++ b/web/components/modules/form.tsx @@ -70,7 +70,7 @@ export const ModuleForm: React.FC = ({ const startDate = watch("start_date"); const targetDate = watch("target_date"); - const minDate = startDate ? new Date(startDate) : new Date(); + const minDate = startDate ? new Date(startDate) : null; minDate?.setDate(minDate.getDate()); const maxDate = targetDate ? new Date(targetDate) : null; @@ -159,7 +159,6 @@ export const ModuleForm: React.FC = ({ onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)} buttonVariant="border-with-text" placeholder="Start date" - minDate={new Date()} maxDate={maxDate ?? undefined} tabIndex={3} /> diff --git a/web/components/modules/module-mobile-header.tsx b/web/components/modules/module-mobile-header.tsx new file mode 100644 index 0000000000..b08c443d2c --- /dev/null +++ b/web/components/modules/module-mobile-header.tsx @@ -0,0 +1,162 @@ +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +import { ProjectAnalyticsModal } from "components/analytics"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue"; +import { useIssues, useLabel, useMember, useModule, useProjectState } from "hooks/store"; +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +import router from "next/router"; +import { useCallback, useState } from "react"; + +export const ModuleMobileHeader = () => { + const [analyticsModal, setAnalyticsModal] = useState(false); + const { getModuleById } = useModule(); + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + const { workspaceSlug, projectId, moduleId } = router.query as { + workspaceSlug: string; + projectId: string; + moduleId: string; + }; + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + const activeLayout = issueFilters?.displayFilters?.layout; + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId); + }, + [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ + Filters + + + } + > + + +
+
+ + Display + + + } + > + + +
+ + +
+ + ); +}; diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index c724780051..947885f9af 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -168,18 +168,6 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { if (!watch("target_date") || watch("target_date") === "") endDateButtonRef.current?.click(); if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("target_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create module in past date. Please enter a valid date.", - }); - reset({ - ...moduleDetails, - }); - return; - } - submitChanges({ start_date: renderFormattedPayloadDate(`${watch("start_date")}`), target_date: renderFormattedPayloadDate(`${watch("target_date")}`), @@ -198,18 +186,6 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { if (!watch("start_date") || watch("start_date") === "") endDateButtonRef.current?.click(); if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("target_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create module in past date. Please enter a valid date.", - }); - reset({ - ...moduleDetails, - }); - return; - } - submitChanges({ start_date: renderFormattedPayloadDate(`${watch("start_date")}`), target_date: renderFormattedPayloadDate(`${watch("target_date")}`), diff --git a/web/components/page-views/workspace-dashboard.tsx b/web/components/page-views/workspace-dashboard.tsx index dc9c4de612..93756d22a2 100644 --- a/web/components/page-views/workspace-dashboard.tsx +++ b/web/components/page-views/workspace-dashboard.tsx @@ -62,16 +62,18 @@ export const WorkspaceDashboardView = observer(() => { {homeDashboardId && joinedProjectIds ? ( <> {joinedProjectIds.length > 0 ? ( -
+ <> - {currentUser && } - {currentUser && !currentUser.is_tour_completed && ( -
- -
- )} - -
+
+ {currentUser && } + {currentUser && !currentUser.is_tour_completed && ( +
+ +
+ )} + +
+ ) : ( { const isDarkMode = currentUser?.theme.theme === "dark"; return ( - -
+
{WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
diff --git a/web/constants/dashboard.ts b/web/constants/dashboard.ts index d23480cfe5..3f251ca781 100644 --- a/web/constants/dashboard.ts +++ b/web/constants/dashboard.ts @@ -121,21 +121,25 @@ export const DURATION_FILTER_OPTIONS: { key: TDurationFilterOptions; label: string; }[] = [ + { + key: "none", + label: "None", + }, { key: "today", - label: "Today", + label: "Due today", }, { key: "this_week", - label: "This week", + label: " Due this week", }, { key: "this_month", - label: "This month", + label: "Due this month", }, { key: "this_year", - label: "This year", + label: "Due this year", }, ]; @@ -152,7 +156,7 @@ export const PROJECT_BACKGROUND_COLORS = [ ]; // assigned and created issues widgets tabs list -export const ISSUES_TABS_LIST: { +export const FILTERED_ISSUES_TABS_LIST: { key: TIssuesListTypes; label: string; }[] = [ @@ -170,7 +174,27 @@ export const ISSUES_TABS_LIST: { }, ]; +// assigned and created issues widgets tabs list +export const UNFILTERED_ISSUES_TABS_LIST: { + key: TIssuesListTypes; + label: string; +}[] = [ + { + key: "pending", + label: "Pending", + }, + { + key: "completed", + label: "Marked completed", + }, +]; + export const ASSIGNED_ISSUES_EMPTY_STATES = { + pending: { + title: "Issues assigned to you that are pending\nwill show up here.", + darkImage: UpcomingIssuesDark, + lightImage: UpcomingIssuesLight, + }, upcoming: { title: "Upcoming issues assigned to\nyou will show up here.", darkImage: UpcomingIssuesDark, @@ -189,6 +213,11 @@ export const ASSIGNED_ISSUES_EMPTY_STATES = { }; export const CREATED_ISSUES_EMPTY_STATES = { + pending: { + title: "Issues created by you that are pending\nwill show up here.", + darkImage: UpcomingIssuesDark, + lightImage: UpcomingIssuesLight, + }, upcoming: { title: "Upcoming issues you created\nwill show up here.", darkImage: UpcomingIssuesDark, diff --git a/web/helpers/dashboard.helper.ts b/web/helpers/dashboard.helper.ts index 2a8ade4fa5..8003f15e32 100644 --- a/web/helpers/dashboard.helper.ts +++ b/web/helpers/dashboard.helper.ts @@ -9,6 +9,8 @@ export const getCustomDates = (duration: TDurationFilterOptions): string => { let firstDay, lastDay; switch (duration) { + case "none": + return ""; case "today": firstDay = renderFormattedPayloadDate(today); lastDay = renderFormattedPayloadDate(today); @@ -32,7 +34,9 @@ export const getRedirectionFilters = (type: TIssuesListTypes): string => { const today = renderFormattedPayloadDate(new Date()); const filterParams = - type === "upcoming" + type === "pending" + ? "?state_group=backlog,unstarted,started" + : type === "upcoming" ? `?target_date=${today};after` : type === "overdue" ? `?target_date=${today};before` diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index 22ec70b310..0541dfce41 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -108,14 +108,13 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)} onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")} > -
+
{CYCLE_TAB_LIST.map((tab) => ( - `border-b-2 p-4 text-sm font-medium outline-none ${ - selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" + `border-b-2 p-4 text-sm font-medium outline-none ${selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" }` } > @@ -123,32 +122,32 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { ))} - {cycleTab !== "active" && ( -
- {CYCLE_VIEW_LAYOUTS.map((layout) => { - if (layout.key === "gantt" && cycleTab === "draft") return null; +
+ {cycleTab !== "active" && ( +
+ {CYCLE_VIEW_LAYOUTS.map((layout) => { + if (layout.key === "gantt" && cycleTab === "draft") return null; - return ( - - - - ); - })} -
- )} + return ( + + + + ); + })} +
+ )} +