Merge branch 'sync/ce-ee' of github.com:makeplane/plane-ee into develop
@@ -1,36 +1,39 @@
|
||||
# Python imports
|
||||
import jwt
|
||||
from datetime import datetime
|
||||
|
||||
import jwt
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Count
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.app.serializers import (
|
||||
WorkSpaceMemberSerializer,
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
WorkSpaceMemberSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from .. import BaseViewSet
|
||||
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
)
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.bgtasks.event_tracking_task import workspace_invite_event
|
||||
from plane.utils.cache import invalidate_cache
|
||||
from plane.utils.cache import invalidate_cache, invalidate_cache_directly
|
||||
|
||||
from .. import BaseViewSet
|
||||
|
||||
|
||||
class WorkspaceInvitationsViewset(BaseViewSet):
|
||||
"""Endpoint for creating, listing and deleting workspaces"""
|
||||
@@ -265,9 +268,6 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
@invalidate_cache(path="/api/users/me/workspaces/")
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
)
|
||||
def create(self, request):
|
||||
invitations = request.data.get("invitations", [])
|
||||
workspace_invitations = WorkspaceMemberInvite.objects.filter(
|
||||
@@ -276,6 +276,12 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||
|
||||
# If the user is already a member of workspace and was deactivated then activate the user
|
||||
for invitation in workspace_invitations:
|
||||
invalidate_cache_directly(
|
||||
path=f"/api/workspaces/{invitation.workspace.slug}/members/",
|
||||
user=False,
|
||||
request=request,
|
||||
multiple=True,
|
||||
)
|
||||
# Update the WorkspaceMember for this specific invitation
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace_id=invitation.workspace_id, member=request.user
|
||||
|
||||
@@ -102,7 +102,10 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
path="/api/workspaces/:slug/members/",
|
||||
url_params=True,
|
||||
user=False,
|
||||
multiple=True,
|
||||
)
|
||||
def partial_update(self, request, slug, pk):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
@@ -147,9 +150,15 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
path="/api/workspaces/:slug/members/",
|
||||
url_params=True,
|
||||
user=False,
|
||||
multiple=True,
|
||||
)
|
||||
@invalidate_cache(path="/api/users/me/settings/")
|
||||
@invalidate_cache(
|
||||
path="/api/users/me/workspaces/", user=False, multiple=True
|
||||
)
|
||||
def destroy(self, request, slug, pk):
|
||||
# Check the user role who is deleting the user
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
@@ -215,9 +224,15 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/members/", url_params=True, user=False
|
||||
path="/api/workspaces/:slug/members/",
|
||||
url_params=True,
|
||||
user=False,
|
||||
multiple=True,
|
||||
)
|
||||
@invalidate_cache(path="/api/users/me/settings/")
|
||||
@invalidate_cache(
|
||||
path="api/users/me/workspaces/", user=False, multiple=True
|
||||
)
|
||||
def leave(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
|
||||
@@ -33,12 +33,12 @@ def cache_response(timeout=60 * 60, path=None, user=True):
|
||||
custom_path = path if path is not None else request.get_full_path()
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
cached_result = cache.get(key)
|
||||
|
||||
if cached_result is not None:
|
||||
return Response(
|
||||
cached_result["data"], status=cached_result["status"]
|
||||
)
|
||||
response = view_func(instance, request, *args, **kwargs)
|
||||
|
||||
if response.status_code == 200 and not settings.DEBUG:
|
||||
cache.set(
|
||||
key,
|
||||
@@ -53,34 +53,42 @@ def cache_response(timeout=60 * 60, path=None, user=True):
|
||||
return decorator
|
||||
|
||||
|
||||
def invalidate_cache(path=None, url_params=False, user=True):
|
||||
"""invalidate cache per user"""
|
||||
def invalidate_cache_directly(
|
||||
path=None, url_params=False, user=True, request=None, multiple=False
|
||||
):
|
||||
if url_params and path:
|
||||
path_with_values = path
|
||||
# Assuming `kwargs` could be passed directly if needed, otherwise, skip this part
|
||||
for key, value in request.resolver_match.kwargs.items():
|
||||
path_with_values = path_with_values.replace(f":{key}", str(value))
|
||||
custom_path = path_with_values
|
||||
else:
|
||||
custom_path = path if path is not None else request.get_full_path()
|
||||
auth_header = (
|
||||
None
|
||||
if request.user.is_anonymous
|
||||
else str(request.user.id) if user else None
|
||||
)
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
|
||||
if multiple:
|
||||
cache.delete_many(keys=cache.keys(f"*{key}*"))
|
||||
else:
|
||||
cache.delete(key)
|
||||
|
||||
|
||||
def invalidate_cache(path=None, url_params=False, user=True, multiple=False):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def _wrapped_view(instance, request, *args, **kwargs):
|
||||
# Invalidate cache before executing the view function
|
||||
if url_params:
|
||||
path_with_values = path
|
||||
for key, value in kwargs.items():
|
||||
path_with_values = path_with_values.replace(
|
||||
f":{key}", str(value)
|
||||
)
|
||||
|
||||
custom_path = path_with_values
|
||||
else:
|
||||
custom_path = (
|
||||
path if path is not None else request.get_full_path()
|
||||
)
|
||||
|
||||
auth_header = (
|
||||
None
|
||||
if request.user.is_anonymous
|
||||
else str(request.user.id) if user else None
|
||||
# invalidate the cache
|
||||
invalidate_cache_directly(
|
||||
path=path,
|
||||
url_params=url_params,
|
||||
user=user,
|
||||
request=request,
|
||||
multiple=multiple,
|
||||
)
|
||||
key = generate_cache_key(custom_path, auth_header)
|
||||
cache.delete(key)
|
||||
# Execute the view function
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view
|
||||
|
||||
@@ -189,7 +189,7 @@ function createToolbox({
|
||||
tippyOptions,
|
||||
onSelectColor,
|
||||
onClickItem,
|
||||
colors,
|
||||
colors = {},
|
||||
}: {
|
||||
triggerButton: Element | null;
|
||||
items: ToolboxItem[];
|
||||
|
||||
@@ -16,7 +16,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
|
||||
const { priority, className = "", containerClassName = "", size = 14, withContainer = false } = props;
|
||||
|
||||
const priorityClasses = {
|
||||
urgent: "bg-red-500 text-red-500 border-red-500",
|
||||
urgent: "bg-red-600 text-red-500 border-red-600",
|
||||
high: "bg-orange-500/20 text-orange-500 border-orange-500",
|
||||
medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500",
|
||||
low: "bg-custom-primary-100/20 text-custom-primary-100 border-custom-primary-100",
|
||||
@@ -40,7 +40,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
|
||||
{withContainer ? (
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center border rounded p-0.5 flex-shrink-0",
|
||||
"flex items-center justify-center border rounded p-0.5 flex-shrink-0",
|
||||
priorityClasses[priority],
|
||||
containerClassName
|
||||
)}
|
||||
@@ -63,8 +63,9 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
|
||||
<Icon
|
||||
size={size}
|
||||
className={cn(
|
||||
"flex-shrink-0",
|
||||
{
|
||||
"text-red-500": priority === "urgent",
|
||||
"text-red-600": priority === "urgent",
|
||||
"text-orange-500": priority === "high",
|
||||
"text-yellow-500": priority === "medium",
|
||||
"text-custom-primary-100": priority === "low",
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = {
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const AppliedFiltersList: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleRemoveAllFilters, handleRemoveFilter, states } = props;
|
||||
const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter, states } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||
|
||||
@@ -79,7 +79,7 @@ const BorderButton = (props: ButtonProps) => {
|
||||
// compact the icons if text is hidden
|
||||
"px-0.5": hideText,
|
||||
// highlight the whole button if text is hidden and priority is urgent
|
||||
"bg-red-500 border-red-500": priority === "urgent" && hideText && highlightUrgent,
|
||||
"bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent,
|
||||
},
|
||||
className
|
||||
)}
|
||||
@@ -88,7 +88,7 @@ const BorderButton = (props: ButtonProps) => {
|
||||
<div
|
||||
className={cn({
|
||||
// highlight just the icon if text is visible and priority is urgent
|
||||
"bg-red-500 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
|
||||
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
|
||||
})}
|
||||
>
|
||||
<PriorityIcon
|
||||
@@ -155,7 +155,7 @@ const BackgroundButton = (props: ButtonProps) => {
|
||||
// compact the icons if text is hidden
|
||||
"px-0.5": hideText,
|
||||
// highlight the whole button if text is hidden and priority is urgent
|
||||
"bg-red-500 border-red-500": priority === "urgent" && hideText && highlightUrgent,
|
||||
"bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent,
|
||||
},
|
||||
className
|
||||
)}
|
||||
@@ -164,7 +164,7 @@ const BackgroundButton = (props: ButtonProps) => {
|
||||
<div
|
||||
className={cn({
|
||||
// highlight just the icon if text is visible and priority is urgent
|
||||
"bg-red-500 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
|
||||
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
|
||||
})}
|
||||
>
|
||||
<PriorityIcon
|
||||
@@ -232,7 +232,7 @@ const TransparentButton = (props: ButtonProps) => {
|
||||
// compact the icons if text is hidden
|
||||
"px-0.5": hideText,
|
||||
// highlight the whole button if text is hidden and priority is urgent
|
||||
"bg-red-500 border-red-500": priority === "urgent" && hideText && highlightUrgent,
|
||||
"bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent,
|
||||
"bg-custom-background-80": isActive,
|
||||
},
|
||||
className
|
||||
@@ -242,7 +242,7 @@ const TransparentButton = (props: ButtonProps) => {
|
||||
<div
|
||||
className={cn({
|
||||
// highlight just the icon if text is visible and priority is urgent
|
||||
"bg-red-500 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
|
||||
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
|
||||
})}
|
||||
>
|
||||
<PriorityIcon
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { ILayoutDisplayFiltersOptions } from "@/constants/issue";
|
||||
|
||||
type Props = {
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
displayFilters: IIssueDisplayFilterOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
|
||||
@@ -80,8 +80,8 @@ export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
|
||||
{/* sub-group by */}
|
||||
{isDisplayFilterEnabled("sub_group_by") &&
|
||||
displayFilters.group_by !== null &&
|
||||
displayFilters.layout === "kanban" && (
|
||||
displayFilters?.group_by !== null &&
|
||||
displayFilters?.layout === "kanban" && (
|
||||
<div className="py-2">
|
||||
<FilterSubGroupBy
|
||||
displayFilters={displayFilters}
|
||||
@@ -100,7 +100,7 @@ export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
{isDisplayFilterEnabled("order_by") && (
|
||||
<div className="py-2">
|
||||
<FilterOrderBy
|
||||
selectedOrderBy={displayFilters.order_by}
|
||||
selectedOrderBy={displayFilters?.order_by}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
order_by: val,
|
||||
@@ -115,7 +115,7 @@ export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
{isDisplayFilterEnabled("type") && (
|
||||
<div className="py-2">
|
||||
<FilterIssueType
|
||||
selectedIssueType={displayFilters.type}
|
||||
selectedIssueType={displayFilters?.type}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
type: val,
|
||||
@@ -130,8 +130,8 @@ export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
<div className="py-2">
|
||||
<FilterExtraOptions
|
||||
selectedExtraOptions={{
|
||||
show_empty_groups: displayFilters.show_empty_groups ?? true,
|
||||
sub_issue: displayFilters.sub_issue ?? true,
|
||||
show_empty_groups: displayFilters?.show_empty_groups ?? true,
|
||||
sub_issue: displayFilters?.sub_issue ?? true,
|
||||
}}
|
||||
handleUpdate={(key, val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ISSUE_GROUP_BY_OPTIONS } from "@/constants/issue";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
displayFilters: IIssueDisplayFilterOptions | undefined;
|
||||
groupByOptions: TIssueGroupByOptions[];
|
||||
handleUpdate: (val: TIssueGroupByOptions) => void;
|
||||
ignoreGroupedFilters: Partial<TIssueGroupByOptions>[];
|
||||
@@ -19,8 +19,8 @@ export const FilterGroupBy: React.FC<Props> = observer((props) => {
|
||||
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
||||
const selectedGroupBy = displayFilters.group_by ?? null;
|
||||
const selectedSubGroupBy = displayFilters.sub_group_by ?? null;
|
||||
const selectedGroupBy = displayFilters?.group_by ?? null;
|
||||
const selectedSubGroupBy = displayFilters?.sub_group_by ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -32,7 +32,11 @@ export const FilterGroupBy: React.FC<Props> = observer((props) => {
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{ISSUE_GROUP_BY_OPTIONS.filter((option) => groupByOptions.includes(option.key)).map((groupBy) => {
|
||||
if (displayFilters.layout === "kanban" && selectedSubGroupBy !== null && groupBy.key === selectedSubGroupBy)
|
||||
if (
|
||||
displayFilters?.layout === "kanban" &&
|
||||
selectedSubGroupBy !== null &&
|
||||
groupBy.key === selectedSubGroupBy
|
||||
)
|
||||
return null;
|
||||
if (ignoreGroupedFilters.includes(groupBy?.key)) return null;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error, tabIndex }
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className={`flex items-center justify-center gap-2 text-xs ${error ? "text-red-500" : ""}`}>
|
||||
<div className={`flex items-center justify-center gap-2 text-xs py-0.5 ${error ? "text-red-500" : ""}`}>
|
||||
{value ? (
|
||||
<ModuleStatusIcon status={value} />
|
||||
) : (
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
|
||||
<div className="flex items-start justify-between gap-1.5">
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||
{/* Applied filters */}
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
{Object.entries(appliedFilters ?? {}).map(([key, value]) => {
|
||||
const filterKey = key as keyof TProjectFilters;
|
||||
|
||||
if (!value) return;
|
||||
|
||||
@@ -197,16 +197,16 @@ export const CreateProjectForm: FC<Props> = observer((props) => {
|
||||
onChange={(val: any) => {
|
||||
let logoValue = {};
|
||||
|
||||
if (val.type === "emoji")
|
||||
if (val?.type === "emoji")
|
||||
logoValue = {
|
||||
value: convertHexEmojiToDecimal(val.value.unified),
|
||||
url: val.value.imageUrl,
|
||||
};
|
||||
else if (val.type === "icon") logoValue = val.value;
|
||||
else if (val?.type === "icon") logoValue = val.value;
|
||||
|
||||
onChange({
|
||||
in_use: val.type,
|
||||
[val.type]: logoValue,
|
||||
in_use: val?.type,
|
||||
[val?.type]: logoValue,
|
||||
});
|
||||
}}
|
||||
defaultIconColor={value.in_use && value.in_use === "icon" ? value.icon?.color : undefined}
|
||||
|
||||
@@ -157,16 +157,16 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
onChange={(val) => {
|
||||
let logoValue = {};
|
||||
|
||||
if (val.type === "emoji")
|
||||
if (val?.type === "emoji")
|
||||
logoValue = {
|
||||
value: convertHexEmojiToDecimal(val.value.unified),
|
||||
url: val.value.imageUrl,
|
||||
};
|
||||
else if (val.type === "icon") logoValue = val.value;
|
||||
else if (val?.type === "icon") logoValue = val.value;
|
||||
|
||||
onChange({
|
||||
in_use: val.type,
|
||||
[val.type]: logoValue,
|
||||
in_use: val?.type,
|
||||
[val?.type]: logoValue,
|
||||
});
|
||||
}}
|
||||
defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
|
||||
|
||||
@@ -93,6 +93,10 @@ export enum EmptyStateType {
|
||||
|
||||
WORKSPACE_ACTIVE_CYCLES = "workspace-active-cycles",
|
||||
DISABLED_PROJECT_INBOX = "disabled-project-inbox",
|
||||
DISABLED_PROJECT_CYCLE = "disabled-project-cycle",
|
||||
DISABLED_PROJECT_MODULE = "disabled-project-module",
|
||||
DISABLED_PROJECT_VIEW = "disabled-project-view",
|
||||
DISABLED_PROJECT_PAGE = "disabled-project-page",
|
||||
|
||||
INBOX_SIDEBAR_OPEN_TAB = "inbox-sidebar-open-tab",
|
||||
INBOX_SIDEBAR_CLOSED_TAB = "inbox-sidebar-closed-tab",
|
||||
@@ -642,6 +646,54 @@ const emptyStateDetails = {
|
||||
text: "Manage features",
|
||||
},
|
||||
},
|
||||
[EmptyStateType.DISABLED_PROJECT_CYCLE]: {
|
||||
key: EmptyStateType.DISABLED_PROJECT_CYCLE,
|
||||
title: "Cycles is not enabled for this project.",
|
||||
description:
|
||||
"Break work down by timeboxed chunks, work backwards from your project deadline to set dates, and make tangible progress as a team. Enable the cycles feature for your project to start using them.",
|
||||
accessType: "project",
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
path: "/empty-state/disabled-feature/cycles",
|
||||
primaryButton: {
|
||||
text: "Manage features",
|
||||
},
|
||||
},
|
||||
[EmptyStateType.DISABLED_PROJECT_MODULE]: {
|
||||
key: EmptyStateType.DISABLED_PROJECT_MODULE,
|
||||
title: "Modules are not enabled for the project.",
|
||||
description:
|
||||
"A group of issues that belong to a logical, hierarchical parent form a module. Think of them as a way to track work by project milestones. Enable modules from project settings.",
|
||||
accessType: "project",
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
path: "/empty-state/disabled-feature/modules",
|
||||
primaryButton: {
|
||||
text: "Manage features",
|
||||
},
|
||||
},
|
||||
[EmptyStateType.DISABLED_PROJECT_PAGE]: {
|
||||
key: EmptyStateType.DISABLED_PROJECT_PAGE,
|
||||
title: "Pages are not enabled for the project.",
|
||||
description:
|
||||
"Pages are thought spotting 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. Enable the pages feature to start creating them in your project.",
|
||||
accessType: "project",
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
path: "/empty-state/disabled-feature/pages",
|
||||
primaryButton: {
|
||||
text: "Manage features",
|
||||
},
|
||||
},
|
||||
[EmptyStateType.DISABLED_PROJECT_VIEW]: {
|
||||
key: EmptyStateType.DISABLED_PROJECT_VIEW,
|
||||
title: "Views is not enabled for this project.",
|
||||
description:
|
||||
"Views are a set of saved filters that you use frequently or want easy access to. All your colleagues in a project can see everyone’s views and choose whichever suits their needs best. Enable views in the project settings to start using them.",
|
||||
accessType: "project",
|
||||
access: EUserProjectRoles.ADMIN,
|
||||
path: "/empty-state/disabled-feature/views",
|
||||
primaryButton: {
|
||||
text: "Manage features",
|
||||
},
|
||||
},
|
||||
[EmptyStateType.INBOX_SIDEBAR_OPEN_TAB]: {
|
||||
key: EmptyStateType.INBOX_SIDEBAR_OPEN_TAB,
|
||||
title: "No open issues",
|
||||
|
||||
@@ -59,9 +59,9 @@ const myIssuesParamsToKey = (params: any) => {
|
||||
let labelsKey = labels ? labels.split(",") : [];
|
||||
const startDateKey = start_date ?? "";
|
||||
const targetDateKey = target_date ?? "";
|
||||
const type = params.type ? params.type.toUpperCase() : "NULL";
|
||||
const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL";
|
||||
const orderBy = params.order_by ? params.order_by.toUpperCase() : "NULL";
|
||||
const type = params?.type ? params.type.toUpperCase() : "NULL";
|
||||
const groupBy = params?.group_by ? params.group_by.toUpperCase() : "NULL";
|
||||
const orderBy = params?.order_by ? params.order_by.toUpperCase() : "NULL";
|
||||
|
||||
// sorting each keys in ascending order
|
||||
assigneesKey = assigneesKey.sort().join("_");
|
||||
|
||||
@@ -152,6 +152,8 @@ export const getNumberCount = (number: number): string => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ const useReloadConfirmations = (isActive = true) => {
|
||||
const leave = confirm("Are you sure you want to leave? Changes you made may not be saved.");
|
||||
if (!leave) {
|
||||
router.events.emit("routeChangeError");
|
||||
throw `Route change to "${url}" was aborted (this error can be safely ignored).`;
|
||||
}
|
||||
},
|
||||
[isActive, showAlert, router.events]
|
||||
|
||||
@@ -35,7 +35,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
// store hooks
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { currentProjectCycleIds, loader } = useCycle();
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, peekCycle } = router.query;
|
||||
@@ -60,7 +60,18 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => {
|
||||
updateFilters(projectId.toString(), { [key]: newValues });
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to cycle
|
||||
if (currentProjectDetails?.cycle_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_CYCLE}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loader)
|
||||
return (
|
||||
|
||||
@@ -5,11 +5,13 @@ import { TModuleFilters } from "@plane/types";
|
||||
// layouts
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { ModulesListHeader } from "@/components/headers";
|
||||
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules";
|
||||
// types
|
||||
// hooks
|
||||
import ModulesListMobileHeader from "@/components/modules/moduels-list-mobile-header";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
import { useModuleFilter, useProject } from "@/hooks/store";
|
||||
import { AppLayout } from "@/layouts/app-layout";
|
||||
@@ -17,9 +19,9 @@ import { NextPageWithLayout } from "@/lib/types";
|
||||
|
||||
const ProjectModulesPage: NextPageWithLayout = observer(() => {
|
||||
const router = useRouter();
|
||||
const { projectId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
const { currentProjectFilters, clearAllFilters, updateFilters } = useModuleFilter();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
@@ -38,6 +40,19 @@ const ProjectModulesPage: NextPageWithLayout = observer(() => {
|
||||
[currentProjectFilters, projectId, updateFilters]
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to
|
||||
if (currentProjectDetails?.module_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_MODULE}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
||||
@@ -56,7 +56,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
||||
commandPalette: { toggleCreatePageModal },
|
||||
} = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
const { fetchProjectPages, fetchArchivedProjectPages, loader, archivedPageLoader, projectPageIds, archivedPageIds } =
|
||||
useProjectPages();
|
||||
// hooks
|
||||
@@ -75,6 +75,19 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
||||
workspaceSlug && projectId ? () => fetchArchivedProjectPages(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to
|
||||
if (currentProjectDetails?.page_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_PAGE}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentTabValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "Recent":
|
||||
|
||||
@@ -3,8 +3,11 @@ import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { ProjectViewsHeader } from "@/components/headers";
|
||||
import { ProjectViewsList } from "@/components/views";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
// layouts
|
||||
@@ -15,13 +18,26 @@ import { NextPageWithLayout } from "@/lib/types";
|
||||
const ProjectViewsPage: NextPageWithLayout = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { projectId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Views` : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to
|
||||
if (currentProjectDetails?.issue_views_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_VIEW}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
||||
@@ -55,7 +55,7 @@ const AppPostInstallation: NextPageWithLayout = () => {
|
||||
window.close();
|
||||
})
|
||||
.catch((err) => {
|
||||
throw err.response;
|
||||
throw err?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
BIN
web/public/empty-state/disabled-feature/cycles-dark.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
web/public/empty-state/disabled-feature/cycles-light.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
web/public/empty-state/disabled-feature/modules-dark.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
web/public/empty-state/disabled-feature/modules-light.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
web/public/empty-state/disabled-feature/pages-dark.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
web/public/empty-state/disabled-feature/pages-light.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
web/public/empty-state/disabled-feature/views-dark.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
web/public/empty-state/disabled-feature/views-light.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |