From ea78c2bcebbaa5a98ee99246dd05be6dadc7f093 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 24 Dec 2024 14:01:47 +0530 Subject: [PATCH 01/92] fix: active cycle update payload (#6270) --- web/core/components/cycles/modal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/core/components/cycles/modal.tsx b/web/core/components/cycles/modal.tsx index f8b1590c488..25fabc3f2aa 100644 --- a/web/core/components/cycles/modal.tsx +++ b/web/core/components/cycles/modal.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; +import { format } from "date-fns"; import { mutate } from "swr"; // types import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types"; @@ -131,8 +132,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { if (payload.start_date && payload.end_date) { if (data?.start_date && data?.end_date) isDateValid = await dateChecker(payload.project_id ?? projectId, { - start_date: payload.start_date, - end_date: payload.end_date, + start_date: format(payload.start_date, "yyyy-MM-dd"), + end_date: format(payload.end_date, "yyyy-MM-dd"), cycle_id: data.id, }); else From ff936887d204efa306a63ac2c93c658bf0ceffac Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Tue, 24 Dec 2024 20:51:15 +0530 Subject: [PATCH 02/92] chore: quick link migration (#6274) * chore: added workspace link queryset * chore: added workspace in sort order --- ...088_sticky_sort_order_workspaceuserlink.py | 112 ++++++++++++++++++ apiserver/plane/db/models/__init__.py | 1 + apiserver/plane/db/models/page.py | 2 +- apiserver/plane/db/models/sticky.py | 16 +++ apiserver/plane/db/models/workspace.py | 20 ++++ 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py diff --git a/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py new file mode 100644 index 00000000000..0efdf41f28d --- /dev/null +++ b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py @@ -0,0 +1,112 @@ +# Generated by Django 4.2.15 on 2024-12-24 14:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0087_remove_issueversion_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name="sticky", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.CreateModel( + name="WorkspaceUserLink", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(blank=True, max_length=255, null=True)), + ("url", models.TextField()), + ("metadata", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_workspace_user_link", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace User Link", + "verbose_name_plural": "Workspace User Links", + "db_table": "workspace_user_links", + "ordering": ("-created_at",), + }, + ), + migrations.AlterField( + model_name="pagelog", + name="entity_name", + field=models.CharField(max_length=30, verbose_name="Transaction Type"), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 1cbd6276161..216e445e6b2 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -68,6 +68,7 @@ WorkspaceMemberInvite, WorkspaceTheme, WorkspaceUserProperties, + WorkspaceUserLink, ) from .favorite import UserFavorite diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 81e2b15a0fc..7ebf5ff6003 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -90,7 +90,7 @@ class PageLog(BaseModel): page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE) entity_identifier = models.UUIDField(null=True) entity_name = models.CharField( - max_length=30, choices=TYPE_CHOICES, verbose_name="Transaction Type" + max_length=30, verbose_name="Transaction Type" ) workspace = models.ForeignKey( "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" diff --git a/apiserver/plane/db/models/sticky.py b/apiserver/plane/db/models/sticky.py index 5f1c62660ba..96060d9e2e0 100644 --- a/apiserver/plane/db/models/sticky.py +++ b/apiserver/plane/db/models/sticky.py @@ -24,9 +24,25 @@ class Sticky(BaseModel): owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies" ) + sort_order = models.FloatField(default=65535) class Meta: verbose_name = "Sticky" verbose_name_plural = "Stickies" db_table = "stickies" ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self._state.adding: + # Get the maximum sequence value from the database + last_id = Sticky.objects.filter(workspace=self.workspace).aggregate( + largest=models.Max("sort_order") + )["largest"] + # if last_id is not None + if last_id is not None: + self.sort_order = last_id + 10000 + + super(Sticky, self).save(*args, **kwargs) + + def __str__(self): + return str(self.name) diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index f8082e492b3..4a879aacc12 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -322,3 +322,23 @@ class Meta: def __str__(self): return f"{self.workspace.name} {self.user.email}" + + +class WorkspaceUserLink(WorkspaceBaseModel): + title = models.CharField(max_length=255, null=True, blank=True) + url = models.TextField() + metadata = models.JSONField(default=dict) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owner_workspace_user_link", + ) + + class Meta: + verbose_name = "Workspace User Link" + verbose_name_plural = "Workspace User Links" + db_table = "workspace_user_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.id} {self.url}" \ No newline at end of file From fedcdf0c84335a14a8092dbf9d0129e93457090f Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 24 Dec 2024 20:52:03 +0530 Subject: [PATCH 03/92] [WEB-2879] chore sub issue analytics improvements (#6275) * chore: epics type added to package * chore: epic analytics helper function added * chore: sub issue analytics mutation improvement --- packages/types/src/epics.d.ts | 16 +++++ packages/types/src/index.d.ts | 1 + web/ce/helpers/epic-analytics.ts | 15 +++++ .../sub-issues/helper.tsx | 64 ++++++++++++++++--- .../issues/sub-issues/issue-list-item.tsx | 10 +-- .../issues/sub-issues/properties.tsx | 4 +- .../store/issue/helpers/base-issues.store.ts | 11 +++- web/ee/helpers/epic-analytics.ts | 1 + 8 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 packages/types/src/epics.d.ts create mode 100644 web/ce/helpers/epic-analytics.ts create mode 100644 web/ee/helpers/epic-analytics.ts diff --git a/packages/types/src/epics.d.ts b/packages/types/src/epics.d.ts new file mode 100644 index 00000000000..1ba50e2f2f3 --- /dev/null +++ b/packages/types/src/epics.d.ts @@ -0,0 +1,16 @@ +export type TEpicAnalyticsGroup = + | "backlog_issues" + | "unstarted_issues" + | "started_issues" + | "completed_issues" + | "cancelled_issues" + | "overdue_issues"; + +export type TEpicAnalytics = { + backlog_issues: number; + unstarted_issues: number; + started_issues: number; + completed_issues: number; + cancelled_issues: number; + overdue_issues: number; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 43cc3084a39..af1e3ff485d 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -36,3 +36,4 @@ export * from "./workspace-draft-issues/base"; export * from "./command-palette"; export * from "./timezone"; export * from "./activity"; +export * from "./epics"; diff --git a/web/ce/helpers/epic-analytics.ts b/web/ce/helpers/epic-analytics.ts new file mode 100644 index 00000000000..43e6ffef05d --- /dev/null +++ b/web/ce/helpers/epic-analytics.ts @@ -0,0 +1,15 @@ +import { TEpicAnalyticsGroup } from "@plane/types"; + +export const updateEpicAnalytics = () => { + const updateAnalytics = ( + workspaceSlug: string, + projectId: string, + epicId: string, + data: { + incrementStateGroupCount?: TEpicAnalyticsGroup; + decrementStateGroupCount?: TEpicAnalyticsGroup; + } + ) => {}; + + return { updateAnalytics }; +}; diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx index cc8abd82fd4..b67c1a7ee7d 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/helper.tsx @@ -1,13 +1,15 @@ "use client"; import { useMemo } from "react"; -import { usePathname } from "next/navigation"; +import { useParams, usePathname } from "next/navigation"; import { EIssueServiceType } from "@plane/constants"; import { TIssue, TIssueServiceType } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; // helper import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks -import { useEventTracker, useIssueDetail } from "@/hooks/store"; +import { useEventTracker, useIssueDetail, useProjectState } from "@/hooks/store"; +// plane-web +import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics"; // type import { TSubIssueOperations } from "../../sub-issues"; @@ -20,16 +22,26 @@ export type TRelationIssueOperations = { export const useSubIssueOperations = ( issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES ): TSubIssueOperations => { + // router + const { epicId: epicIdParam } = useParams(); + const pathname = usePathname(); + // store hooks const { + issue: { getIssueById }, subIssues: { setSubIssueHelpers }, - fetchSubIssues, createSubIssues, updateSubIssue, deleteSubIssue, } = useIssueDetail(); - const { removeSubIssue } = useIssueDetail(issueServiceType); + const { getStateById } = useProjectState(); + const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS); + // const { updateEpicAnalytics } = useIssueTypes(); + const { updateAnalytics } = updateEpicAnalytics(); + const { fetchSubIssues, removeSubIssue } = useIssueDetail(issueServiceType); const { captureIssueEvent } = useEventTracker(); - const pathname = usePathname(); + + // derived values + const epicId = epicIdParam || epicPeekIssue?.issueId; const subIssueOperations: TSubIssueOperations = useMemo( () => ({ @@ -39,7 +51,7 @@ export const useSubIssueOperations = ( setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", - message: "Issue link copied to clipboard.", + message: `${issueServiceType === EIssueServiceType.ISSUES ? "Issue" : "Epic"} link copied to clipboard`, }); }); }, @@ -50,7 +62,7 @@ export const useSubIssueOperations = ( setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Error fetching sub-issues", + message: `Error fetching ${issueServiceType === EIssueServiceType.ISSUES ? "sub-issues" : "issues"}`, }); } }, @@ -60,13 +72,13 @@ export const useSubIssueOperations = ( setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", - message: "Sub-issues added successfully", + message: `${issueServiceType === EIssueServiceType.ISSUES ? "Sub-issues" : "Issues"} added successfully`, }); } catch (error) { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: "Error adding sub-issue", + message: `Error adding ${issueServiceType === EIssueServiceType.ISSUES ? "sub-issues" : "issues"}`, }); } }, @@ -82,6 +94,30 @@ export const useSubIssueOperations = ( try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal); + + if (issueServiceType === EIssueServiceType.EPICS) { + const oldState = getStateById(oldIssue?.state_id)?.group; + + if (oldState && oldIssue && issueData && epicId) { + // Check if parent_id is changed if yes then decrement the epic analytics count + if (issueData.parent_id && oldIssue?.parent_id && issueData.parent_id !== oldIssue?.parent_id) { + updateAnalytics(workspaceSlug, projectId, epicId.toString(), { + decrementStateGroupCount: `${oldState}_issues`, + }); + } + + // Check if state_id is changed if yes then decrement the old state group count and increment the new state group count + if (issueData.state_id) { + const newState = getStateById(issueData.state_id)?.group; + if (oldState && newState && oldState !== newState) { + updateAnalytics(workspaceSlug, projectId, epicId.toString(), { + decrementStateGroupCount: `${oldState}_issues`, + incrementStateGroupCount: `${newState}_issues`, + }); + } + } + } + } captureIssueEvent({ eventName: "Sub-issue updated", payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" }, @@ -118,6 +154,16 @@ export const useSubIssueOperations = ( try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId); + if (issueServiceType === EIssueServiceType.EPICS) { + const issueBeforeRemoval = getIssueById(issueId); + const oldState = getStateById(issueBeforeRemoval?.state_id)?.group; + + if (epicId && oldState) { + updateAnalytics(workspaceSlug, projectId, epicId.toString(), { + decrementStateGroupCount: `${oldState}_issues`, + }); + } + } setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", diff --git a/web/core/components/issues/sub-issues/issue-list-item.tsx b/web/core/components/issues/sub-issues/issue-list-item.tsx index 64a5c7a02b6..bfecc019343 100644 --- a/web/core/components/issues/sub-issues/issue-list-item.tsx +++ b/web/core/components/issues/sub-issues/issue-list-item.tsx @@ -3,6 +3,7 @@ import React from "react"; import { observer } from "mobx-react"; import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react"; +import { EIssueServiceType } from "@plane/constants"; import { TIssue, TIssueServiceType } from "@plane/types"; // ui import { ControlLink, CustomMenu, Tooltip } from "@plane/ui"; @@ -50,14 +51,14 @@ export const IssueListItem: React.FC = observer((props) => { disabled, handleIssueCrudState, subIssueOperations, + issueServiceType = EIssueServiceType.ISSUES, } = props; const { issue: { getIssueById }, subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers }, - toggleCreateIssueModal, - toggleDeleteIssueModal, - } = useIssueDetail(); + } = useIssueDetail(issueServiceType); + const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail(issueServiceType); const project = useProject(); const { getProjectStates } = useProjectState(); const { handleRedirection } = useIssuePeekOverviewRedirection(); @@ -164,6 +165,7 @@ export const IssueListItem: React.FC = observer((props) => { issueId={issueId} disabled={disabled} subIssueOperations={subIssueOperations} + issueServiceType={issueServiceType} /> @@ -209,7 +211,7 @@ export const IssueListItem: React.FC = observer((props) => { >
- Remove parent issue + {`Remove ${issueServiceType === EIssueServiceType.ISSUES ? "parent" : ""} issue`}
)} diff --git a/web/core/components/issues/sub-issues/properties.tsx b/web/core/components/issues/sub-issues/properties.tsx index c4ea3bbd2d8..73148766875 100644 --- a/web/core/components/issues/sub-issues/properties.tsx +++ b/web/core/components/issues/sub-issues/properties.tsx @@ -17,11 +17,11 @@ export interface IIssueProperty { } export const IssueProperty: React.FC = (props) => { - const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations } = props; + const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props; // hooks const { issue: { getIssueById }, - } = useIssueDetail(); + } = useIssueDetail(issueServiceType); const issue = getIssueById(issueId); diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index 3b115bb06ba..6a369f69fb1 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -13,7 +13,7 @@ import update from "lodash/update"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // plane constants -import { EIssueLayoutTypes, ALL_ISSUES } from "@plane/constants"; +import { EIssueLayoutTypes, ALL_ISSUES, EIssueServiceType } from "@plane/constants"; // types import { TIssue, @@ -203,7 +203,12 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // API Abort controller controller: AbortController; - constructor(_rootStore: IIssueRootStore, issueFilterStore: IBaseIssueFilterStore, isArchived = false) { + constructor( + _rootStore: IIssueRootStore, + issueFilterStore: IBaseIssueFilterStore, + isArchived = false, + serviceType = EIssueServiceType.ISSUES + ) { makeObservable(this, { // observable loader: observable, @@ -257,7 +262,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.isArchived = isArchived; - this.issueService = new IssueService(); + this.issueService = new IssueService(serviceType); this.issueArchiveService = new IssueArchiveService(); this.issueDraftService = new IssueDraftService(); this.moduleService = new ModuleService(); diff --git a/web/ee/helpers/epic-analytics.ts b/web/ee/helpers/epic-analytics.ts new file mode 100644 index 00000000000..48884d4c1a0 --- /dev/null +++ b/web/ee/helpers/epic-analytics.ts @@ -0,0 +1 @@ +export * from "ce/helpers/epic-analytics"; From c0b5e0e7662824a22559aba68fde886b4b7cdd0e Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+mathalav55@users.noreply.github.com> Date: Tue, 24 Dec 2024 20:52:31 +0530 Subject: [PATCH 04/92] fix: label creation (#6271) --- .../issues/issue-detail/label/select/label-select.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/core/components/issues/issue-detail/label/select/label-select.tsx b/web/core/components/issues/issue-detail/label/select/label-select.tsx index 0c61feee8ca..20cb0b2599a 100644 --- a/web/core/components/issues/issue-detail/label/select/label-select.tsx +++ b/web/core/components/issues/issue-detail/label/select/label-select.tsx @@ -183,9 +183,12 @@ export const IssueLabelSelect: React.FC = observer((props) => ) : submitting ? ( ) : canCreateLabel ? ( -

