diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index e4f20c4aee6..50f24ec9777 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -15,6 +15,7 @@ from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns from .sticky import urlpatterns as sticky_patterns +from .subscriber import urlpatterns as subscriber_patterns urlpatterns = [ *asset_patterns, @@ -30,4 +31,5 @@ *work_item_patterns, *invite_patterns, *sticky_patterns, + *subscriber_patterns, ] diff --git a/apps/api/plane/api/urls/subscriber.py b/apps/api/plane/api/urls/subscriber.py new file mode 100644 index 00000000000..72d5bd6dc10 --- /dev/null +++ b/apps/api/plane/api/urls/subscriber.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views import WorkItemSubscriberAPIEndpoint + +urlpatterns = [ + path( + "workspaces//projects//work-items//subscribers/", + WorkItemSubscriberAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-subscribers", + ), + path( + "workspaces//projects//work-items//subscribers//", + WorkItemSubscriberAPIEndpoint.as_view(http_method_names=["delete"]), + name="work-item-subscriber", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 691bef63ad3..28aa783166d 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -73,6 +73,8 @@ from .sticky import StickyViewSet +from .subscriber import WorkItemSubscriberAPIEndpoint + from .release import ( ReleaseCandidateAPIEndpoint, ReleaseChangelogAPIEndpoint, diff --git a/apps/api/plane/api/views/subscriber.py b/apps/api/plane/api/views/subscriber.py new file mode 100644 index 00000000000..f91cab105dd --- /dev/null +++ b/apps/api/plane/api/views/subscriber.py @@ -0,0 +1,158 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse, extend_schema + +# Module imports +from plane.api.views.base import BaseAPIView +from plane.db.models import Issue, IssueSubscriber, ProjectMember, User +from plane.utils.openapi import ( + FORBIDDEN_RESPONSE, + PROJECT_ID_PARAMETER, + PROJECT_NOT_FOUND_RESPONSE, + UNAUTHORIZED_RESPONSE, + WORKSPACE_SLUG_PARAMETER, +) +from plane.utils.permissions import ProjectAdminPermission, ProjectEntityPermission + + +class WorkItemSubscriberAPIEndpoint(BaseAPIView): + """Manage who is notified about a work item, over the API-key surface. + + Upstream exposes issue subscribers only on the session-authenticated app API + (`/api/workspaces/.../issues//issue-subscribers/`), where `subscribe` + can only ever act on `request.user`. That is unusable for an integration: + an API token acts as its own bot user, so there is no way for an external + system to say "notify THIS person about THIS work item". + + We need exactly that. Bug reports filed from the Psyclo apps land in Intake + as work items owned by the token user, and a reporter who ticked "allow + Psyclopedia to contact me" has to receive Plane's own comment notification + email so the thread can be two-way. See the notification recipient filter in + `plane/bgtasks/notification_task.py` -- a subscriber is only mailed if they + are ALSO an active `ProjectMember`, which is why that is validated here + rather than left to fail silently later. + """ + + permission_classes = [ProjectEntityPermission] + + def get_permissions(self): + # Reading who is subscribed is ordinary project-entity access; changing + # someone else's notifications is not, so writes require project admin. + if self.request.method == "GET": + return [ProjectEntityPermission()] + return [ProjectAdminPermission()] + + @extend_schema( + operation_id="get_work_item_subscribers", + summary="List work item subscribers", + description="Retrieve the users subscribed to notifications for a work item.", + tags=["Work Items"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 200: OpenApiResponse(description="List of subscriber user ids"), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + subscriber_ids = IssueSubscriber.objects.filter( + workspace__slug=slug, project_id=project_id, issue_id=issue_id + ).values_list("subscriber_id", flat=True) + return Response( + [str(subscriber_id) for subscriber_id in subscriber_ids], + status=status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="create_work_item_subscriber", + summary="Subscribe a user to a work item", + description=( + "Subscribe a user to notifications for a work item. Accepts either " + "`subscriber` (a user id) or `email`. The user must already be an " + "active member of the project, otherwise Plane will not send them " + "notifications." + ), + tags=["Work Items"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 201: OpenApiResponse(description="Subscriber created"), + 200: OpenApiResponse(description="Subscriber already existed"), + 400: OpenApiResponse(description="Invalid subscriber"), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def post(self, request, slug, project_id, issue_id): + issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).first() + if issue is None: + return Response({"error": "Work item does not exist"}, status=status.HTTP_404_NOT_FOUND) + + # `email` is accepted alongside `subscriber` because the calling system + # generally knows the person by address, not by their Plane user id, and + # would otherwise have to page the whole member list to translate it. + subscriber_id = request.data.get("subscriber") + email = request.data.get("email") + if subscriber_id: + user = User.objects.filter(pk=subscriber_id).first() + elif email: + user = User.objects.filter(email__iexact=str(email).strip()).first() + else: + return Response( + {"error": "Either subscriber or email is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if user is None: + return Response({"error": "User does not exist"}, status=status.HTTP_400_BAD_REQUEST) + + # Enforced here, not merely documented: a subscriber who is not an + # active project member is dropped by the notification task's recipient + # filter, so accepting one would create a row that looks correct and + # silently never delivers anything. + if not ProjectMember.objects.filter(project_id=project_id, member=user, is_active=True).exists(): + return Response( + {"error": "User is not an active member of this project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + _, created = IssueSubscriber.objects.get_or_create( + issue_id=issue_id, + subscriber=user, + defaults={"project_id": project_id, "workspace_id": issue.workspace_id}, + ) + return Response( + {"subscriber": str(user.id), "created": created}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="delete_work_item_subscriber", + summary="Unsubscribe a user from a work item", + description="Remove a user's subscription to a work item's notifications.", + tags=["Work Items"], + parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + responses={ + 204: OpenApiResponse(description="Subscriber removed"), + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, issue_id, subscriber_id): + subscription = IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + subscriber_id=subscriber_id, + ).first() + if subscription is None: + return Response({"error": "Subscription does not exist"}, status=status.HTTP_404_NOT_FOUND) + subscription.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index 08a38e632eb..2b566c22cde 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -19,7 +19,6 @@ import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url"; import faviconIco from "@/app/assets/favicon/favicon.ico?url"; import icon180 from "@/app/assets/icons/icon-180x180.png?url"; import icon512 from "@/app/assets/icons/icon-512x512.png?url"; -import ogImage from "@/app/assets/og-image.png?url"; import globalStyles from "@/styles/globals.css?url"; import type { Route } from "./+types/root"; // components @@ -99,27 +98,11 @@ export function Layout({ children }: { children: ReactNode }) { export const meta: Route.MetaFunction = () => [ { title: APP_TITLE }, { name: "description", content: SITE_DESCRIPTION }, - { property: "og:title", content: APP_TITLE }, - { - property: "og:description", - content: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", - }, - { property: "og:url", content: "https://app.plane.so/" }, - { property: "og:image", content: ogImage }, - { property: "og:image:width", content: "1200" }, - { property: "og:image:height", content: "630" }, - { property: "og:image:alt", content: "Plane - Modern project management" }, { name: "keywords", content: "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", }, - { name: "twitter:site", content: "@planepowers" }, - { name: "twitter:card", content: "summary_large_image" }, - { name: "twitter:image", content: ogImage }, - { name: "twitter:image:width", content: "1200" }, - { name: "twitter:image:height", content: "630" }, - { name: "twitter:image:alt", content: "Plane - Modern project management" }, ]; export default function Root() { diff --git a/apps/web/core/components/dropdowns/module/base.tsx b/apps/web/core/components/dropdowns/module/base.tsx index 6487e57ba78..6087c9fd8ea 100644 --- a/apps/web/core/components/dropdowns/module/base.tsx +++ b/apps/web/core/components/dropdowns/module/base.tsx @@ -64,6 +64,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM multiple, onChange, onClose, + onDropdownOpen, placeholder = "", placement, projectId, @@ -194,6 +195,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM getModuleById={getModuleById} moduleIds={moduleIds} value={value} + onDropdownOpen={onDropdownOpen} /> )} diff --git a/apps/web/core/components/issues/bulk-operations/root.tsx b/apps/web/core/components/issues/bulk-operations/root.tsx index 567fd12d232..e389626730c 100644 --- a/apps/web/core/components/issues/bulk-operations/root.tsx +++ b/apps/web/core/components/issues/bulk-operations/root.tsx @@ -4,11 +4,22 @@ * See the LICENSE file for details. */ +import { useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Trash2, X } from "lucide-react"; +// plane imports +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TBulkOperationsPayload, TIssuePriorities } from "@plane/types"; +import { AlertModalCore } from "@plane/ui"; +import { cn } from "@plane/utils"; // components -import { BulkOperationsUpgradeBanner } from "@/components/issues/bulk-operations/upgrade-banner"; +import { PriorityDropdown } from "@/components/dropdowns/priority"; +import { StateDropdown } from "@/components/dropdowns/state/dropdown"; // hooks +import { useIssues } from "@/hooks/store/use-issues"; import { useMultipleSelectStore } from "@/hooks/store/use-multiple-select-store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import type { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { @@ -18,10 +29,148 @@ type Props = { export const IssueBulkOperationsRoot = observer(function IssueBulkOperationsRoot(props: Props) { const { className, selectionHelpers } = props; + // router + const { workspaceSlug: routerWorkspaceSlug } = useParams(); + const workspaceSlug = routerWorkspaceSlug?.toString(); // store hooks - const { isSelectionActive } = useMultipleSelectStore(); + const storeType = useIssueStoreType(); + const { issues, issueMap } = useIssues(storeType); + const { isSelectionActive, selectedEntityIds } = useMultipleSelectStore(); + // states + const [isApplying, setIsApplying] = useState(false); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); - if (!isSelectionActive || selectionHelpers.isSelectionDisabled) return null; + if (!isSelectionActive || selectionHelpers.isSelectionDisabled || !workspaceSlug) return null; - return ; + // State ids only make sense within one project, so bulk state changes are grouped and + // applied per project. Priority is a fixed, project-agnostic enum, so it can always apply. + const projectGroups = new Map(); + selectedEntityIds.forEach((issueId) => { + const projectId = issueMap?.[issueId]?.project_id; + if (!projectId) return; + projectGroups.set(projectId, [...(projectGroups.get(projectId) ?? []), issueId]); + }); + const projectIds = Array.from(projectGroups.keys()); + const singleProjectId = projectIds.length === 1 ? projectIds[0] : undefined; + + const applyToSelection = async (properties: TBulkOperationsPayload["properties"]) => { + if (projectGroups.size === 0) return; + setIsApplying(true); + try { + await Promise.all( + Array.from(projectGroups.entries()).map(([projectId, issueIds]) => + issues.bulkUpdateProperties(workspaceSlug, projectId, { issue_ids: issueIds, properties }) + ) + ); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Updated", + message: `${selectedEntityIds.length} work item${selectedEntityIds.length === 1 ? "" : "s"} updated`, + }); + } catch (_error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error", + message: "Could not update the selected work items", + }); + } finally { + setIsApplying(false); + } + }; + + const handleBulkDelete = async () => { + if (projectGroups.size === 0) return; + setIsDeleting(true); + const deletedCount = selectedEntityIds.length; + try { + await Promise.all( + Array.from(projectGroups.entries()).map(([projectId, issueIds]) => + issues.removeBulkIssues(workspaceSlug, projectId, issueIds) + ) + ); + selectionHelpers.handleClearSelection(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Deleted", + message: `${deletedCount} work item${deletedCount === 1 ? "" : "s"} deleted`, + }); + } catch (_error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error", + message: "Could not delete the selected work items", + }); + } finally { + setIsDeleting(false); + setIsDeleteConfirmOpen(false); + } + }; + + return ( + <> + setIsDeleteConfirmOpen(false)} + handleSubmit={handleBulkDelete} + isSubmitting={isDeleting} + title="Delete work items" + content={ + <> + {`Are you sure you want to delete `} + + {selectedEntityIds.length} work item{selectedEntityIds.length === 1 ? "" : "s"} + + {`? All of the data related to ${selectedEntityIds.length === 1 ? "it" : "them"} will be permanently removed. This action cannot be undone.`} + + } + /> +
+
+ + {selectedEntityIds.length} selected + +
+
+ applyToSelection({ priority })} + buttonVariant="border-with-text" + disabled={isApplying} + /> +
+ {singleProjectId && ( +
+ stateId && applyToSelection({ state_id: stateId })} + projectId={singleProjectId} + buttonVariant="border-with-text" + disabled={isApplying} + /> +
+ )} +
+ + +
+
+ + ); }); diff --git a/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx b/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx deleted file mode 100644 index bfd33369020..00000000000 --- a/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) 2023-present Plane Software, Inc. and contributors - * SPDX-License-Identifier: AGPL-3.0-only - * See the LICENSE file for details. - */ - -import { MARKETING_PLANE_ONE_PAGE_LINK } from "@plane/constants"; -import { getButtonStyling } from "@plane/propel/button"; -import { cn } from "@plane/utils"; - -type Props = { - className?: string; -}; - -export function BulkOperationsUpgradeBanner(props: Props) { - const { className } = props; - - return ( -
-
-

- Change state, priority, and more for several work items at once. Save three minutes on an average per - operation. -

- - Upgrade to One - -
-
- ); -} diff --git a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx index 9650adea7d7..d182eb26f38 100644 --- a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -20,7 +20,6 @@ import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; import { CycleDropdown } from "@/components/dropdowns/cycle"; import { DateDropdown } from "@/components/dropdowns/date"; import { EstimateDropdown } from "@/components/dropdowns/estimate"; -import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal"; import { IssueLabelSelect } from "@/components/issues/select"; import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier"; @@ -81,202 +80,183 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p maxDate?.setDate(maxDate.getDate()); return ( -
- ( -
- { - onChange(labelIds); - handleFormChange(); - }} - projectId={projectId ?? undefined} - tabIndex={getIndex("label_ids")} - createLabelEnabled={!!canCreateLabel} - groupsToShow="rest" - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - maxDate={maxDate ?? undefined} - placeholder={t("start_date")} - tabIndex={getIndex("start_date")} - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - minDate={minDate ?? undefined} - placeholder={t("due_date")} - tabIndex={getIndex("target_date")} - /> -
- )} - /> - {projectDetails?.cycle_view && ( +
+
(
- { - onChange(cycleId); + { + onChange(labelIds); handleFormChange(); }} - placeholder={t("cycle.label", { count: 1 })} - value={value} - buttonVariant="border-with-text" - tabIndex={getIndex("cycle_id")} + projectId={projectId ?? undefined} + tabIndex={getIndex("label_ids")} + createLabelEnabled={!!canCreateLabel} + groupsToShow="rest" />
)} /> - )} - {projectDetails?.module_view && workspaceSlug && ( +
+
(
- { - onChange(moduleIds); + { + onChange(date ? renderFormattedPayloadDate(date) : null); handleFormChange(); }} - placeholder={t("modules")} buttonVariant="border-with-text" - tabIndex={getIndex("module_ids")} - multiple - showCount + maxDate={maxDate ?? undefined} + placeholder={t("start_date")} + tabIndex={getIndex("start_date")} />
)} /> - )} - {projectId && areEstimateEnabledByProjectId(projectId) && ( (
- { - onChange(estimatePoint); + { + onChange(date ? renderFormattedPayloadDate(date) : null); handleFormChange(); }} - projectId={projectId} buttonVariant="border-with-text" - tabIndex={getIndex("estimate_point")} - placeholder={t("estimate")} + minDate={minDate ?? undefined} + placeholder={t("due_date")} + tabIndex={getIndex("target_date")} />
)} /> - )} -
- {parentId ? ( - - {selectedParentIssue?.project_id && ( - - )} - - } - placement="bottom-start" - className="h-full w-full" - customButtonClassName="h-full" - tabIndex={getIndex("parent_id")} - > - <> - setParentIssueListModalOpen(true)}> - {t("change_parent_issue")} - - ( - { - onChange(null); - handleFormChange(); - }} - > - {t("remove_parent_issue")} - - )} - /> - - - ) : ( - + {projectDetails?.cycle_view && ( + ( +
+ { + onChange(cycleId); + handleFormChange(); + }} + placeholder={t("cycle.label", { count: 1 })} + value={value} + buttonVariant="border-with-text" + tabIndex={getIndex("cycle_id")} + /> +
+ )} + /> )} -
- ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - handleFormChange(); - setSelectedParentIssue(issue); - }} - projectId={projectId ?? undefined} - issueId={isDraft ? undefined : id} + {projectId && areEstimateEnabledByProjectId(projectId) && ( + ( +
+ { + onChange(estimatePoint); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + tabIndex={getIndex("estimate_point")} + placeholder={t("estimate")} + /> +
+ )} /> )} - /> +
+ {parentId ? ( + + {selectedParentIssue?.project_id && ( + + )} + + } + placement="bottom-start" + className="h-full w-full" + customButtonClassName="h-full" + tabIndex={getIndex("parent_id")} + > + <> + setParentIssueListModalOpen(true)}> + {t("change_parent_issue")} + + ( + { + onChange(null); + handleFormChange(); + }} + > + {t("remove_parent_issue")} + + )} + /> + + + ) : ( + + )} +
+ ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + handleFormChange(); + setSelectedParentIssue(issue); + }} + projectId={projectId ?? undefined} + issueId={isDraft ? undefined : id} + /> + )} + /> +
); }); diff --git a/apps/web/core/components/issues/issue-modal/components/top-properties.tsx b/apps/web/core/components/issues/issue-modal/components/top-properties.tsx index bdc93d430df..48f3b3863c1 100644 --- a/apps/web/core/components/issues/issue-modal/components/top-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/top-properties.tsx @@ -13,10 +13,12 @@ import type { TIssue } from "@plane/types"; import { getTabIndex } from "@plane/utils"; // components import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; +import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; import { PriorityDropdown } from "@/components/dropdowns/priority"; import { StateDropdown } from "@/components/dropdowns/state/dropdown"; import { IssueLabelSelect } from "@/components/issues/select"; // hooks +import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -34,8 +36,11 @@ export const IssueTopProperties = observer(function IssueTopProperties(props: TI const { t } = useTranslation(); const { isMobile } = usePlatformOS(); const { allowPermissions } = useUserPermissions(); + const { getProjectById } = useProject(); const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); + const projectDetails = getProjectById(projectId); + const canCreateLabel = projectId && allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); @@ -115,6 +120,29 @@ export const IssueTopProperties = observer(function IssueTopProperties(props: TI /> )} /> + {projectDetails?.module_view && workspaceSlug && ( + ( +
+ { + onChange(moduleIds); + handleFormChange(); + }} + placeholder={t("modules")} + buttonVariant="border-with-text" + tabIndex={getIndex("module_ids")} + multiple + showCount + /> +
+ )} + /> + )}
); }); diff --git a/apps/web/core/hooks/use-bulk-operation-status.ts b/apps/web/core/hooks/use-bulk-operation-status.ts index 539779061ff..f60ecc5c2fe 100644 --- a/apps/web/core/hooks/use-bulk-operation-status.ts +++ b/apps/web/core/hooks/use-bulk-operation-status.ts @@ -4,4 +4,4 @@ * See the LICENSE file for details. */ -export const useBulkOperationStatus = () => false; +export const useBulkOperationStatus = () => true;