{ - if(!query.length) return + { + e.preventDefault(); + e.stopPropagation(); + if (!query.length) return; handleAddLabel(query); }} className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`} @@ -197,7 +200,7 @@ export const IssueLabelSelect: React.FC = observer((props) => ) : ( "Type to add a new label" )} -

+ ) : (

No matching results.

)} From 70f72a2b0f5628046db319d8e991fe93ec995160 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+mathalav55@users.noreply.github.com> Date: Tue, 24 Dec 2024 20:53:06 +0530 Subject: [PATCH 05/92] [WEB-2699]chore: added issue count for upcoming cycles (#6273) * chore: added issue count for upcoming cycles * chore: memoized show issue count --- .../cycles/list/cycle-list-item-action.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 73dca345d7c..d83256b89bc 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { FC, MouseEvent, useEffect } from "react"; +import React, { FC, MouseEvent, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import { usePathname, useSearchParams } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; @@ -8,7 +8,16 @@ import { Eye, Users } from "lucide-react"; // types import { ICycle, TCycleGroups } from "@plane/types"; // ui -import { Avatar, AvatarGroup, FavoriteStar, TOAST_TYPE, Tooltip, setPromiseToast, setToast } from "@plane/ui"; +import { + Avatar, + AvatarGroup, + FavoriteStar, + LayersIcon, + TOAST_TYPE, + Tooltip, + setPromiseToast, + setToast, +} from "@plane/ui"; // components import { CycleQuickActions } from "@/components/cycles"; import { DateRangeDropdown } from "@/components/dropdowns"; @@ -66,6 +75,7 @@ export const CycleListItemAction: FC = observer((props) => { // derived values const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]); const isEditingAllowed = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT, @@ -216,6 +226,13 @@ export const CycleListItemAction: FC = observer((props) => { More details + {showIssueCount && ( +
+ + {cycleDetails.total_issues} +
+ )} + {!isActive && ( Date: Tue, 24 Dec 2024 21:00:50 +0530 Subject: [PATCH 06/92] chore: create unique constraints for webhook (#6257) * chore: create unique constraints for webhook * chore: updated the migration file --- apiserver/plane/app/serializers/webhook.py | 2 +- .../0088_sticky_sort_order_workspaceuserlink.py | 12 ++++++++++++ apiserver/plane/db/models/webhook.py | 9 ++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index fa4019f7ae0..1036b700c69 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -116,7 +116,7 @@ def update(self, instance, validated_data): class Meta: model = Webhook fields = "__all__" - read_only_fields = ["workspace", "secret_key"] + read_only_fields = ["workspace", "secret_key", "deleted_at"] class WebhookLogSerializer(DynamicBaseSerializer): diff --git a/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py index 0efdf41f28d..1b312215778 100644 --- a/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py +++ b/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py @@ -109,4 +109,16 @@ class Migration(migrations.Migration): name="entity_name", field=models.CharField(max_length=30, verbose_name="Transaction Type"), ), + migrations.AlterUniqueTogether( + name="webhook", + unique_together={("workspace", "url", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="webhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("workspace", "url"), + name="webhook_url_unique_url_when_deleted_at_null", + ), + ), ] diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index ec8fcda3afc..dc04e041998 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -47,11 +47,18 @@ def __str__(self): return f"{self.workspace.slug} {self.url}" class Meta: - unique_together = ["workspace", "url"] + unique_together = ["workspace", "url", "deleted_at"] verbose_name = "Webhook" verbose_name_plural = "Webhooks" db_table = "webhooks" ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["workspace", "url"], + condition=models.Q(deleted_at__isnull=True), + name="webhook_url_unique_url_when_deleted_at_null", + ) + ] class WebhookLog(BaseModel): From f54f3a60915cf213d32659ca1722d18c5bf80aac Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Thu, 26 Dec 2024 15:00:32 +0530 Subject: [PATCH 07/92] chore: workspace entity search endpoint (#6272) * chore: workspace entity search endpoint * fix: editor entity search endpoint * chore: restrict guest users --------- Co-authored-by: Aaryan Khandelwal --- apiserver/plane/app/urls/search.py | 2 +- apiserver/plane/app/views/search/base.py | 599 ++++++++++++------ packages/types/src/search.d.ts | 2 + .../lite-text-editor/lite-text-editor.tsx | 11 +- .../modals/create-modal/issue-description.tsx | 10 +- .../components/issues/description-input.tsx | 14 +- .../components/description-editor.tsx | 14 +- .../components/pages/editor/editor-body.tsx | 10 +- web/core/services/project/project.service.ts | 25 +- web/core/services/workspace.service.ts | 15 + 10 files changed, 475 insertions(+), 227 deletions(-) diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index de4f1e7b2c0..0bbbd9cf7f4 100644 --- a/apiserver/plane/app/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -16,7 +16,7 @@ name="project-issue-search", ), path( - "workspaces//projects//entity-search/", + "workspaces//entity-search/", SearchEndpoint.as_view(), name="entity-search", ), diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py index 3736d8f81af..1f6754a9e7c 100644 --- a/apiserver/plane/app/views/search/base.py +++ b/apiserver/plane/app/views/search/base.py @@ -34,6 +34,7 @@ IssueView, ProjectMember, ProjectPage, + WorkspaceMember, ) @@ -252,214 +253,456 @@ def get(self, request, slug): class SearchEndpoint(BaseAPIView): - def get(self, request, slug, project_id): + def get(self, request, slug): query = request.query_params.get("query", False) query_types = request.query_params.get("query_type", "user_mention").split(",") query_types = [qt.strip() for qt in query_types] count = int(request.query_params.get("count", 5)) + project_id = request.query_params.get("project_id", None) + issue_id = request.query_params.get("issue_id", None) response_data = {} - for query_type in query_types: - if query_type == "user_mention": - fields = [ - "member__first_name", - "member__last_name", - "member__display_name", - ] - q = Q() - - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) - users = ( - ProjectMember.objects.filter( - q, is_active=True, project_id=project_id, workspace__slug=slug, member__is_bot=False + if project_id: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + base_filters = Q( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + project_id=project_id, + role__gt=10, ) - .annotate( - member__avatar_url=Case( - When( - member__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "member__avatar_asset", - Value("/"), + if issue_id: + issue_created_by = ( + Issue.objects.filter(id=issue_id) + .values_list("created_by_id", flat=True) + .first() + ) + # Add condition to include `issue_created_by` in the query + filters = Q(member_id=issue_created_by) | base_filters + else: + filters = base_filters + + # Query to fetch users + users = ( + ProjectMember.objects.filter(filters) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", ), - ), - When( - member__avatar_asset__isnull=True, then="member__avatar" - ), - default=Value(None), - output_field=models.CharField(), + default=Value(None), + output_field=CharField(), + ) ) + .order_by("-created_at") + .values( + "member__avatar_url", + "member__display_name", + "member__id", + )[:count] ) - .order_by("-created_at") - .values("member__avatar_url", "member__display_name", "member__id")[ - :count - ] - ) - response_data["user_mention"] = list(users) - elif query_type == "project": - fields = ["name", "identifier"] - q = Q() + response_data["user_mention"] = list(users) - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) - projects = ( - Project.objects.filter( - q, - Q(project_projectmember__member=self.request.user) - | Q(network=2), - workspace__slug=slug, + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", "id", "identifier", "logo_props", "workspace__slug" - )[:count] - ) - response_data["project"] = list(projects) - - elif query_type == "issue": - fields = ["name", "sequence_id", "project__identifier"] - q = Q() - - if query: - for field in fields: - if field == "sequence_id": - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: q |= Q(**{f"{field}__icontains": query}) - issues = ( - Issue.issue_objects.filter( - q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - workspace__slug=slug, - project_id=project_id, + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), + then=Value("UPCOMING"), + ), + When( + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", - "id", - "sequence_id", - "project__identifier", - "project_id", - "priority", - "state_id", - "type_id", - )[:count] - ) - response_data["issue"] = list(issues) + response_data["cycle"] = list(cycles) - elif query_type == "cycle": - fields = ["name"] - q = Q() + elif query_type == "module": + fields = ["name"] + q = Q() - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) - cycles = ( - Cycle.objects.filter( - q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - workspace__slug=slug, + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] ) - .annotate( - status=Case( - When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), - then=Value("CURRENT"), - ), - When(start_date__gt=timezone.now(), then=Value("UPCOMING")), - When(end_date__lt=timezone.now(), then=Value("COMPLETED")), - When( - Q(start_date__isnull=True) & Q(end_date__isnull=True), - then=Value("DRAFT"), - ), - default=Value("DRAFT"), - output_field=CharField(), + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__id=project_id, + workspace__slug=slug, + access=0, ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", - "id", - "project_id", - "project__identifier", - "status", - "workspace__slug", - )[:count] - ) - response_data["cycle"] = list(cycles) + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) + + else: + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() - elif query_type == "module": - fields = ["name"] - q = Q() + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + users = ( + WorkspaceMember.objects.filter( + q, + is_active=True, + workspace__slug=slug, + member__is_bot=False, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, + then="member__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .order_by("-created_at") + .values( + "member__avatar_url", "member__display_name", "member__id" + )[:count] + ) + response_data["user_mention"] = list(users) - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() - modules = ( - Module.objects.filter( - q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - workspace__slug=slug, + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", - "id", - "project_id", - "project__identifier", - "status", - "workspace__slug", - )[:count] - ) - response_data["module"] = list(modules) + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) - elif query_type == "page": - fields = ["name"] - q = Q() + elif query_type == "cycle": + fields = ["name"] + q = Q() - if query: - for field in fields: - q |= Q(**{f"{field}__icontains": query}) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) - pages = ( - Page.objects.filter( - q, - projects__project_projectmember__member=self.request.user, - projects__project_projectmember__is_active=True, - projects__id=project_id, - workspace__slug=slug, - access=0, + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), + then=Value("UPCOMING"), + ), + When( + end_date__lt=timezone.now(), then=Value("COMPLETED") + ), + When( + Q(start_date__isnull=True) + & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] ) - .order_by("-created_at") - .distinct() - .values( - "name", "id", "logo_props", "projects__id", "workspace__slug" - )[:count] - ) - response_data["page"] = list(pages) + response_data["cycle"] = list(cycles) - else: - return Response( - {"error": f"Invalid query type: {query_type}"}, - status=status.HTTP_400_BAD_REQUEST, - ) + elif query_type == "module": + fields = ["name"] + q = Q() - return Response(response_data, status=status.HTTP_200_OK) + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + workspace__slug=slug, + access=0, + is_global=True, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "logo_props", + "projects__id", + "workspace__slug", + )[:count] + ) + response_data["page"] = list(pages) + return Response(response_data, status=status.HTTP_200_OK) diff --git a/packages/types/src/search.d.ts b/packages/types/src/search.d.ts index 5ab01549c92..6eb1475129c 100644 --- a/packages/types/src/search.d.ts +++ b/packages/types/src/search.d.ts @@ -76,6 +76,8 @@ export type TSearchResponse = { export type TSearchEntityRequestPayload = { count: number; + project_id?: string; query_type: TSearchEntities[]; query: string; + team_id?: string; }; diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index d1a7b06405d..da3ac44923c 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -14,9 +14,9 @@ import { useEditorMention } from "@/hooks/use-editor-mention"; // plane web hooks import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { useFileSize } from "@/plane-web/hooks/use-file-size"; -// services -import { ProjectService } from "@/services/project"; -const projectService = new ProjectService(); +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +const workspaceService = new WorkspaceService(); interface LiteTextEditorWrapperProps extends Omit { @@ -55,7 +55,10 @@ export const LiteTextEditor = React.forwardRef - await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload), + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }), }); // file size const { maxFileSize } = useFileSize(); diff --git a/web/core/components/inbox/modals/create-modal/issue-description.tsx b/web/core/components/inbox/modals/create-modal/issue-description.tsx index 14816cbc0d2..7f279e92a99 100644 --- a/web/core/components/inbox/modals/create-modal/issue-description.tsx +++ b/web/core/components/inbox/modals/create-modal/issue-description.tsx @@ -19,11 +19,12 @@ import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks import { useProjectInbox } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; // services import { FileService } from "@/services/file.service"; -import { ProjectService } from "@/services/project"; const fileService = new FileService(); -const projectService = new ProjectService(); +const workspaceService = new WorkspaceService(); type TInboxIssueDescription = { containerClassName?: string; @@ -75,7 +76,10 @@ export const InboxIssueDescription: FC = observer((props onChange={(_description: object, description_html: string) => handleData("description_html", description_html)} placeholder={getDescriptionPlaceholder} searchMentionCallback={async (payload) => - await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload) + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }) } containerClassName={containerClassName} onEnterKeyPress={onEnterKeyPress} diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx index 32964cec1f6..9008c9f7687 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -16,11 +16,12 @@ import { TIssueOperations } from "@/components/issues/issue-detail"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; // services import { FileService } from "@/services/file.service"; -import { ProjectService } from "@/services/project"; +const workspaceService = new WorkspaceService(); const fileService = new FileService(); -const projectService = new ProjectService(); export type IssueDescriptionInputProps = { containerClassName?: string; @@ -121,11 +122,10 @@ export const IssueDescriptionInput: FC = observer((p placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) } searchMentionCallback={async (payload) => - await projectService.searchEntity( - workspaceSlug?.toString() ?? "", - projectId?.toString() ?? "", - payload - ) + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }) } containerClassName={containerClassName} uploadFile={async (file) => { diff --git a/web/core/components/issues/issue-modal/components/description-editor.tsx b/web/core/components/issues/issue-modal/components/description-editor.tsx index 19ddbc1f40a..8fc7b115b62 100644 --- a/web/core/components/issues/issue-modal/components/description-editor.tsx +++ b/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -23,10 +23,11 @@ import { getTabIndex } from "@/helpers/tab-indices.helper"; import { useInstance, useWorkspace } from "@/hooks/store"; import useKeypress from "@/hooks/use-keypress"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; // services import { AIService } from "@/services/ai.service"; import { FileService } from "@/services/file.service"; -import { ProjectService } from "@/services/project"; type TIssueDescriptionEditorProps = { control: Control; @@ -48,9 +49,9 @@ type TIssueDescriptionEditorProps = { }; // services +const workspaceService = new WorkspaceService(); const aiService = new AIService(); const fileService = new FileService(); -const projectService = new ProjectService(); export const IssueDescriptionEditor: React.FC = observer((props) => { const { @@ -191,11 +192,10 @@ export const IssueDescriptionEditor: React.FC = ob tabIndex={getIndex("description_html")} placeholder={getDescriptionPlaceholder} searchMentionCallback={async (payload) => - await projectService.searchEntity( - workspaceSlug?.toString() ?? "", - projectId?.toString() ?? "", - payload - ) + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }) } containerClassName="pt-3 min-h-[120px]" uploadFile={async (file) => { diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index ddd45f62286..2f1bce38598 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -30,14 +30,15 @@ import { EditorAIMenu } from "@/plane-web/components/pages"; import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { useFileSize } from "@/plane-web/hooks/use-file-size"; import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; // services import { FileService } from "@/services/file.service"; -import { ProjectService } from "@/services/project"; // store import { IPage } from "@/store/pages/page"; // services init +const workspaceService = new WorkspaceService(); const fileService = new FileService(); -const projectService = new ProjectService(); type Props = { editorRef: React.RefObject; @@ -63,7 +64,10 @@ export const PageEditorBody: React.FC = observer((props) => { // use editor mention const { fetchMentions } = useEditorMention({ searchEntity: async (payload) => - await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload), + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }), }); // editor flaggings const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString()); diff --git a/web/core/services/project/project.service.ts b/web/core/services/project/project.service.ts index e3be9c5f251..f9c2af8b66a 100644 --- a/web/core/services/project/project.service.ts +++ b/web/core/services/project/project.service.ts @@ -1,10 +1,4 @@ -import type { - GithubRepositoriesResponse, - ISearchIssueResponse, - TProjectIssuesSearchParams, - TSearchEntityRequestPayload, - TSearchResponse, -} from "@plane/types"; +import type { GithubRepositoriesResponse, ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // plane web types @@ -170,21 +164,4 @@ export class ProjectService extends APIService { throw error?.response?.data; }); } - - async searchEntity( - workspaceSlug: string, - projectId: string, - params: TSearchEntityRequestPayload - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/entity-search/`, { - params: { - ...params, - query_type: params.query_type.join(","), - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } diff --git a/web/core/services/workspace.service.ts b/web/core/services/workspace.service.ts index e1aa9b7cd2b..6fc0d21b429 100644 --- a/web/core/services/workspace.service.ts +++ b/web/core/services/workspace.service.ts @@ -12,6 +12,8 @@ import { IUserProjectsRole, IWorkspaceView, TIssuesResponse, + TSearchResponse, + TSearchEntityRequestPayload, } from "@plane/types"; import { APIService } from "@/services/api.service"; // helpers @@ -277,4 +279,17 @@ export class WorkspaceService extends APIService { throw error?.response?.data; }); } + + async searchEntity(workspaceSlug: string, params: TSearchEntityRequestPayload): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/entity-search/`, { + params: { + ...params, + query_type: params.query_type.join(","), + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } From ed64168ca7bca1975fcccb97234b738254344286 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 26 Dec 2024 15:27:40 +0530 Subject: [PATCH 08/92] chore(utils): copy helper functions from web/helpers (#6264) * chore(utils): copy helper functions from web/helpers Co-Authored-By: sriram@plane.so * chore(utils): bump version to 0.24.2 Co-Authored-By: sriram@plane.so * chore: bump root package version to 0.24.2 Co-Authored-By: sriram@plane.so * fix: remove duplicate function and simplify auth utils Co-Authored-By: sriram@plane.so * fix: improve HTML entity escaping in sanitizeHTML Co-Authored-By: sriram@plane.so * fix: version changes --------- Co-authored-by: sriram veeraghanta Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: sriram@plane.so --- packages/constants/src/auth.ts | 2 +- packages/utils/package.json | 1 + packages/utils/src/array.ts | 197 +++++++++++++++++++++ packages/utils/src/auth.ts | 75 +++++++- packages/utils/src/color.ts | 6 +- packages/utils/src/datetime.ts | 303 ++++++++++++++++++++++++++++++++- packages/utils/src/index.ts | 2 + packages/utils/src/string.ts | 278 +++++++++++++++++++++++++----- 8 files changed, 807 insertions(+), 57 deletions(-) create mode 100644 packages/utils/src/array.ts diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts index 884a8dd1c89..bcdda31b4d4 100644 --- a/packages/constants/src/auth.ts +++ b/packages/constants/src/auth.ts @@ -7,7 +7,7 @@ export enum E_PASSWORD_STRENGTH { export const PASSWORD_MIN_LENGTH = 8; -export const PASSWORD_CRITERIA = [ +export const SPACE_PASSWORD_CRITERIA = [ { key: "min_8_char", label: "Min 8 characters", diff --git a/packages/utils/package.json b/packages/utils/package.json index 6fa156f626a..f1c2cdd9b69 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "isomorphic-dompurify": "^2.16.0", + "lodash": "^4.17.21", "react": "^18.3.1", "tailwind-merge": "^2.5.5", "zxcvbn": "^4.4.2" diff --git a/packages/utils/src/array.ts b/packages/utils/src/array.ts new file mode 100644 index 00000000000..12727d3a005 --- /dev/null +++ b/packages/utils/src/array.ts @@ -0,0 +1,197 @@ +import isEmpty from "lodash/isEmpty"; +import { IIssueLabel, IIssueLabelTree } from "@plane/types"; + +/** + * @description Groups an array of objects by a specified key + * @param {any[]} array Array to group + * @param {string} key Key to group by (supports dot notation for nested objects) + * @returns {Object} Grouped object with keys being the grouped values + * @example + * const array = [{type: 'A', value: 1}, {type: 'B', value: 2}, {type: 'A', value: 3}]; + * groupBy(array, 'type') // returns { A: [{type: 'A', value: 1}, {type: 'A', value: 3}], B: [{type: 'B', value: 2}] } + */ +export const groupBy = (array: any[], key: string) => { + const innerKey = key.split("."); // split the key by dot + return array.reduce((result, currentValue) => { + const key = innerKey.reduce((obj, i) => obj?.[i], currentValue) ?? "None"; // get the value of the inner key + (result[key] = result[key] || []).push(currentValue); + return result; + }, {}); +}; + +/** + * @description Orders an array by a specified key in ascending or descending order + * @param {any[]} orgArray Original array to order + * @param {string} key Key to order by (supports dot notation for nested objects) + * @param {"ascending" | "descending"} ordering Sort order + * @returns {any[]} Ordered array + * @example + * const array = [{value: 2}, {value: 1}, {value: 3}]; + * orderArrayBy(array, 'value', 'ascending') // returns [{value: 1}, {value: 2}, {value: 3}] + */ +export const orderArrayBy = (orgArray: any[], key: string, ordering: "ascending" | "descending" = "ascending") => { + if (!orgArray || !Array.isArray(orgArray) || orgArray.length === 0) return []; + + const array = [...orgArray]; + + if (key[0] === "-") { + ordering = "descending"; + key = key.slice(1); + } + + const innerKey = key.split("."); // split the key by dot + + return array.sort((a, b) => { + const keyA = innerKey.reduce((obj, i) => obj[i], a); // get the value of the inner key + const keyB = innerKey.reduce((obj, i) => obj[i], b); // get the value of the inner key + if (keyA < keyB) { + return ordering === "ascending" ? -1 : 1; + } + if (keyA > keyB) { + return ordering === "ascending" ? 1 : -1; + } + return 0; + }); +}; + +/** + * @description Checks if an array contains duplicate values + * @param {any[]} array Array to check for duplicates + * @returns {boolean} True if duplicates exist, false otherwise + * @example + * checkDuplicates([1, 2, 2, 3]) // returns true + * checkDuplicates([1, 2, 3]) // returns false + */ +export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length; + +/** + * @description Finds the string with the most characters in an array of strings + * @param {string[]} strings Array of strings to check + * @returns {string} String with the most characters + * @example + * findStringWithMostCharacters(['a', 'bb', 'ccc']) // returns 'ccc' + */ +export const findStringWithMostCharacters = (strings: string[]): string => { + if (!strings || strings.length === 0) return ""; + + return strings.reduce((longestString, currentString) => + currentString.length > longestString.length ? currentString : longestString + ); +}; + +/** + * @description Checks if two arrays have the same elements regardless of order + * @param {any[] | null} arr1 First array + * @param {any[] | null} arr2 Second array + * @returns {boolean} True if arrays have same elements, false otherwise + * @example + * checkIfArraysHaveSameElements([1, 2], [2, 1]) // returns true + * checkIfArraysHaveSameElements([1, 2], [1, 3]) // returns false + */ +export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => { + if (!arr1 || !arr2) return false; + if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false; + if (arr1.length === 0 && arr2.length === 0) return true; + + return arr1.length === arr2.length && arr1.every((e) => arr2.includes(e)); +}; + + +type GroupedItems = { [key: string]: T[] }; + +/** + * @description Groups an array of objects by a specified field + * @param {T[]} array Array to group + * @param {keyof T} field Field to group by + * @returns {GroupedItems} Grouped object + * @example + * const array = [{type: 'A', value: 1}, {type: 'B', value: 2}]; + * groupByField(array, 'type') // returns { A: [{type: 'A', value: 1}], B: [{type: 'B', value: 2}] } + */ +export const groupByField = (array: T[], field: keyof T): GroupedItems => + array.reduce((grouped: GroupedItems, item: T) => { + const key = String(item[field]); + grouped[key] = (grouped[key] || []).concat(item); + return grouped; + }, {}); + +/** + * @description Sorts an array of objects by a specified field + * @param {any[]} array Array to sort + * @param {string} field Field to sort by + * @returns {any[]} Sorted array + * @example + * const array = [{value: 2}, {value: 1}]; + * sortByField(array, 'value') // returns [{value: 1}, {value: 2}] + */ +export const sortByField = (array: any[], field: string): any[] => + array.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0)); + +/** + * @description Orders grouped data by a specified field + * @param {GroupedItems} groupedData Grouped data object + * @param {keyof T} orderBy Field to order by + * @returns {GroupedItems} Ordered grouped data + */ +export const orderGroupedDataByField = (groupedData: GroupedItems, orderBy: keyof T): GroupedItems => { + for (const key in groupedData) { + if (groupedData.hasOwnProperty(key)) { + groupedData[key] = groupedData[key].sort((a, b) => { + if (a[orderBy] < b[orderBy]) return -1; + if (a[orderBy] > b[orderBy]) return 1; + return 0; + }); + } + } + return groupedData; +}; + +/** + * @description Builds a tree structure from an array of labels + * @param {IIssueLabel[]} array Array of labels + * @param {any} parent Parent ID + * @returns {IIssueLabelTree[]} Tree structure + */ +export const buildTree = (array: IIssueLabel[], parent = null) => { + const tree: IIssueLabelTree[] = []; + + array.forEach((item: any) => { + if (item.parent === parent) { + const children = buildTree(array, item.id); + item.children = children; + tree.push(item); + } + }); + + return tree; +}; + +/** + * @description Returns valid keys from object whose value is not falsy + * @param {any} obj Object to check + * @returns {string[]} Array of valid keys + * @example + * getValidKeysFromObject({a: 1, b: 0, c: null}) // returns ['a'] + */ +export const getValidKeysFromObject = (obj: any) => { + if (!obj || isEmpty(obj) || typeof obj !== "object" || Array.isArray(obj)) return []; + + return Object.keys(obj).filter((key) => !!obj[key]); +}; + +/** + * @description Converts an array of strings into an object with boolean true values + * @param {string[]} arrayStrings Array of strings + * @returns {Object} Object with string keys and boolean values + * @example + * convertStringArrayToBooleanObject(['a', 'b']) // returns {a: true, b: true} + */ +export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => { + const obj: { [key: string]: boolean } = {}; + + for (const arrayString of arrayStrings) { + obj[arrayString] = true; + } + + return obj; +}; diff --git a/packages/utils/src/auth.ts b/packages/utils/src/auth.ts index bea3eb275f5..297b4c9ed17 100644 --- a/packages/utils/src/auth.ts +++ b/packages/utils/src/auth.ts @@ -1,8 +1,71 @@ import { ReactNode } from "react"; import zxcvbn from "zxcvbn"; -import { E_PASSWORD_STRENGTH, PASSWORD_CRITERIA, PASSWORD_MIN_LENGTH } from "@plane/constants"; +import { + E_PASSWORD_STRENGTH, + SPACE_PASSWORD_CRITERIA, + PASSWORD_MIN_LENGTH, + EErrorAlertType, + EAuthErrorCodes, +} from "@plane/constants"; -import { EPageTypes, EErrorAlertType, EAuthErrorCodes } from "@plane/constants"; +/** + * @description Password strength levels + */ +export enum PasswordStrength { + EMPTY = "empty", + WEAK = "weak", + FAIR = "fair", + GOOD = "good", + STRONG = "strong", +} + +/** + * @description Password strength criteria type + */ +export type PasswordCriterion = { + regex: RegExp; + description: string; +}; + +/** + * @description Password strength criteria + */ +export const PASSWORD_CRITERIA: PasswordCriterion[] = [ + { regex: /[a-z]/, description: "lowercase" }, + { regex: /[A-Z]/, description: "uppercase" }, + { regex: /[0-9]/, description: "number" }, + { regex: /[^a-zA-Z0-9]/, description: "special character" }, +]; + +/** + * @description Checks if password meets all criteria + * @param {string} password - Password to check + * @returns {boolean} Whether password meets all criteria + */ +export const checkPasswordCriteria = (password: string): boolean => + PASSWORD_CRITERIA.every((criterion) => criterion.regex.test(password)); + +/** + * @description Checks password strength against criteria + * @param {string} password - Password to check + * @returns {PasswordStrength} Password strength level + * @example + * checkPasswordStrength("abc") // returns PasswordStrength.WEAK + * checkPasswordStrength("Abc123!@#") // returns PasswordStrength.STRONG + */ +export const checkPasswordStrength = (password: string): PasswordStrength => { + if (!password || password.length === 0) return PasswordStrength.EMPTY; + if (password.length < PASSWORD_MIN_LENGTH) return PasswordStrength.WEAK; + + const criteriaCount = PASSWORD_CRITERIA.filter((criterion) => criterion.regex.test(password)).length; + + const zxcvbnScore = zxcvbn(password).score; + + if (criteriaCount <= 1 || zxcvbnScore <= 1) return PasswordStrength.WEAK; + if (criteriaCount === 2 || zxcvbnScore === 2) return PasswordStrength.FAIR; + if (criteriaCount === 3 || zxcvbnScore === 3) return PasswordStrength.GOOD; + return PasswordStrength.STRONG; +}; export type TAuthErrorInfo = { type: EErrorAlertType; @@ -26,9 +89,9 @@ export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => { return passwordStrength; } - const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every( - (criterion) => criterion - ); + const passwordCriteriaValidation = SPACE_PASSWORD_CRITERIA.map((criteria) => + criteria.isCriteriaValid(password) + ).every((criterion) => criterion); const passwordStrengthScore = zxcvbn(password).score; if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) { @@ -76,7 +139,7 @@ const errorCodeMessages: { // sign up [EAuthErrorCodes.USER_ALREADY_EXIST]: { title: `User already exists`, - message: (email = undefined) => `Your account is already registered. Sign in now.`, + message: () => `Your account is already registered. Sign in now.`, }, [EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { title: `Email and password required`, diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index 702719c7962..77a5c15c539 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -8,9 +8,13 @@ export type RGB = { r: number; g: number; b: number }; /** - * Validates and clamps color values to RGB range (0-255) + * @description Validates and clamps color values to RGB range (0-255) * @param {number} value - The color value to validate * @returns {number} Clamped and floored value between 0-255 + * @example + * validateColor(-10) // returns 0 + * validateColor(300) // returns 255 + * validateColor(128) // returns 128 */ export const validateColor = (value: number) => { if (value < 0) return 0; diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index d558d1661b9..0a12a227084 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -1,4 +1,4 @@ -import { format, isValid } from "date-fns"; +import { differenceInDays, format, formatDistanceToNow, isAfter, isEqual, isValid, parseISO } from "date-fns"; /** * This method returns a date from string of type yyyy-mm-dd @@ -31,16 +31,305 @@ export const getDate = (date: string | Date | undefined | null): Date | undefine * @param {Date | string} date * @example renderFormattedDate("2024-01-01") // Jan 01, 2024 */ -export const renderFormattedDate = (date: string | Date | undefined | null): string | null => { +/** + * @description Returns date in the formatted format + * @param {Date | string} date Date to format + * @param {string} formatToken Format token (optional, default: MMM dd, yyyy) + * @returns {string | undefined} Formatted date in the desired format + * @example + * renderFormattedDate("2024-01-01") // returns "Jan 01, 2024" + * renderFormattedDate("2024-01-01", "MM-DD-YYYY") // returns "01-01-2024" + */ +export const renderFormattedDate = ( + date: string | Date | undefined | null, + formatToken: string = "MMM dd, yyyy" +): string | undefined => { // Parse the date to check if it is valid const parsedDate = getDate(date); // return if undefined - if (!parsedDate) return null; + if (!parsedDate) return; // Check if the parsed date is valid before formatting - if (!isValid(parsedDate)) return null; // Return null for invalid dates - // Format the date in format (MMM dd, yyyy) - const formattedDate = format(parsedDate, "MMM dd, yyyy"); + if (!isValid(parsedDate)) return; // Return undefined for invalid dates + let formattedDate; + try { + // Format the date in the format provided or default format (MMM dd, yyyy) + formattedDate = format(parsedDate, formatToken); + } catch (e) { + // Format the date in format (MMM dd, yyyy) in case of any error + formattedDate = format(parsedDate, "MMM dd, yyyy"); + } return formattedDate; }; -// Note: timeAgo function was incomplete in the original file, so it has been omitted +/** + * @description Returns total number of days in range + * @param {string | Date} startDate - Start date + * @param {string | Date} endDate - End date + * @param {boolean} inclusive - Include start and end dates (optional, default: true) + * @returns {number | undefined} Total number of days + * @example + * findTotalDaysInRange("2024-01-01", "2024-01-08") // returns 8 + */ +export const findTotalDaysInRange = ( + startDate: Date | string | undefined | null, + endDate: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + // Parse the dates to check if they are valid + const parsedStartDate = getDate(startDate); + const parsedEndDate = getDate(endDate); + // return if undefined + if (!parsedStartDate || !parsedEndDate) return; + // Check if the parsed dates are valid before calculating the difference + if (!isValid(parsedStartDate) || !isValid(parsedEndDate)) return 0; // Return 0 for invalid dates + // Calculate the difference in days + const diffInDays = differenceInDays(parsedEndDate, parsedStartDate); + // Return the difference in days based on inclusive flag + return inclusive ? diffInDays + 1 : diffInDays; +}; + +/** + * @description Add number of days to the provided date + * @param {string | Date} startDate - Start date + * @param {number} numberOfDays - Number of days to add + * @returns {Date | undefined} Resulting date + * @example + * addDaysToDate("2024-01-01", 7) // returns Date(2024-01-08) + */ +export const addDaysToDate = (startDate: Date | string | undefined | null, numberOfDays: number): Date | undefined => { + // Parse the dates to check if they are valid + const parsedStartDate = getDate(startDate); + // return if undefined + if (!parsedStartDate) return; + const newDate = new Date(parsedStartDate); + newDate.setDate(newDate.getDate() + numberOfDays); + return newDate; +}; + +/** + * @description Returns number of days left from today + * @param {string | Date} date - Target date + * @param {boolean} inclusive - Include today (optional, default: true) + * @returns {number | undefined} Number of days left + * @example + * findHowManyDaysLeft("2024-01-08") // returns days between today and Jan 8, 2024 + */ +export const findHowManyDaysLeft = ( + date: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + if (!date) return undefined; + return findTotalDaysInRange(new Date(), date, inclusive); +}; + +/** + * @description Returns time passed since the event happened + * @param {string | number | Date} time - Time to calculate from + * @returns {string} Formatted time ago string + * @example + * calculateTimeAgo("2023-01-01") // returns "1 year ago" + */ +export const calculateTimeAgo = (time: string | number | Date | null): string => { + if (!time) return ""; + const parsedTime = typeof time === "string" || typeof time === "number" ? parseISO(String(time)) : time; + if (!parsedTime) return ""; + const distance = formatDistanceToNow(parsedTime, { addSuffix: true }); + return distance; +}; + +/** + * @description Returns short form of time passed (e.g., 1y, 2mo, 3d) + * @param {string | number | Date} date - Date to calculate from + * @returns {string} Short form time ago + * @example + * calculateTimeAgoShort("2023-01-01") // returns "1y" + */ +export const calculateTimeAgoShort = (date: string | number | Date | null): string => { + if (!date) return ""; + + const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date); + const now = new Date(); + const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000; + + if (diffInSeconds < 60) return `${Math.floor(diffInSeconds)}s`; + const diffInMinutes = diffInSeconds / 60; + if (diffInMinutes < 60) return `${Math.floor(diffInMinutes)}m`; + const diffInHours = diffInMinutes / 60; + if (diffInHours < 24) return `${Math.floor(diffInHours)}h`; + const diffInDays = diffInHours / 24; + if (diffInDays < 30) return `${Math.floor(diffInDays)}d`; + const diffInMonths = diffInDays / 30; + if (diffInMonths < 12) return `${Math.floor(diffInMonths)}mo`; + const diffInYears = diffInMonths / 12; + return `${Math.floor(diffInYears)}y`; +}; + +/** + * @description Checks if a date is greater than today + * @param {string} dateStr - Date string to check + * @returns {boolean} True if date is greater than today + * @example + * isDateGreaterThanToday("2024-12-31") // returns true + */ +export const isDateGreaterThanToday = (dateStr: string): boolean => { + if (!dateStr) return false; + const date = parseISO(dateStr); + const today = new Date(); + if (!isValid(date)) return false; + return isAfter(date, today); +}; + +/** + * @description Returns week number of date + * @param {Date} date - Date to get week number from + * @returns {number} Week number (1-52) + * @example + * getWeekNumberOfDate(new Date("2023-09-01")) // returns 35 + */ +export const getWeekNumberOfDate = (date: Date): number => { + const currentDate = date; + const startDate = new Date(currentDate.getFullYear(), 0, 1); + const days = Math.floor((currentDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)); + const weekNumber = Math.ceil((days + 1) / 7); + return weekNumber; +}; + +/** + * @description Checks if two dates are equal + * @param {Date | string} date1 - First date + * @param {Date | string} date2 - Second date + * @returns {boolean} True if dates are equal + * @example + * checkIfDatesAreEqual("2024-01-01", "2024-01-01") // returns true + */ +export const checkIfDatesAreEqual = ( + date1: Date | string | null | undefined, + date2: Date | string | null | undefined +): boolean => { + const parsedDate1 = getDate(date1); + const parsedDate2 = getDate(date2); + if (!parsedDate1 && !parsedDate2) return true; + if (!parsedDate1 || !parsedDate2) return false; + return isEqual(parsedDate1, parsedDate2); +}; + +/** + * @description Checks if a string matches date format YYYY-MM-DD + * @param {string} date - Date string to check + * @returns {boolean} True if string matches date format + * @example + * isInDateFormat("2024-01-01") // returns true + */ +export const isInDateFormat = (date: string): boolean => { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + return datePattern.test(date); +}; + +/** + * @description Converts date string to ISO format + * @param {string} dateString - Date string to convert + * @returns {string | undefined} ISO date string + * @example + * convertToISODateString("2024-01-01") // returns "2024-01-01T00:00:00.000Z" + */ +export const convertToISODateString = (dateString: string | undefined): string | undefined => { + if (!dateString) return dateString; + const date = new Date(dateString); + return date.toISOString(); +}; + +/** + * @description Converts date string to epoch timestamp + * @param {string} dateString - Date string to convert + * @returns {number | undefined} Epoch timestamp + * @example + * convertToEpoch("2024-01-01") // returns 1704067200000 + */ +export const convertToEpoch = (dateString: string | undefined): number | undefined => { + if (!dateString) return undefined; + const date = new Date(dateString); + return date.getTime(); +}; + +/** + * @description Gets current date time in ISO format + * @returns {string} Current date time in ISO format + * @example + * getCurrentDateTimeInISO() // returns "2024-01-01T12:00:00.000Z" + */ +export const getCurrentDateTimeInISO = (): string => { + const date = new Date(); + return date.toISOString(); +}; + +/** + * @description Converts hours and minutes to total minutes + * @param {number} hours - Number of hours + * @param {number} minutes - Number of minutes + * @returns {number} Total minutes + * @example + * convertHoursMinutesToMinutes(2, 30) // returns 150 + */ +export const convertHoursMinutesToMinutes = (hours: number, minutes: number): number => hours * 60 + minutes; + +/** + * @description Converts total minutes to hours and minutes + * @param {number} mins - Total minutes + * @returns {{ hours: number; minutes: number }} Hours and minutes + * @example + * convertMinutesToHoursAndMinutes(150) // returns { hours: 2, minutes: 30 } + */ +export const convertMinutesToHoursAndMinutes = (mins: number): { hours: number; minutes: number } => { + const hours = Math.floor(mins / 60); + const minutes = Math.floor(mins % 60); + return { hours, minutes }; +}; + +/** + * @description Converts minutes to hours and minutes string + * @param {number} totalMinutes - Total minutes + * @returns {string} Formatted string (e.g., "2h 30m") + * @example + * convertMinutesToHoursMinutesString(150) // returns "2h 30m" + */ +export const convertMinutesToHoursMinutesString = (totalMinutes: number): string => { + const { hours, minutes } = convertMinutesToHoursAndMinutes(totalMinutes); + return `${hours ? `${hours}h ` : ``}${minutes ? `${minutes}m ` : ``}`; +}; + +/** + * @description Calculates read time in seconds from word count + * @param {number} wordsCount - Number of words + * @returns {number} Read time in seconds + * @example + * getReadTimeFromWordsCount(400) // returns 120 + */ +export const getReadTimeFromWordsCount = (wordsCount: number): number => { + const wordsPerMinute = 200; + const minutes = wordsCount / wordsPerMinute; + return minutes * 60; +}; + +/** + * @description Generates array of dates between start and end dates + * @param {string | Date} startDate - Start date + * @param {string | Date} endDate - End date + * @returns {Array<{ date: string }>} Array of dates + * @example + * generateDateArray("2024-01-01", "2024-01-03") + * // returns [{ date: "2024-01-02" }, { date: "2024-01-03" }] + */ +export const generateDateArray = (startDate: string | Date, endDate: string | Date): Array<{ date: string }> => { + const start = new Date(startDate); + const end = new Date(endDate); + end.setDate(end.getDate() + 1); + + const dateArray = []; + while (start <= end) { + start.setDate(start.getDate() + 1); + dateArray.push({ + date: new Date(start).toISOString().split("T")[0], + }); + } + return dateArray; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 597fb5db950..a7d6a79609d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,6 @@ +export * from "./array"; export * from "./auth"; +export * from "./datetime"; export * from "./color"; export * from "./common"; export * from "./datetime"; diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 7b2ffa858af..2fc52a254ef 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -1,9 +1,182 @@ import DOMPurify from "isomorphic-dompurify"; -export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); +/** + * @description Adds space between camelCase words + * @param {string} str - String to add spaces to + * @returns {string} String with spaces between camelCase words + * @example + * addSpaceIfCamelCase("camelCase") // returns "camel Case" + * addSpaceIfCamelCase("thisIsATest") // returns "this Is A Test" + */ +export const addSpaceIfCamelCase = (str: string) => { + if (str === undefined || str === null) return ""; + + if (typeof str !== "string") str = `${str}`; + + return str.replace(/([a-z])([A-Z])/g, "$1 $2"); +}; +/** + * @description Replaces underscores with spaces in snake_case strings + * @param {string} str - String to replace underscores in + * @returns {string} String with underscores replaced by spaces + * @example + * replaceUnderscoreIfSnakeCase("snake_case") // returns "snake case" + */ export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); +/** + * @description Truncates text to specified length and adds ellipsis + * @param {string} str - String to truncate + * @param {number} length - Maximum length before truncation + * @returns {string} Truncated string with ellipsis if needed + * @example + * truncateText("This is a long text", 7) // returns "This is..." + */ +export const truncateText = (str: string, length: number) => { + if (!str || str === "") return ""; + + return str.length > length ? `${str.substring(0, length)}...` : str; +}; + +/** + * @description Creates a similar string by randomly shuffling characters + * @param {string} str - String to shuffle + * @returns {string} Shuffled string with same characters + * @example + * createSimilarString("hello") // might return "olleh" or "lehol" + */ +export const createSimilarString = (str: string) => { + const shuffled = str + .split("") + .sort(() => Math.random() - 0.5) + .join(""); + + return shuffled; +}; + +/** + * @description Copies full URL (origin + path) to clipboard + * @param {string} path - URL path to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" + */ +/** + * @description Copies text to clipboard + * @param {string} text - Text to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard + */ +export const copyTextToClipboard = async (text: string): Promise => { + if (typeof navigator === "undefined") return; + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error("Failed to copy text: ", err); + } +}; + +/** + * @description Copies full URL (origin + path) to clipboard + * @param {string} path - URL path to copy + * @returns {Promise} Promise that resolves when copying is complete + * @example + * await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123" + */ +export const copyUrlToClipboard = async (path: string) => { + const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + await copyTextToClipboard(`${originUrl}/${path}`); +}; + +/** + * @description Generates a deterministic HSL color based on input string + * @param {string} string - Input string to generate color from + * @returns {string} HSL color string + * @example + * generateRandomColor("hello") // returns consistent HSL color for "hello" + * generateRandomColor("") // returns "rgb(var(--color-primary-100))" + */ +export const generateRandomColor = (string: string): string => { + if (!string) return "rgb(var(--color-primary-100))"; + + string = `${string}`; + + const uniqueId = string.length.toString() + string; + const combinedString = uniqueId + string; + + const hash = Array.from(combinedString).reduce((acc, char) => { + const charCode = char.charCodeAt(0); + return (acc << 5) - acc + charCode; + }, 0); + + const hue = hash % 360; + const saturation = 70; + const lightness = 60; + + const randomColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`; + + return randomColor; +}; + +/** + * @description Gets first character of first word or first characters of first two words + * @param {string} str - Input string + * @returns {string} First character(s) + * @example + * getFirstCharacters("John") // returns "J" + * getFirstCharacters("John Doe") // returns "JD" + */ +export const getFirstCharacters = (str: string) => { + const words = str.trim().split(" "); + if (words.length === 1) { + return words[0].charAt(0); + } else { + return words[0].charAt(0) + words[1].charAt(0); + } +}; + +/** + * @description Formats number count, showing "99+" for numbers over 99 + * @param {number} number - Number to format + * @returns {string} Formatted number string + * @example + * getNumberCount(50) // returns "50" + * getNumberCount(100) // returns "99+" + */ +export const getNumberCount = (number: number): string => { + if (number > 99) { + return "99+"; + } + return number.toString(); +}; + +/** + * @description Converts object to URL query parameters string + * @param {Object} obj - Object to convert + * @returns {string} URL query parameters string + * @example + * objToQueryParams({ page: 1, search: "test" }) // returns "page=1&search=test" + * objToQueryParams({ a: null, b: "test" }) // returns "b=test" + */ +export const objToQueryParams = (obj: any) => { + const params = new URLSearchParams(); + + if (!obj) return params.toString(); + + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined && value !== null) params.append(key, value as string); + } + + return params.toString(); +}; + +/** + * @description: This function will capitalize the first letter of a string + * @param str String + * @returns String + */ export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); /** @@ -15,9 +188,30 @@ export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase( * const text = stripHTML(html); * console.log(text); // Some text */ +/** + * @description Sanitizes HTML string by removing tags and properly escaping entities + * @param {string} htmlString - HTML string to sanitize + * @returns {string} Sanitized string with escaped HTML entities + * @example + * sanitizeHTML("

Hello & 'world'

") // returns "Hello & 'world'" + */ export const sanitizeHTML = (htmlString: string) => { - const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags - return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces + if (!htmlString) return ""; + + // First use DOMPurify to remove all HTML tags while preserving text content + const sanitizedText = DOMPurify.sanitize(htmlString, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + USE_PROFILES: { + html: false, + svg: false, + svgFilters: false, + mathMl: false, + }, + }); + + // Additional escaping for quotes and apostrophes + return sanitizedText.trim().replace(/'/g, "'").replace(/"/g, """); }; /** @@ -86,42 +280,42 @@ export const checkURLValidity = (url: string): boolean => { }; // Browser-only clipboard functions -let copyTextToClipboard: (text: string) => Promise; - -if (typeof window !== "undefined") { - const fallbackCopyTextToClipboard = (text: string) => { - const textArea = document.createElement("textarea"); - textArea.value = text; - - // Avoid scrolling to bottom - textArea.style.top = "0"; - textArea.style.left = "0"; - textArea.style.position = "fixed"; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. - // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand - document.execCommand("copy"); - } catch (err) {} - - document.body.removeChild(textArea); - }; - - copyTextToClipboard = async (text: string) => { - if (!navigator.clipboard) { - fallbackCopyTextToClipboard(text); - return; - } - await navigator.clipboard.writeText(text); - }; -} else { - copyTextToClipboard = async () => { - throw new Error("copyTextToClipboard is only available in browser environments"); - }; -} - -export { copyTextToClipboard }; +// let copyTextToClipboard: (text: string) => Promise; + +// if (typeof window !== "undefined") { +// const fallbackCopyTextToClipboard = (text: string) => { +// const textArea = document.createElement("textarea"); +// textArea.value = text; + +// // Avoid scrolling to bottom +// textArea.style.top = "0"; +// textArea.style.left = "0"; +// textArea.style.position = "fixed"; + +// document.body.appendChild(textArea); +// textArea.focus(); +// textArea.select(); + +// try { +// // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. +// // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand +// document.execCommand("copy"); +// } catch (err) {} + +// document.body.removeChild(textArea); +// }; + +// copyTextToClipboard = async (text: string) => { +// if (!navigator.clipboard) { +// fallbackCopyTextToClipboard(text); +// return; +// } +// await navigator.clipboard.writeText(text); +// }; +// } else { +// copyTextToClipboard = async () => { +// throw new Error("copyTextToClipboard is only available in browser environments"); +// }; +// } + +// export { copyTextToClipboard }; From a5c1282e52135cf6a2e7fb4d645ced923a1d4e65 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Thu, 26 Dec 2024 16:46:52 +0530 Subject: [PATCH 09/92] [WEB-2896] fix: mutation problem with issue properties while accepting an intake issue. (#6277) --- web/core/local-db/utils/utils.ts | 5 +++-- web/core/store/inbox/inbox-issue.store.ts | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web/core/local-db/utils/utils.ts b/web/core/local-db/utils/utils.ts index b0330e057aa..4f5f0befb8c 100644 --- a/web/core/local-db/utils/utils.ts +++ b/web/core/local-db/utils/utils.ts @@ -64,9 +64,10 @@ export const updatePersistentLayer = async (issueIds: string | string[]) => { issueIds.forEach(async (issueId) => { const dbIssue = await persistence.getIssue(issueId); const issue = rootStore.issue.issues.getIssueById(issueId); + const updatedIssue = dbIssue ? { ...dbIssue, ...issue } : issue; - if (issue) { - addIssueToPersistanceLayer(issue); + if (updatedIssue) { + addIssueToPersistanceLayer(updatedIssue); } }); }; diff --git a/web/core/store/inbox/inbox-issue.store.ts b/web/core/store/inbox/inbox-issue.store.ts index 74e6534d67a..31bb5f591a4 100644 --- a/web/core/store/inbox/inbox-issue.store.ts +++ b/web/core/store/inbox/inbox-issue.store.ts @@ -98,7 +98,9 @@ export class InboxIssueStore implements IInboxIssueStore { // If issue accepted sync issue to local db if (status === EInboxIssueStatus.ACCEPTED) { - addIssueToPersistanceLayer({ ...this.issue, ...inboxIssue.issue }); + const updatedIssue = { ...this.issue, ...inboxIssue.issue }; + this.store.issue.issues.addIssue([updatedIssue]); + await addIssueToPersistanceLayer(updatedIssue); } } catch { runInAction(() => set(this, "status", previousData.status)); From 36b3328c5ea6a6a0471edcc38ff0b489e85fb97a Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+mathalav55@users.noreply.github.com> Date: Thu, 26 Dec 2024 17:19:43 +0530 Subject: [PATCH 10/92] fix: user role not updating in user profile (#6278) --- web/app/profile/page.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index d451042d9d6..ea02ab56bc4 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { ChevronDown, CircleUserRound } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; -import type { IUser } from "@plane/types"; +import type { IUser, TUserProfile } from "@plane/types"; import { Button, CustomSelect, @@ -27,7 +27,7 @@ import { USER_ROLES } from "@/constants/workspace"; // helpers import { getFileURL } from "@/helpers/file.helper"; // hooks -import { useUser } from "@/hooks/store"; +import { useUser, useUserProfile } from "@/hooks/store"; const defaultValues: Partial = { avatar_url: "", @@ -59,29 +59,34 @@ const ProfileSettingsPage = observer(() => { const userCover = watch("cover_image_url"); // store hooks const { data: currentUser, updateCurrentUser } = useUser(); + const { updateUserProfile, data: currentUserProfile } = useUserProfile(); useEffect(() => { - reset({ ...defaultValues, ...currentUser }); - }, [currentUser, reset]); + reset({ ...defaultValues, ...currentUser, ...currentUserProfile }); + }, [currentUser, currentUserProfile, reset]); const onSubmit = async (formData: IUser) => { setIsLoading(true); - const payload: Partial = { + const userPayload: Partial = { first_name: formData.first_name, last_name: formData.last_name, avatar_url: formData.avatar_url, - role: formData.role, display_name: formData?.display_name, user_timezone: formData.user_timezone, }; + const userProfilePayload: Partial = { + role: formData.role ?? undefined, + }; // if unsplash or a pre-defined image is uploaded, delete the old uploaded asset if (formData.cover_image_url?.startsWith("http")) { - payload.cover_image = formData.cover_image_url; - payload.cover_image_asset = null; + userPayload.cover_image = formData.cover_image_url; + userPayload.cover_image_asset = null; } - const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false)); - setPromiseToast(updateCurrentUserDetail, { + const updateUser = Promise.all([updateCurrentUser(userPayload), updateUserProfile(userProfilePayload)]).finally( + () => setIsLoading(false) + ); + setPromiseToast(updateUser, { loading: "Updating...", success: { title: "Success!", From 756a71ca78248e7bed36f45421949e95adabd588 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:01:32 +0530 Subject: [PATCH 11/92] [WEB-2907] chore: issue store updated and code refactor (#6279) * chore: issue and epic store updated and code refactor * chore: layout ux copy updated --- web/ce/components/issues/quick-add/root.tsx | 4 +++- .../store/issue/issue-details/activity.store.ts | 7 ++++--- .../issues/create-issue-toast-action-items.tsx | 9 +++++---- .../calendar/quick-add-issue-actions.tsx | 10 ++++++---- .../issues/issue-layouts/kanban/default.tsx | 1 + .../issues/issue-layouts/kanban/kanban-group.tsx | 4 +++- .../issues/issue-layouts/list/default.tsx | 1 + .../issue-layouts/quick-add/form/calendar.tsx | 11 ++++++----- .../issue-layouts/quick-add/form/gantt.tsx | 8 ++++---- .../issue-layouts/quick-add/form/kanban.tsx | 6 +++--- .../issues/issue-layouts/quick-add/form/list.tsx | 8 ++++---- .../issue-layouts/quick-add/form/spreadsheet.tsx | 8 ++++---- .../issues/issue-layouts/quick-add/root.tsx | 16 +++++++++++----- .../components/issues/issue-layouts/utils.tsx | 4 +++- web/core/hooks/store/use-issue-detail.ts | 2 +- web/core/store/issue/issue.store.ts | 12 +++++++----- web/core/store/issue/root.store.ts | 7 +++++-- web/core/store/root.store.ts | 3 --- 18 files changed, 71 insertions(+), 50 deletions(-) diff --git a/web/ce/components/issues/quick-add/root.tsx b/web/ce/components/issues/quick-add/root.tsx index c0bd3914edf..72daa259e17 100644 --- a/web/ce/components/issues/quick-add/root.tsx +++ b/web/ce/components/issues/quick-add/root.tsx @@ -30,10 +30,11 @@ export type TQuickAddIssueFormRoot = { register: UseFormRegister; onSubmit: () => void; onClose: () => void; + isEpic: boolean; }; export const QuickAddIssueFormRoot: FC = observer((props) => { - const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose } = props; + const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose, isEpic } = props; // store hooks const { getProjectById } = useProject(); // derived values @@ -70,6 +71,7 @@ export const QuickAddIssueFormRoot: FC = observer((props hasError={hasError} register={register} onSubmit={onSubmit} + isEpic={isEpic} /> ); }); diff --git a/web/ce/store/issue/issue-details/activity.store.ts b/web/ce/store/issue/issue-details/activity.store.ts index de84fb87d9a..2ec70f3cc18 100644 --- a/web/ce/store/issue/issue-details/activity.store.ts +++ b/web/ce/store/issue/issue-details/activity.store.ts @@ -93,10 +93,11 @@ export class IssueActivityStore implements IIssueActivityStore { let activityComments: TIssueActivityComment[] = []; - const currentStore = this.serviceType === EIssueServiceType.EPICS ? this.store.epic : this.store.issue; + const currentStore = + this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail; const activities = this.getActivitiesByIssueId(issueId) || []; - const comments = currentStore.issueDetail.comment.getCommentsByIssueId(issueId) || []; + const comments = currentStore.comment.getCommentsByIssueId(issueId) || []; activities.forEach((activityId) => { const activity = this.getActivityById(activityId); @@ -109,7 +110,7 @@ export class IssueActivityStore implements IIssueActivityStore { }); comments.forEach((commentId) => { - const comment = currentStore.issueDetail.comment.getCommentById(commentId); + const comment = currentStore.comment.getCommentById(commentId); if (!comment) return; activityComments.push({ id: comment.id, diff --git a/web/core/components/issues/create-issue-toast-action-items.tsx b/web/core/components/issues/create-issue-toast-action-items.tsx index 0fe76fb9b66..aff00f00031 100644 --- a/web/core/components/issues/create-issue-toast-action-items.tsx +++ b/web/core/components/issues/create-issue-toast-action-items.tsx @@ -10,10 +10,11 @@ type TCreateIssueToastActionItems = { workspaceSlug: string; projectId: string; issueId: string; + isEpic?: boolean; }; export const CreateIssueToastActionItems: FC = observer((props) => { - const { workspaceSlug, projectId, issueId } = props; + const { workspaceSlug, projectId, issueId, isEpic = false } = props; // state const [copied, setCopied] = useState(false); // store hooks @@ -26,7 +27,7 @@ export const CreateIssueToastActionItems: FC = obs if (!issue) return null; - const issueLink = `${workspaceSlug}/projects/${projectId}/issues/${issueId}`; + const issueLink = `${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}/${issueId}`; const copyToClipboard = async (e: React.MouseEvent) => { try { @@ -43,12 +44,12 @@ export const CreateIssueToastActionItems: FC = obs return (
- View issue + {`View ${isEpic ? "epic" : "issue"}`} {copied ? ( diff --git a/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx b/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx index 79779e54a1b..75afad975ef 100644 --- a/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx +++ b/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx @@ -24,10 +24,11 @@ type TCalendarQuickAddIssueActions = { quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; addIssuesToView?: (issueIds: string[]) => Promise; onOpen?: () => void; + isEpic?: boolean; }; export const CalendarQuickAddIssueActions: FC = observer((props) => { - const { prePopulatedData, quickAddCallback, addIssuesToView, onOpen } = props; + const { prePopulatedData, quickAddCallback, addIssuesToView, onOpen, isEpic = false } = props; // router const { workspaceSlug, projectId, moduleId } = useParams(); // states @@ -118,15 +119,16 @@ export const CalendarQuickAddIssueActions: FC = o customButton={
- New issue + {`New ${isEpic ? "Epic" : "Issue"}`}
} > - New issue - Add existing issue + {`New ${isEpic ? "Epic" : "Issue"}`} + {!isEpic && Add existing issue}
} + isEpic={isEpic} /> ); diff --git a/web/core/components/issues/issue-layouts/kanban/default.tsx b/web/core/components/issues/issue-layouts/kanban/default.tsx index 3f20646d1c8..661c2fee58c 100644 --- a/web/core/components/issues/issue-layouts/kanban/default.tsx +++ b/web/core/components/issues/issue-layouts/kanban/default.tsx @@ -98,6 +98,7 @@ export const KanBan: React.FC = observer((props) => { groupBy: group_by as GroupByColumnTypes, includeNone: true, isWorkspaceLevel: isWorkspaceLevel(storeType), + isEpic: isEpic, }); if (!list) return null; diff --git a/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx index 7e1a2f02875..3134b110c09 100644 --- a/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -82,7 +82,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => { quickAddCallback, scrollableContainerRef, handleOnDrop, - isEpic =false + isEpic = false, } = props; // hooks const projectState = useProjectState(); @@ -285,6 +285,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => { dropErrorMessage={dropErrorMessage} orderBy={orderBy} isDraggingOverColumn={isDraggingOverColumn} + isEpic={isEpic} /> { ...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)), }} quickAddCallback={quickAddCallback} + isEpic={isEpic} /> )} diff --git a/web/core/components/issues/issue-layouts/list/default.tsx b/web/core/components/issues/issue-layouts/list/default.tsx index b09802c24e7..be7e7836550 100644 --- a/web/core/components/issues/issue-layouts/list/default.tsx +++ b/web/core/components/issues/issue-layouts/list/default.tsx @@ -84,6 +84,7 @@ export const List: React.FC = observer((props) => { groupBy: group_by as GroupByColumnTypes, includeNone: true, isWorkspaceLevel: isWorkspaceLevel(storeType), + isEpic: isEpic, }); // Enable Auto Scroll for Main Kanban diff --git a/web/core/components/issues/issue-layouts/quick-add/form/calendar.tsx b/web/core/components/issues/issue-layouts/quick-add/form/calendar.tsx index 66e7990569b..8b8d108d5e8 100644 --- a/web/core/components/issues/issue-layouts/quick-add/form/calendar.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/form/calendar.tsx @@ -3,12 +3,13 @@ import { observer } from "mobx-react"; import { TQuickAddIssueForm } from "../root"; export const CalendarQuickAddIssueForm: FC = observer((props) => { - const { ref, isOpen, projectDetail, register, onSubmit } = props; + const { ref, isOpen, projectDetail, register, onSubmit, isEpic } = props; return (
= observer((props diff --git a/web/core/components/issues/issue-layouts/quick-add/form/gantt.tsx b/web/core/components/issues/issue-layouts/quick-add/form/gantt.tsx index d0afadb0984..e9b7bb38d0c 100644 --- a/web/core/components/issues/issue-layouts/quick-add/form/gantt.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/form/gantt.tsx @@ -4,7 +4,7 @@ import { cn } from "@/helpers/common.helper"; import { TQuickAddIssueForm } from "../root"; export const GanttQuickAddIssueForm: FC = observer((props) => { - const { ref, projectDetail, hasError, register, onSubmit } = props; + const { ref, projectDetail, hasError, register, onSubmit, isEpic } = props; return (
@@ -18,15 +18,15 @@ export const GanttQuickAddIssueForm: FC = observer((props) =
-
{`Press 'Enter' to add another issue`}
+
{`Press 'Enter' to add another ${isEpic ? "epic" : "issue"}`}
); }); diff --git a/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx b/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx index 8111f539e47..1f25be6e942 100644 --- a/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { TQuickAddIssueForm } from "../root"; export const KanbanQuickAddIssueForm: FC = observer((props) => { - const { ref, projectDetail, register, onSubmit } = props; + const { ref, projectDetail, register, onSubmit, isEpic } = props; return (
@@ -12,7 +12,7 @@ export const KanbanQuickAddIssueForm: FC = observer((props)

{projectDetail?.identifier ?? "..."}

= observer((props) />
-
{`Press 'Enter' to add another issue`}
+
{`Press 'Enter' to add another ${isEpic ? "epic" : "issue"}`}
); }); diff --git a/web/core/components/issues/issue-layouts/quick-add/form/list.tsx b/web/core/components/issues/issue-layouts/quick-add/form/list.tsx index 0a894511ff0..75e2c8d3a9a 100644 --- a/web/core/components/issues/issue-layouts/quick-add/form/list.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/form/list.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { TQuickAddIssueForm } from "../root"; export const ListQuickAddIssueForm: FC = observer((props) => { - const { ref, projectDetail, register, onSubmit } = props; + const { ref, projectDetail, register, onSubmit, isEpic } = props; return (
@@ -17,15 +17,15 @@ export const ListQuickAddIssueForm: FC = observer((props) =>
-
{`Press 'Enter' to add another issue`}
+
{`Press 'Enter' to add another ${isEpic ? "epic" : "issue"}`}
); }); diff --git a/web/core/components/issues/issue-layouts/quick-add/form/spreadsheet.tsx b/web/core/components/issues/issue-layouts/quick-add/form/spreadsheet.tsx index b4317f35936..4918157c74f 100644 --- a/web/core/components/issues/issue-layouts/quick-add/form/spreadsheet.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/form/spreadsheet.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { TQuickAddIssueForm } from "../root"; export const SpreadsheetQuickAddIssueForm: FC = observer((props) => { - const { ref, projectDetail, register, onSubmit } = props; + const { ref, projectDetail, register, onSubmit, isEpic } = props; return (
@@ -16,15 +16,15 @@ export const SpreadsheetQuickAddIssueForm: FC = observer((pr

- Press {"'"}Enter{"'"} to add another issue + {`Press Enter to add another ${isEpic ? "epic" : "issue"}`}

); diff --git a/web/core/components/issues/issue-layouts/quick-add/root.tsx b/web/core/components/issues/issue-layouts/quick-add/root.tsx index 61575fb23b3..73be7ef1fc3 100644 --- a/web/core/components/issues/issue-layouts/quick-add/root.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/root.tsx @@ -6,7 +6,7 @@ import { useParams, usePathname } from "next/navigation"; import { useForm, UseFormRegister } from "react-hook-form"; import { PlusIcon } from "lucide-react"; // plane constants -import { EIssueLayoutTypes } from "@plane/constants"; +import { EIssueLayoutTypes, EIssueServiceType } from "@plane/constants"; // types import { IProject, TIssue } from "@plane/types"; // ui @@ -30,9 +30,11 @@ export type TQuickAddIssueForm = { hasError: boolean; register: UseFormRegister; onSubmit: () => void; + isEpic: boolean; }; export type TQuickAddIssueButton = { + isEpic?: boolean; onClick: () => void; }; @@ -45,6 +47,7 @@ type TQuickAddIssueRoot = { containerClassName?: string; setIsQuickAddOpen?: (isOpen: boolean) => void; quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; + isEpic?: boolean; }; const defaultValues: Partial = { @@ -61,6 +64,7 @@ export const QuickAddIssueRoot: FC = observer((props) => { containerClassName = "", setIsQuickAddOpen, quickAddCallback, + isEpic = false, } = props; // router const { workspaceSlug, projectId } = useParams(); @@ -109,15 +113,16 @@ export const QuickAddIssueRoot: FC = observer((props) => { if (quickAddCallback) { const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload }); setPromiseToast(quickAddPromise, { - loading: "Adding issue...", + loading: `Adding ${isEpic ? "epic" : "issue"}...`, success: { title: "Success!", - message: () => "Issue created successfully.", + message: () => `${isEpic ? "Epic" : "Issue"} created successfully.`, actionItems: (data) => ( ), }, @@ -165,10 +170,11 @@ export const QuickAddIssueRoot: FC = observer((props) => { register={register} onSubmit={handleSubmit(onSubmitHandler)} onClose={() => handleIsOpen(false)} + isEpic={isEpic} /> ) : ( <> - {QuickAddButton && handleIsOpen(true)} />} + {QuickAddButton && handleIsOpen(true)} />} {customQuickAddButton && <>{customQuickAddButton}} {!QuickAddButton && !customQuickAddButton && (
= observer((props) => { onClick={() => handleIsOpen(true)} > - New Issue + {`New ${isEpic ? "Epic" : "Issue"}`}
)} diff --git a/web/core/components/issues/issue-layouts/utils.tsx b/web/core/components/issues/issue-layouts/utils.tsx index 3e7d72b1bd0..77d8ae19221 100644 --- a/web/core/components/issues/issue-layouts/utils.tsx +++ b/web/core/components/issues/issue-layouts/utils.tsx @@ -68,6 +68,7 @@ type TGetGroupByColumns = { groupBy: GroupByColumnTypes | null; includeNone: boolean; isWorkspaceLevel: boolean; + isEpic?: boolean; }; // NOTE: Type of groupBy is different compared to what's being passed from the components. @@ -77,13 +78,14 @@ export const getGroupByColumns = ({ groupBy, includeNone, isWorkspaceLevel, + isEpic = false, }: TGetGroupByColumns): IGroupByColumn[] | undefined => { // If no groupBy is specified and includeNone is true, return "All Issues" group if (!groupBy && includeNone) { return [ { id: "All Issues", - name: "All Issues", + name: isEpic ? "All Epics" : "All Issues", payload: {}, icon: undefined, }, diff --git a/web/core/hooks/store/use-issue-detail.ts b/web/core/hooks/store/use-issue-detail.ts index 786173fd3f4..309bfedbaaf 100644 --- a/web/core/hooks/store/use-issue-detail.ts +++ b/web/core/hooks/store/use-issue-detail.ts @@ -9,6 +9,6 @@ import { IIssueDetail } from "@/store/issue/issue-details/root.store"; export const useIssueDetail = (serviceType: TIssueServiceType = EIssueServiceType.ISSUES): IIssueDetail => { const context = useContext(StoreContext); if (context === undefined) throw new Error("useIssueDetail must be used within StoreProvider"); - if (serviceType === EIssueServiceType.EPICS) return context.epic.issueDetail; + if (serviceType === EIssueServiceType.EPICS) return context.issue.epicDetail; else return context.issue.issueDetail; }; diff --git a/web/core/store/issue/issue.store.ts b/web/core/store/issue/issue.store.ts index a6ff334b9a6..e5ba1b1079d 100644 --- a/web/core/store/issue/issue.store.ts +++ b/web/core/store/issue/issue.store.ts @@ -3,7 +3,7 @@ import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { TIssue, TIssueServiceType } from "@plane/types"; +import { TIssue } from "@plane/types"; // helpers import { getCurrentDateTimeInISO } from "@/helpers/date-time.helper"; // services @@ -30,7 +30,7 @@ export class IssueStore implements IIssueStore { // service issueService; - constructor(serviceType: TIssueServiceType) { + constructor() { makeObservable(this, { // observable issuesMap: observable, @@ -39,8 +39,7 @@ export class IssueStore implements IIssueStore { updateIssue: action, removeIssue: action, }); - - this.issueService = new IssueService(serviceType); + this.issueService = new IssueService(); } // actions @@ -85,7 +84,10 @@ export class IssueStore implements IIssueStore { set(this.issuesMap, [issueId, key], issue[key as keyof TIssue]); }); }); - updatePersistentLayer(issueId); + + if (!this.issuesMap[issueId]?.is_epic) { + updatePersistentLayer(issueId); + } }; /** diff --git a/web/core/store/issue/root.store.ts b/web/core/store/issue/root.store.ts index 002ede3ba4d..7df2f24a083 100644 --- a/web/core/store/issue/root.store.ts +++ b/web/core/store/issue/root.store.ts @@ -67,6 +67,7 @@ export interface IIssueRootStore { issues: IIssueStore; issueDetail: IIssueDetail; + epicDetail: IIssueDetail; workspaceIssuesFilter: IWorkspaceIssuesFilter; workspaceIssues: IWorkspaceIssues; @@ -134,6 +135,7 @@ export class IssueRootStore implements IIssueRootStore { issues: IIssueStore; issueDetail: IIssueDetail; + epicDetail: IIssueDetail; workspaceIssuesFilter: IWorkspaceIssuesFilter; workspaceIssues: IWorkspaceIssues; @@ -221,9 +223,10 @@ export class IssueRootStore implements IIssueRootStore { if (!isEmpty(rootStore?.cycle?.cycleMap)) this.cycleMap = rootStore?.cycle?.cycleMap; }); - this.issues = new IssueStore(this.serviceType); + this.issues = new IssueStore(); - this.issueDetail = new IssueDetail(this, this.serviceType); + this.issueDetail = new IssueDetail(this, EIssueServiceType.ISSUES); + this.epicDetail = new IssueDetail(this, EIssueServiceType.EPICS); this.workspaceIssuesFilter = new WorkspaceIssuesFilter(this); this.workspaceIssues = new WorkspaceIssues(this, this.workspaceIssuesFilter); diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index ebd118da87c..6dc0b89f0ca 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -43,7 +43,6 @@ export class CoreRootStore { projectView: IProjectViewStore; globalView: IGlobalViewStore; issue: IIssueRootStore; - epic: IIssueRootStore; state: IStateStore; label: ILabelStore; dashboard: IDashboardStore; @@ -77,7 +76,6 @@ export class CoreRootStore { this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this as unknown as RootStore); - this.epic = new IssueRootStore(this as unknown as RootStore, EIssueServiceType.EPICS); this.state = new StateStore(this as unknown as RootStore); this.label = new LabelStore(this); this.dashboard = new DashboardStore(this); @@ -109,7 +107,6 @@ export class CoreRootStore { this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this as unknown as RootStore); - this.epic = new IssueRootStore(this as unknown as RootStore, EIssueServiceType.EPICS); this.state = new StateStore(this as unknown as RootStore); this.label = new LabelStore(this); this.dashboard = new DashboardStore(this); From 2f2f8dc5f4357064e17397dd73af84a2d7062e3b Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 27 Dec 2024 09:17:35 +0530 Subject: [PATCH 12/92] [WEB-2880] chore: project detail response updated (#6281) * chore: project detail response updated * chore: code refactor --- packages/types/src/project/projects.d.ts | 1 + web/core/components/dropdowns/project.tsx | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index f878266b75d..d992bc7105b 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -15,6 +15,7 @@ export interface IProject { archived_at: string | null; archived_issues: number; archived_sub_issues: number; + completed_issues: number; close_in: number; created_at: Date; created_by: string; diff --git a/web/core/components/dropdowns/project.tsx b/web/core/components/dropdowns/project.tsx index f94014eb8b8..052527ab6dd 100644 --- a/web/core/components/dropdowns/project.tsx +++ b/web/core/components/dropdowns/project.tsx @@ -1,7 +1,7 @@ import { ReactNode, useRef, useState } from "react"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; +import { Briefcase, Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // ui import { ComboDropDown } from "@plane/ui"; @@ -143,10 +143,14 @@ export const ProjectDropdown: React.FC = observer((props) => { if (Array.isArray(value)) { return (
- {value.map((projectId) => { - const projectDetails = getProjectById(projectId); - return projectDetails ? renderIcon(projectDetails) : null; - })} + {value.length > 0 ? ( + value.map((projectId) => { + const projectDetails = getProjectById(projectId); + return projectDetails ? renderIcon(projectDetails) : null; + }) + ) : ( + + )}
); } else { From 4159d129593d7a1a4343ca290d3872ac7a1c6bbe Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 27 Dec 2024 13:03:26 +0530 Subject: [PATCH 13/92] [WEB-2889] fix: global views sorting when hyper model is enabled. (#6280) --- web/core/components/workspace/views/form.tsx | 6 +++++- web/core/store/issue/helpers/base-issues.store.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web/core/components/workspace/views/form.tsx b/web/core/components/workspace/views/form.tsx index 6fce455a825..0bad0add36f 100644 --- a/web/core/components/workspace/views/form.tsx +++ b/web/core/components/workspace/views/form.tsx @@ -3,6 +3,8 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; +// constant +import { EIssueLayoutTypes } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, IWorkspaceView } from "@plane/types"; // ui @@ -30,7 +32,9 @@ const defaultValues: Partial = { description: "", access: EViewAccess.PUBLIC, display_properties: getComputedDisplayProperties(), - display_filters: getComputedDisplayFilters(), + display_filters: getComputedDisplayFilters({ + layout: EIssueLayoutTypes.SPREADSHEET, + }), }; export const WorkspaceViewForm: React.FC = observer((props) => { diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index 6a369f69fb1..9ce6b45cb4d 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -297,6 +297,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // Temporary code to fix no load order by if ( this.rootIssueStore.rootStore.user.localDBEnabled && + this.rootIssueStore.rootStore.router.projectId && layout !== EIssueLayoutTypes.SPREADSHEET && orderBy && Object.keys(SPECIAL_ORDER_BY).includes(orderBy) From 3c6bbaef3c67b1598e3ff460f96ee4d06fa908c6 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+mathalav55@users.noreply.github.com> Date: Fri, 27 Dec 2024 17:46:40 +0530 Subject: [PATCH 14/92] fix: modified link behaviour to improve accessibility (#6284) --- .../issues/issue-detail/links/link-item.tsx | 34 +++++++++---------- .../components/modules/links/list-item.tsx | 21 +++++------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/web/core/components/issues/issue-detail/links/link-item.tsx b/web/core/components/issues/issue-detail/links/link-item.tsx index 68e8acf4ded..c092bf4282c 100644 --- a/web/core/components/issues/issue-detail/links/link-item.tsx +++ b/web/core/components/issues/issue-detail/links/link-item.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react"; +import { Pencil, Trash2, LinkIcon, Copy } from "lucide-react"; import { EIssueServiceType } from "@plane/constants"; import { TIssueServiceType } from "@plane/types"; // ui @@ -48,33 +48,33 @@ export const IssueLinkItem: FC = observer((props) => {
- { - copyTextToClipboard(linkDetail.url); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link copied!", - message: "Link copied to clipboard", - }); - }} > {linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} - +
@@ -68,14 +65,12 @@ export const ModulesLinksListItem: React.FC = observer((props) => { )} - copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} + className="grid place-items-center p-1 hover:bg-custom-background-80 cursor-pointer" > - - + + {isEditingAllowed && ( ); diff --git a/web/core/components/issues/issue-layouts/quick-add/button/kanban.tsx b/web/core/components/issues/issue-layouts/quick-add/button/kanban.tsx index 5338cba9df8..918ef33120e 100644 --- a/web/core/components/issues/issue-layouts/quick-add/button/kanban.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/button/kanban.tsx @@ -4,7 +4,7 @@ import { PlusIcon } from "lucide-react"; import { TQuickAddIssueButton } from "../root"; export const KanbanQuickAddIssueButton: FC = observer((props) => { - const { onClick } = props; + const { onClick, isEpic = false } = props; return (
= observer((pro onClick={onClick} > - New Issue + {`New ${isEpic ? "Epic" : "Issue"}`}
); }); diff --git a/web/core/components/issues/issue-layouts/quick-add/button/list.tsx b/web/core/components/issues/issue-layouts/quick-add/button/list.tsx index 09b90dbf46f..3dcbf5990a8 100644 --- a/web/core/components/issues/issue-layouts/quick-add/button/list.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/button/list.tsx @@ -5,7 +5,7 @@ import { Row } from "@plane/ui"; import { TQuickAddIssueButton } from "../root"; export const ListQuickAddIssueButton: FC = observer((props) => { - const { onClick } = props; + const { onClick, isEpic = false } = props; return ( = observer((props onClick={onClick} > - New Issue + {`New ${isEpic ? "Epic" : "Issue"}`} ); }); diff --git a/web/core/components/issues/issue-layouts/quick-add/button/spreadsheet.tsx b/web/core/components/issues/issue-layouts/quick-add/button/spreadsheet.tsx index b5663bb5699..170f891909c 100644 --- a/web/core/components/issues/issue-layouts/quick-add/button/spreadsheet.tsx +++ b/web/core/components/issues/issue-layouts/quick-add/button/spreadsheet.tsx @@ -4,7 +4,7 @@ import { PlusIcon } from "lucide-react"; import { TQuickAddIssueButton } from "../root"; export const SpreadsheetAddIssueButton: FC = observer((props) => { - const { onClick } = props; + const { onClick, isEpic = false } = props; return (
@@ -14,7 +14,7 @@ export const SpreadsheetAddIssueButton: FC = observer((pro onClick={onClick} > - New Issue + {`New ${isEpic ? "Epic" : "Issue"}`}
); diff --git a/web/core/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx b/web/core/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx index 5cf0e3d202a..72020ccab60 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx @@ -15,10 +15,11 @@ interface Props { displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; onClose: () => void; + isEpic?: boolean; } export const HeaderColumn = (props: Props) => { - const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props; + const { displayFilters, handleDisplayFilterUpdate, property, onClose, isEpic = false } = props; const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( "spreadsheetViewSorting", @@ -46,7 +47,7 @@ export const HeaderColumn = (props: Props) => {
{} - {propertyDetails.title} + {propertyDetails.title === "Sub-issue" && isEpic ? "Issues" : propertyDetails.title}
{activeSortingProperty === property && ( diff --git a/web/core/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx b/web/core/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx index 4db6da50706..b050655fa6f 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx @@ -18,16 +18,19 @@ export const SpreadsheetSubIssueColumn: React.FC = observer((props: Props // router const router = useAppRouter(); // hooks - const { workspaceSlug } = useParams(); + const { workspaceSlug, epicId } = useParams(); // derived values const subIssueCount = issue?.sub_issues_count ?? 0; const redirectToIssueDetail = () => { router.push( - `/${workspaceSlug?.toString()}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${issue.id}#sub-issues` + `/${workspaceSlug?.toString()}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${epicId ? "epics" : "issues"}/${issue.id}#sub-issues` ); }; + const issueLabel = epicId ? "issue" : "sub-issue"; + const label = `${subIssueCount} ${issueLabel}${subIssueCount !== 1 ? "s" : ""}`; + return ( {}} @@ -38,7 +41,7 @@ export const SpreadsheetSubIssueColumn: React.FC = observer((props: Props } )} > - {subIssueCount} {subIssueCount === 1 ? "sub-issue" : "sub-issues"} + {label} ); }); diff --git a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx index e66fe74df53..f75c4ddb31d 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx @@ -12,9 +12,17 @@ interface Props { isEstimateEnabled: boolean; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; + isEpic?: boolean; } export const SpreadsheetHeaderColumn = observer((props: Props) => { - const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props; + const { + displayProperties, + displayFilters, + property, + isEstimateEnabled, + handleDisplayFilterUpdate, + isEpic = false, + } = props; //hooks const tableHeaderCellRef = useRef(null); @@ -39,6 +47,7 @@ export const SpreadsheetHeaderColumn = observer((props: Props) => { onClose={() => { tableHeaderCellRef?.current?.focus(); }} + isEpic={isEpic} /> diff --git a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index ca4ea948e7a..7b67209896b 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -21,6 +21,7 @@ interface Props { isEstimateEnabled: boolean; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; selectionHelpers: TSelectionHelper; + isEpic?: boolean; } export const SpreadsheetHeader = observer((props: Props) => { @@ -32,6 +33,7 @@ export const SpreadsheetHeader = observer((props: Props) => { isEstimateEnabled, spreadsheetColumnsList, selectionHelpers, + isEpic = false, } = props; // router const { projectId } = useParams(); @@ -62,7 +64,7 @@ export const SpreadsheetHeader = observer((props: Props) => { />
)} - Issues + {`${isEpic ? "Epics" : "Issues"}`}
@@ -74,6 +76,7 @@ export const SpreadsheetHeader = observer((props: Props) => { displayFilters={displayFilters} handleDisplayFilterUpdate={handleDisplayFilterUpdate} isEstimateEnabled={isEstimateEnabled} + isEpic={isEpic} /> ))} diff --git a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index 14c9ee3228c..7f6e5669a74 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -112,6 +112,7 @@ export const SpreadsheetTable = observer((props: Props) => { isEstimateEnabled={isEstimateEnabled} spreadsheetColumnsList={spreadsheetColumnsList} selectionHelpers={selectionHelpers} + isEpic={isEpic} /> {issueIds.map((id) => ( diff --git a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 6d70b923f27..d7ac791af14 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -117,6 +117,7 @@ export const SpreadsheetView: React.FC = observer((props) => { layout={EIssueLayoutTypes.SPREADSHEET} QuickAddButton={SpreadsheetAddIssueButton} quickAddCallback={quickAddCallback} + isEpic={isEpic} /> )} diff --git a/web/core/components/issues/parent-issues-list-modal.tsx b/web/core/components/issues/parent-issues-list-modal.tsx index 193b2327e2e..5d2d46caf68 100644 --- a/web/core/components/issues/parent-issues-list-modal.tsx +++ b/web/core/components/issues/parent-issues-list-modal.tsx @@ -69,7 +69,7 @@ export const ParentIssuesListModal: React.FC = ({ projectService .projectIssuesSearch(workspaceSlug as string, projectId as string, { search: debouncedSearchTerm, - parent: true, + parent: searchEpic ? undefined : true, issue_id: issueId, workspace_search: false, epic: searchEpic ? true : undefined, diff --git a/web/core/components/issues/sub-issues/issues-list.tsx b/web/core/components/issues/sub-issues/issues-list.tsx index 2ac8f7394d3..9fe1a9ababc 100644 --- a/web/core/components/issues/sub-issues/issues-list.tsx +++ b/web/core/components/issues/sub-issues/issues-list.tsx @@ -60,6 +60,7 @@ export const IssueList: FC = observer((props) => { disabled={disabled} handleIssueCrudState={handleIssueCrudState} subIssueOperations={subIssueOperations} + issueServiceType={issueServiceType} /> ))} diff --git a/web/core/constants/empty-state.ts b/web/core/constants/empty-state.ts index 70df416a466..9545b57c904 100644 --- a/web/core/constants/empty-state.ts +++ b/web/core/constants/empty-state.ts @@ -29,6 +29,9 @@ export enum EmptyStateType { WORKSPACE_DASHBOARD = "workspace-dashboard", WORKSPACE_ANALYTICS = "workspace-analytics", WORKSPACE_PROJECTS = "workspace-projects", + WORKSPACE_TEAMS = "workspace-teams", + WORKSPACE_INITIATIVES = "workspace-initiatives", + WORKSPACE_INITIATIVES_EMPTY_SEARCH = "workspace-initiatives-empty-search", WORKSPACE_ALL_ISSUES = "workspace-all-issues", WORKSPACE_ASSIGNED = "workspace-assigned", WORKSPACE_CREATED = "workspace-created", @@ -96,6 +99,7 @@ export enum EmptyStateType { ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state", ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state", + WORKSPACE_ACTIVE_CYCLES = "workspace-active-cycles", DISABLED_PROJECT_INBOX = "disabled-project-inbox", DISABLED_PROJECT_CYCLE = "disabled-project-cycle", DISABLED_PROJECT_MODULE = "disabled-project-module", @@ -110,6 +114,11 @@ export enum EmptyStateType { WORKSPACE_DRAFT_ISSUES = "workspace-draft-issues", PROJECT_NO_EPICS = "project-no-epics", + // Teams + TEAM_NO_ISSUES = "team-no-issues", + TEAM_EMPTY_FILTER = "team-empty-filter", + TEAM_VIEW = "team-view", + TEAM_PAGE = "team-page", } const emptyStateDetails = { @@ -165,6 +174,35 @@ const emptyStateDetails = { accessType: "workspace", access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], }, + [EmptyStateType.WORKSPACE_TEAMS]: { + key: EmptyStateType.WORKSPACE_TEAMS, + title: "Teams", + description: "Teams are groups of people who collaborate on projects. Create a team to get started.", + path: "/empty-state/teams/teams", + primaryButton: { + text: "Create new team", + }, + accessType: "workspace", + access: [EUserPermissions.ADMIN], + }, + [EmptyStateType.WORKSPACE_INITIATIVES]: { + key: EmptyStateType.WORKSPACE_INITIATIVES, + title: "Organize work at the highest level with Initiatives", + description: + "When you need to organize work spanning several projects and teams, Initiatives come in handy. Connect projects and epics to initiatives, see automatically rolled up updates, and see the forests before you get to the trees.", + path: "/empty-state/initiatives/initiatives", + primaryButton: { + text: "Create an initiative", + }, + accessType: "workspace", + access: [EUserPermissions.ADMIN], + }, + [EmptyStateType.WORKSPACE_INITIATIVES_EMPTY_SEARCH]: { + key: EmptyStateType.WORKSPACE_INITIATIVES_EMPTY_SEARCH, + title: "No matching initiatives", + description: "No initiatives detected with the matching criteria. \n Create a new initiative instead.", + path: "/empty-state/search/project", + }, // all-issues [EmptyStateType.WORKSPACE_ALL_ISSUES]: { key: EmptyStateType.WORKSPACE_ALL_ISSUES, @@ -695,6 +733,13 @@ const emptyStateDetails = { title: "Add labels to issues to see the \n breakdown of work by labels.", path: "/empty-state/active-cycle/label", }, + [EmptyStateType.WORKSPACE_ACTIVE_CYCLES]: { + key: EmptyStateType.WORKSPACE_ACTIVE_CYCLES, + title: "No active cycles", + description: + "Cycles of your projects that includes any period that encompasses today's date within its range. Find the progress and details of all your active cycle here.", + path: "/empty-state/onboarding/workspace-active-cycles", + }, [EmptyStateType.DISABLED_PROJECT_INBOX]: { key: EmptyStateType.DISABLED_PROJECT_INBOX, title: "Intake is not enabled for the project.", @@ -795,9 +840,63 @@ const emptyStateDetails = { description: "For larger bodies of work that span several cycles and can live across modules, create an epic. Link issues and sub-issues in a project to an epic and jump into an issue from the overview.", path: "/empty-state/onboarding/issues", + primaryButton: { + text: "Create an Epic", + }, accessType: "project", access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], }, + // Teams + [EmptyStateType.TEAM_NO_ISSUES]: { + key: EmptyStateType.TEAM_NO_ISSUES, + title: "Create an issue in your team projects and assign it to someone, even yourself", + description: + "Think of issues as jobs, tasks, work, or JTBD. Which we like. An issue and its sub-issues are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes issues to move your project towards its goal.", + path: "/empty-state/onboarding/issues", + primaryButton: { + text: "Create your first issue", + comicBox: { + title: "Issues are building blocks in Plane.", + description: + "Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of issues that likely have sub-issues.", + }, + }, + accessType: "workspace", + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + }, + [EmptyStateType.TEAM_EMPTY_FILTER]: { + key: EmptyStateType.TEAM_EMPTY_FILTER, + title: "No issues found matching the filters applied", + path: "/empty-state/empty-filters/", + secondaryButton: { + text: "Clear all filters", + }, + accessType: "workspace", + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + }, + [EmptyStateType.TEAM_VIEW]: { + key: EmptyStateType.TEAM_VIEW, + title: "Save filtered views for your team. Create as many as you need", + description: + "Views are a set of saved filters that you use frequently or want easy access to. All your colleagues in a team can see everyone’s views and choose whichever suits their needs best.", + path: "/empty-state/onboarding/views", + primaryButton: { + text: "Create your first view", + comicBox: { + title: "Views work atop Issue properties.", + description: "You can create a view from here with as many properties as filters as you see fit.", + }, + }, + accessType: "workspace", + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + }, + [EmptyStateType.TEAM_PAGE]: { + key: EmptyStateType.TEAM_PAGE, + title: "Team pages are coming soon!", + description: + "Write a note, a doc, or a full knowledge base. Get Galileo, Plane’s AI assistant, to help you get started. Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your project’s context. To make short work of any doc, invoke Galileo, Plane’s AI, with a shortcut or the click of a button.", + path: "/empty-state/onboarding/pages", + }, } as const; export const EMPTY_STATE_DETAILS: Record = emptyStateDetails; diff --git a/web/core/constants/issue.ts b/web/core/constants/issue.ts index 5ef54075a5b..02d7ee01ad3 100644 --- a/web/core/constants/issue.ts +++ b/web/core/constants/issue.ts @@ -78,8 +78,8 @@ export const ISSUE_FILTER_OPTIONS: { title: string; }[] = [ { key: null, title: "All" }, - { key: "active", title: "Active Issues" }, - { key: "backlog", title: "Backlog Issues" }, + { key: "active", title: "Active" }, + { key: "backlog", title: "Backlog" }, // { key: "draft", title: "Draft Issues" }, ]; diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index f140eb49f08..66daf82a6ec 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -43,7 +43,7 @@ export class IssueService extends APIService { ): Promise { const path = (queries.expand as string)?.includes("issue_relation") && !queries.group_by - ? `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues-detail/` + ? `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}-detail/` : `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/`; return this.get( path, @@ -76,8 +76,9 @@ export class IssueService extends APIService { } async getIssues(workspaceSlug: string, projectId: string, queries?: any, config = {}): Promise { - if (getIssuesShouldFallbackToServer(queries)) + if (getIssuesShouldFallbackToServer(queries) || this.serviceType !== EIssueServiceType.ISSUES) { return await this.getIssuesFromServer(workspaceSlug, projectId, queries, config); + } const response = await persistence.getIssues(workspaceSlug, projectId, queries, config); return response as TIssuesResponse; @@ -112,7 +113,8 @@ export class IssueService extends APIService { params: queries, }) .then((response) => { - if (response.data) { + // skip issue update when the service type is epic + if (response.data && this.serviceType === EIssueServiceType.ISSUES) { updateIssue({ ...response.data, is_local_update: 1 }); } return response?.data; @@ -127,7 +129,7 @@ export class IssueService extends APIService { params: { issues: issueIds.join(",") }, }) .then((response) => { - if (response?.data && Array.isArray(response?.data)) { + if (response?.data && Array.isArray(response?.data) && this.serviceType === EIssueServiceType.ISSUES) { addIssuesBulk(response.data); } return response?.data; @@ -233,7 +235,9 @@ export class IssueService extends APIService { } async deleteIssue(workspaceSlug: string, projectId: string, issuesId: string): Promise { - deleteIssueFromLocal(issuesId); + if (this.serviceType === EIssueServiceType.ISSUES) { + deleteIssueFromLocal(issuesId); + } return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issuesId}/`) .then((response) => response?.data) .catch((error) => { @@ -335,7 +339,9 @@ export class IssueService extends APIService { async bulkOperations(workspaceSlug: string, projectId: string, data: TBulkOperationsPayload): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-operation-issues/`, data) .then((response) => { - persistence.syncIssues(projectId); + if (this.serviceType === EIssueServiceType.ISSUES) { + persistence.syncIssues(projectId); + } return response?.data; }) .catch((error) => { @@ -352,7 +358,9 @@ export class IssueService extends APIService { ): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data) .then((response) => { - persistence.syncIssues(projectId); + if (this.serviceType === EIssueServiceType.ISSUES) { + persistence.syncIssues(projectId); + } return response?.data; }) .catch((error) => { @@ -371,7 +379,9 @@ export class IssueService extends APIService { }> { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-archive-issues/`, data) .then((response) => { - persistence.syncIssues(projectId); + if (this.serviceType === EIssueServiceType.ISSUES) { + persistence.syncIssues(projectId); + } return response?.data; }) .catch((error) => { @@ -411,4 +421,18 @@ export class IssueService extends APIService { throw error?.response?.data; }); } + + async bulkSubscribeIssues( + workspaceSlug: string, + projectId: string, + data: { + issue_ids: string[]; + } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-subscribe-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index 9ce6b45cb4d..42c1c384c5e 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -672,6 +672,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { const issueBeforeRemoval = clone(this.rootIssueStore.issues.getIssueById(issueId)); // update parent stats optimistically this.updateParentStats(issueBeforeRemoval, undefined); + // Male API call await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); // Remove from Respective issue Id list diff --git a/web/core/store/issue/issue-details/issue.store.ts b/web/core/store/issue/issue-details/issue.store.ts index 3fe18984206..08c6e4bb308 100644 --- a/web/core/store/issue/issue-details/issue.store.ts +++ b/web/core/store/issue/issue-details/issue.store.ts @@ -49,6 +49,7 @@ export class IssueStore implements IIssueStore { // services serviceType; issueService; + epicService; issueArchiveService; issueDraftService; @@ -62,6 +63,7 @@ export class IssueStore implements IIssueStore { // services this.serviceType = serviceType; this.issueService = new IssueService(serviceType); + this.epicService = new IssueService(EIssueServiceType.EPICS); this.issueArchiveService = new IssueArchiveService(serviceType); this.issueDraftService = new IssueDraftService(); } @@ -93,7 +95,9 @@ export class IssueStore implements IIssueStore { let issue: TIssue | undefined; // fetch issue from local db - issue = await persistence.getIssue(issueId); + if (this.serviceType === EIssueServiceType.ISSUES) { + issue = await persistence.getIssue(issueId); + } this.fetchingIssueDetails = issueId; diff --git a/web/core/store/issue/issue-details/sub_issues.store.ts b/web/core/store/issue/issue-details/sub_issues.store.ts index df87df67c5f..c2c160c3902 100644 --- a/web/core/store/issue/issue-details/sub_issues.store.ts +++ b/web/core/store/issue/issue-details/sub_issues.store.ts @@ -4,6 +4,7 @@ import set from "lodash/set"; import uniq from "lodash/uniq"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; +import { EIssueServiceType } from "@plane/constants"; // types import { TIssue, @@ -64,6 +65,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // root store rootIssueDetailStore: IIssueDetail; // services + serviceType; issueService; constructor(rootStore: IIssueDetail, serviceType: TIssueServiceType) { @@ -84,6 +86,7 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // root store this.rootIssueDetailStore = rootStore; // services + this.serviceType = serviceType; this.issueService = new IssueService(serviceType); } @@ -182,7 +185,10 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { [parentIssueId, "sub_issues_count"], this.subIssues[parentIssueId].length ); - updatePersistentLayer([parentIssueId, ...issueIds]); + + if (this.serviceType === EIssueServiceType.ISSUES) { + updatePersistentLayer([parentIssueId, ...issueIds]); + } return; }; @@ -280,7 +286,9 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { ); }); - updatePersistentLayer([parentIssueId]); + if (this.serviceType === EIssueServiceType.ISSUES) { + updatePersistentLayer([parentIssueId]); + } return; }; @@ -315,7 +323,9 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { ); }); - updatePersistentLayer([parentIssueId]); + if (this.serviceType === EIssueServiceType.ISSUES) { + updatePersistentLayer([parentIssueId]); + } return; }; From 8d7425a3b71bc1c220d406dfffc073b95e0972e0 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 27 Dec 2024 20:41:38 +0530 Subject: [PATCH 16/92] [PE-182] refactor: pages' components and store for scalability (#6283) * refactor: created a generic base page instance * refactor: project store hooks * chore: add missing prop declaration * refactor: editor page root and body * refactor: issue embed hook * chore: update search entity types * fix: version editor component * fix: add page to favorites action --------- Co-authored-by: Prateek Shourya --- packages/editor/src/core/types/editor.ts | 9 +- packages/types/src/pages.d.ts | 20 +- packages/types/src/search.d.ts | 18 +- packages/utils/src/editor.ts | 103 ------- packages/utils/src/index.ts | 1 - .../pages/(detail)/[pageId]/page.tsx | 110 +++++++- .../[projectId]/pages/(detail)/header.tsx | 6 +- .../[projectId]/pages/(list)/page.tsx | 6 +- .../pages/editor/ai/ask-pi-menu.tsx | 4 +- web/ce/components/pages/editor/ai/menu.tsx | 5 +- web/ce/components/pages/extra-actions.tsx | 9 +- web/ce/hooks/use-issue-embed.tsx | 12 +- .../dropdowns/edit-information-popover.tsx | 4 +- .../pages/dropdowns/quick-actions.tsx | 8 +- .../components/pages/editor/editor-body.tsx | 115 ++++---- .../pages/editor/header/extra-options.tsx | 4 +- .../pages/editor/header/mobile-root.tsx | 6 +- .../pages/editor/header/options-dropdown.tsx | 4 +- .../components/pages/editor/header/root.tsx | 4 +- .../components/pages/editor/page-root.tsx | 76 +++--- web/core/components/pages/editor/title.tsx | 19 +- .../pages/list/block-item-action.tsx | 17 +- web/core/components/pages/list/block.tsx | 17 +- web/core/components/pages/list/root.tsx | 8 +- .../pages/modals/delete-page-modal.tsx | 15 +- web/core/components/pages/version/editor.tsx | 5 +- web/core/hooks/store/pages/use-page.ts | 9 +- .../hooks/use-collaborative-page-actions.tsx | 10 +- web/core/hooks/use-favorite-item-details.tsx | 4 +- .../services/page/project-page.service.ts | 9 +- .../store/pages/{page.ts => base-page.ts} | 252 +++++------------- web/core/store/pages/project-page.store.ts | 18 +- web/core/store/pages/project-page.ts | 163 +++++++++++ web/core/store/root.store.ts | 4 +- 34 files changed, 553 insertions(+), 521 deletions(-) delete mode 100644 packages/utils/src/editor.ts rename web/core/store/pages/{page.ts => base-page.ts} (58%) create mode 100644 web/core/store/pages/project-page.ts diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 27a719f0476..cdb469f8b52 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,5 +1,9 @@ import { Extensions, JSONContent } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; +// plane types +import { TWebhookConnectionQueryParams } from "@plane/types"; +// extension types +import { TTextAlign } from "@/extensions"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types @@ -15,7 +19,6 @@ import { TReadOnlyMentionHandler, TServerHandler, } from "@/types"; -import { TTextAlign } from "@/extensions"; export type TEditorCommands = | "text" @@ -185,7 +188,5 @@ export type TUserDetails = { export type TRealtimeConfig = { url: string; - queryParams: { - [key: string]: string; - }; + queryParams: TWebhookConnectionQueryParams; }; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 011f92d69ba..183d015bf69 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -15,7 +15,8 @@ export type TPage = { label_ids: string[] | undefined; name: string | undefined; owned_by: string | undefined; - project_ids: string[] | undefined; + project_ids?: string[] | undefined; + team: string | null | undefined; updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; @@ -25,11 +26,7 @@ export type TPage = { // page filters export type TPageNavigationTabs = "public" | "private" | "archived"; -export type TPageFiltersSortKey = - | "name" - | "created_at" - | "updated_at" - | "opened_at"; +export type TPageFiltersSortKey = "name" | "created_at" | "updated_at" | "opened_at"; export type TPageFiltersSortBy = "asc" | "desc"; @@ -63,10 +60,17 @@ export type TPageVersion = { updated_at: string; updated_by: string; workspace: string; -} +}; export type TDocumentPayload = { description_binary: string; description_html: string; description: object; -} \ No newline at end of file +}; + +export type TWebhookConnectionQueryParams = { + documentType: "project_page" | "team_page" | "workspace_page"; + projectId?: string; + teamId?: string; + workspaceSlug: string; +}; diff --git a/packages/types/src/search.d.ts b/packages/types/src/search.d.ts index 6eb1475129c..41f6a102130 100644 --- a/packages/types/src/search.d.ts +++ b/packages/types/src/search.d.ts @@ -6,13 +6,7 @@ import { IProject } from "./project"; import { IUser } from "./users"; import { IWorkspace } from "./workspace"; -export type TSearchEntities = - | "user_mention" - | "issue_mention" - | "project_mention" - | "cycle_mention" - | "module_mention" - | "page_mention"; +export type TSearchEntities = "user_mention" | "issue" | "project" | "cycle" | "module" | "page"; export type TUserSearchResponse = { member__avatar_url: IUser["avatar_url"]; @@ -66,11 +60,11 @@ export type TPageSearchResponse = { }; export type TSearchResponse = { - cycle_mention?: TCycleSearchResponse[]; - issue_mention?: TIssueSearchResponse[]; - module_mention?: TModuleSearchResponse[]; - page_mention?: TPageSearchResponse[]; - project_mention?: TProjectSearchResponse[]; + cycle?: TCycleSearchResponse[]; + issue?: TIssueSearchResponse[]; + module?: TModuleSearchResponse[]; + page?: TPageSearchResponse[]; + project?: TProjectSearchResponse[]; user_mention?: TUserSearchResponse[]; }; diff --git a/packages/utils/src/editor.ts b/packages/utils/src/editor.ts deleted file mode 100644 index 809c1dd3d2a..00000000000 --- a/packages/utils/src/editor.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { MAX_FILE_SIZE } from "@plane/constants"; -import { getFileURL } from "./file"; - -// Define image-related types locally -type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; -type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; -type UploadImage = (file: File) => Promise; - -// Define the FileService interface based on usage -interface IFileService { - deleteOldEditorAsset: (workspaceId: string, src: string) => Promise; - deleteNewAsset: (url: string) => Promise; - restoreOldEditorAsset: (workspaceId: string, src: string) => Promise; - restoreNewAsset: (anchor: string, src: string) => Promise; - cancelUpload: () => void; -} - -// Define TFileHandler locally since we can't import from @plane/editor -interface TFileHandler { - getAssetSrc: (path: string) => Promise; - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - validation: { - maxFileSize: number; - }; -} - -/** - * @description generate the file source using assetId - * @param {string} anchor - * @param {string} assetId - */ -export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => { - const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`); - return url; -}; - -type TArgs = { - anchor: string; - uploadFile: (file: File) => Promise; - workspaceId: string; - fileService: IFileService; -}; - -/** - * @description this function returns the file handler required by the editors - * @param {TArgs} args - */ -export const getEditorFileHandlers = (args: TArgs): TFileHandler => { - const { anchor, uploadFile, workspaceId, fileService } = args; - - return { - getAssetSrc: async (path: string) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return getEditorAssetSrc(anchor, path) ?? ""; - } - }, - upload: uploadFile, - delete: async (src: string) => { - if (src?.startsWith("http")) { - await fileService.deleteOldEditorAsset(workspaceId, src); - } else { - await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); - } - }, - restore: async (src: string) => { - if (src?.startsWith("http")) { - await fileService.restoreOldEditorAsset(workspaceId, src); - } else { - await fileService.restoreNewAsset(anchor, src); - } - }, - cancel: fileService.cancelUpload, - validation: { - maxFileSize: MAX_FILE_SIZE, - }, - }; -}; - -/** - * @description this function returns the file handler required by the read-only editors - */ -export const getReadOnlyEditorFileHandlers = ( - args: Pick -): { getAssetSrc: TFileHandler["getAssetSrc"] } => { - const { anchor } = args; - - return { - getAssetSrc: async (path: string) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return getEditorAssetSrc(anchor, path) ?? ""; - } - }, - }; -}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a7d6a79609d..510155f6a1e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -4,7 +4,6 @@ export * from "./datetime"; export * from "./color"; export * from "./common"; export * from "./datetime"; -export * from "./editor"; export * from "./emoji"; export * from "./file"; export * from "./issue"; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 4d3f395ea00..1aabb14181b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,29 +1,58 @@ "use client"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import useSWR from "swr"; -// ui +// plane types +import { TSearchEntityRequestPayload } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; +// plane ui import { getButtonStyling } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; import { IssuePeekOverview } from "@/components/issues"; -import { PageRoot } from "@/components/pages"; +import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages"; // helpers -import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; // hooks -import { usePage, useProjectPages } from "@/hooks/store"; +import { useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +// services +import { FileService } from "@/services/file.service"; +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +const workspaceService = new WorkspaceService(); +const fileService = new FileService(); +const projectPageService = new ProjectPageService(); +const projectPageVersionService = new ProjectPageVersionService(); const PageDetailsPage = observer(() => { const { workspaceSlug, projectId, pageId } = useParams(); - // store hooks - const { getPageById } = useProjectPages(); - const page = usePage(pageId?.toString() ?? ""); - const { id, name } = page; - + const { createPage, getPageById } = useProjectPages(); + const page = useProjectPage(pageId?.toString() ?? ""); + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const { id, name, updateDescription } = page; + // entity search handler + const fetchEntityCallback = useCallback( + async (payload: TSearchEntityRequestPayload) => + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + // file size + const { maxFileSize } = useFileSize(); // fetch page details const { error: pageDetailsError } = useSWR( workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, @@ -36,6 +65,62 @@ const PageDetailsPage = observer(() => { revalidateOnReconnect: true, } ); + // page root handlers + const pageRootHandlers: TPageRootHandlers = useMemo( + () => ({ + create: createPage, + fetchAllVersions: async (pageId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId); + }, + fetchDescriptionBinary: async () => { + if (!workspaceSlug || !projectId || !page.id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), page.id); + }, + fetchEntity: fetchEntityCallback, + fetchVersionDetails: async (pageId, versionId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchVersionById( + workspaceSlug.toString(), + projectId.toString(), + pageId, + versionId + ); + }, + getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`, + updateDescription, + }), + [createPage, fetchEntityCallback, page.id, projectId, updateDescription, workspaceSlug] + ); + // page root config + const pageRootConfig: TPageRootConfig = useMemo( + () => ({ + fileHandler: getEditorFileHandlers({ + maxFileSize, + projectId: projectId?.toString() ?? "", + uploadFile: async (file) => { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug?.toString() ?? "", + projectId?.toString() ?? "", + { + entity_identifier: id ?? "", + entity_type: EFileAssetType.PAGE_DESCRIPTION, + }, + file + ); + return asset_id; + }, + workspaceId, + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + webhookConnectionParams: { + documentType: "project_page", + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }, + }), + [id, maxFileSize, projectId, workspaceId, workspaceSlug] + ); if ((!page || !id) && !pageDetailsError) return ( @@ -65,7 +150,12 @@ const PageDetailsPage = observer(() => {
- +
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 1c3d96b5718..a6b2b83a894 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -15,7 +15,7 @@ import { PageEditInformationPopover } from "@/components/pages"; import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; import { getPageName } from "@/helpers/page.helper"; // hooks -import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store"; +import { useProjectPage, useProject, useUser, useUserPermissions } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; @@ -32,7 +32,7 @@ export const PageDetailsHeader = observer(() => { const [isOpen, setIsOpen] = useState(false); // store hooks const { currentProjectDetails, loader } = useProject(); - const page = usePage(pageId?.toString() ?? ""); + const page = useProjectPage(pageId?.toString() ?? ""); const { name, logo_props, updatePageLogo, owned_by } = page; const { allowPermissions } = useUserPermissions(); const { data: currentUser } = useUser(); @@ -169,7 +169,7 @@ export const PageDetailsHeader = observer(() => { - + ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 4171e1f332d..93f37ea83b0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -51,11 +51,7 @@ const ProjectPagesPage = observer(() => { projectId={projectId.toString()} pageType={currentPageType()} > - + ); diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index 211155d37d7..b9d6c85ef57 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -11,13 +11,12 @@ type Props = { handleInsertText: (insertOnNextLine: boolean) => void; handleRegenerate: () => Promise; isRegenerating: boolean; - projectId: string; response: string | undefined; workspaceSlug: string; }; export const AskPiMenu: React.FC = (props) => { - const { handleInsertText, handleRegenerate, isRegenerating, projectId, response, workspaceSlug } = props; + const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props; // states const [query, setQuery] = useState(""); @@ -42,7 +41,6 @@ export const AskPiMenu: React.FC = (props) => { containerClassName="!p-0 border-none" editorClassName="!pl-0" workspaceSlug={workspaceSlug} - projectId={projectId} />