Compare commits

...

50 Commits

Author SHA1 Message Date
gurusainath
9a60cfd44e chore: updated view create and edit flow 2024-02-17 22:14:01 +05:30
gurusainath
4783e791ec Merge branch 'develop' of gurusainath:makeplane/plane into feat-global-views 2024-02-16 20:09:27 +05:30
gurusainath
61f184a2ef chore: view ui and new store workflow 2024-02-16 18:28:21 +05:30
gurusainath
5661ae730a Merge branch 'develop' of gurusainath:makeplane/plane into feat-global-views 2024-02-15 23:45:06 +05:30
gurusainath
77c89427e6 Merge branch 'develop' of gurusainath:makeplane/plane into feat-global-views 2024-02-14 20:07:10 +05:30
gurusainath
e55d7175dc chore: view rootstore update 2024-02-14 20:06:59 +05:30
gurusainath
430d9de722 chore: updated empty states 2024-02-14 19:14:18 +05:30
gurusainath
c35d650de0 chore: implemented extra options in the display filter properties dropdown and hanlded the arrow functions with useCallback 2024-02-14 16:13:37 +05:30
gurusainath
6bde956166 Merge branch 'develop' of gurusainath:makeplane/plane into feat-global-views 2024-02-14 16:11:58 +05:30
gurusainath
178ad3dd73 Merge branch 'feat-global-views' of gurusainath:makeplane/plane into feat-global-views 2024-02-14 16:02:11 +05:30
gurusainath
01f5a0a4e2 Merge branch 'develop' of gurusainath:makeplane/plane into feat-global-views 2024-02-14 12:52:34 +05:30
NarayanBavisetti
9d39096ed2 Merge branch 'feat-global-views' of github.com:makeplane/plane into feat-global-views 2024-02-14 12:13:39 +05:30
NarayanBavisetti
06f6a21077 chore: global issues listing 2024-02-14 12:13:09 +05:30
gurusainath
8c5f42874c conflicts:merge conflicts resolved 2024-02-13 21:42:20 +05:30
gurusainath
a5fd6f0e8a chore: store changes on the filters and display filters 2024-02-13 18:41:34 +05:30
gurusainath
02380c730d Merge branch 'develop' of gurusainath:makeplane/plane into feat-global-views 2024-02-13 18:13:18 +05:30
gurusainath
68e8c449c8 Merge branch 'develop' of gurusainath:makeplane/plane into feat-global-views 2024-02-13 15:46:24 +05:30
gurusainath
608eea67f7 chore: stabilising the views 2024-02-13 12:28:05 +05:30
gurusainath
badb190c25 Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-13 01:47:12 +05:30
gurusainath
7e1b90c97a Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-12 17:18:39 +05:30
gurusainath
46a6a599a1 chore: updated filters and filter components 2024-02-12 12:30:59 +05:30
NarayanBavisetti
8ae1e3cff2 chore: visibility and lock 2024-02-12 12:23:03 +05:30
NarayanBavisetti
ffdd515cf8 chore: migrations and visiblity of views 2024-02-12 00:49:37 +05:30
gurusainath
5d9393cfa6 chore: filter saving store and ui updates 2024-02-10 14:15:57 +05:30
gurusainath
0fb531e4b7 chore: ui and filter store updates 2024-02-09 22:16:17 +05:30
gurusainath
cf10e3445d conflict: merge conflicts resolved 2024-02-09 22:14:46 +05:30
gurusainath
f90595ca31 chore: updated ui and store 2024-02-09 14:33:22 +05:30
gurusainath
1ce7f20c2d chore: ui fixes on filter and implemented default data 2024-02-08 23:41:30 +05:30
gurusainath
84d3d34e14 conflict: merge conlficts resolved 2024-02-08 23:40:49 +05:30
gurusainath
76e55bee95 Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-08 12:04:51 +05:30
gurusainath
f05b8de91d chore: implemented view create and edit operations and updated the store 2024-02-07 11:30:06 +05:30
gurusainath
bc694bb742 Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-06 22:52:28 +05:30
gurusainath
65d2a2546d fix: SWR config 2024-02-05 20:09:59 +05:30
gurusainath
cd81ec1002 fix: view compoennt and store updates 2024-02-05 20:09:17 +05:30
gurusainath
6325f97c8e Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-05 14:33:30 +05:30
gurusainath
fe505e6b31 Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-03 21:23:56 +05:30
gurusainath
d57d91e530 fix: store updates 2024-02-02 13:52:38 +05:30
gurusainath
984f7ed6b8 Merge branch 'feat/mobx-global-views' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-02 12:11:21 +05:30
gurusainath
90bcdeccf4 Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-02-02 12:11:01 +05:30
NarayanBavisetti
1d88de472e chore: list of all views in workspace 2024-02-01 14:04:12 +05:30
NarayanBavisetti
4af5fef210 Merge branch 'feat/mobx-global-views' of github.com:makeplane/plane into feat/mobx-global-views 2024-01-31 16:13:33 +05:30
NarayanBavisetti
f1ded8540e chore: lock and favorite 2024-01-31 16:12:51 +05:30
gurusainath
b38a352c98 Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-01-31 15:42:40 +05:30
NarayanBavisetti
a77839a942 fix: user and workspace views 2024-01-31 11:50:40 +05:30
NarayanBavisetti
f63a04c1ab Merge branch 'develop' of github.com:makeplane/plane into feat/mobx-global-views 2024-01-31 11:49:50 +05:30
gurusainath
dfb6b2b247 fix: updated store and types in packages 2024-01-31 11:37:02 +05:30
gurusainath
088cc8c659 fix: updated store and added favorite 2024-01-30 16:45:14 +05:30
gurusainath
05e4311e06 fix: build errors in the inbox store and updated the types for filters, display_filters and display_properties in the global views 2024-01-30 14:58:35 +05:30
gurusainath
5674acd985 Merge branch 'develop' of gurusainath:makeplane/plane into feat/mobx-global-views 2024-01-30 14:39:33 +05:30
gurusainath
8e590f6f60 init: stort init for global view store 2024-01-30 14:26:59 +05:30
99 changed files with 7228 additions and 737 deletions

View File

@@ -1,238 +0,0 @@
# All the python scripts that are used for back migrations
import uuid
import random
from django.contrib.auth.hashers import make_password
from plane.db.models import ProjectIdentifier
from plane.db.models import (
Issue,
IssueComment,
User,
Project,
ProjectMember,
Label,
Integration,
)
# Update description and description html values for old descriptions
def update_description():
try:
issues = Issue.objects.all()
updated_issues = []
for issue in issues:
issue.description_html = f"<p>{issue.description}</p>"
issue.description_stripped = issue.description
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues,
["description_html", "description_stripped"],
batch_size=100,
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_comments():
try:
issue_comments = IssueComment.objects.all()
updated_issue_comments = []
for issue_comment in issue_comments:
issue_comment.comment_html = (
f"<p>{issue_comment.comment_stripped}</p>"
)
updated_issue_comments.append(issue_comment)
IssueComment.objects.bulk_update(
updated_issue_comments, ["comment_html"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_project_identifiers():
try:
project_identifiers = ProjectIdentifier.objects.filter(
workspace_id=None
).select_related("project", "project__workspace")
updated_identifiers = []
for identifier in project_identifiers:
identifier.workspace_id = identifier.project.workspace_id
updated_identifiers.append(identifier)
ProjectIdentifier.objects.bulk_update(
updated_identifiers, ["workspace_id"], batch_size=50
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_user_empty_password():
try:
users = User.objects.filter(password="")
updated_users = []
for user in users:
user.password = make_password(uuid.uuid4().hex)
user.is_password_autoset = True
updated_users.append(user)
User.objects.bulk_update(updated_users, ["password"], batch_size=50)
print("Success")
except Exception as e:
print(e)
print("Failed")
def updated_issue_sort_order():
try:
issues = Issue.objects.all()
updated_issues = []
for issue in issues:
issue.sort_order = issue.sequence_id * random.randint(100, 500)
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues, ["sort_order"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_project_cover_images():
try:
project_cover_images = [
"https://images.unsplash.com/photo-1677432658720-3d84f9d657b4?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1661107564401-57497d8fe86f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1677352241429-dc90cfc7a623?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1677196728306-eeafea692454?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1331&q=80",
"https://images.unsplash.com/photo-1660902179734-c94c944f7830?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1255&q=80",
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1677040628614-53936ff66632?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1676920410907-8d5f8dd4b5ba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1676846328604-ce831c481346?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1155&q=80",
"https://images.unsplash.com/photo-1676744843212-09b7e64c3a05?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1676798531090-1608bedeac7b?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1597088758740-56fd7ec8a3f0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1169&q=80",
"https://images.unsplash.com/photo-1676638392418-80aad7c87b96?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80",
"https://images.unsplash.com/photo-1649639194967-2fec0b4ea7bc?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1675883086902-b453b3f8146e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80",
"https://images.unsplash.com/photo-1675887057159-40fca28fdc5d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1173&q=80",
"https://images.unsplash.com/photo-1675373980203-f84c5a672aa5?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1675191475318-d2bf6bad1200?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
"https://images.unsplash.com/photo-1675456230532-2194d0c4bcc0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
"https://images.unsplash.com/photo-1675371788315-60fa0ef48267?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80",
]
projects = Project.objects.all()
updated_projects = []
for project in projects:
project.cover_image = project_cover_images[random.randint(0, 19)]
updated_projects.append(project)
Project.objects.bulk_update(
updated_projects, ["cover_image"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_user_view_property():
try:
project_members = ProjectMember.objects.all()
updated_project_members = []
for project_member in project_members:
project_member.default_props = {
"filters": {"type": None},
"orderBy": "-created_at",
"collapsed": True,
"issueView": "list",
"filterIssue": None,
"groupByProperty": None,
"showEmptyGroups": True,
}
updated_project_members.append(project_member)
ProjectMember.objects.bulk_update(
updated_project_members, ["default_props"], batch_size=100
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_label_color():
try:
labels = Label.objects.filter(color="")
updated_labels = []
for label in labels:
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
updated_labels.append(label)
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
print("Success")
except Exception as e:
print(e)
print("Failed")
def create_slack_integration():
try:
_ = Integration.objects.create(
provider="slack", network=2, title="Slack"
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_integration_verified():
try:
integrations = Integration.objects.all()
updated_integrations = []
for integration in integrations:
integration.verified = True
updated_integrations.append(integration)
Integration.objects.bulk_update(
updated_integrations, ["verified"], batch_size=10
)
print("Success")
except Exception as e:
print(e)
print("Failed")
def update_start_date():
try:
issues = Issue.objects.filter(
state__group__in=["started", "completed"]
)
updated_issues = []
for issue in issues:
issue.start_date = issue.created_at.date()
updated_issues.append(issue)
Issue.objects.bulk_update(
updated_issues, ["start_date"], batch_size=500
)
print("Success")
except Exception as e:
print(e)
print("Failed")

View File

@@ -36,9 +36,8 @@ from .project import (
)
from .state import StateSerializer, StateLiteSerializer
from .view import (
GlobalViewSerializer,
IssueViewSerializer,
IssueViewFavoriteSerializer,
ViewSerializer,
ViewFavoriteSerializer,
)
from .cycle import (
CycleSerializer,

View File

@@ -3,69 +3,33 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
from plane.db.models import View, ViewFavorite
from plane.utils.issue_filters import issue_filters
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = GlobalView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return GlobalView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewSerializer(DynamicBaseSerializer):
class ViewSerializer(DynamicBaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = IssueView
model = View
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"query",
"access",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
query_params = validated_data.get("filters", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = {}
return IssueView.objects.create(**validated_data)
return View.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
query_params = validated_data.get("filters", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
@@ -74,11 +38,10 @@ class IssueViewSerializer(DynamicBaseSerializer):
return super().update(instance, validated_data)
class IssueViewFavoriteSerializer(BaseSerializer):
view_detail = IssueViewSerializer(source="issue_view", read_only=True)
class ViewFavoriteSerializer(BaseSerializer):
class Meta:
model = IssueViewFavorite
model = ViewFavorite
fields = "__all__"
read_only_fields = [
"workspace",

View File

@@ -14,6 +14,8 @@ from plane.app.views import (
UserActivityGraphEndpoint,
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
UserWorkspaceViewViewSet,
UserProjectViewViewSet,
## End Workspaces
)
@@ -85,6 +87,7 @@ urlpatterns = [
UserIssueCompletedGraphEndpoint.as_view(),
name="completed-graph",
),
## End User Graph
path(
"users/me/workspaces/<str:slug>/dashboard/",
UserWorkspaceDashboardEndpoint.as_view(),
@@ -95,5 +98,83 @@ urlpatterns = [
SetUserPasswordEndpoint.as_view(),
name="set-password",
),
## End User Graph
path(
"users/me/workspaces/<str:slug>/views/",
UserWorkspaceViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-workspace-views",
),
path(
"users/me/workspaces/<str:slug>/views/<uuid:pk>/",
UserWorkspaceViewViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="user-workspace-views",
),
path(
"users/me/workspaces/<str:slug>/views/<uuid:pk>/duplicate/",
UserWorkspaceViewViewSet.as_view(
{
"post": "duplicate",
}
),
name="user-workspace-views",
),
path(
"users/me/workspaces/<str:slug>/views/<uuid:pk>/lock/",
UserWorkspaceViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="user-workspace-views-lock",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/",
UserProjectViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-project-views",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
UserProjectViewViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="user-project-views",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
UserProjectViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="user-project-lock-views",
),
path(
"users/me/workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/duplicate/",
UserWorkspaceViewViewSet.as_view(
{
"post": "duplicate",
}
),
name="user-project-duplicate-views",
),
]

View File

@@ -2,17 +2,86 @@ from django.urls import path
from plane.app.views import (
IssueViewViewSet,
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewFavoriteViewSet,
ProjectViewViewSet,
WorkspaceViewViewSet,
WorkspaceViewFavoriteViewSet,
ProjectViewFavoriteViewSet,
WorkspaceViewIssuesViewSet,
)
urlpatterns = [
path(
"workspaces/<str:slug>/issues/",
WorkspaceViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="workspace-view-issues",
),
path(
"workspaces/<str:slug>/views/",
WorkspaceViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="workspace-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
WorkspaceViewViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="workspace-view",
),
path(
"workspaces/<str:slug>/views/<uuid:view_id>/duplicate/",
WorkspaceViewFavoriteViewSet.as_view(
{
"post": "duplicate",
}
),
name="workspace-duplicate-view",
),
path(
"workspaces/<str:slug>/views/<uuid:view_id>/favorite/",
WorkspaceViewFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="workspace-favorite-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/visibility/",
WorkspaceViewViewSet.as_view(
{
"post": "visibility",
}
),
name="workspace-duplicate-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
WorkspaceViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="project-lock-views",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/",
IssueViewViewSet.as_view(
ProjectViewViewSet.as_view(
{
"get": "list",
"post": "create",
@@ -22,7 +91,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/",
IssueViewViewSet.as_view(
ProjectViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
@@ -33,53 +102,41 @@ urlpatterns = [
name="project-view",
),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/duplicate/",
ProjectViewViewSet.as_view(
{
"post": "duplicate",
}
),
name="project-duplicate-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/visibility/",
ProjectViewViewSet.as_view(
{
"post": "visibility",
}
),
name="project-duplicate-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:pk>/lock/",
ProjectViewViewSet.as_view(
{
"post": "toggle_lock",
}
),
name="project-lock-views",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/views/<uuid:view_id>/favorite/",
ProjectViewFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-favorite-view",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/<uuid:view_id>/",
IssueViewFavoriteViewSet.as_view(
{
"delete": "destroy",
}
),
name="user-favorite-view",
name="project-favorite-view",
),
]

View File

@@ -52,10 +52,14 @@ from .workspace import (
)
from .state import StateViewSet
from .view import (
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
IssueViewFavoriteViewSet,
WorkspaceViewViewSet,
ProjectViewViewSet,
WorkspaceViewFavoriteViewSet,
ProjectViewFavoriteViewSet,
UserWorkspaceViewViewSet,
UserProjectViewViewSet,
ProjectViewViewSet,
WorkspaceViewIssuesViewSet,
)
from .cycle import (
CycleViewSet,

View File

@@ -17,7 +17,7 @@ from plane.db.models import (
Cycle,
Module,
Page,
IssueView,
View,
)
from plane.utils.issue_search import search_issues
@@ -161,7 +161,7 @@ class GlobalSearchEndpoint(BaseAPIView):
for field in fields:
q |= Q(**{f"{field}__icontains": query})
issue_views = IssueView.objects.filter(
issue_views = View.objects.filter(
q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,

View File

@@ -1,6 +1,5 @@
# Django imports
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
@@ -10,68 +9,370 @@ from django.db.models import (
When,
Exists,
Max,
Q,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models import Prefetch, OuterRef, Exists
# Third party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseViewSet, BaseAPIView
from . import BaseViewSet
from plane.app.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
ViewSerializer,
IssueSerializer,
IssueViewFavoriteSerializer,
ViewFavoriteSerializer,
)
from plane.app.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkspaceViewerPermission,
ProjectLitePermission,
)
from plane.db.models import (
Workspace,
GlobalView,
IssueView,
View,
Issue,
IssueViewFavorite,
IssueReaction,
ViewFavorite,
IssueLink,
IssueAttachment,
IssueSubscriber,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
class UserWorkspaceViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
serializer.save(
workspace_id=workspace.id, access=0, owned_by=self.request.user
)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
# .filter(project__isnull=True)
.filter(Q(owned_by=self.request.user) & Q(access=0))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-is_pinned"))
.order_by("-is_pinned", "-created_at")
.distinct()
)
def partial_update(self, request, slug, pk):
view = View.objects.get(pk=pk, workspace__slug=slug)
if view.owned_by == self.request.user and not view.is_locked:
serializer = ViewSerializer(view, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
def list(self, request, slug):
type = request.GET.get("type", None)
views = self.get_queryset()
if type == "workspace":
views = views.filter(project__isnull=True)
if type == "project":
views = views.filter(project__isnull=False)
serializer = ViewSerializer(views, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def toggle_lock(self, request, slug, pk):
view = View.objects.get(pk=pk, workspace__slug=slug)
lock = request.data.get("lock", view.is_locked)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot lock the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, pk):
view = View.objects.get(workspace__slug=slug, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, pk):
view = View.objects.get(workspace__slug=slug, pk=pk)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot delete the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id, owned_by=self.request.user)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__isnull=True)
.filter(Q(access=1))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
def toggle_lock(self, request, slug, pk):
view = View.objects.get(pk=pk, workspace__slug=slug)
lock = request.data.get("lock", view.is_locked)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, pk):
view = View.objects.get(workspace__slug=slug, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
class GlobalViewIssuesViewSet(BaseViewSet):
def visibility(self, request, slug, pk):
view = (
self.get_queryset()
.filter(pk=pk, workspace__slug=slug)
.first()
)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.access = request.data.get("access", view.access)
view.save(update_fields=["access"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
class UserProjectViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(
workspace_id=workspace.id,
project_id=self.kwargs.get("project_id"),
access=0,
owned_by=self.request.user,
)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(Q(owned_by=self.request.user) & Q(access=0))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
def partial_update(self, request, slug, project_id, pk):
view = View.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
if view.owned_by == self.request.user and not view.is_locked:
serializer = ViewSerializer(view, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
def toggle_lock(self, request, slug, project_id, pk):
view = View.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug
)
lock = request.data.get("lock", view.is_locked)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot lock the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, project_id, pk):
view = View.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, pk):
view = View.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot delete the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectViewViewSet(BaseViewSet):
serializer_class = ViewSerializer
model = View
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(
workspace_id=workspace.id,
project_id=self.kwargs.get("project_id"),
owned_by=self.request.user,
)
def get_queryset(self):
subquery = ViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(Q(access=1))
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.distinct()
)
def toggle_lock(self, request, slug, project_id, pk):
view = (
self.get_queryset()
.filter(pk=pk, project_id=project_id, workspace__slug=slug)
.first()
)
lock = request.data.get("lock", view.is_locked)
view.is_locked = lock
view.save(update_fields=["is_locked"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
def duplicate(self, request, slug, project_id, pk):
view = View.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
# Create a shallow copy of the original view object
new_view = view
# Set the primary key of the new view to None to ensure it gets a new primary key
new_view.pk = None
# Modify the name of the new view to indicate that it's a copy
new_view.name = f"{view.name} (Copy)"
new_view.save(owned_by=request.user)
return Response(ViewSerializer(new_view).data, status=status.HTTP_201_CREATED)
def visibility(self, request, slug, project_id, pk):
view = (
self.get_queryset()
.filter(pk=pk, project_id=project_id, workspace__slug=slug)
.first()
)
if view.owned_by != self.request.user:
return Response(
{"error": "You cannot update the view"},
status=status.HTTP_403_FORBIDDEN,
)
view.access = request.data.get("access", view.access)
view.save(update_fields=["access"])
return Response(ViewSerializer(view).data, status=status.HTTP_200_OK)
class WorkspaceViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
@@ -87,41 +388,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@@ -147,6 +416,29 @@ class GlobalViewIssuesViewSet(BaseViewSet):
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
@@ -213,52 +505,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
model = IssueView
permission_classes = [
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(project_id=self.kwargs.get("project_id"))
def get_queryset(self):
subquery = IssueViewFavorite.objects.filter(
user=self.request.user,
view_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.annotate(is_favorite=Exists(subquery))
.order_by("-is_favorite", "name")
.distinct()
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
views = IssueViewSerializer(
queryset, many=True, fields=fields if fields else None
).data
return Response(views, status=status.HTTP_200_OK)
class IssueViewFavoriteViewSet(BaseViewSet):
serializer_class = IssueViewFavoriteSerializer
model = IssueViewFavorite
class WorkspaceViewFavoriteViewSet(BaseViewSet):
serializer_class = ViewFavoriteSerializer
model = ViewFavorite
def get_queryset(self):
return self.filter_queryset(
@@ -269,19 +518,52 @@ class IssueViewFavoriteViewSet(BaseViewSet):
.select_related("view")
)
def create(self, request, slug, project_id):
serializer = IssueViewFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def create(self, request, slug, view_id):
workspace = Workspace.objects.get(slug=slug)
view = ViewFavorite.objects.create(
view_id=view_id, user=request.user, workspace_id=workspace.id
)
return Response(
ViewFavoriteSerializer(view).data, status=status.HTTP_201_CREATED
)
def destroy(self, request, slug, view_id):
view_favorite = ViewFavorite.objects.get(
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectViewFavoriteViewSet(BaseViewSet):
serializer_class = ViewFavoriteSerializer
model = ViewFavorite
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related("view")
)
def create(self, request, slug, project_id, view_id):
view = ViewFavorite.objects.create(
view_id=view_id, user=request.user, project_id=project_id
)
return Response(
ViewFavoriteSerializer(view).data, status=status.HTTP_201_CREATED
)
def destroy(self, request, slug, project_id, view_id):
view_favourite = IssueViewFavorite.objects.get(
view_favorite = ViewFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favourite.delete()
view_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1516,11 +1516,9 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug):
(
workspace_properties,
_,
) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace__slug=slug
workspace = Workspace.objects.get(slug=slug)
workspace_properties, _ = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace=workspace
)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -1,11 +1,12 @@
# Generated by Django 4.2.7 on 2024-01-02 13:15
from plane.db.models import WorkspaceUserProperties, ProjectMember, IssueView
from plane.db.models import ProjectMember
from django.db import migrations
def workspace_user_properties(apps, schema_editor):
WorkspaceMember = apps.get_model("db", "WorkspaceMember")
WorkspaceUserProperties = apps.get_model("db", "WorkspaceUserProperties")
updated_workspace_user_properties = []
for workspace_members in WorkspaceMember.objects.all():
updated_workspace_user_properties.append(
@@ -49,6 +50,7 @@ def project_user_properties(apps, schema_editor):
def issue_view(apps, schema_editor):
GlobalView = apps.get_model("db", "GlobalView")
IssueView = apps.get_model("db", "IssueView")
updated_issue_views = []
for global_view in GlobalView.objects.all():

View File

@@ -0,0 +1,70 @@
# Generated by Django 4.2.7 on 2024-01-30 07:49
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from django.db.models import F
def views_owned_by(apps, schema_editor):
View = apps.get_model("db", "View")
View.objects.update(owned_by=F('created_by'))
class Migration(migrations.Migration):
dependencies = [
('db', '0060_cycle_progress_snapshot'),
]
operations = [
migrations.RenameModel(
old_name='IssueView',
new_name='View',
),
migrations.AlterModelTable(
name='view',
table='views',
),
migrations.RenameModel(
old_name='IssueViewFavorite',
new_name='ViewFavorite',
),
migrations.AlterField(
model_name='viewfavorite',
name='project',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterModelTable(
name='workspaceuserproperties',
table='workspace_user_properties',
),
migrations.AlterModelOptions(
name='view',
options={'ordering': ('-created_at',), 'verbose_name': 'View', 'verbose_name_plural': 'Views'},
),
migrations.AddField(
model_name='view',
name='is_locked',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='view',
name='is_pinned',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='view',
name='owned_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='views', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='view',
name='access',
field=models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public'), (2, 'Shared')], default=1),
),
migrations.AlterField(
model_name='viewfavorite',
name='view',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.view'),
),
migrations.RunPython(views_owned_by)
]

View File

@@ -52,7 +52,7 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite, CycleUserProperties
from .view import GlobalView, IssueView, IssueViewFavorite
from .view import View, ViewFavorite
from .module import (
Module,

View File

@@ -3,7 +3,7 @@ from django.db import models
from django.conf import settings
# Module import
from . import ProjectBaseModel, BaseModel, WorkspaceBaseModel
from . import BaseModel, WorkspaceBaseModel
def get_default_filters():
@@ -84,7 +84,7 @@ class GlobalView(BaseModel):
return f"{self.name} <{self.workspace.name}>"
class IssueView(WorkspaceBaseModel):
class View(WorkspaceBaseModel):
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
@@ -94,29 +94,44 @@ class IssueView(WorkspaceBaseModel):
default=get_default_display_properties
)
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
default=1, choices=((0, "Private"), (1, "Public"), (2, "Shared"))
)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views", null=True, blank=True
)
sort_order = models.FloatField(default=65535)
is_locked = models.BooleanField(default=False)
is_pinned = models.BooleanField(default=False)
class Meta:
verbose_name = "Issue View"
verbose_name_plural = "Issue Views"
db_table = "issue_views"
verbose_name = "View"
verbose_name_plural = "Views"
db_table = "views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = View.objects.filter(
workspace=self.workspace
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(View, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.project.name}>"
class IssueViewFavorite(ProjectBaseModel):
class ViewFavorite(WorkspaceBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_view_favorites",
)
view = models.ForeignKey(
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
"db.View", on_delete=models.CASCADE, related_name="view_favorites"
)
class Meta:

View File

@@ -326,7 +326,7 @@ class WorkspaceUserProperties(BaseModel):
unique_together = ["workspace", "user"]
verbose_name = "Workspace User Property"
verbose_name_plural = "Workspace User Property"
db_table = "Workspace_user_properties"
db_table = "workspace_user_properties"
ordering = ("-created_at",)
def __str__(self):

View File

@@ -1,72 +0,0 @@
import { TIssue } from "./issues/base";
import type { IProjectLite } from "./projects";
export type TInboxIssueExtended = {
completed_at: string | null;
start_date: string | null;
target_date: string | null;
};
export interface IInboxIssue extends TIssue, TInboxIssueExtended {
issue_inbox: {
duplicate_to: string | null;
id: string;
snoozed_till: Date | null;
source: string;
status: -2 | -1 | 0 | 1 | 2;
}[];
}
export interface IInbox {
id: string;
project_detail: IProjectLite;
pending_issue_count: number;
created_at: Date;
updated_at: Date;
name: string;
description: string;
is_default: boolean;
created_by: string;
updated_by: string;
project: string;
view_props: { filters: IInboxFilterOptions };
workspace: string;
}
interface StatePending {
readonly status: -2;
}
interface StatusReject {
status: -1;
}
interface StatusSnoozed {
status: 0;
snoozed_till: Date;
}
interface StatusAccepted {
status: 1;
}
interface StatusDuplicate {
status: 2;
duplicate_to: string;
}
export type TInboxStatus =
| StatusReject
| StatusSnoozed
| StatusAccepted
| StatusDuplicate
| StatePending;
export interface IInboxFilterOptions {
priority?: string[] | null;
inbox_status?: number[] | null;
}
export interface IInboxQueryParams {
priority: string | null;
inbox_status: string | null;
}

View File

@@ -13,11 +13,7 @@ export * from "./pages";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./inbox";
export * from "./inbox/root";
export * from "./analytics";
export * from "./calendar";
export * from "./notifications";
@@ -31,6 +27,7 @@ export * from "./auth";
export * from "./api_token";
export * from "./instance";
export * from "./app";
export * from "./view/root";
export type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object

54
packages/types/src/view/base.d.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
import {
TViewFilters,
TViewDisplayFilters,
TViewDisplayProperties,
} from "./filter";
export type TViewTypes =
| "WORKSPACE_PRIVATE_VIEWS"
| "WORKSPACE_PUBLIC_VIEWS"
| "PROJECT_PRIVATE_VIEWS"
| "PROJECT_PUBLIC_VIEWS";
declare enum EViewAccess {
"public" = 0,
"private" = 1,
"shared" = 2,
}
export type TViewAccess =
| EViewAccess.public
| EViewAccess.private
| EViewAccess.shared;
export type TView = {
id: string | undefined;
workspace: string | undefined;
project: string | undefined;
name: string | undefined;
description: string | undefined;
query: string | undefined;
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
access: TViewAccess | undefined;
owned_by: string | undefined;
sort_order: number | undefined;
is_locked: boolean;
is_pinned: boolean;
is_favorite: boolean;
created_by: string | undefined;
updated_by: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
// local view variables
is_local_view: boolean;
};
export type TUpdateView = {
name: string | undefined;
description: string | undefined;
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
};

134
packages/types/src/view/filter.d.ts vendored Normal file
View File

@@ -0,0 +1,134 @@
declare enum EViewLayouts {
LIST = "list",
KANBAN = "kanban",
CALENDAR = "calendar",
SPREADSHEET = "spreadsheet",
GANTT = "gantt",
}
export type TViewLayouts =
| EViewLayouts.LIST
| EViewLayouts.KANBAN
| EViewLayouts.CALENDAR
| EViewLayouts.SPREADSHEET
| EViewLayouts.GANTT;
export type TViewDisplayFiltersGrouped =
| "project"
| "state_detail.group"
| "state"
| "priority"
| "labels"
| "created_by"
| "assignees"
| "mentions"
| "modules"
| "cycles";
export type TViewDisplayFiltersOrderBy =
| "sort_order"
| "created_at"
| "-created_at"
| "updated_at"
| "-updated_at"
| "start_date"
| "-start_date"
| "target_date"
| "-target_date"
| "state__name"
| "-state__name"
| "priority"
| "-priority"
| "labels__name"
| "-labels__name"
| "assignees__first_name"
| "-assignees__first_name"
| "estimate_point"
| "-estimate_point"
| "link_count"
| "-link_count"
| "attachment_count"
| "-attachment_count"
| "sub_issues_count"
| "-sub_issues_count";
export type TViewDisplayFiltersExtraOptions = "sub_issue" | "show_empty_groups";
export type TViewDisplayFiltersType = "active" | "backlog";
export type TViewCalendarLayouts = "month" | "week";
export type TViewFilters = {
project: string[];
module: string[];
cycle: string[];
priority: string[];
state: string[];
state_group: string[];
assignees: string[];
mentions: string[];
subscriber: string[];
created_by: string[];
labels: string[];
start_date: string[];
target_date: string[];
};
export type TViewDisplayFilters = {
layout: TViewLayouts;
group_by: TViewDisplayFiltersGrouped | undefined;
sub_group_by: TViewDisplayFiltersGrouped | undefined;
order_by: TViewDisplayFiltersOrderBy;
type: TViewDisplayFiltersType | undefined;
sub_issue: boolean;
show_empty_groups: boolean;
calendar: {
show_weekends: boolean;
layout: TViewCalendarLayouts;
};
};
export type TViewDisplayProperties = {
assignee: boolean;
start_date: boolean;
due_date: boolean;
labels: boolean;
key: boolean;
priority: boolean;
state: boolean;
sub_issue_count: boolean;
link: boolean;
attachment_count: boolean;
estimate: boolean;
created_on: boolean;
updated_on: boolean;
};
export type TViewFilterProps = {
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
};
export type TViewFilterPartialProps = {
filters: Partial<TViewFilters>;
display_filters: Partial<TViewDisplayFilters>;
display_properties: Partial<TViewDisplayProperties>;
};
export type TViewFilterQueryParams =
| "project"
| "module"
| "cycle"
| "priority"
| "state"
| "state_group"
| "assignees"
| "mentions"
| "subscriber"
| "created_by"
| "labels"
| "start_date"
| "target_date"
| "type"
| "sub_issue";

3
packages/types/src/view/root.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
export * from "./filter";
export * from "./base";
export * from "./user-base";

18
packages/types/src/view/user-base.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import {
TViewFilters,
TViewDisplayFilters,
TViewDisplayProperties,
} from "./filter";
export type TUserView = {
id: string | undefined;
workspace: string | undefined;
user: string | undefined;
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
created_by: string | undefined;
updated_by: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
};

View File

@@ -1,4 +1,4 @@
import { useEffect, } from "react";
import { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
@@ -138,14 +138,18 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return (
<div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
<div
className={cn(
"relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100",
!isProjectLevel ? "flex-col" : ""
)}
>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
{analytics ? analytics.total : "..."}{" "}
<div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
@@ -154,8 +158,8 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,13 @@
import { FC } from "react";
type TGlobalViewsRootProps = {
workspaceSlug: string;
projectId: string;
viewId: string;
};
export const GlobalViewsRoot: FC<TGlobalViewsRootProps> = (props) => {
const { viewId } = props;
return <div>GlobalViewsRoot {viewId}</div>;
};

View File

@@ -154,7 +154,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
link={
<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
}
/>
</Breadcrumbs>
</div>
@@ -203,7 +205,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
</div>
{currentProjectDetails?.inbox_view && inboxDetails && (
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxDetails?.id}`}>
<span className="hidden md:block" >
<span className="hidden md:block">
<Button variant="neutral-primary" size="sm" className="relative">
Inbox
{inboxDetails?.pending_issue_count > 0 && (
@@ -218,7 +220,12 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
)}
{canUserCreateIssue && (
<>
<Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
<Button
className="hidden md:block"
onClick={() => setAnalyticsModal(true)}
variant="neutral-primary"
size="sm"
>
Analytics
</Button>
<Button

View File

@@ -18,7 +18,7 @@ import { Button } from "@plane/ui";
// icons
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react";
// types
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
import type { TInboxDetailedStatus } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
import { ISSUE_DELETED } from "constants/event-tracker";
@@ -30,7 +30,7 @@ type TInboxIssueActionsHeader = {
};
type TInboxIssueOperations = {
updateInboxIssueStatus: (data: TInboxStatus) => Promise<void>;
updateInboxIssueStatus: (data: TInboxDetailedStatus) => Promise<void>;
removeInboxIssue: () => Promise<void>;
};

View File

@@ -0,0 +1,128 @@
import React, { Fragment, useCallback, useMemo } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssues, useUser, useViewDetail } from "hooks/store";
import { useWorkspaceIssueProperties } from "hooks/use-workspace-issue-properties";
// components
import { IssuePeekOverview } from "components/issues";
import { SpreadsheetView } from "components/issues/issue-layouts";
import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns";
// ui
import { SpreadsheetLayoutLoader } from "components/ui";
// types
import { TIssue, IIssueDisplayFilterOptions, TViewTypes } from "@plane/types";
import { EIssueActions } from "../types";
// constants
import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue";
import { EViewPageType } from "constants/view";
type TGlobalViewIssueLayoutRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
};
export const GlobalViewIssueLayoutRoot: React.FC<TGlobalViewIssueLayoutRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType } = props;
// hooks
const {
issues: { loader, groupedIssueIds, updateIssue, removeIssue },
} = useIssues(EIssuesStoreType.GLOBAL);
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { issueIds } = groupedIssueIds;
//swr hook for fetching issue properties
useWorkspaceIssueProperties(workspaceSlug);
const issueActions = useMemo(
() => ({
[EIssueActions.UPDATE]: async (issue: TIssue) => {
const projectId = issue.project_id;
if (!workspaceSlug || !projectId || !viewId) return;
await updateIssue(workspaceSlug.toString(), projectId, issue.id, issue, viewId.toString());
},
[EIssueActions.DELETE]: async (issue: TIssue) => {
const projectId = issue.project_id;
if (!workspaceSlug || !projectId || !viewId) return;
await removeIssue(workspaceSlug.toString(), projectId, issue.id, viewId.toString());
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[updateIssue, removeIssue, workspaceSlug]
);
const handleIssues = useCallback(
async (issue: TIssue, action: EIssueActions) => {
if (action === EIssueActions.UPDATE) await issueActions[action]!(issue);
if (action === EIssueActions.DELETE) await issueActions[action]!(issue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const handleDisplayFiltersUpdate = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !viewId) return;
viewDetailStore?.setDisplayFilters({ order_by: updatedDisplayFilter?.order_by });
},
[viewDetailStore, workspaceSlug, viewId]
);
const renderQuickActions = useCallback(
(issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => (
<AllIssueQuickActions
customActionButton={customActionButton}
issue={issue}
handleUpdate={async () => handleIssues({ ...issue }, EIssueActions.UPDATE)}
handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)}
portalElement={portalElement}
/>
),
[handleIssues]
);
const canEditProperties = useCallback(
(projectId: string | undefined) => {
if (!projectId) return false;
const currentProjectRole = currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId];
return !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
},
[currentWorkspaceAllProjectsRole]
);
if (loader === "init-loader" || !issueIds) {
return <SpreadsheetLayoutLoader />;
}
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{issueIds.length === 0 ? (
<div>Empty state</div>
) : (
<Fragment>
<SpreadsheetView
displayProperties={viewDetailStore?.appliedFilters?.display_properties ?? {}}
displayFilters={viewDetailStore?.appliedFilters?.filters as IIssueDisplayFilterOptions} // Fix: Update the type of displayFilters prop
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
issueIds={issueIds}
quickActions={renderQuickActions}
handleIssues={handleIssues}
canEditProperties={canEditProperties}
viewId={viewId}
/>
{/* peek overview */}
<IssuePeekOverview />
</Fragment>
)}
</div>
);
});

View File

@@ -5,3 +5,4 @@ export * from "./project-view-layout-root";
export * from "./archived-issue-layout-root";
export * from "./draft-issue-layout-root";
export * from "./all-issue-layout-root";
export * from "./global-view-issue-layout-root";

View File

@@ -162,12 +162,13 @@ export const ProfileSidebar = observer(() => {
{project.assigned_issues > 0 && (
<Tooltip tooltipContent="Completion percentage" position="left">
<div
className={`rounded px-1 py-0.5 text-xs font-medium ${completedIssuePercentage <= 35
? "bg-red-500/10 text-red-500"
: completedIssuePercentage <= 70
className={`rounded px-1 py-0.5 text-xs font-medium ${
completedIssuePercentage <= 35
? "bg-red-500/10 text-red-500"
: completedIssuePercentage <= 70
? "bg-yellow-500/10 text-yellow-500"
: "bg-green-500/10 text-green-500"
}`}
}`}
>
{completedIssuePercentage}%
</div>

View File

@@ -54,7 +54,7 @@ const navigation = (workspaceSlug: string, projectId: string) => [
},
{
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
href: `/${workspaceSlug}/projects/${projectId}/views/public`,
Icon: PhotoFilterIcon,
},
{
@@ -147,8 +147,9 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
{({ open }) => (
<>
<div
className={`group relative flex w-full items-center rounded-md px-2 py-1 text-custom-sidebar-text-10 hover:bg-custom-sidebar-background-80 ${snapshot?.isDragging ? "opacity-60" : ""
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
className={`group relative flex w-full items-center rounded-md px-2 py-1 text-custom-sidebar-text-10 hover:bg-custom-sidebar-background-80 ${
snapshot?.isDragging ? "opacity-60" : ""
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
>
{provided && (
<Tooltip
@@ -157,9 +158,11 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
>
<button
type="button"
className={`absolute -left-2.5 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-custom-sidebar-text-400 ${isCollapsed ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "cursor-not-allowed opacity-60" : ""} ${isMenuActive ? "!flex" : ""
}`}
className={`absolute -left-2.5 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-custom-sidebar-text-400 ${
isCollapsed ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "cursor-not-allowed opacity-60" : ""} ${
isMenuActive ? "!flex" : ""
}`}
{...provided?.dragHandleProps}
>
<MoreVertical className="h-3.5" />
@@ -170,12 +173,14 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
<Tooltip tooltipContent={`${project.name}`} position="right" className="ml-2" disabled={!isCollapsed}>
<Disclosure.Button
as="div"
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-sm font-medium ${isCollapsed ? "justify-center" : `justify-between`
}`}
className={`flex flex-grow cursor-pointer select-none items-center truncate text-left text-sm font-medium ${
isCollapsed ? "justify-center" : `justify-between`
}`}
>
<div
className={`flex w-full flex-grow items-center gap-x-2 truncate ${isCollapsed ? "justify-center" : ""
}`}
className={`flex w-full flex-grow items-center gap-x-2 truncate ${
isCollapsed ? "justify-center" : ""
}`}
>
{project.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
@@ -195,8 +200,9 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div>
{!isCollapsed && (
<ChevronDown
className={`hidden h-4 w-4 flex-shrink-0 ${open ? "rotate-180" : ""} ${isMenuActive ? "!block" : ""
} mb-0.5 text-custom-sidebar-text-400 duration-300 group-hover:!block`}
className={`hidden h-4 w-4 flex-shrink-0 ${open ? "rotate-180" : ""} ${
isMenuActive ? "!block" : ""
} mb-0.5 text-custom-sidebar-text-400 duration-300 group-hover:!block`}
/>
)}
</Disclosure.Button>
@@ -320,10 +326,11 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
disabled={!isCollapsed}
>
<div
className={`group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-medium outline-none ${router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${isCollapsed ? "justify-center" : ""}`}
className={`group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${isCollapsed ? "justify-center" : ""}`}
>
<item.Icon className="h-4 w-4 stroke-[1.5]" />
{!isCollapsed && item.name}

View File

@@ -0,0 +1,48 @@
import { FC, useCallback } from "react";
import { ImagePlus, X } from "lucide-react";
// hooks
import { useViewDetail, useViewFilter } from "hooks/store";
// types
import { TViewFilters, TViewTypes } from "@plane/types";
type TViewAppliedFiltersItem = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
filterKey: keyof TViewFilters;
propertyId: string;
isLocalView: boolean;
};
export const ViewAppliedFiltersItem: FC<TViewAppliedFiltersItem> = (props) => {
const { workspaceSlug, projectId, viewId, viewType, filterKey, propertyId, isLocalView } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType, isLocalView);
const viewFilterHelper = useViewFilter(workspaceSlug, projectId);
const propertyDetail = viewFilterHelper?.propertyDetails(filterKey, propertyId) || undefined;
const removeFilterOption = useCallback(
() => viewDetailStore?.setFilters(filterKey, propertyId),
[viewDetailStore, filterKey, propertyId]
);
return (
<div
key={`filter_value_${filterKey}_${propertyId}`}
className="bg-custom-background-80 rounded relative flex items-center gap-1.5 p-1"
>
<div className="flex-shrink-0 w-4 h-4 relative flex justify-center items-center overflow-hidden">
{propertyDetail?.icon || <ImagePlus size={14} />}
</div>
<div className="text-xs">{propertyDetail?.label || propertyId}</div>
<div
className="flex-shrink-0 w-3.5 h-3.5 relative flex justify-center items-center overflow-hidden rounded-full transition-all cursor-pointer bg-custom-background-80 hover:bg-custom-background-90 text-custom-text-300 hover:text-custom-text-200"
onClick={removeFilterOption}
>
<X size={10} />
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import { FC, Fragment, useCallback, useMemo } from "react";
import { observer } from "mobx-react-lite";
import isEmpty from "lodash/isEmpty";
import { X } from "lucide-react";
// hooks
import { useViewDetail, useViewFilter } from "hooks/store";
// components
import { ViewAppliedFiltersItem } from "./filter-item";
// types
import { TViewFilters, TViewTypes } from "@plane/types";
type TViewAppliedFilters = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
filterKey: keyof TViewFilters;
propertyVisibleCount?: number | undefined;
isLocalView: boolean;
};
export const ViewAppliedFilters: FC<TViewAppliedFilters> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, filterKey, propertyVisibleCount, isLocalView } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType, isLocalView);
const viewFilterStore = useViewFilter(workspaceSlug, projectId);
const currentDefaultFilterDetails = useMemo(
() => viewFilterStore?.propertyDefaultDetails(filterKey),
[viewFilterStore, filterKey]
);
const propertyValues = useMemo(
() =>
viewDetailStore?.appliedFilters?.filters && !isEmpty(viewDetailStore?.appliedFilters?.filters)
? viewDetailStore?.appliedFilters?.filters?.[filterKey] || undefined
: undefined,
[filterKey, viewDetailStore?.appliedFilters?.filters]
);
const clearPropertyFilter = useCallback(
() => viewDetailStore?.setFilters(filterKey, "clear_all"),
[viewDetailStore, filterKey]
);
if (!propertyValues || propertyValues.length <= 0) return <></>;
return (
<div className="relative flex items-center gap-2 border border-custom-border-200 rounded p-1 px-1.5">
<div className="flex-shrink-0 text-xs text-custom-text-200 capitalize">{filterKey.replaceAll("_", " ")}</div>
<div className="relative flex items-center gap-1.5 flex-wrap">
{propertyVisibleCount && propertyValues.length >= propertyVisibleCount ? (
<div className="text-xs bg-custom-primary-100/20 rounded relative flex items-center gap-1 p-1 px-2">
<div className="flex-shrink-0 w-4-h-4">{currentDefaultFilterDetails?.icon}</div>
<div className="whitespace-nowrap">
{propertyValues.length} {currentDefaultFilterDetails?.label}
</div>
</div>
) : (
<>
{propertyValues.map((propertyId) => (
<Fragment key={propertyId}>
<ViewAppliedFiltersItem
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
filterKey={filterKey}
propertyId={propertyId}
isLocalView={isLocalView}
/>
</Fragment>
))}
</>
)}
</div>
<div
className="flex-shrink-0 relative flex justify-center items-center w-4 h-4 rounded-full cursor-pointer transition-all bg-custom-background-80 hover:bg-custom-background-90 text-custom-text-300 hover:text-custom-text-200"
onClick={clearPropertyFilter}
>
<X size={10} />
</div>
</div>
);
});

View File

@@ -0,0 +1,83 @@
import { FC, Fragment, useCallback, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
import isEmpty from "lodash/isEmpty";
// hooks
import { useViewDetail } from "hooks/store";
// components
import { ViewAppliedFilters } from "./filter";
// types
import { TViewTypes, TViewFilters } from "@plane/types";
type TViewAppliedFiltersRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
propertyVisibleCount?: number | undefined;
showClearAll?: boolean;
isLocalView?: boolean;
};
export const ViewAppliedFiltersRoot: FC<TViewAppliedFiltersRoot> = observer((props) => {
const {
workspaceSlug,
projectId,
viewId,
viewType,
propertyVisibleCount = undefined,
showClearAll = false,
isLocalView = false,
} = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType, isLocalView);
const filterKeys = useMemo(
() =>
viewDetailStore?.filtersToUpdate && !isEmpty(viewDetailStore?.filtersToUpdate?.filters)
? Object.keys(viewDetailStore?.filtersToUpdate?.filters)
: undefined,
[viewDetailStore?.filtersToUpdate]
);
const clearAllFilters = useCallback(() => viewDetailStore?.setFilters(undefined, "clear_all"), [viewDetailStore]);
if (!filterKeys || !viewDetailStore?.isFiltersApplied)
return (
<div className="relative w-full text-sm text-custom-text-200 inline-block truncate line-clamp-1 pt-1.5">
No filters applied. Apply filters to create views.
</div>
);
return (
<div className="relative flex items-center gap-2 flex-wrap">
{filterKeys.map((key) => {
const filterKey = key as keyof TViewFilters;
return (
<Fragment key={filterKey}>
<ViewAppliedFilters
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
filterKey={filterKey}
propertyVisibleCount={propertyVisibleCount}
isLocalView={isLocalView}
/>
</Fragment>
);
})}
{showClearAll && (
<div
className="relative flex items-center gap-2 border border-custom-border-300 rounded p-1.5 px-2 cursor-pointer transition-all hover:bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100 min-h-[36px]"
onClick={clearAllFilters}
>
<div className="text-xs">Clear All</div>
<div className="flex-shrink-0 relative flex justify-center items-center w-4 h-4">
<X size={10} />
</div>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,77 @@
import { FC, Fragment, useCallback, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/ui";
// types
import { TViewOperations } from "../types";
type TViewDeleteConfirmationModal = {
viewId: string;
viewOperations: TViewOperations;
};
export const ViewDeleteConfirmationModal: FC<TViewDeleteConfirmationModal> = (props) => {
const { viewId, viewOperations } = props;
// state
const [modalToggle, setModalToggle] = useState(false);
const [loader, setLoader] = useState(false);
const modalOpen = useCallback(() => setModalToggle(true), [setModalToggle]);
const modalClose = useCallback(() => {
setModalToggle(false);
}, [setModalToggle]);
useEffect(() => {
if (viewId) modalOpen();
}, [viewId, modalOpen, modalClose]);
const onContinue = async () => {
setLoader(true);
setLoader(false);
};
return (
<Transition.Root show={modalToggle} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={modalClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem] py-5 border-[0.1px] border-custom-border-100">
<div className="p-3 px-5 relative flex items-center gap-2">Content</div>
<div className="p-3 px-5 relative flex justify-end items-center gap-2">
<Button variant="neutral-primary" onClick={modalClose} disabled={loader}>
Cancel
</Button>
<Button variant="primary" onClick={onContinue} disabled={loader}>
{loader ? `Duplicating` : `Duplicate View`}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,77 @@
import { FC, Fragment, useCallback, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/ui";
// types
import { TViewOperations } from "../types";
type TViewDuplicateConfirmationModal = {
viewId: string;
viewOperations: TViewOperations;
};
export const ViewDuplicateConfirmationModal: FC<TViewDuplicateConfirmationModal> = (props) => {
const { viewId, viewOperations } = props;
// state
const [modalToggle, setModalToggle] = useState(false);
const [loader, setLoader] = useState(false);
const modalOpen = useCallback(() => setModalToggle(true), [setModalToggle]);
const modalClose = useCallback(() => {
setModalToggle(false);
}, [setModalToggle]);
useEffect(() => {
if (viewId) modalOpen();
}, [viewId, modalOpen, modalClose]);
const onContinue = async () => {
setLoader(true);
setLoader(false);
};
return (
<Transition.Root show={modalToggle} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={modalClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem] py-5 border-[0.1px] border-custom-border-100">
<div className="p-3 px-5 relative flex items-center gap-2">Content</div>
<div className="p-3 px-5 relative flex justify-end items-center gap-2">
<Button variant="neutral-primary" onClick={modalClose} disabled={loader}>
Cancel
</Button>
<Button variant="primary" onClick={onContinue} disabled={loader}>
{loader ? `Duplicating` : `Duplicate View`}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,72 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useViewDetail, useViewFilter } from "hooks/store";
// components
import { ViewDisplayFiltersItem, ViewDisplayFilterSelection } from "..";
// types
import { TViewDisplayFilters, TViewTypes } from "@plane/types";
type TViewDisplayFiltersItemRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
filterKey: keyof TViewDisplayFilters;
};
export const ViewDisplayFiltersItemRoot: FC<TViewDisplayFiltersItemRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, filterKey } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const viewFilterHelper = useViewFilter(workspaceSlug, projectId);
const filterPropertyIds = viewFilterHelper?.displayFilterIdsWithKey(filterKey) || [];
const handlePropertySelection = (_propertyId: string) => {
viewDetailStore?.setDisplayFilters({ [filterKey]: _propertyId });
};
const renderElement = (_propertyId: string) =>
filterKey === "group_by"
? viewDetailStore?.appliedFilters?.display_filters?.["sub_group_by"] !== _propertyId
? true
: false
: filterKey === "sub_group_by"
? viewDetailStore?.appliedFilters?.display_filters?.["group_by"] !== _propertyId
? true
: false
: true;
if (filterPropertyIds.length <= 0)
return <div className="text-xs italic py-1 text-custom-text-300">No items are available.</div>;
return (
<div className="space-y-0.5">
{filterPropertyIds.map(
(propertyId) =>
renderElement(propertyId) && (
<button
key={`filterKey_${propertyId}`}
className="relative w-full flex items-center overflow-hidden gap-2.5 cursor-pointer p-1 py-1.5 rounded hover:bg-custom-background-80 transition-all group"
onClick={() => handlePropertySelection(propertyId)}
>
<ViewDisplayFilterSelection
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
filterKey={filterKey}
propertyId={propertyId}
/>
<ViewDisplayFiltersItem
workspaceSlug={workspaceSlug}
projectId={projectId}
filterKey={filterKey}
propertyId={propertyId}
/>
</button>
)
)}
</div>
);
});

View File

@@ -0,0 +1,27 @@
import { FC, Fragment } from "react";
// hooks
import { useViewFilter } from "hooks/store";
// types
import { TViewDisplayFilters } from "@plane/types";
type TViewDisplayFiltersItem = {
workspaceSlug: string;
projectId: string | undefined;
filterKey: keyof TViewDisplayFilters;
propertyId: string;
};
export const ViewDisplayFiltersItem: FC<TViewDisplayFiltersItem> = (props) => {
const { workspaceSlug, projectId, filterKey, propertyId } = props;
// hooks
const viewFilterHelper = useViewFilter(workspaceSlug, projectId);
const propertyDetail = viewFilterHelper?.displayPropertyDetails(filterKey, propertyId) || undefined;
if (!propertyDetail) return <></>;
return (
<div className="text-xs block truncate line-clamp-1 text-custom-text-200 group-hover:text-custom-text-100">
{propertyDetail?.label || propertyId}
</div>
);
};

View File

@@ -0,0 +1,38 @@
import { FC } from "react";
import { Check } from "lucide-react";
import { observer } from "mobx-react-lite";
// hooks
import { useViewDetail } from "hooks/store";
// types
import { TViewDisplayFilters, TViewTypes } from "@plane/types";
type TViewDisplayFilterSelection = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
filterKey: keyof TViewDisplayFilters;
propertyId: string;
};
export const ViewDisplayFilterSelection: FC<TViewDisplayFilterSelection> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, filterKey, propertyId } = props;
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const propertyIds = viewDetailStore?.appliedFilters?.display_filters?.[filterKey] || undefined;
const isSelected = propertyIds === propertyId || false;
return (
<div
className={`flex-shrink-0 w-3 h-3 flex justify-center items-center border rounded-full text-bold ${
isSelected
? "border-custom-primary-100 bg-custom-primary-100"
: "border-custom-border-400 bg-custom-background-100"
}`}
>
{isSelected && <Check size={14} className="text-white" />}
</div>
);
});

View File

@@ -0,0 +1,133 @@
import { FC, Fragment, ReactNode, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import { MonitorDot } from "lucide-react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { ViewDisplayFiltersRoot } from "../";
// ui
import { Tooltip } from "@plane/ui";
// types
import { TViewTypes } from "@plane/types";
// constants
import { EViewPageType } from "constants/view";
type NewType = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
children?: ReactNode;
displayDropdownText?: boolean;
dropdownPlacement?: Placement;
};
type TViewDisplayFiltersDropdown = NewType;
export const ViewDisplayFiltersDropdown: FC<TViewDisplayFiltersDropdown> = observer((props) => {
const {
workspaceSlug,
projectId,
viewId,
viewType,
viewPageType,
children,
displayDropdownText = true,
dropdownPlacement = "bottom-start",
} = props;
// state
const [dropdownToggle, setDropdownToggle] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: dropdownPlacement,
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
{
name: "offset",
options: {
offset: [0, 10],
},
},
],
});
const handleDropdownOpen = () => setDropdownToggle(true);
const handleDropdownClose = () => setDropdownToggle(false);
const handleDropdownToggle = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
event.stopPropagation();
if (!dropdownToggle) handleDropdownOpen();
else handleDropdownClose();
};
useOutsideClickDetector(dropdownRef, handleDropdownClose);
return (
<Combobox as="div" ref={dropdownRef}>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={"block h-full w-full outline-none"}
onClick={handleDropdownToggle}
>
{children ? (
<div className="relative inline-block">{children}</div>
) : (
<Tooltip tooltipContent={"Display"} position="bottom">
<div
className={`relative flex items-center gap-1 h-8 rounded px-2 transition-all
${
displayDropdownText
? `border border-custom-border-300 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80`
: `hover:bg-custom-background-80`
}
`}
>
<div className="w-4 h-4 relative flex justify-center items-center overflow-hidden">
<MonitorDot size={14} />
</div>
{displayDropdownText && <div className="text-sm whitespace-nowrap">Display</div>}
</div>
</Tooltip>
)}
</button>
</Combobox.Button>
{dropdownToggle && (
<Combobox.Options className="fixed z-10" static>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="my-1 w-72 p-2 space-y-2 rounded bg-custom-background-100 border-[0.5px] border-custom-border-300 shadow-custom-shadow-rg focus:outline-none"
>
<div className="max-h-[500px] space-y-0.5 overflow-y-scroll mb-2">
<ViewDisplayFiltersRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewPageType={viewPageType}
/>
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@@ -0,0 +1,54 @@
import { FC, Fragment, useCallback, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Check } from "lucide-react";
// hooks
import { useViewDetail } from "hooks/store";
// types
import { TViewDisplayFiltersExtraOptions, TViewTypes } from "@plane/types";
// constants
import { EXTRA_OPTIONS_PROPERTY } from "constants/view";
type TDisplayFilterExtraOptions = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
filterKey: TViewDisplayFiltersExtraOptions;
};
export const DisplayFilterExtraOptions: FC<TDisplayFilterExtraOptions> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, filterKey } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const optionTitle = useMemo(() => EXTRA_OPTIONS_PROPERTY[filterKey].label, [filterKey]);
const isSelected = viewDetailStore?.appliedFilters?.display_filters?.[filterKey] ? true : false;
const handlePropertySelection = useCallback(
() => viewDetailStore?.setDisplayFilters({ [filterKey]: !isSelected }),
[viewDetailStore, filterKey, isSelected]
);
return (
<Fragment>
<div
className="relative w-full flex items-center overflow-hidden gap-2.5 cursor-pointer p-1 py-1.5 rounded hover:bg-custom-background-80 transition-all group"
onClick={handlePropertySelection}
>
<div
className={`flex-shrink-0 w-3 h-3 flex justify-center items-center border rounded text-bold ${
isSelected
? "border-custom-primary-100 bg-custom-primary-100"
: "border-custom-border-400 bg-custom-background-100"
}`}
>
{isSelected && <Check size={14} className="text-white" />}
</div>
<div className="text-xs block truncate line-clamp-1 text-custom-text-200 group-hover:text-custom-text-100">
{optionTitle || "Extra Option"}
</div>
</div>
</Fragment>
);
});

View File

@@ -0,0 +1,111 @@
import { FC, useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { ChevronUp, ChevronDown } from "lucide-react";
import filter from "lodash/filter";
import concat from "lodash/concat";
import uniq from "lodash/uniq";
// hooks
import { useViewDetail } from "hooks/store";
// components
import { ViewDisplayPropertiesRoot, ViewDisplayFiltersItemRoot, DisplayFilterExtraOptions } from "../";
// types
import { TViewDisplayFilters, TViewDisplayFiltersExtraOptions, TViewTypes } from "@plane/types";
// constants
import { EViewPageType, viewDefaultFilterParametersByViewTypeAndLayout } from "constants/view";
type TViewDisplayFiltersRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
};
export const ViewDisplayFiltersRoot: FC<TViewDisplayFiltersRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, viewPageType } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
// state
const [filterVisibility, setFilterVisibility] = useState<(Partial<keyof TViewDisplayFilters> | "display_property")[]>(
[]
);
const handleFilterVisibility = useCallback((key: keyof TViewDisplayFilters | "display_property") => {
setFilterVisibility((prevData = []) => {
if (prevData.includes(key)) return filter(prevData, (item) => item !== key);
return uniq(concat(prevData, [key]));
});
}, []);
const filtersProperties = useMemo(() => {
const layout = viewDetailStore?.appliedFilters?.display_filters?.layout;
return layout ? viewDefaultFilterParametersByViewTypeAndLayout(viewPageType, layout, "display_filters") : [];
}, [viewDetailStore, viewPageType]);
const filtersExtraProperties = useMemo(() => {
const layout = viewDetailStore?.appliedFilters?.display_filters?.layout;
return layout ? viewDefaultFilterParametersByViewTypeAndLayout(viewPageType, layout, "extra_options") : [];
}, [viewDetailStore, viewPageType]);
return (
<div className="space-y-1 divide-y divide-custom-border-300 [&>div]:first:pt-0 [&>div]:last:pb-0">
<div className="relative py-1">
<div className="sticky top-0 z-20 flex justify-between items-center gap-2 bg-custom-background-100 select-none">
<div className="font-medium text-xs text-custom-text-300 capitalize py-1">Properties</div>
<div
className="flex-shrink-0 relative overflow-hidden w-5 h-5 rounded flex justify-center items-center cursor-pointer hover:bg-custom-background-80"
onClick={() => handleFilterVisibility("display_property")}
>
{!filterVisibility.includes("display_property") ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div>
</div>
{!filterVisibility.includes("display_property") && (
<div className="py-1">
<ViewDisplayPropertiesRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
/>
</div>
)}
</div>
{filtersProperties.map((filterKey) => (
<div key={filterKey} className="relative py-1">
<div className="sticky top-0 z-20 flex justify-between items-center gap-2 bg-custom-background-100 select-none">
<div className="font-medium text-xs text-custom-text-300 capitalize py-1">
{filterKey.replaceAll("_", " ")}
</div>
<div
className="flex-shrink-0 relative overflow-hidden w-5 h-5 rounded flex justify-center items-center cursor-pointer hover:bg-custom-background-80"
onClick={() => handleFilterVisibility(filterKey)}
>
{!filterVisibility.includes(filterKey) ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div>
</div>
{!filterVisibility.includes(filterKey) && (
<ViewDisplayFiltersItemRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
filterKey={filterKey}
/>
)}
</div>
))}
{filtersExtraProperties.map((option) => (
<div className="py-1">
<DisplayFilterExtraOptions
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
filterKey={option as TViewDisplayFiltersExtraOptions}
/>
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,41 @@
import { FC, useCallback } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useViewDetail } from "hooks/store";
// types
import { TViewDisplayProperties, TViewTypes } from "@plane/types";
type TViewDisplayPropertySelection = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
property: keyof TViewDisplayProperties;
};
export const ViewDisplayPropertySelection: FC<TViewDisplayPropertySelection> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, property } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const handlePropertySelection = useCallback(
() => viewDetailStore?.setDisplayProperties(property),
[viewDetailStore, property]
);
const propertyIsSelected = viewDetailStore?.appliedFilters?.display_properties?.[property];
return (
<div
className={`relative flex items-center gap-1 text-xs rounded p-0.5 px-2 border transition-all capitalize cursor-pointer
${
propertyIsSelected
? `border-custom-primary-100 bg-custom-primary-100 text-white`
: `border-custom-border-300 hover:bg-custom-background-80`
}`}
onClick={handlePropertySelection}
>
{["key"].includes(property) ? "ID" : property.replaceAll("_", " ")}
</div>
);
});

View File

@@ -0,0 +1,47 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react-lite";
// components
import { ViewDisplayPropertySelection } from "../";
// types
import { TViewDisplayProperties, TViewTypes } from "@plane/types";
type TViewDisplayPropertiesRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
};
export const ViewDisplayPropertiesRoot: FC<TViewDisplayPropertiesRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType } = props;
const displayProperties: Partial<keyof TViewDisplayProperties>[] = [
"key",
"state",
"labels",
"priority",
"assignee",
"start_date",
"due_date",
"sub_issue_count",
"attachment_count",
"estimate",
"link",
];
return (
<div className="relative flex items-center flex-wrap gap-2">
{displayProperties.map((property) => (
<Fragment key={property}>
<ViewDisplayPropertySelection
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
property={property}
/>
</Fragment>
))}
</div>
);
});

View File

@@ -0,0 +1,103 @@
import { FC, ReactNode } from "react";
import { observer } from "mobx-react-lite";
import { useTheme } from "next-themes";
import useSWR from "swr";
// hooks
import { useApplication, useEventTracker, useIssues, useProject, useUser, useView } from "hooks/store";
// components
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// types
import { TViewTypes } from "@plane/types";
// constants
import { ALL_ISSUES_EMPTY_STATE_DETAILS } from "constants/empty-state";
import { EUserWorkspaceRoles } from "constants/workspace";
import { EIssuesStoreType } from "constants/issue";
type TViewEmptyStateRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
children: ReactNode;
};
export const ViewEmptyStateRoot: FC<TViewEmptyStateRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, children } = props;
// hooks
const { commandPalette } = useApplication();
const { resolvedTheme } = useTheme();
const { workspaceProjectIds } = useProject();
const {
currentUser,
membership: { currentWorkspaceRole },
} = useUser();
const { setTrackElement } = useEventTracker();
const { issueMap } = useIssues();
const viewStore = useView(workspaceSlug, projectId, viewType);
const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(viewId);
const currentView = isDefaultView ? viewId : "custom-view";
const currentViewDetails = ALL_ISSUES_EMPTY_STATE_DETAILS[currentView as keyof typeof ALL_ISSUES_EMPTY_STATE_DETAILS];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("all-issues", currentView, isLightMode);
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
useSWR(
workspaceSlug && viewId && viewType && viewStore ? `WORKSPACE_VIEWS_${workspaceSlug}_${viewType}` : null,
async () => {
if (workspaceSlug && viewType && viewStore)
await viewStore?.fetch(
workspaceSlug,
projectId,
viewStore?.viewIds.length > 0 ? "view-mutation-loader" : "view-loader"
);
await viewStore?.fetchById(workspaceSlug, projectId, viewId);
}
);
const issueIds = projectId ? true : (Object.values(issueMap) ?? []).length === 0;
if (!workspaceSlug) return <></>;
return (
<>
{(workspaceProjectIds ?? []).length === 0 || issueIds ? (
<div className="relative w-full h-full">
<EmptyState
image={emptyStateImage}
title={(workspaceProjectIds ?? []).length > 0 ? currentViewDetails.title : "No project"}
description={
(workspaceProjectIds ?? []).length > 0
? currentViewDetails.description
: "To create issues or manage your work, you need to create a project or be a part of one."
}
size="sm"
primaryButton={
(workspaceProjectIds ?? []).length > 0
? currentView !== "custom-view" && currentView !== "subscribed"
? {
text: "Create new issue",
onClick: () => {
setTrackElement("All issues empty state");
commandPalette.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
},
}
: undefined
: {
text: "Start your first project",
onClick: () => {
setTrackElement("All issues empty state");
commandPalette.toggleCreateProjectModal(true);
},
}
}
disabled={!isEditingAllowed}
/>
</div>
) : (
<>{children}</>
)}
</>
);
});

View File

@@ -0,0 +1,103 @@
import { FC, ReactNode } from "react";
import { observer } from "mobx-react-lite";
import { useTheme } from "next-themes";
import useSWR from "swr";
// hooks
import { useApplication, useEventTracker, useIssues, useProject, useUser, useView } from "hooks/store";
// components
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
// types
import { TViewTypes } from "@plane/types";
// constants
import { ALL_ISSUES_EMPTY_STATE_DETAILS } from "constants/empty-state";
import { EUserWorkspaceRoles } from "constants/workspace";
import { EIssuesStoreType } from "constants/issue";
type TViewEmptyStateRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
children: ReactNode;
};
export const ViewEmptyStateRoot: FC<TViewEmptyStateRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, children } = props;
// hooks
const { commandPalette } = useApplication();
const { resolvedTheme } = useTheme();
const { workspaceProjectIds } = useProject();
const {
currentUser,
membership: { currentWorkspaceRole },
} = useUser();
const { setTrackElement } = useEventTracker();
const { issueMap } = useIssues();
const viewStore = useView(workspaceSlug, projectId, viewType);
const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(viewId);
const currentView = isDefaultView ? viewId : "custom-view";
const currentViewDetails = ALL_ISSUES_EMPTY_STATE_DETAILS[currentView as keyof typeof ALL_ISSUES_EMPTY_STATE_DETAILS];
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("all-issues", currentView, isLightMode);
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
useSWR(
workspaceSlug && viewId && viewType && viewStore ? `WORKSPACE_VIEWS_${workspaceSlug}_${viewType}` : null,
async () => {
if (workspaceSlug && viewType && viewStore)
await viewStore?.fetch(
workspaceSlug,
projectId,
viewStore?.viewIds.length > 0 ? "view-mutation-loader" : "view-loader"
);
await viewStore?.fetchById(workspaceSlug, projectId, viewId);
}
);
const issueIds = projectId ? true : (Object.values(issueMap) ?? []).length === 0;
if (!workspaceSlug) return <></>;
return (
<>
{(workspaceProjectIds ?? []).length === 0 || issueIds ? (
<div className="relative w-full h-full">
<EmptyState
image={emptyStateImage}
title={(workspaceProjectIds ?? []).length > 0 ? currentViewDetails.title : "No project"}
description={
(workspaceProjectIds ?? []).length > 0
? currentViewDetails.description
: "To create issues or manage your work, you need to create a project or be a part of one."
}
size="sm"
primaryButton={
(workspaceProjectIds ?? []).length > 0
? currentView !== "custom-view" && currentView !== "subscribed"
? {
text: "Create new issue",
onClick: () => {
setTrackElement("All issues empty state");
commandPalette.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
},
}
: undefined
: {
text: "Start your first project",
onClick: () => {
setTrackElement("All issues empty state");
commandPalette.toggleCreateProjectModal(true);
},
}
}
disabled={!isEditingAllowed}
/>
</div>
) : (
<>{children}</>
)}
</>
);
});

View File

@@ -0,0 +1,37 @@
import { FC, ReactNode } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssues, useProject } from "hooks/store";
// types
import { TViewTypes } from "@plane/types";
type TViewEmptyStateRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
children: ReactNode;
};
export const ViewEmptyStateRoot: FC<TViewEmptyStateRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, children } = props;
// hooks
const { workspaceProjectIds } = useProject();
const { issueMap } = useIssues();
const areIssueAvailable = projectId ? true : (Object.values(issueMap) ?? []).length === 0 ? true : false;
if (!workspaceSlug) return <></>;
return (
<>
{(workspaceProjectIds ?? []).length === 0 ? (
<div className="relative w-full h-full">No Projects are available.</div>
) : (
<>
{areIssueAvailable ? <>{children}</> : <div className="relative w-full h-full">No issues are available.</div>}
</>
)}
</>
);
});

View File

@@ -0,0 +1,152 @@
import { FC, Fragment, ReactNode, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import { ListFilter, Search } from "lucide-react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { ViewFiltersRoot } from "../";
// ui
import { Tooltip } from "@plane/ui";
// types
import { TViewTypes } from "@plane/types";
import { EViewPageType } from "constants/view";
type TViewFiltersDropdown = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
children?: ReactNode;
displayDropdownText?: boolean;
dropdownPlacement?: Placement;
isLocalView?: boolean;
};
export const ViewFiltersDropdown: FC<TViewFiltersDropdown> = observer((props) => {
const {
workspaceSlug,
projectId,
viewId,
viewType,
viewPageType,
children,
displayDropdownText = true,
dropdownPlacement = "bottom-start",
isLocalView = false,
} = props;
// state
const [dropdownToggle, setDropdownToggle] = useState(false);
const [query, setQuery] = useState("");
const [dateCustomFilterToggle, setDateCustomFilterToggle] = useState<string | undefined>(undefined);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: dropdownPlacement,
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
{
name: "offset",
options: {
offset: [0, 10],
},
},
],
});
const handleDropdownOpen = useCallback(() => setDropdownToggle(true), []);
const handleDropdownClose = useCallback(() => setDropdownToggle(false), []);
const handleDropdownToggle = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
event.stopPropagation();
if (!dropdownToggle) handleDropdownOpen();
else handleDropdownClose();
},
[dropdownToggle, handleDropdownOpen, handleDropdownClose]
);
useOutsideClickDetector(dropdownRef, () => dateCustomFilterToggle === undefined && handleDropdownClose());
return (
<Combobox as="div" ref={dropdownRef}>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={"block h-full w-full outline-none"}
onClick={handleDropdownToggle}
>
{children ? (
<span className="relative inline-block">{children}</span>
) : (
<Tooltip tooltipContent={"Filters"} position="bottom">
<div
className={`relative flex items-center gap-1 h-8 rounded px-2 transition-all
${
displayDropdownText
? `border border-custom-border-300 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80`
: `hover:bg-custom-background-80`
}
`}
>
<div className="w-4 h-4 relative flex justify-center items-center overflow-hidden">
<ListFilter size={14} />
</div>
{displayDropdownText && <div className="text-sm whitespace-nowrap">Filters</div>}
</div>
</Tooltip>
)}
</button>
</Combobox.Button>
{dropdownToggle && (
<Combobox.Options className="fixed z-10" static>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="my-1 w-72 p-2 space-y-2 rounded bg-custom-background-100 border-[0.5px] border-custom-border-300 shadow-custom-shadow-rg focus:outline-none"
>
<div className="relative p-0.5 px-2 text-sm flex items-center gap-2 rounded border border-custom-border-100 bg-custom-background-90">
<Search className="h-3 w-3 text-custom-text-300" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a view..."
displayValue={(assigned: any) => assigned?.name}
autoFocus
/>
</div>
<div className="max-h-[500px] space-y-0.5 overflow-y-scroll mb-2">
<ViewFiltersRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewPageType={viewPageType}
dateCustomFilterToggle={dateCustomFilterToggle}
setDateCustomFilterToggle={setDateCustomFilterToggle}
isLocalView={isLocalView}
/>
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@@ -0,0 +1,127 @@
import { FC, Fragment, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
import { observer } from "mobx-react-lite";
import { usePopper } from "react-popper";
import { Menu, Transition } from "@headlessui/react";
// hooks
import { useViewDetail } from "hooks/store";
// ui
import { PhotoFilterIcon } from "@plane/ui";
// types
import { TViewTypes } from "@plane/types";
import { TViewFilterEditDropdownOptions, TViewOperations } from "../types";
type TViewFiltersEditDropdown = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewOperations: TViewOperations;
};
export const ViewFiltersEditDropdown: FC<TViewFiltersEditDropdown> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, viewOperations } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-end",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
{
name: "offset",
options: {
offset: [0, 10],
},
},
],
});
// dropdown options
const dropdownOptions: TViewFilterEditDropdownOptions[] = useMemo(
() => [
{
icon: PhotoFilterIcon,
key: "save_as_new",
label: "Save as new view",
onClick: () => {
viewOperations.localViewCreateEdit(viewId, "SAVE_AS_NEW");
},
},
{
icon: RotateCcw,
key: "reset_changes",
label: "Reset filter changes",
onClick: () => viewDetailStore?.resetChanges(),
},
],
[viewId, viewOperations, viewDetailStore]
);
if (viewDetailStore?.is_local_view) return <></>;
if (!viewDetailStore?.isFiltersUpdateEnabled) return <></>;
return (
<Menu as="div" className="relative flex-shrink-0" ref={dropdownRef}>
<div className=" relative flex items-center rounded h-8 transition-all cursor-pointer bg-custom-primary-100/20 text-custom-primary-100">
<button
className="text-sm px-3 font-medium h-full border-r border-white/50 flex justify-center items-center rounded-l transition-all hover:bg-custom-primary-100/30"
disabled={viewDetailStore?.loader === "updating"}
onClick={() => viewOperations.update()}
>
{viewDetailStore?.loader === "updating" ? "updating..." : "Update"}
</button>
<Menu.Button
as="div"
className="flex-shrink-0 px-1.5 hover:bg-custom-primary-100/30 h-full flex justify-center items-center rounded-r transition-all outline-none"
ref={setReferenceElement}
>
{({ open }) => (!open ? <ChevronDown size={16} /> : <ChevronUp size={16} />)}
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 z-20 mt-1.5 flex w-52 flex-col rounded border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 text-xs shadow-lg outline-none p-1"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{dropdownOptions &&
dropdownOptions.length > 0 &&
dropdownOptions.map((option) => (
<Menu.Item
key={option.key}
as="button"
type="button"
className="relative flex items-center gap-2 p-1 py-1.5 rounded transition-all hover:bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100 cursor-pointer"
onClick={option.onClick}
>
<div className="flex-shrink-0 w-4 h-4 relative flex justify-center items-center">
<option.icon size={12} className="w-3 h-3" />
</div>
<div className="text-xs whitespace-nowrap">{option.label}</div>
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
);
});

View File

@@ -0,0 +1,118 @@
import { FC, useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useViewDetail, useViewFilter } from "hooks/store";
// components
import { ViewFiltersItem, ViewFilterSelection } from "../";
import { DateFilterModal } from "components/core";
// types
import { TViewFilters, TViewTypes } from "@plane/types";
type TViewFiltersItemRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
filterKey: keyof TViewFilters;
dateCustomFilterToggle: string | undefined;
setDateCustomFilterToggle: (value: string | undefined) => void;
isLocalView: boolean;
};
export const ViewFiltersItemRoot: FC<TViewFiltersItemRoot> = observer((props) => {
const {
workspaceSlug,
projectId,
viewId,
viewType,
filterKey,
dateCustomFilterToggle,
setDateCustomFilterToggle,
isLocalView,
} = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType, isLocalView);
const viewFilterHelper = useViewFilter(workspaceSlug, projectId);
// state
const [viewAll, setViewAll] = useState(false);
const propertyIds = useMemo(() => viewFilterHelper?.filterIdsWithKey(filterKey) || [], [viewFilterHelper, filterKey]);
const filterPropertyIds = useMemo(
() => (propertyIds.length > 5 ? (viewAll ? propertyIds : propertyIds.slice(0, 5)) : propertyIds),
[propertyIds, viewAll]
);
const handlePropertySelection = useCallback(
(_propertyId: string) => {
if (["start_date", "target_date"].includes(filterKey)) {
if (_propertyId === "custom") {
const _propertyIds = viewDetailStore?.appliedFilters?.filters?.[filterKey] || [];
const selectedDates = _propertyIds.filter((id) => id.includes("-"));
if (selectedDates.length > 0)
selectedDates.forEach((date: string) => viewDetailStore?.setFilters(filterKey, date));
else setDateCustomFilterToggle(filterKey);
} else viewDetailStore?.setFilters(filterKey, _propertyId);
} else viewDetailStore?.setFilters(filterKey, _propertyId);
},
[filterKey, viewDetailStore, setDateCustomFilterToggle]
);
const handleCustomDateSelection = useCallback(
(selectedDates: string[]) => {
selectedDates.forEach((date: string) => {
viewDetailStore?.setFilters(filterKey, date);
setDateCustomFilterToggle(undefined);
});
},
[filterKey, viewDetailStore, setDateCustomFilterToggle]
);
if (propertyIds.length <= 0)
return <div className="text-xs italic py-1 text-custom-text-300">No items are available.</div>;
return (
<div className="space-y-0.5">
{filterPropertyIds.map((propertyId) => (
<button
key={`filterKey_${propertyId}`}
className="relative w-full flex items-center overflow-hidden gap-2.5 cursor-pointer p-1 py-1.5 rounded hover:bg-custom-background-80 transition-all group"
onClick={() => handlePropertySelection(propertyId)}
>
<ViewFilterSelection
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
filterKey={filterKey}
propertyId={propertyId}
isLocalView={isLocalView}
/>
<ViewFiltersItem
workspaceSlug={workspaceSlug}
projectId={projectId}
filterKey={filterKey}
propertyId={propertyId}
/>
</button>
))}
{propertyIds.length > 5 && (
<div
className="text-xs transition-all text-custom-primary-100/90 hover:text-custom-primary-100 font-medium pl-8 cursor-pointer py-1"
onClick={() => setViewAll((prevData) => !prevData)}
>
{viewAll ? "View less" : "View all"}
</div>
)}
{dateCustomFilterToggle === filterKey && (
<DateFilterModal
handleClose={() => setDateCustomFilterToggle(undefined)}
isOpen={dateCustomFilterToggle === filterKey ? true : false}
onSelect={handleCustomDateSelection}
title="Start date"
/>
)}
</div>
);
});

View File

@@ -0,0 +1,33 @@
import { FC, Fragment } from "react";
import { ImagePlus } from "lucide-react";
// hooks
import { useViewFilter } from "hooks/store";
// types
import { TViewFilters } from "@plane/types";
type TViewFiltersItem = {
workspaceSlug: string;
projectId: string | undefined;
filterKey: keyof TViewFilters;
propertyId: string;
};
export const ViewFiltersItem: FC<TViewFiltersItem> = (props) => {
const { workspaceSlug, projectId, filterKey, propertyId } = props;
// hooks
const viewFilterHelper = useViewFilter(workspaceSlug, projectId);
const propertyDetail = viewFilterHelper?.propertyDetails(filterKey, propertyId) || undefined;
if (!propertyDetail) return <></>;
return (
<Fragment>
<div className="flex-shrink-0 w-4 h-4 flex justify-center items-center">
{propertyDetail?.icon || <ImagePlus size={14} />}
</div>
<div className="text-xs block truncate line-clamp-1 text-custom-text-200 group-hover:text-custom-text-100">
{propertyDetail?.label || propertyId}
</div>
</Fragment>
);
};

View File

@@ -0,0 +1,48 @@
import { FC, useMemo } from "react";
import { Check } from "lucide-react";
import { observer } from "mobx-react-lite";
// hooks
import { useViewDetail } from "hooks/store";
// types
import { TViewFilters, TViewTypes } from "@plane/types";
type TViewFilterSelection = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
filterKey: keyof TViewFilters;
propertyId: string;
isLocalView: boolean;
};
export const ViewFilterSelection: FC<TViewFilterSelection> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, filterKey, propertyId, isLocalView } = props;
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType, isLocalView);
const propertyIds = useMemo(
() => viewDetailStore?.appliedFilters?.filters?.[filterKey] || [],
[viewDetailStore?.appliedFilters?.filters, filterKey]
);
const isSelected = ["start_date", "target_date"].includes(filterKey)
? propertyId === "custom"
? propertyIds.filter((id) => id.includes("-")).length > 0
? true
: false
: propertyIds?.includes(propertyId)
: propertyIds?.includes(propertyId) || false;
return (
<div
className={`flex-shrink-0 w-3 h-3 flex justify-center items-center border rounded text-bold ${
isSelected
? "border-custom-primary-100 bg-custom-primary-100"
: "border-custom-border-400 bg-custom-background-100"
}`}
>
{isSelected && <Check size={14} className="text-white" />}
</div>
);
});

View File

@@ -0,0 +1,85 @@
import { FC, useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { ChevronDown, ChevronUp } from "lucide-react";
import concat from "lodash/concat";
import uniq from "lodash/uniq";
import filter from "lodash/filter";
// hooks
import { useViewDetail } from "hooks/store";
// components
import { ViewFiltersItemRoot } from "../";
// types
import { TViewFilters, TViewTypes } from "@plane/types";
import { EViewPageType, viewDefaultFilterParametersByViewTypeAndLayout } from "constants/view";
type TViewFiltersRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
dateCustomFilterToggle: string | undefined;
setDateCustomFilterToggle: (value: string | undefined) => void;
isLocalView: boolean;
};
export const ViewFiltersRoot: FC<TViewFiltersRoot> = observer((props) => {
const {
workspaceSlug,
projectId,
viewId,
viewType,
viewPageType,
dateCustomFilterToggle,
setDateCustomFilterToggle,
isLocalView,
} = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType, isLocalView);
// state
const [filterVisibility, setFilterVisibility] = useState<Partial<keyof TViewFilters>[]>([]);
const handleFilterVisibility = useCallback((key: keyof TViewFilters) => {
setFilterVisibility((prevData = []) => {
if (prevData.includes(key)) return filter(prevData, (item) => item !== key);
return uniq(concat(prevData, [key]));
});
}, []);
const filtersProperties = useMemo(() => {
const layout = viewDetailStore?.appliedFilters?.display_filters?.layout;
return layout ? viewDefaultFilterParametersByViewTypeAndLayout(viewPageType, layout, "filters") : [];
}, [viewDetailStore?.appliedFilters?.display_filters?.layout, viewPageType]);
if (filtersProperties.length <= 0) return <></>;
return (
<div className="space-y-1 divide-y divide-custom-border-300">
{filtersProperties.map((filterKey) => (
<div key={filterKey} className="relative py-1 first:pt-0 last:pb-0">
<div className="sticky top-0 z-20 flex justify-between items-center gap-2 bg-custom-background-100 select-none">
<div className="font-medium text-xs text-custom-text-300 capitalize py-1">
{filterKey.replaceAll("_", " ")}
</div>
<div
className="flex-shrink-0 relative overflow-hidden w-5 h-5 rounded flex justify-center items-center cursor-pointer hover:bg-custom-background-80"
onClick={() => handleFilterVisibility(filterKey)}
>
{!filterVisibility.includes(filterKey) ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div>
</div>
{!filterVisibility.includes(filterKey) && (
<ViewFiltersItemRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
filterKey={filterKey}
dateCustomFilterToggle={dateCustomFilterToggle}
setDateCustomFilterToggle={setDateCustomFilterToggle}
isLocalView={isLocalView}
/>
)}
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,81 @@
import { FC, Fragment, ReactNode, useMemo } from "react";
import Link from "next/link";
import { Briefcase, CheckCircle, ChevronRight } from "lucide-react";
// hooks
import { useProject } from "hooks/store";
// types
import { TViewTypes } from "@plane/types";
import { renderEmoji } from "helpers/emoji.helper";
type TViewHeader = {
projectId: string | undefined;
viewType: TViewTypes;
titleIcon: ReactNode;
title: string;
workspaceViewTabOptions: { key: TViewTypes; title: string; href: string }[];
};
export const ViewHeader: FC<TViewHeader> = (props) => {
const { projectId, viewType, titleIcon, title, workspaceViewTabOptions } = props;
// hooks
const { getProjectById } = useProject();
const projectDetails = useMemo(
() => (projectId ? getProjectById(projectId) : undefined),
[projectId, getProjectById]
);
return (
<div className="relative flex items-center gap-2">
{projectDetails && (
<Fragment>
<div className="relative flex items-center gap-2 overflow-hidden">
<div className="flex-shrink-0 w-6 h-6 rounded relative flex justify-center items-center bg-custom-background-80 text-sm">
{projectDetails?.emoji ? (
renderEmoji(projectDetails?.emoji)
) : projectDetails?.icon_prop ? (
renderEmoji(projectDetails?.icon_prop)
) : (
<Briefcase size={12} />
)}
</div>
<div className="font-medium inline-block whitespace-nowrap overflow-hidden truncate line-clamp-1 text-sm">
{projectDetails?.name ? projectDetails?.name : "Project Issues"}
</div>
</div>
<div className="text-custom-text-200">
<ChevronRight size={12} />
</div>
</Fragment>
)}
<div className="relative flex items-center gap-2 overflow-hidden">
<div className="flex-shrink-0 w-6 h-6 rounded relative flex justify-center items-center bg-custom-background-80">
{titleIcon ? titleIcon : <CheckCircle size={12} />}
</div>
<div className="font-medium inline-block whitespace-nowrap overflow-hidden truncate line-clamp-1 text-sm">
{title ? title : "All Issues"}
</div>
</div>
<div className="ml-auto relative flex items-center gap-3">
<div className="relative flex items-center rounded border border-custom-border-200 bg-custom-background-80">
{workspaceViewTabOptions.map((tab) => (
<Link
key={tab.key}
href={tab.href}
className={`p-4 py-1.5 rounded text-sm transition-all cursor-pointer font-medium
${
viewType === tab.key
? "text-custom-text-100 bg-custom-background-100"
: "text-custom-text-200 bg-custom-background-80 hover:text-custom-text-100"
}`}
>
{tab.title}
</Link>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
export * from "./root";
export * from "./header-tabs";
// empty states
export * from "./empty-states/empty-state";
// views
export * from "./views/root";
export * from "./views/view-item";
export * from "./views/view-dropdown";
export * from "./views/view-dropdown-item";
export * from "./views/create-edit-form";
export * from "./views/edit-dropdown";
// layouts
export * from "./layout";
// view filters
export * from "./filters/dropdown";
export * from "./filters/root";
export * from "./filters/filter-item-root";
export * from "./filters/filter-item";
export * from "./filters/filter-selection";
export * from "./filters/edit-dropdown";
// view display filters
export * from "./display-filters/dropdown";
export * from "./display-filters/root";
export * from "./display-filters/display-filter-item-root";
export * from "./display-filters/display-filter-item";
export * from "./display-filters/display-filter-selection";
export * from "./display-filters/extra-options";
// view display properties
export * from "./display-properties/root";
export * from "./display-properties/property-selection";
// view applied filters
export * from "./applied-filters/root";
// confirmation modals
export * from "./confirmation-modals/duplicate";
export * from "./confirmation-modals/delete";

View File

@@ -0,0 +1,65 @@
import { FC, Fragment, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { LucideIcon, List, Kanban, Calendar, Sheet, GanttChartSquare } from "lucide-react";
// hooks
import { useViewDetail } from "hooks/store";
// ui
import { Tooltip } from "@plane/ui";
// types
import { TViewTypes } from "@plane/types";
// constants
import { EViewLayouts, EViewPageType, viewPageDefaultLayoutsByPageType } from "constants/view";
type TViewLayoutRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
};
export const ViewLayoutRoot: FC<TViewLayoutRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, viewPageType } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const LAYOUTS_DATA: { key: EViewLayouts; title: string; icon: LucideIcon }[] = useMemo(
() => [
{ key: EViewLayouts.LIST, title: "List Layout", icon: List },
{ key: EViewLayouts.KANBAN, title: "Kanban Layout", icon: Kanban },
{ key: EViewLayouts.CALENDAR, title: "Calendar Layout", icon: Calendar },
{ key: EViewLayouts.SPREADSHEET, title: "Spreadsheet Layout", icon: Sheet },
{ key: EViewLayouts.GANTT, title: "Gantt Chart layout", icon: GanttChartSquare },
],
[]
);
const validLayouts = useMemo(() => viewPageDefaultLayoutsByPageType(viewPageType), [viewPageType]);
if (!viewDetailStore || validLayouts.length <= 1) return <></>;
return (
<div className="relative flex gap-0.5 items-center bg-custom-background-80 rounded p-1 shadow-custom-shadow-2xs">
{LAYOUTS_DATA.map((layout) => {
if (!validLayouts.includes(layout.key)) return <Fragment key={layout.key} />;
return (
<Fragment key={layout.key}>
<Tooltip tooltipContent={layout.title} position="bottom">
<div
className={`relative h-6 w-7 flex justify-center items-center overflow-hidden rounded transition-all cursor-pointer
${
viewDetailStore?.filtersToUpdate?.display_filters?.layout === layout.key
? `bg-custom-background-100 shadow-custom-shadow-2xs`
: `hover:bg-custom-background-100`
}
`}
onClick={() => viewDetailStore.setDisplayFilters({ layout: layout.key })}
>
<layout.icon size={12} />
</div>
</Tooltip>
</Fragment>
);
})}
</div>
);
});

View File

@@ -0,0 +1,362 @@
import { FC, Fragment, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import isEmpty from "lodash/isEmpty";
// hooks
import { useView, useViewDetail } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import {
ViewRoot,
ViewCreateEditForm,
ViewEditDropdown,
ViewLayoutRoot,
ViewFiltersDropdown,
ViewFiltersEditDropdown,
ViewDisplayFiltersDropdown,
ViewAppliedFiltersRoot,
ViewDuplicateConfirmationModal,
ViewDeleteConfirmationModal,
} from "./";
// ui
import { Loader } from "@plane/ui";
// constants
import {
ELocalViews,
EViewLayouts,
EViewPageType,
TViewCRUD,
viewDefaultFilterParametersByViewTypeAndLayout,
} from "constants/view";
// types
import { TViewOperations } from "./types";
import { TViewFilters, TViewTypes } from "@plane/types";
type TGlobalViewRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
baseRoute: string;
};
type TViewOperationsToggle = {
type: "DUPLICATE" | "DELETE" | undefined;
viewId: string | undefined;
};
export const GlobalViewRoot: FC<TGlobalViewRoot> = observer((props) => {
// router
const router = useRouter();
const { workspaceSlug, projectId, viewId, viewType, viewPageType, baseRoute } = props;
// hooks
const viewStore = useView(workspaceSlug, projectId, viewType);
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const { setToastAlert } = useToast();
// states
const [viewOperationsToggle, setViewOperationsToggle] = useState<TViewOperationsToggle>({
type: undefined,
viewId: undefined,
});
const handleViewOperationsToggle = useCallback(
(type: TViewOperationsToggle["type"], viewId: string | undefined) => setViewOperationsToggle({ type, viewId }),
[]
);
const viewOperations: TViewOperations = useMemo(
() => ({
localViewCreateEdit: (viewId: string | undefined, status: TViewCRUD) => {
viewStore?.localViewHandler(viewId, status);
},
fetch: async () => {
try {
await viewStore?.fetch(workspaceSlug, projectId);
} catch {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again later or contact the support team.",
});
}
},
create: async () => {
try {
await viewStore?.create();
handleViewOperationsToggle(undefined, undefined);
setToastAlert({
type: "success",
title: "Success!",
message: "View created successfully.",
});
} catch {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again later or contact the support team.",
});
}
},
update: async () => {
try {
await viewStore?.update();
handleViewOperationsToggle(undefined, undefined);
setToastAlert({
type: "success",
title: "Success!",
message: "View created successfully.",
});
} catch {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again later or contact the support team.",
});
}
},
saveChanges: async () => {
try {
await viewDetailStore?.saveChanges();
handleViewOperationsToggle(undefined, undefined);
setToastAlert({
type: "success",
title: "Success!",
message: "View updated successfully.",
});
} catch {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again later or contact the support team.",
});
}
},
remove: async (viewId: string) => {
try {
await viewStore?.remove(viewId);
handleViewOperationsToggle(undefined, undefined);
setToastAlert({
type: "success",
title: "Success!",
message: "View removed successfully.",
});
} catch {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again later or contact the support team.",
});
}
},
duplicate: async (viewId: string) => {
try {
await viewStore?.duplicate(viewId);
handleViewOperationsToggle(undefined, undefined);
setToastAlert({
type: "success",
title: "Success!",
message: "View removed successfully.",
});
} catch {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again later or contact the support team.",
});
}
},
}),
[viewStore, viewDetailStore, handleViewOperationsToggle, setToastAlert, workspaceSlug, projectId]
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const applyFIltersFromRouter = () => {
const routerParams: Partial<Record<keyof TViewFilters, string[]>> = {};
if (workspaceSlug && viewId && Object.values(ELocalViews).includes(viewId as ELocalViews)) {
const routerQueryParams = { ...router.query };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ["workspaceSlug"]: _workspaceSlug, ["viewId"]: _viewId, ...filters } = routerQueryParams;
const acceptedFilters = viewDefaultFilterParametersByViewTypeAndLayout(
viewPageType,
EViewLayouts.SPREADSHEET,
"filters"
);
Object.keys(filters).forEach((key: string) => {
const filterKey: keyof TViewFilters | undefined = key as keyof TViewFilters;
if (filterKey) {
const filterValue = filters[key]?.toString() || undefined;
if (filterKey && filterValue && acceptedFilters.includes(filterKey)) {
const _filterValues = filterValue.split(",");
routerParams[filterKey] = _filterValues;
}
}
});
}
return isEmpty(routerParams) ? undefined : routerParams;
};
// fetch all views
useEffect(() => {
const fetchViews = async () => {
await viewStore?.fetch(
workspaceSlug,
projectId,
viewStore?.viewIds.length > 0 ? "view-mutation-loader" : "view-loader"
);
};
if (workspaceSlug && viewType && viewStore) fetchViews();
}, [workspaceSlug, projectId, viewType, viewStore]);
// fetch view by id
useEffect(() => {
const fetchViewByViewId = async () => {
await viewStore?.fetchById(workspaceSlug, projectId, viewId, applyFIltersFromRouter());
};
if (workspaceSlug && viewId && viewType && viewStore) {
fetchViewByViewId();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId, viewId, viewType, viewStore]);
return (
<div className="relative w-full h-full">
{viewStore?.loader && viewStore?.loader === "view-loader" ? (
<Loader className="relative w-full flex items-center gap-2 pt-2 pb-1 px-5 border-b border-custom-border-300">
<div>
<Loader.Item height="30px" width="120px" />
<div className="border-t-2 rounded-t border-custom-primary-100" />
</div>
<Loader.Item height="30px" width="120px" />
<Loader.Item height="30px" width="120px" />
<Loader.Item height="30px" width="120px" />
<Loader.Item height="30px" width="120px" />
<div className="ml-auto">
<Loader.Item height="30px" width="120px" />
</div>
</Loader>
) : (
<>
<div className="border-b border-custom-border-200 pt-2">
<ViewRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewOperations={viewOperations}
baseRoute={baseRoute}
/>
</div>
{viewStore?.loader === "view-detail-loader" ? (
<Loader className="relative w-full flex items-center gap-2 py-3 px-5 border-b border-custom-border-300">
<div className="mr-auto relative flex items-center gap-2">
<Loader.Item width="140px" height="30px" />
<Loader.Item width="140px" height="30px" />
<Loader.Item width="140px" height="30px" />
<Loader.Item width="140px" height="30px" />
</div>
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30px" height="30px" />
<Loader.Item width="120px" height="30px" />
</Loader>
) : (
<div className="p-5 py-2 border-b border-custom-border-200 relative flex items-start gap-1">
<div className="w-full overflow-hidden">
<ViewAppliedFiltersRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
propertyVisibleCount={5}
/>
</div>
<div className="flex-shrink-0">
<ViewLayoutRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewPageType={viewPageType}
/>
</div>
<div className="flex-shrink-0">
<ViewFiltersDropdown
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewPageType={viewPageType}
displayDropdownText={false}
/>
</div>
<div className="flex-shrink-0">
<ViewDisplayFiltersDropdown
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewPageType={viewPageType}
displayDropdownText={false}
/>
</div>
<div className="flex-shrink-0">
<ViewEditDropdown
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewOperations={viewOperations}
/>
</div>
<div className="flex-shrink-0">
<ViewFiltersEditDropdown
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewOperations={viewOperations}
/>
</div>
</div>
)}
</>
)}
{/* create edit modal */}
{viewStore?.viewMapCEN?.id && (
<ViewCreateEditForm
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewStore?.viewMapCEN?.id}
viewType={viewType}
viewPageType={viewPageType}
viewOperations={viewOperations}
isLocalView={true}
/>
)}
{viewOperationsToggle.type && viewOperationsToggle.viewId && (
<Fragment>
{["DUPLICATE"].includes(viewOperationsToggle.type) && (
<ViewDuplicateConfirmationModal viewId={viewOperationsToggle.viewId} viewOperations={viewOperations} />
)}
{["DELETE"].includes(viewOperationsToggle.type) && (
<ViewDeleteConfirmationModal viewId={viewOperationsToggle.viewId} viewOperations={viewOperations} />
)}
</Fragment>
)}
</div>
);
});

31
web/components/view/types.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
import { LucideIcon } from "lucide-react";
// types
import { TView, TUpdateView } from "@plane/types";
// constants
import { TViewCRUD } from "constants/view";
export type TViewOperations = {
localViewCreateEdit: (viewId: string | undefined, status: TViewCRUD) => void;
fetch: () => Promise<void>;
create: () => Promise<void>;
update: () => Promise<void>;
remove: (viewId: string) => Promise<void>;
duplicate: (viewId: string) => Promise<void>;
};
// view and view filter edit dropdowns
export type TViewEditDropdownOptions = {
icon: LucideIcon;
key: string;
label: string;
onClick: () => void;
children: TViewEditDropdownOptions[] | undefined;
};
export type TViewFilterEditDropdownOptions = {
icon: LucideIcon | any;
key: string;
label: string;
onClick: () => void;
};

View File

@@ -0,0 +1,198 @@
import { FC, Fragment, useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
import { Briefcase, Globe2, Plus, X } from "lucide-react";
// hooks
import { useViewDetail, useProject, useView } from "hooks/store";
// components
import { ViewAppliedFiltersRoot, ViewFiltersDropdown } from "../";
// ui
import { Input, Button } from "@plane/ui";
// types
import { TViewTypes } from "@plane/types";
import { TViewOperations } from "../types";
// constants
import { EViewPageType } from "constants/view";
type TViewCreateEditForm = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewPageType: EViewPageType;
viewOperations: TViewOperations;
isLocalView: boolean;
};
export const ViewCreateEditForm: FC<TViewCreateEditForm> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, viewPageType, viewOperations, isLocalView } = props;
// hooks
const viewStore = useView(workspaceSlug, projectId, viewType);
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType, isLocalView);
const { getProjectById } = useProject();
// states
const [modalToggle, setModalToggle] = useState(false);
const modalOpen = useCallback(() => setModalToggle(true), [setModalToggle]);
const modalClose = useCallback(() => {
setModalToggle(false);
setTimeout(() => {
viewOperations.localViewCreateEdit(undefined, "CLEAR");
}, 200);
}, [setModalToggle, viewOperations]);
useEffect(() => {
if (viewId) modalOpen();
}, [viewId, modalOpen, modalClose]);
const onContinue = async () => {
if (!viewDetailStore) return;
if (viewDetailStore?.id === "create") {
await viewOperations.create();
modalClose();
} else {
console.log("coming here...");
await viewOperations.update();
// modalClose();
}
// if (viewDetailStore?.id != "create") {
// const payload = viewDetailStore?.filtersToUpdate;
// await viewOperations.create(payload);
// modalClose();
// } else {
// const payload = viewDetailStore?.filtersToUpdate;
// if (!payload) return;
// await viewOperations.update();
// modalClose();
// }
};
const projectDetails = projectId ? getProjectById(projectId) : undefined;
if (!viewDetailStore) return <></>;
return (
<Transition.Root show={modalToggle} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={modalClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[40rem] py-5 border-[0.1px] border-custom-border-100">
<div className="p-3 px-5 relative flex items-center gap-2">
{projectId && projectDetails ? (
<div className="relative rounded p-1.5 px-2 flex items-center gap-1 border border-custom-border-100 bg-custom-background-80">
<div className="flex-shrink-0 relative flex justify-center items-center w-5 h-5 overflow-hidden">
<Briefcase className="w-3.5 h-3.5" />
</div>
<div className="text-xs uppercase font-medium">{projectDetails?.identifier || "Project"}</div>
</div>
) : (
<div className="relative rounded p-1.5 px-2 flex items-center gap-1 border border-custom-border-100 bg-custom-background-80">
<div className="flex-shrink-0 relative flex justify-center items-center w-5 h-5 overflow-hidden">
<Globe2 className="w-3.5 h-3.5" />
</div>
<div className="text-xs uppercase font-medium">Workspace</div>
</div>
)}
<div className="font-medium text-lg">Save View</div>
</div>
<div className="p-3 px-5">
<Input
id="name"
name="name"
type="text"
value={viewDetailStore?.filtersToUpdate?.name || ""}
onChange={(e) => {
viewDetailStore?.setName(e.target.value);
}}
placeholder="What do you want to call this view?"
className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
</div>
<div className="p-3 px-5 relative flex justify-between items-center gap-2">
<ViewFiltersDropdown
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewPageType={viewPageType}
dropdownPlacement="right"
isLocalView={isLocalView}
>
<div className="cursor-pointer relative rounded p-1.5 px-2 flex items-center gap-1 border border-custom-border-100 bg-custom-background-80">
<div className="flex-shrink-0 relative flex justify-center items-center w-4 h-4 overflow-hidden">
<Plus className="w-3 h-3" />
</div>
<div className="text-xs">Filters</div>
</div>
</ViewFiltersDropdown>
{viewDetailStore?.isFiltersApplied && (
<div
className="cursor-pointer relative rounded p-1.5 px-2 flex items-center gap-1 border border-dashed border-custom-border-100 bg-custom-background-80"
onClick={() => {
viewDetailStore.setFilters(undefined, "clear_all");
}}
>
<div className="text-xs">Clear all filters</div>
<div className="flex-shrink-0 relative flex justify-center items-center w-4 h-4 overflow-hidden">
<X className="w-3 h-3" />
</div>
</div>
)}
</div>
<div className="p-3 px-5 relative bg-custom-background-90 max-h-36 overflow-hidden overflow-y-auto">
<ViewAppliedFiltersRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
propertyVisibleCount={undefined}
isLocalView={isLocalView}
/>
</div>
<div className="p-3 px-5 relative flex justify-end items-center gap-2">
<Button
variant="neutral-primary"
onClick={modalClose}
disabled={viewStore?.loader == "create-submitting"}
>
Cancel
</Button>
<Button variant="primary" onClick={onContinue} disabled={viewStore?.loader == "create-submitting"}>
{viewStore?.loader == "create-submitting"
? `Saving...`
: `${viewDetailStore?.id === "create" ? `Save` : `Update`} View`}
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -0,0 +1,152 @@
import { FC, Fragment, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Menu, Transition } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Copy, Eye, Globe2, Link2, Pencil, Trash } from "lucide-react";
// hooks
import { useViewDetail } from "hooks/store";
// types
import { TViewEditDropdownOptions, TViewOperations } from "../types";
import { TViewTypes } from "@plane/types";
type TViewEditDropdown = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewOperations: TViewOperations;
};
export const ViewEditDropdown: FC<TViewEditDropdown> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, viewOperations } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-end",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
{
name: "offset",
options: {
offset: [0, 10],
},
},
],
});
// dropdown options
const dropdownOptions: TViewEditDropdownOptions[] = useMemo(
() => [
{
icon: Pencil,
key: "rename",
label: "Rename",
onClick: () => viewOperations.localViewCreateEdit(viewId, "EDIT"),
children: undefined,
},
{
icon: Eye,
key: "accessability",
label: "Change Accessability",
onClick: () => {},
children: [
{
icon: Eye,
key: "private",
label: "Private",
onClick: () => viewOperations.create({}),
children: undefined,
},
{
icon: Globe2,
key: "public",
label: "Public",
onClick: () => viewOperations.create({}),
children: undefined,
},
],
},
{
icon: Copy,
key: "duplicate",
label: "Duplicate view",
onClick: () => viewOperations.remove(viewId),
children: undefined,
},
{
icon: Link2,
key: "copy_link",
label: "Copy view link",
onClick: () => viewOperations.remove(viewId),
children: undefined,
},
{
icon: Trash,
key: "delete",
label: "Delete view",
onClick: () => viewOperations.remove(viewId),
children: undefined,
},
],
[viewOperations, viewId]
);
if (viewDetailStore?.is_local_view) return <></>;
return (
<Menu as="div" className="relative flex-shrink-0" ref={dropdownRef}>
<Menu.Button
className="relative flex items-center gap-1 rounded px-2 h-8 transition-all hover:bg-custom-background-80 cursor-pointer outline-none"
ref={setReferenceElement}
>
<div className="w-4 h-4 relative flex justify-center items-center overflow-hidden">
<Pencil size={12} />
</div>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 z-20 mt-1.5 flex w-52 flex-col rounded border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 text-xs shadow-lg outline-none p-1"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{dropdownOptions &&
dropdownOptions.length > 0 &&
dropdownOptions.map((option) => (
<Menu.Item
key={option.key}
as="button"
type="button"
className="relative w-full flex items-center gap-2 p-1 py-1.5 rounded transition-all hover:bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100 cursor-pointer"
onClick={option.onClick}
>
<div className="flex-shrink-0 w-4 h-4 relative flex justify-center items-center">
<option.icon size={12} />
</div>
<div className="text-xs whitespace-nowrap">{option.label}</div>
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
);
});

View File

@@ -0,0 +1,112 @@
import { FC, Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// hooks
import { useView } from "hooks/store";
// components
import { ViewItem, ViewDropdown } from "../";
// ui
import { Button } from "@plane/ui";
// types
import { TViewOperations } from "../types";
import { TViewTypes } from "@plane/types";
type TViewRoot = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewOperations: TViewOperations;
baseRoute: string;
};
export const ViewRoot: FC<TViewRoot> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, viewOperations, baseRoute } = props;
// hooks
const viewStore = useView(workspaceSlug, projectId, viewType);
// state
const [itemsToRenderViewsCount, setItemsToRenderViewCount] = useState<number>(0);
useEffect(() => {
const handleViewTabsVisibility = () => {
const tabContainer = document.getElementById("tab-container");
const tabItemViewMore = document.getElementById("tab-item-view-more");
const itemWidth = 120;
if (!tabContainer || !tabItemViewMore) return;
const containerWidth = tabContainer.clientWidth;
const itemViewMoreLeftOffset = tabItemViewMore.offsetLeft + (tabItemViewMore.clientWidth + 10);
const itemViewMoreRightOffset = containerWidth - itemViewMoreLeftOffset;
const itemsToRenderLeft = Math.floor(itemViewMoreLeftOffset / itemWidth) || 0;
const itemsToRenderRight = Math.floor(itemViewMoreRightOffset / itemWidth) || 0;
setItemsToRenderViewCount(itemsToRenderLeft + itemsToRenderRight);
};
window.addEventListener("resize", () => handleViewTabsVisibility());
handleViewTabsVisibility();
return () => window.removeEventListener("resize", () => handleViewTabsVisibility());
}, [viewStore?.viewIds]);
const viewIds = viewStore?.viewIds?.slice(0, itemsToRenderViewsCount || viewStore?.viewIds.length) || [];
if (!viewIds.includes(viewId)) {
viewIds.pop();
viewIds.push(viewId);
}
return (
<div className="relative flex justify-between px-5 gap-2">
<div className="w-full">
{viewStore?.viewIds && viewStore?.viewIds.length > 0 && (
<div id="tab-container" className="relative flex items-center w-full overflow-hidden">
{viewIds.map((_viewId) => (
<Fragment key={_viewId}>
<ViewItem
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewItemId={_viewId}
baseRoute={baseRoute}
/>
</Fragment>
))}
<div id="tab-item-view-more" className="min-w-[90px]">
{viewStore?.viewIds.length <= (itemsToRenderViewsCount || viewStore?.viewIds.length) ? null : (
<ViewDropdown
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
viewOperations={viewOperations}
baseRoute={baseRoute}
>
<div className="text-sm mb-1 p-2 px-2.5 text-custom-text-200 cursor-pointer hover:bg-custom-background-80 whitespace-nowrap rounded relative flex items-center gap-1">
<span>
<Plus size={12} />
</span>
<span>
{viewStore?.viewIds.length - (itemsToRenderViewsCount || viewStore?.viewIds.length)} More...
</span>
</div>
</ViewDropdown>
)}
</div>
</div>
)}
</div>
<div className="flex-shrink-0 my-auto pb-1">
<Button
size="sm"
prependIcon={<Plus />}
onClick={() => viewOperations?.localViewCreateEdit(undefined, "CREATE")}
>
New View
</Button>
</div>
</div>
);
});

View File

@@ -0,0 +1,75 @@
import { FC } from "react";
import Link from "next/link";
import { Combobox } from "@headlessui/react";
import { GripVertical, MoreVertical } from "lucide-react";
// hooks
import { useViewDetail } from "hooks/store";
// ui
import { PhotoFilterIcon, Tooltip } from "@plane/ui";
// types
import { TViewTypes } from "@plane/types";
type TViewDropdownItem = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
currentViewId: string;
searchQuery: string;
baseRoute: string;
};
export const ViewDropdownItem: FC<TViewDropdownItem> = (props) => {
const { workspaceSlug, projectId, viewId, viewType, currentViewId, searchQuery, baseRoute } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewId, viewType);
const isDragEnabled = false;
const isEditable = !viewDetailStore?.is_local_view || false;
if (!viewDetailStore) return <></>;
if (!searchQuery || (searchQuery && viewDetailStore?.name?.toLowerCase().includes(searchQuery.toLowerCase())))
return (
<Combobox.Option
value={undefined}
className={`w-full px-1 pl-2 truncate flex items-center justify-between gap-1 rounded cursor-pointer select-none group
${currentViewId === viewDetailStore?.id ? `bg-custom-primary-100/10` : `hover:bg-custom-background-80`}
`}
>
<Tooltip tooltipContent={viewDetailStore?.name} position="left">
<div className="relative w-full flex items-center gap-1 overflow-hidden">
{isDragEnabled && (
<div className="flex-shrink-0 w-5 h-5 relative rounded flex justify-center items-center hover:bg-custom-background-100">
<GripVertical className="w-3.5 h-3.5 text-custom-text-200 group-hover:text-custom-text-100" />
</div>
)}
<Link
href={`${baseRoute}/${viewDetailStore?.id}`}
className={`w-full overflow-hidden relative flex items-center gap-1 py-1.5
${
currentViewId === viewDetailStore?.id
? `text-custom-text-100`
: `text-custom-text-200 group-hover:text-custom-text-100`
}
`}
>
<div className="flex-shrink-0 w-4 h-4 relative flex justify-center items-center">
<PhotoFilterIcon className="w-3 h-3 " />
</div>
<div className="w-full line-clamp-1 truncate overflow-hidden inline-block whitespace-nowrap text-sm">
{viewDetailStore?.name}
</div>
</Link>
</div>
</Tooltip>
{isEditable && (
<div className="flex-shrink-0 w-5 h-5 relative rounded flex justify-center items-center hover:bg-custom-background-100 invisible group-hover:visible">
<MoreVertical className="h-3.5 w-3.5 flex-shrink-0" />
</div>
)}
</Combobox.Option>
);
return <></>;
};

View File

@@ -0,0 +1,147 @@
import { FC, Fragment, ReactNode, useCallback, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Placement } from "@popperjs/core";
import { Plus, Search } from "lucide-react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { useView } from "hooks/store";
// components
import { ViewDropdownItem } from "../";
// types
import { TViewTypes } from "@plane/types";
import { TViewOperations } from "../types";
type TViewDropdown = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewOperations: TViewOperations;
children?: ReactNode;
baseRoute: string;
dropdownPlacement?: Placement;
};
export const ViewDropdown: FC<TViewDropdown> = (props) => {
const {
workspaceSlug,
projectId,
viewId: currentViewId,
viewType,
viewOperations,
children,
baseRoute,
dropdownPlacement = "bottom-start",
} = props;
// hooks
const viewStore = useView(workspaceSlug, projectId, viewType);
// states
const [dropdownToggle, setDropdownToggle] = useState(false);
const [query, setQuery] = useState("");
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: dropdownPlacement,
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
{
name: "offset",
options: {
offset: [0, 10],
},
},
],
});
const handleDropdownOpen = useCallback(() => setDropdownToggle(true), []);
const handleDropdownClose = useCallback(() => setDropdownToggle(false), []);
const handleDropdownToggle = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
event.stopPropagation();
if (!dropdownToggle) handleDropdownOpen();
else handleDropdownClose();
},
[dropdownToggle, handleDropdownOpen, handleDropdownClose]
);
useOutsideClickDetector(dropdownRef, handleDropdownClose);
return (
<Combobox as="div" ref={dropdownRef}>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={"block h-full w-full outline-none"}
onClick={handleDropdownToggle}
>
{children ? (
<span className="relative inline-block">{children}</span>
) : (
<span className="whitespace-nowrap">More...</span>
)}
</button>
</Combobox.Button>
{dropdownToggle && (
<Combobox.Options className="fixed z-10" static>
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="w-64 p-2 space-y-2 rounded bg-custom-background-100 border-[0.5px] border-custom-border-300 shadow-custom-shadow-rg focus:outline-none"
>
<div className="relative p-0.5 px-2 text-sm flex items-center gap-2 rounded border border-custom-border-100 bg-custom-background-90">
<Search className="h-3 w-3 text-custom-text-300" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-0.5 text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a view..."
displayValue={(assigned: any) => assigned?.name}
autoFocus
/>
</div>
<div className="max-h-60 space-y-0.5 overflow-y-scroll">
{viewStore?.viewIds &&
viewStore?.viewIds.length > 0 &&
viewStore?.viewIds.map((viewId) => (
<Fragment key={viewId}>
<ViewDropdownItem
workspaceSlug={workspaceSlug}
projectId={projectId}
viewId={viewId}
viewType={viewType}
currentViewId={currentViewId}
searchQuery={query}
baseRoute={baseRoute}
/>
</Fragment>
))}
</div>
<div
className="relative flex justify-center items-center gap-1 rounded p-1 py-1 transition-all border border-custom-border-200 bg-custom-background-90 hover:bg-custom-background-80 text-custom-text-300 hover:text-custom-text-200 cursor-pointer"
onClick={() => viewOperations?.localViewCreateEdit(undefined, "CREATE")}
>
<Plus className="w-3 h-3" />
<div className="text-sm">New view</div>
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
};

View File

@@ -0,0 +1,51 @@
import { FC, Fragment } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
// hooks
import { useViewDetail } from "hooks/store";
// ui
import { PhotoFilterIcon, Tooltip } from "@plane/ui";
// types
import { TViewTypes } from "@plane/types";
type TViewItem = {
workspaceSlug: string;
projectId: string | undefined;
viewId: string;
viewType: TViewTypes;
viewItemId: string;
baseRoute: string;
};
export const ViewItem: FC<TViewItem> = observer((props) => {
const { workspaceSlug, projectId, viewId, viewType, viewItemId, baseRoute } = props;
// hooks
const viewDetailStore = useViewDetail(workspaceSlug, projectId, viewItemId, viewType);
if (!viewDetailStore) return <></>;
return (
<div className="space-y-0.5 relative h-full flex flex-col justify-between ">
<Tooltip tooltipContent={viewDetailStore?.name} position="top">
<Link
href={`${baseRoute}/${viewItemId}`}
className={`cursor-pointer relative p-2 px-2.5 flex justify-center items-center gap-1 rounded transition-all hover:bg-custom-background-80
${viewItemId === viewId ? `text-custom-primary-100 bg-custom-primary-100/10` : `border-transparent`}
`}
onClick={(e) => viewItemId === viewId && e.preventDefault()}
>
<div className={`flex-shrink-0 rounded-sm relative w-3 h-3 flex justify-center items-center overflow-hidden`}>
<PhotoFilterIcon className="w-3 h-3" />
</div>
<div className="w-full max-w-[80px] inline-block text-sm line-clamp-1 truncate overflow-hidden">
{viewDetailStore?.name}
</div>
</Link>
</Tooltip>
<div
className={`border-b-2 rounded-t-sm ${
viewItemId === viewId ? `border-custom-primary-100` : `border-transparent`
}`}
/>
</div>
);
});

View File

@@ -270,7 +270,7 @@ export const SIDEBAR_MENU_ITEMS: {
{
key: "all-issues",
label: "All Issues",
href: `/workspace-views/all-issues`,
href: `/views/public/all-issues`,
access: EUserWorkspaceRoles.GUEST,
highlight: (pathname: string, baseUrl: string) => pathname.includes(`${baseUrl}/workspace-views`),
Icon: CheckCircle,

View File

@@ -449,4 +449,4 @@ export const groupReactionEmojis = (reactions: any) => {
}
return _groupedEmojis;
};
};

View File

@@ -0,0 +1,322 @@
// types
import {
TStateGroups,
TIssuePriorities,
TViewFilters,
TViewDisplayFilters,
TViewDisplayFiltersGrouped,
TViewDisplayFiltersOrderBy,
TViewDisplayFiltersType,
TViewDisplayFiltersExtraOptions,
} from "@plane/types";
// global variables
export enum EViewPageType {
ALL = "all",
PROFILE = "profile",
PROJECT = "project",
ARCHIVED = "archived",
DRAFT = "draft",
}
export enum ELocalViews {
ALL_ISSUES = "all-issues",
ASSIGNED = "assigned",
CREATED = "created",
SUBSCRIBED = "subscribed",
}
export enum EFilterTypes {
FILTERS = "filters",
DISPLAY_FILTERS = "display_filters",
DISPLAY_PROPERTIES = "display_properties",
KANBAN_FILTERS = "kanban_filters",
}
export enum EViewLayouts {
LIST = "list",
KANBAN = "kanban",
CALENDAR = "calendar",
SPREADSHEET = "spreadsheet",
GANTT = "gantt",
}
// filters constants
export const STATE_GROUP_PROPERTY: Record<TStateGroups, { label: string; color: string }> = {
backlog: { label: "Backlog", color: "#d9d9d9" },
unstarted: { label: "Unstarted", color: "#3f76ff" },
started: { label: "Started", color: "#f59e0b" },
completed: { label: "Completed", color: "#16a34a" },
cancelled: { label: "Canceled", color: "#dc2626" },
};
export const PRIORITIES_PROPERTY: Record<TIssuePriorities, { label: string }> = {
urgent: { label: "Urgent" },
high: { label: "High" },
medium: { label: "Medium" },
low: { label: "Low" },
none: { label: "None" },
};
export const DATE_PROPERTY: Record<string, { label: string }> = {
"1_weeks;after;fromnow": { label: "1 week from now" },
"2_weeks;after;fromnow": { label: "2 weeks from now" },
"1_months;after;fromnow": { label: "1 month from now" },
"2_months;after;fromnow": { label: "2 months from now" },
custom: { label: "Custom" },
};
// display filter constants
export const GROUP_BY_PROPERTY: Partial<Record<TViewDisplayFiltersGrouped | "null", { label: string }>> = {
state: { label: "states" },
priority: { label: "Priority" },
labels: { label: "labels" },
assignees: { label: "Assignees" },
created_by: { label: "Created By" },
cycles: { label: "Cycles" },
modules: { label: "Modules" },
null: { label: "None" },
};
export const ORDER_BY_PROPERTY: Partial<Record<TViewDisplayFiltersOrderBy, Record<string, string>>> = {
sort_order: { label: "Manual" },
"-created_at": { label: "Last Created" },
"-updated_at": { label: "Last Updated" },
start_date: { label: "Start Date" },
target_date: { label: "Due Date" },
"-priority": { label: "Priority" },
};
export const TYPE_PROPERTY: Record<TViewDisplayFiltersType | "null", { label: string }> = {
null: { label: "All" },
active: { label: "Active issues" },
backlog: { label: "Backlog issues" },
};
export const EXTRA_OPTIONS_PROPERTY: Record<TViewDisplayFiltersExtraOptions, { label: string }> = {
sub_issue: { label: "Sub Issues" },
show_empty_groups: { label: "Show Empty Groups" },
};
export type TViewLayoutFilterProperties = {
filters: Partial<keyof TViewFilters>[];
display_filters: Partial<keyof TViewDisplayFilters>[];
extra_options: TViewDisplayFiltersExtraOptions[];
display_properties: boolean;
readonlyFilters?: Partial<keyof TViewFilters>[];
};
export type TViewLayoutFilters = {
layouts: Partial<EViewLayouts>[];
[EViewLayouts.LIST]: TViewLayoutFilterProperties;
[EViewLayouts.KANBAN]: TViewLayoutFilterProperties;
[EViewLayouts.CALENDAR]: TViewLayoutFilterProperties;
[EViewLayouts.SPREADSHEET]: TViewLayoutFilterProperties;
[EViewLayouts.GANTT]: TViewLayoutFilterProperties;
};
export type TFilterPermissions = {
[EViewPageType.ALL]: Partial<TViewLayoutFilters>;
[EViewPageType.PROFILE]: Partial<TViewLayoutFilters>;
[EViewPageType.PROJECT]: TViewLayoutFilters;
[EViewPageType.ARCHIVED]: Partial<TViewLayoutFilters>;
[EViewPageType.DRAFT]: Partial<TViewLayoutFilters>;
};
const ALL_FILTER_PERMISSIONS: TFilterPermissions["all"] = {
layouts: [EViewLayouts.SPREADSHEET],
[EViewLayouts.SPREADSHEET]: {
filters: ["project", "priority", "state_group", "assignees", "created_by", "labels", "start_date", "target_date"],
display_filters: ["type"],
extra_options: [],
display_properties: true,
},
};
const PROFILE_FILTER_PERMISSIONS: TFilterPermissions["profile"] = {
layouts: [EViewLayouts.LIST, EViewLayouts.KANBAN],
[EViewLayouts.LIST]: {
filters: ["priority", "state_group", "labels", "start_date", "target_date"],
display_filters: ["group_by", "order_by", "type"],
extra_options: [],
display_properties: true,
},
[EViewLayouts.KANBAN]: {
filters: ["priority", "state_group", "labels", "start_date", "target_date"],
display_filters: ["group_by", "order_by", "type"],
extra_options: [],
display_properties: true,
},
};
const PROJECT_FILTER_PERMISSIONS: TFilterPermissions["project"] = {
layouts: [
EViewLayouts.LIST,
EViewLayouts.KANBAN,
EViewLayouts.CALENDAR,
EViewLayouts.SPREADSHEET,
EViewLayouts.GANTT,
],
[EViewLayouts.LIST]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["group_by", "order_by", "type"],
extra_options: ["sub_issue", "show_empty_groups"],
display_properties: true,
},
[EViewLayouts.KANBAN]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["group_by", "sub_group_by", "order_by", "type"],
extra_options: ["sub_issue", "show_empty_groups"],
display_properties: true,
},
[EViewLayouts.CALENDAR]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["type"],
extra_options: ["sub_issue"],
display_properties: true,
},
[EViewLayouts.SPREADSHEET]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["order_by", "type"],
extra_options: [],
display_properties: true,
},
[EViewLayouts.GANTT]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["order_by", "type"],
extra_options: ["sub_issue"],
display_properties: false,
},
};
const ARCHIVED_FILTER_PERMISSIONS: TFilterPermissions["archived"] = {
layouts: [EViewLayouts.LIST],
[EViewLayouts.LIST]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["group_by", "order_by"],
extra_options: [],
display_properties: true,
},
};
const DRAFT_FILTER_PERMISSIONS: TFilterPermissions["draft"] = {
layouts: [EViewLayouts.LIST, EViewLayouts.KANBAN],
[EViewLayouts.LIST]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["group_by", "order_by", "type"],
extra_options: ["sub_issue", "show_empty_groups"],
display_properties: true,
},
[EViewLayouts.KANBAN]: {
filters: [
"priority",
"state",
"assignees",
"mentions",
"created_by",
"labels",
"start_date",
"target_date",
"module",
"cycle",
],
display_filters: ["group_by", "sub_group_by", "order_by", "type"],
extra_options: ["sub_issue", "show_empty_groups"],
display_properties: true,
},
};
export const VIEW_DEFAULT_FILTER_PARAMETERS: TFilterPermissions = {
[EViewPageType.ALL]: ALL_FILTER_PERMISSIONS,
[EViewPageType.PROFILE]: PROFILE_FILTER_PERMISSIONS,
[EViewPageType.PROJECT]: PROJECT_FILTER_PERMISSIONS,
[EViewPageType.ARCHIVED]: ARCHIVED_FILTER_PERMISSIONS,
[EViewPageType.DRAFT]: DRAFT_FILTER_PERMISSIONS,
};
export const viewPageDefaultLayoutsByPageType = (_viewPageType: EViewPageType) =>
VIEW_DEFAULT_FILTER_PARAMETERS?.[_viewPageType]?.layouts || [];
export const viewDefaultFilterParametersByViewTypeAndLayout = <K extends keyof TViewLayoutFilterProperties>(
_viewPageType: EViewPageType,
_layout: EViewLayouts,
property: K
): TViewLayoutFilterProperties[K] =>
VIEW_DEFAULT_FILTER_PARAMETERS?.[_viewPageType]?.[_layout]?.[property] as TViewLayoutFilterProperties[K];

View File

@@ -0,0 +1,2 @@
export * from "./root";
export * from "./filters";

View File

@@ -0,0 +1,27 @@
// types
import { TViewTypes, TView } from "@plane/types";
export const VIEW_TYPES: Record<TViewTypes, TViewTypes> = {
WORKSPACE_PRIVATE_VIEWS: "WORKSPACE_PRIVATE_VIEWS",
WORKSPACE_PUBLIC_VIEWS: "WORKSPACE_PUBLIC_VIEWS",
PROJECT_PRIVATE_VIEWS: "PROJECT_PRIVATE_VIEWS",
PROJECT_PUBLIC_VIEWS: "PROJECT_PUBLIC_VIEWS",
};
export type TViewCRUD = "CREATE" | "EDIT" | "SAVE_AS_NEW" | "CLEAR";
export const viewLocalPayload: Partial<TView> = {
id: "create",
name: "",
description: "",
filters: undefined,
display_filters: undefined,
display_properties: undefined,
is_local_view: false,
};
export const generateViewStoreKey = (
workspaceSlug: string,
projectId: string | undefined,
viewType: TViewTypes
): string => `${workspaceSlug}_${projectId}_${viewType}`;

View File

@@ -1,5 +1,5 @@
export * from "./use-application";
export * from "./use-event-tracker"
export * from "./use-event-tracker";
export * from "./use-calendar-view";
export * from "./use-cycle";
export * from "./use-dashboard";
@@ -22,3 +22,8 @@ export * from "./use-kanban-view";
export * from "./use-issue-detail";
export * from "./use-inbox";
export * from "./use-inbox-issues";
// new store
export * from "./views/use-view";
export * from "./views/use-view-detail";
export * from "./views/use-view-filters";

View File

@@ -0,0 +1,41 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// store
import { TViewStore } from "store/view/view.store";
// types
import { TViewTypes } from "@plane/types";
// constants
import { VIEW_TYPES } from "constants/view";
export const useViewDetail = (
workspaceSlug: string,
projectId: string | undefined,
viewId: string | undefined,
viewType: TViewTypes | undefined,
isEditable: boolean = false
): TViewStore | undefined => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useViewDetail must be used within StoreProvider");
if (!workspaceSlug || !viewId) return undefined;
switch (viewType) {
case VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS:
if (isEditable) return context.view.workspacePrivateViewStore.viewMapCEN;
return context.view.workspacePrivateViewStore.viewById(viewId);
case VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS:
if (isEditable) return context.view.workspacePublicViewStore.viewMapCEN;
return context.view.workspacePublicViewStore.viewById(viewId);
case VIEW_TYPES.PROJECT_PRIVATE_VIEWS:
if (!projectId) return undefined;
if (isEditable) return context.view.projectPrivateViewStore.viewMapCEN;
return context.view.projectPrivateViewStore.viewById(viewId);
case VIEW_TYPES.PROJECT_PUBLIC_VIEWS:
if (!projectId) return undefined;
if (isEditable) return context.view.projectPublicViewStore.viewMapCEN;
return context.view.projectPublicViewStore.viewById(viewId);
default:
return undefined;
}
};

View File

@@ -0,0 +1,421 @@
import { ReactNode } from "react";
import { Briefcase, CalendarDays, CircleUser, Tag } from "lucide-react";
// hooks
import { useProject, useModule, useCycle, useProjectState, useMember, useLabel } from "hooks/store";
// ui
import {
Avatar,
ContrastIcon,
CycleGroupIcon,
DiceIcon,
DoubleCircleIcon,
PriorityIcon,
StateGroupIcon,
} from "@plane/ui";
// types
import {
TIssuePriorities,
TStateGroups,
TViewFilters,
TViewDisplayFilters,
TViewDisplayFiltersGrouped,
TViewDisplayFiltersOrderBy,
TViewDisplayFiltersType,
} from "@plane/types";
// constants
import {
STATE_GROUP_PROPERTY,
PRIORITIES_PROPERTY,
DATE_PROPERTY,
GROUP_BY_PROPERTY,
ORDER_BY_PROPERTY,
TYPE_PROPERTY,
EViewLayouts,
} from "constants/view/filters";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
type TFilterPropertyDetails = {
icon: ReactNode;
label: string;
};
type TFilterPropertyDefaultDetails = {
icon: ReactNode;
label: string;
};
type TDisplayFilterPropertyDetails = {
icon: ReactNode;
label: string;
};
export const useViewFilter = (workspaceSlug: string, projectId: string | undefined) => {
const { projectMap, getProjectById } = useProject();
const { getProjectModuleIds, getModuleById } = useModule();
const { getProjectCycleIds, getCycleById } = useCycle();
const { getProjectStates, getStateById } = useProjectState();
const {
getUserDetails,
workspace: { workspaceMemberIds },
project: { getProjectMemberIds },
} = useMember();
const { workspaceLabels, getProjectLabels, getLabelById } = useLabel();
if (!workspaceSlug) return undefined;
const filterIdsWithKey = (filterKey: keyof TViewFilters): string[] | undefined => {
if (!filterKey) return undefined;
switch (filterKey) {
case "project":
return Object.keys(projectMap) || undefined;
case "module":
if (!projectId) return undefined;
return getProjectModuleIds(projectId) || undefined;
case "cycle":
if (!projectId) return undefined;
return getProjectCycleIds(projectId) || undefined;
case "priority":
return Object.keys(PRIORITIES_PROPERTY) || undefined;
case "state":
if (!projectId) return undefined;
return getProjectStates(projectId)?.map((state) => state.id) || undefined;
case "state_group":
return Object.keys(STATE_GROUP_PROPERTY) || undefined;
case "assignees":
if (projectId) return getProjectMemberIds(projectId) || undefined;
return workspaceMemberIds || undefined;
case "mentions":
if (projectId) return getProjectMemberIds(projectId) || undefined;
return workspaceMemberIds || undefined;
case "subscriber":
if (projectId) return getProjectMemberIds(projectId) || undefined;
return workspaceMemberIds || undefined;
case "created_by":
if (projectId) return getProjectMemberIds(projectId) || undefined;
return workspaceMemberIds || undefined;
case "labels":
if (projectId) return getProjectLabels(projectId)?.map((label) => label.id) || undefined;
return workspaceLabels?.map((label) => label.id) || undefined;
case "start_date":
return Object.keys(DATE_PROPERTY) || undefined;
case "target_date":
return Object.keys(DATE_PROPERTY) || undefined;
default:
return undefined;
}
};
const propertyDefaultDetails = (filterKey: keyof TViewFilters): TFilterPropertyDefaultDetails | undefined => {
if (!filterKey) return undefined;
switch (filterKey) {
case "project":
return {
icon: <Briefcase size={12} />,
label: "Projects",
};
case "module":
return {
icon: <DiceIcon className="w-3 h-3" />,
label: "Modules",
};
case "cycle":
return {
icon: <ContrastIcon className="w-3 h-3" />,
label: "Cycles",
};
case "priority":
return {
icon: <PriorityIcon priority="high" withContainer size={10} />,
label: "Priorities",
};
case "state":
return {
icon: <DoubleCircleIcon className="w-3 h-3" />,
label: "States",
};
case "state_group":
return {
icon: <DoubleCircleIcon className="w-3 h-3" />,
label: "State Groups",
};
case "assignees":
return {
icon: <CircleUser size={12} />,
label: "Assignees",
};
case "mentions":
return {
icon: <CircleUser size={12} />,
label: "Mentions",
};
case "subscriber":
return {
icon: <CircleUser size={12} />,
label: "Subscribers",
};
case "created_by":
return {
icon: <CircleUser size={12} />,
label: "Creators",
};
case "labels":
return {
icon: <Tag size={12} />,
label: "Labels",
};
case "start_date":
return {
icon: <CalendarDays size={12} />,
label: "Start Dates",
};
case "target_date":
return {
icon: <CalendarDays size={12} />,
label: "Target Dates",
};
default:
return undefined;
}
};
const propertyDetails = (filterKey: keyof TViewFilters, propertyId: string): TFilterPropertyDetails | undefined => {
if (!filterKey || !propertyId) return undefined;
switch (filterKey) {
case "project":
const projectPropertyDetail = getProjectById(propertyId);
if (!projectPropertyDetail) return undefined;
return {
icon: (
<>
{projectPropertyDetail.emoji ? (
<div className="text-xs">{renderEmoji(projectPropertyDetail.emoji)}</div>
) : projectPropertyDetail.icon_prop ? (
<div className="text-xs">{renderEmoji(projectPropertyDetail.icon_prop)}</div>
) : (
<Briefcase size={12} />
)}
</>
),
label: projectPropertyDetail.name,
};
case "module":
const modulePropertyDetail = getModuleById(propertyId);
if (!modulePropertyDetail) return undefined;
return {
icon: <DiceIcon className="w-3 h-3" />,
label: modulePropertyDetail.name,
};
case "cycle":
const cyclePropertyDetail = getCycleById(propertyId);
if (!cyclePropertyDetail) return undefined;
return {
icon: <CycleGroupIcon cycleGroup={cyclePropertyDetail.status} height="14px" width="14px" />,
label: cyclePropertyDetail.name,
};
case "priority":
const priorityPropertyDetail = PRIORITIES_PROPERTY?.[propertyId as TIssuePriorities];
if (!priorityPropertyDetail) return undefined;
return {
icon: <PriorityIcon priority={propertyId as TIssuePriorities} size={10} withContainer />,
label: priorityPropertyDetail.label,
};
case "state":
const statePropertyDetail = getStateById(propertyId);
if (!statePropertyDetail) return undefined;
return {
icon: <StateGroupIcon stateGroup={statePropertyDetail.group} />,
label: statePropertyDetail.name,
};
case "state_group":
const stateGroupPropertyDetail = STATE_GROUP_PROPERTY?.[propertyId as TStateGroups];
if (!stateGroupPropertyDetail) return undefined;
return {
icon: <StateGroupIcon stateGroup={propertyId as TStateGroups} />,
label: stateGroupPropertyDetail.label,
};
case "assignees":
const assigneePropertyDetail = getUserDetails(propertyId);
if (!assigneePropertyDetail) return undefined;
return {
icon: (
<Avatar
name={assigneePropertyDetail.display_name}
src={assigneePropertyDetail.avatar}
size={"sm"}
showTooltip={false}
/>
),
label: assigneePropertyDetail.display_name,
};
case "mentions":
const mentionPropertyDetail = getUserDetails(propertyId);
if (!mentionPropertyDetail) return undefined;
return {
icon: (
<Avatar
name={mentionPropertyDetail.display_name}
src={mentionPropertyDetail.avatar}
size={"sm"}
showTooltip={false}
/>
),
label: mentionPropertyDetail.display_name,
};
case "subscriber":
const subscribedPropertyDetail = getUserDetails(propertyId);
if (!subscribedPropertyDetail) return undefined;
return {
icon: (
<Avatar
name={subscribedPropertyDetail.display_name}
src={subscribedPropertyDetail.avatar}
size={"sm"}
showTooltip={false}
/>
),
label: subscribedPropertyDetail.display_name,
};
case "created_by":
const createdByPropertyDetail = getUserDetails(propertyId);
if (!createdByPropertyDetail) return undefined;
return {
icon: (
<Avatar
name={createdByPropertyDetail.display_name}
src={createdByPropertyDetail.avatar}
size={"sm"}
showTooltip={false}
/>
),
label: createdByPropertyDetail.display_name,
};
case "labels":
const labelPropertyDetail = getLabelById(propertyId);
if (!labelPropertyDetail) return undefined;
return {
icon: (
<div
className="w-2.5 h-2.5 rounded-full"
style={{
backgroundColor: labelPropertyDetail.color,
}}
/>
),
label: labelPropertyDetail.name,
};
case "start_date":
if (propertyId.includes("-")) {
const customDateString = propertyId.split(";");
return {
icon: <CalendarDays size={12} />,
label: `${customDateString[1].charAt(0).toUpperCase()}${customDateString[1].slice(1)} ${renderFormattedDate(
customDateString[0]
)}`,
};
} else {
const startDatePropertyDetail = DATE_PROPERTY?.[propertyId];
if (!startDatePropertyDetail) return undefined;
return {
icon: <CalendarDays size={12} />,
label: startDatePropertyDetail.label,
};
}
case "target_date":
if (propertyId.includes("-")) {
const customDateString = propertyId.split(";");
return {
icon: <CalendarDays size={12} />,
label: `${customDateString[1].charAt(0).toUpperCase()}${customDateString[1].slice(1)} ${renderFormattedDate(
customDateString[0]
)}`,
};
} else {
const targetDatePropertyDetail = DATE_PROPERTY?.[propertyId];
if (!targetDatePropertyDetail) return undefined;
return {
icon: <CalendarDays size={12} />,
label: targetDatePropertyDetail.label,
};
}
default:
return undefined;
}
};
const displayFilterIdsWithKey = (
displayFilterKey: keyof TViewDisplayFilters,
layout?: EViewLayouts
): string[] | undefined => {
if (!displayFilterKey) return undefined;
switch (displayFilterKey) {
case "group_by":
return (
Object.keys(GROUP_BY_PROPERTY).filter((property) =>
layout === EViewLayouts.KANBAN ? (property !== "null" ? false : true) : true
) || undefined
);
case "sub_group_by":
return Object.keys(GROUP_BY_PROPERTY) || undefined;
case "order_by":
return Object.keys(ORDER_BY_PROPERTY) || undefined;
case "type":
return Object.keys(TYPE_PROPERTY) || undefined;
default:
return undefined;
}
};
const displayPropertyDetails = (
displayFilterKey: keyof TViewDisplayFilters,
propertyId: string
): TDisplayFilterPropertyDetails | undefined => {
if (!displayFilterKey) return undefined;
switch (displayFilterKey) {
case "group_by":
const groupBy = GROUP_BY_PROPERTY?.[propertyId as TViewDisplayFiltersGrouped | "null"];
if (!groupBy) return undefined;
return {
icon: undefined,
label: groupBy.label,
};
case "sub_group_by":
const subGroupBy = GROUP_BY_PROPERTY?.[propertyId as TViewDisplayFiltersGrouped | "null"];
if (!subGroupBy) return undefined;
return {
icon: undefined,
label: subGroupBy.label,
};
case "order_by":
const orderBy = ORDER_BY_PROPERTY?.[propertyId as TViewDisplayFiltersOrderBy];
if (!orderBy) return undefined;
return {
icon: undefined,
label: orderBy.label,
};
case "type":
const type = TYPE_PROPERTY?.[propertyId as TViewDisplayFiltersType | "null"];
if (!type) return undefined;
return {
icon: undefined,
label: type.label,
};
default:
return undefined;
}
};
return {
filterIdsWithKey,
propertyDefaultDetails,
propertyDetails,
displayFilterIdsWithKey,
displayPropertyDetails,
};
};

View File

@@ -0,0 +1,34 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { ViewRootStore } from "store/view/view-root.store";
import { TViewTypes } from "@plane/types";
// constants
import { VIEW_TYPES } from "constants/view";
export const useView = (
workspaceSlug: string | undefined,
projectId: string | undefined,
viewType: TViewTypes | undefined
): ViewRootStore | undefined => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useView must be used within StoreProvider");
if (!workspaceSlug || !viewType) return undefined;
switch (viewType) {
case VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS:
return context.view.workspacePrivateViewStore;
case VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS:
return context.view.workspacePublicViewStore;
case VIEW_TYPES.PROJECT_PRIVATE_VIEWS:
if (!projectId) return undefined;
return context.view.projectPrivateViewStore;
case VIEW_TYPES.PROJECT_PUBLIC_VIEWS:
if (!projectId) return undefined;
return context.view.projectPublicViewStore;
default:
return undefined;
}
};

View File

@@ -20,6 +20,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
const {
workspace: { fetchWorkspaceMembers },
} = useMember();
const { fetchWorkspaceLabels } = useLabel();
// router
const router = useRouter();
const { workspaceSlug } = router.query;
@@ -38,6 +39,11 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
workspaceSlug ? `WORKSPACE_MEMBERS_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceMembers(workspaceSlug.toString()) : null
);
// fetch workspace labels
useSWR(
workspaceSlug ? `WORKSPACE_LABELS_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceLabels(workspaceSlug.toString()) : null
);
// fetch workspace user projects role
useSWR(
workspaceSlug ? `WORKSPACE_PROJECTS_ROLE_${workspaceSlug}` : null,

View File

@@ -48,7 +48,7 @@ export const AppProvider: FC<IAppProvider> = observer((props) => {
<InstanceLayout>
<StoreWrapper>
<CrispWrapper user={currentUser}>
<PostHogProvider
{/* <PostHogProvider
user={currentUser}
currentWorkspaceId= {currentWorkspace?.id}
workspaceRole={currentWorkspaceRole}
@@ -57,7 +57,8 @@ export const AppProvider: FC<IAppProvider> = observer((props) => {
posthogHost={envConfig?.posthog_host || null}
>
<SWRConfig value={SWR_CONFIG}>{children}</SWRConfig>
</PostHogProvider>
</PostHogProvider> */}
<SWRConfig value={SWR_CONFIG}>{children}</SWRConfig>
</CrispWrapper>
</StoreWrapper>
</InstanceLayout>

View File

@@ -0,0 +1,53 @@
import { ReactElement, useMemo } from "react";
import { useRouter } from "next/router";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { GlobalViewRoot } from "components/view";
// types
import { NextPageWithLayout } from "lib/types";
// constants
import { EViewPageType, VIEW_TYPES } from "constants/view";
const ProjectPrivateViewPage: NextPageWithLayout = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const workspaceViewTabOptions = useMemo(
() => [
{
key: VIEW_TYPES.PROJECT_PRIVATE_VIEWS,
title: "Private",
href: `/${workspaceSlug}/projects/${projectId}/views/private`,
},
{
key: VIEW_TYPES.PROJECT_PUBLIC_VIEWS,
title: "Public",
href: `/${workspaceSlug}/projects/${projectId}/views/public`,
},
],
[workspaceSlug, projectId]
);
if (!workspaceSlug || !projectId || !viewId) return <></>;
return (
<div className="h-full overflow-hidden bg-custom-background-100">
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
<GlobalViewRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
viewId={viewId.toString()}
viewType={VIEW_TYPES.PROJECT_PRIVATE_VIEWS}
viewPageType={EViewPageType.PROJECT}
baseRoute={`/${workspaceSlug?.toString()}/projects/${projectId}/views/private`}
/>
</div>
</div>
);
};
ProjectPrivateViewPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<></>}>{page}</AppLayout>;
};
export default ProjectPrivateViewPage;

View File

@@ -0,0 +1,115 @@
import { Fragment, ReactElement, useEffect, useMemo } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { useTheme } from "next-themes";
import { CheckCircle } from "lucide-react";
// hooks
import { useUser, useView } from "hooks/store";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
import { ViewHeader } from "components/view";
// ui
import { Spinner } from "@plane/ui";
// types
import { NextPageWithLayout } from "lib/types";
// constants
import { VIEW_TYPES } from "constants/view";
import { VIEW_EMPTY_STATE_DETAILS } from "constants/empty-state";
const ProjectPublicViewPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// hooks
const viewStore = useView(workspaceSlug?.toString(), projectId?.toString(), VIEW_TYPES.PROJECT_PUBLIC_VIEWS);
const { currentUser } = useUser();
// theme
const { resolvedTheme } = useTheme();
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "views", isLightMode);
useSWR(
workspaceSlug && projectId
? `PROJECT_VIEWS_${VIEW_TYPES.PROJECT_PUBLIC_VIEWS}_${workspaceSlug.toString()}_${projectId.toString()}`
: null,
async () => {
if (workspaceSlug && projectId) {
await viewStore?.fetch(workspaceSlug?.toString(), projectId?.toString());
}
}
);
useEffect(() => {
if (workspaceSlug && projectId && viewStore?.viewIds && viewStore?.viewIds.length > 0) {
router.push(`/${workspaceSlug}/projects/${projectId}/views/public/${viewStore?.viewIds[0]}`);
}
}, [workspaceSlug, projectId, viewStore?.viewIds, router]);
const workspaceViewTabOptions = useMemo(
() => [
{
key: VIEW_TYPES.PROJECT_PRIVATE_VIEWS,
title: "Private",
href: `/${workspaceSlug}/projects/${projectId}/views/private`,
},
{
key: VIEW_TYPES.PROJECT_PUBLIC_VIEWS,
title: "Public",
href: `/${workspaceSlug}/projects/${projectId}/views/public`,
},
],
[workspaceSlug, projectId]
);
if (!workspaceSlug || !projectId) return <></>;
return (
<div className="relative w-full h-full flex flex-col overflow-hidden">
{viewStore?.loader === "view-loader" ? (
<div className="relative w-full h-full flex justify-center items-center">
<Spinner />
</div>
) : (
<>
{viewStore?.viewIds && viewStore?.viewIds?.length <= 0 && (
<Fragment>
<div className="flex-shrink-0 px-5 pt-4 pb-4 border-b border-custom-border-200">
<ViewHeader
projectId={projectId.toString()}
viewType={VIEW_TYPES.PROJECT_PRIVATE_VIEWS}
titleIcon={<CheckCircle size={12} />}
title="Views"
workspaceViewTabOptions={workspaceViewTabOptions}
/>
</div>
<div className="relative w-full h-full flex justify-center items-center overflow-hidden overflow-y-auto">
<EmptyState
title={VIEW_EMPTY_STATE_DETAILS["project-views"].title}
description={VIEW_EMPTY_STATE_DETAILS["project-views"].description}
image={EmptyStateImagePath}
comicBox={{
title: VIEW_EMPTY_STATE_DETAILS["project-views"].comicBox.title,
description: VIEW_EMPTY_STATE_DETAILS["project-views"].comicBox.description,
}}
primaryButton={{
text: VIEW_EMPTY_STATE_DETAILS["project-views"].primaryButton.text,
onClick: () => {},
}}
size="lg"
/>
</div>
</Fragment>
)}
</>
)}
</div>
);
});
ProjectPublicViewPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<></>}>{page}</AppLayout>;
};
export default ProjectPublicViewPage;

View File

@@ -0,0 +1,54 @@
import { ReactElement, useMemo } from "react";
import { useRouter } from "next/router";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { GlobalViewRoot } from "components/view";
// types
import { NextPageWithLayout } from "lib/types";
// constants
import { EViewPageType, VIEW_TYPES } from "constants/view";
const ProjectPublicViewPage: NextPageWithLayout = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const workspaceViewTabOptions = useMemo(
() => [
{
key: VIEW_TYPES.PROJECT_PRIVATE_VIEWS,
title: "Private",
href: `/${workspaceSlug}/views/private/assigned`,
},
{
key: VIEW_TYPES.PROJECT_PUBLIC_VIEWS,
title: "Public",
href: `/${workspaceSlug}/views/public/all-issues`,
},
],
[workspaceSlug]
);
if (!workspaceSlug || !viewId) return <></>;
return (
<div className="w-full h-full overflow-hidden bg-custom-background-100 relative flex flex-col">
<div className="flex-shrink-0 w-full">
<GlobalViewRoot
workspaceSlug={workspaceSlug.toString()}
projectId={undefined}
viewId={viewId.toString()}
viewType={VIEW_TYPES.PROJECT_PUBLIC_VIEWS}
viewPageType={EViewPageType.PROJECT}
baseRoute={`/${workspaceSlug?.toString()}/views/public`}
/>
</div>
<div className="w-full h-full overflow-hidden">Issues render</div>
</div>
);
};
ProjectPublicViewPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<></>}>{page}</AppLayout>;
};
export default ProjectPublicViewPage;

View File

@@ -0,0 +1,115 @@
import { Fragment, ReactElement, useEffect, useMemo } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { useTheme } from "next-themes";
import { CheckCircle } from "lucide-react";
// hooks
import { useUser, useView } from "hooks/store";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { EmptyState, getEmptyStateImagePath } from "components/empty-state";
import { ViewHeader } from "components/view";
// ui
import { Spinner } from "@plane/ui";
// types
import { NextPageWithLayout } from "lib/types";
// constants
import { VIEW_TYPES } from "constants/view";
import { VIEW_EMPTY_STATE_DETAILS } from "constants/empty-state";
const ProjectPublicViewPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// hooks
const viewStore = useView(workspaceSlug?.toString(), projectId?.toString(), VIEW_TYPES.PROJECT_PUBLIC_VIEWS);
const { currentUser } = useUser();
// theme
const { resolvedTheme } = useTheme();
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "views", isLightMode);
useSWR(
workspaceSlug && projectId
? `PROJECT_VIEWS_${VIEW_TYPES.PROJECT_PUBLIC_VIEWS}_${workspaceSlug.toString()}_${projectId.toString()}`
: null,
async () => {
if (workspaceSlug && projectId) {
await viewStore?.fetch(workspaceSlug.toString(), projectId.toString());
}
}
);
useEffect(() => {
if (workspaceSlug && projectId && viewStore?.viewIds && viewStore?.viewIds.length > 0) {
router.push(`/${workspaceSlug}/projects/${projectId}/views/public/${viewStore?.viewIds[0]}`);
}
}, [workspaceSlug, projectId, viewStore?.viewIds, router]);
const workspaceViewTabOptions = useMemo(
() => [
{
key: VIEW_TYPES.PROJECT_PRIVATE_VIEWS,
title: "Private",
href: `/${workspaceSlug}/projects/${projectId}/views/private`,
},
{
key: VIEW_TYPES.PROJECT_PUBLIC_VIEWS,
title: "Public",
href: `/${workspaceSlug}/projects/${projectId}/views/public`,
},
],
[workspaceSlug, projectId]
);
if (!workspaceSlug || !projectId) return <></>;
return (
<div className="relative w-full h-full flex flex-col overflow-hidden">
{viewStore?.loader === "view-loader" ? (
<div className="relative w-full h-full flex justify-center items-center">
<Spinner />
</div>
) : (
<>
{viewStore?.viewIds && viewStore?.viewIds?.length <= 0 && (
<Fragment>
<div className="flex-shrink-0 px-5 pt-4 pb-4 border-b border-custom-border-200">
<ViewHeader
projectId={projectId.toString()}
viewType={VIEW_TYPES.PROJECT_PUBLIC_VIEWS}
titleIcon={<CheckCircle size={12} />}
title="Views"
workspaceViewTabOptions={workspaceViewTabOptions}
/>
</div>
<div className="relative w-full h-full flex justify-center items-center overflow-hidden overflow-y-auto">
<EmptyState
title={VIEW_EMPTY_STATE_DETAILS["project-views"].title}
description={VIEW_EMPTY_STATE_DETAILS["project-views"].description}
image={EmptyStateImagePath}
comicBox={{
title: VIEW_EMPTY_STATE_DETAILS["project-views"].comicBox.title,
description: VIEW_EMPTY_STATE_DETAILS["project-views"].comicBox.description,
}}
primaryButton={{
text: VIEW_EMPTY_STATE_DETAILS["project-views"].primaryButton.text,
onClick: () => {},
}}
size="lg"
/>
</div>
</Fragment>
)}
</>
)}
</div>
);
});
ProjectPublicViewPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<></>}>{page}</AppLayout>;
};
export default ProjectPublicViewPage;

View File

@@ -0,0 +1,88 @@
import { ReactElement, useMemo } from "react";
import { useRouter } from "next/router";
import { CheckCircle } from "lucide-react";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { GlobalViewRoot, ViewHeader } from "components/view";
import { GlobalViewIssueLayoutRoot } from "components/issues";
// types
import { NextPageWithLayout } from "lib/types";
// constants
import { ELocalViews, EViewPageType, VIEW_TYPES } from "constants/view";
const WorkspacePrivateViewPage: NextPageWithLayout = () => {
const router = useRouter();
const { workspaceSlug, viewId } = router.query;
const workspaceViewTabOptions = useMemo(
() => [
{
key: VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS,
title: "Private",
href: `/${workspaceSlug}/views/private/${ELocalViews.ASSIGNED}`,
},
{
key: VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS,
title: "Public",
href: `/${workspaceSlug}/views/public/${ELocalViews.ALL_ISSUES}`,
},
],
[workspaceSlug]
);
if (!workspaceSlug || !viewId) return <></>;
return (
<div className="w-full h-full overflow-hidden bg-custom-background-100 relative flex flex-col">
<div className="flex-shrink-0 w-full">
{/* header */}
<div className="px-5 pt-4 pb-2 border-b border-custom-border-200">
<ViewHeader
projectId={undefined}
viewType={VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS}
titleIcon={<CheckCircle size={12} />}
title="All Issues"
workspaceViewTabOptions={workspaceViewTabOptions}
/>
</div>
</div>
<div className="flex-shrink-0 w-full">
{/* content */}
<GlobalViewRoot
workspaceSlug={workspaceSlug.toString()}
projectId={undefined}
viewId={viewId.toString()}
viewType={VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS}
viewPageType={EViewPageType.ALL}
baseRoute={`/${workspaceSlug?.toString()}/views/private`}
/>
</div>
{/* issues */}
<div className="relative w-full h-full overflow-hidden">
<GlobalViewIssueLayoutRoot
workspaceSlug={workspaceSlug.toString()}
projectId={undefined}
viewId={viewId.toString()}
viewType={VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS}
viewPageType={EViewPageType.ALL}
/>
</div>
{/* TODO: once the functionality is done implement the empty states */}
{/* <ViewEmptyStateRoot
workspaceSlug={workspaceSlug.toString()}
projectId={undefined}
viewId={viewId.toString()}
viewType={VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS}
></ViewEmptyStateRoot> */}
</div>
);
};
WorkspacePrivateViewPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<></>}>{page}</AppLayout>;
};
export default WorkspacePrivateViewPage;

View File

@@ -0,0 +1,80 @@
import { ReactElement, useMemo } from "react";
import { useRouter } from "next/router";
import { CheckCircle } from "lucide-react";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { GlobalViewRoot, ViewHeader } from "components/view";
import { GlobalViewIssueLayoutRoot } from "components/issues";
// types
import { NextPageWithLayout } from "lib/types";
// constants
import { ELocalViews, EViewPageType, VIEW_TYPES } from "constants/view";
const WorkspacePublicViewPage: NextPageWithLayout = () => {
const router = useRouter();
const { workspaceSlug, viewId } = router.query;
const workspaceViewTabOptions = useMemo(
() => [
{
key: VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS,
title: "Private",
href: `/${workspaceSlug}/views/private/${ELocalViews.ASSIGNED}`,
},
{
key: VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS,
title: "Public",
href: `/${workspaceSlug}/views/public/${ELocalViews.ALL_ISSUES}`,
},
],
[workspaceSlug]
);
if (!workspaceSlug || !viewId) return <></>;
return (
<div className="w-full h-full overflow-hidden bg-custom-background-100 relative flex flex-col">
<div className="flex-shrink-0 w-full">
{/* header */}
<div className="px-5 pt-4 pb-2 border-b border-custom-border-200">
<ViewHeader
projectId={undefined}
viewType={VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS}
titleIcon={<CheckCircle size={12} />}
title="All Issues"
workspaceViewTabOptions={workspaceViewTabOptions}
/>
</div>
</div>
<div className="flex-shrink-0 w-full">
{/* content */}
<GlobalViewRoot
workspaceSlug={workspaceSlug.toString()}
projectId={undefined}
viewId={viewId.toString()}
viewType={VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS}
viewPageType={EViewPageType.ALL}
baseRoute={`/${workspaceSlug?.toString()}/views/public`}
/>
</div>
{/* issues */}
<div className="relative w-full h-full overflow-hidden">
<GlobalViewIssueLayoutRoot
workspaceSlug={workspaceSlug.toString()}
projectId={undefined}
viewId={viewId.toString()}
viewType={VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS}
viewPageType={EViewPageType.ALL}
/>
</div>
</div>
);
};
WorkspacePublicViewPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<></>}>{page}</AppLayout>;
};
export default WorkspacePublicViewPage;

View File

@@ -1,4 +1,5 @@
import { ReactElement } from "react";
import { useRouter } from "next/router";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
@@ -8,14 +9,20 @@ import { GlobalIssuesHeader } from "components/headers";
// types
import { NextPageWithLayout } from "lib/types";
const GlobalViewIssuesPage: NextPageWithLayout = () => (
<div className="h-full overflow-hidden bg-custom-background-100">
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
<GlobalViewsHeader />
<AllIssueLayoutRoot />
const GlobalViewIssuesPage: NextPageWithLayout = () => {
const router = useRouter();
const { workspaceSlug, globalViewId: viewId } = router.query;
if (!workspaceSlug || !viewId) return <></>;
return (
<div className="h-full overflow-hidden bg-custom-background-100">
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
<GlobalViewsHeader />
<AllIssueLayoutRoot />
</div>
</div>
</div>
);
);
};
GlobalViewIssuesPage.getLayout = function getLayout(page: ReactElement) {
return <AppLayout header={<GlobalIssuesHeader activeLayout="spreadsheet" />}>{page}</AppLayout>;

View File

@@ -1,122 +0,0 @@
import { APIService } from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
// types
import type { IInboxIssue, IInbox, TInboxStatus, IInboxQueryParams } from "@plane/types";
export class InboxService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getInboxes(workspaceSlug: string, projectId: string): Promise<IInbox[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise<IInbox> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchInbox(workspaceSlug: string, projectId: string, inboxId: string, data: Partial<IInbox>): Promise<any> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInboxIssues(
workspaceSlug: string,
projectId: string,
inboxId: string,
params?: IInboxQueryParams
): Promise<IInboxIssue[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInboxIssueById(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string
): Promise<IInboxIssue> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteInboxIssue(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markInboxStatus(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: TInboxStatus
): Promise<IInboxIssue> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchInboxIssue(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: { issue: Partial<IInboxIssue> }
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createInboxIssue(workspaceSlug: string, projectId: string, inboxId: string, data: any): Promise<IInboxIssue> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -10,23 +10,24 @@ export class IssueFiltersService extends APIService {
}
// // workspace issue filters
// async fetchWorkspaceFilters(workspaceSlug: string): Promise<IIssueFiltersResponse> {
// return this.get(`/api/workspaces/${workspaceSlug}/user-properties/`)
// .then((response) => response?.data)
// .catch((error) => {
// throw error?.response?.data;
// });
// }
// async patchWorkspaceFilters(
// workspaceSlug: string,
// data: Partial<IIssueFiltersResponse>
// ): Promise<IIssueFiltersResponse> {
// return this.patch(`/api/workspaces/${workspaceSlug}/user-properties/`, data)
// .then((response) => response?.data)
// .catch((error) => {
// throw error?.response?.data;
// });
// }
async fetchWorkspaceFilters(workspaceSlug: string): Promise<IIssueFiltersResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/user-properties/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchWorkspaceFilters(
workspaceSlug: string,
data: Partial<IIssueFiltersResponse>
): Promise<IIssueFiltersResponse> {
return this.patch(`/api/workspaces/${workspaceSlug}/user-properties/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
// project issue filters
async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise<IIssueFiltersResponse> {

View File

@@ -0,0 +1,11 @@
// view services
export * from "./workspace_private.service";
export * from "./workspace_public.service";
export * from "./project_private.service";
export * from "./project_public.service";
// user view services
export * from "./user/workspace.service";
export * from "./user/project.service";
export * from "./user/module.service";
export * from "./user/cycle.service";

View File

@@ -0,0 +1,139 @@
import { APIService } from "services/api.service";
// types
import { TView } from "@plane/types";
import { TViewService } from "./types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class ProjectPrivateViewService extends APIService implements TViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string, projectId: string | undefined = undefined): Promise<TView[] | undefined> {
if (!projectId) return undefined;
return this.get(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async fetchById(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.get(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async create(
workspaceSlug: string,
data: Partial<TView>,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async update(
workspaceSlug: string,
viewId: string,
data: Partial<TView>,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.patch(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async remove(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<void | undefined> {
if (!projectId) return undefined;
return this.delete(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async lock(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async unlock(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.delete(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async duplicate(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/duplicate/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async makeFavorite(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async removeFavorite(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.delete(`/api/users/me/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}

View File

@@ -0,0 +1,139 @@
import { APIService } from "services/api.service";
// types
import { TView } from "@plane/types";
import { TViewService } from "./types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class ProjectPublicViewService extends APIService implements TViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string, projectId: string | undefined = undefined): Promise<TView[] | undefined> {
if (!projectId) return undefined;
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async fetchById(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async create(
workspaceSlug: string,
data: Partial<TView>,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async update(
workspaceSlug: string,
viewId: string,
data: Partial<TView>,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async remove(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<void | undefined> {
if (!projectId) return undefined;
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async lock(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async unlock(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async duplicate(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/duplicate/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async makeFavorite(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async removeFavorite(
workspaceSlug: string,
viewId: string,
projectId: string | undefined = undefined
): Promise<TView | undefined> {
if (!projectId) return undefined;
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}

25
web/services/view/types.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
import { TView, TUserView } from "@plane/types";
export type TUserViewService = {
// featureId represents moduleId/cycleId
fetch: (workspaceSlug: string, projectId?: string, featureId?: string) => Promise<TUserView | undefined>;
update: (workspaceSlug: string, data: any, projectId?: string, featureId?: string) => Promise<TUserView | undefined>;
};
export type TViewService = {
fetch: (workspaceSlug: string, projectId?: string) => Promise<TView[] | undefined>;
fetchById: (workspaceSlug: string, viewId: string, projectId?: string) => Promise<TView | undefined>;
create: (workspaceSlug: string, data: Partial<TView>, projectId?: string) => Promise<TView | undefined>;
update: (
workspaceSlug: string,
viewId: string,
data: Partial<TView>,
projectId?: string
) => Promise<TView | undefined>;
remove: (workspaceSlug: string, viewId: string, projectId?: string) => Promise<void> | undefined;
lock: (workspaceSlug: string, viewId: string, projectId?: string) => Promise<TView | undefined>;
unlock: (workspaceSlug: string, viewId: string, projectId?: string) => Promise<TView | undefined>;
duplicate: (workspaceSlug: string, viewId: string, projectId?: string) => Promise<TView | undefined>;
makeFavorite: (workspaceSlug: string, viewId: string, projectId?: string) => Promise<TView | undefined>;
removeFavorite: (workspaceSlug: string, viewId: string, projectId?: string) => Promise<TView | undefined>;
};

View File

@@ -0,0 +1,36 @@
// services
import { APIService } from "services/api.service";
// types
import type { TViewFilterProps, TUserView } from "@plane/types";
import { TUserViewService } from "../types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class CycleFiltersService extends APIService implements TUserViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string, projectId?: string, cycleId?: string): Promise<TUserView | undefined> {
if (!projectId || !cycleId) return undefined;
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}user-properties/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async update(
workspaceSlug: string,
data: Partial<TViewFilterProps>,
projectId?: string,
cycleId?: string
): Promise<TUserView | undefined> {
if (!projectId || !cycleId) return undefined;
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}user-properties/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -0,0 +1,39 @@
// services
import { APIService } from "services/api.service";
// types
import type { TViewFilterProps, TUserView } from "@plane/types";
import { TUserViewService } from "../types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class ModuleFiltersService extends APIService implements TUserViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string, projectId?: string, moduleId?: string): Promise<TUserView | undefined> {
if (!projectId || !moduleId) return undefined;
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}user-properties/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async update(
workspaceSlug: string,
data: Partial<TViewFilterProps>,
projectId?: string,
moduleId?: string
): Promise<TUserView | undefined> {
if (!projectId || !moduleId) return undefined;
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}user-properties/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -0,0 +1,35 @@
// services
import { APIService } from "services/api.service";
// types
import type { TViewFilterProps, TUserView } from "@plane/types";
import { TUserViewService } from "../types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class ProjectFiltersService extends APIService implements TUserViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string, projectId?: string): Promise<TUserView | undefined> {
if (!projectId) return undefined;
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async update(
workspaceSlug: string,
data: Partial<TViewFilterProps>,
projectId?: string
): Promise<TUserView | undefined> {
if (!projectId) return undefined;
return this.patch(`/api/workspaces/${workspaceSlug}/user-properties/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -0,0 +1,29 @@
// services
import { APIService } from "services/api.service";
// types
import type { TViewFilterProps, TUserView } from "@plane/types";
import { TUserViewService } from "../types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class WorkspaceFiltersService extends APIService implements TUserViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string): Promise<TUserView | undefined> {
return this.get(`/api/workspaces/${workspaceSlug}/user-properties/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async update(workspaceSlug: string, data: Partial<TViewFilterProps>): Promise<TUserView | undefined> {
return this.patch(`/api/workspaces/${workspaceSlug}/user-properties/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -0,0 +1,92 @@
import { APIService } from "services/api.service";
// types
import { TView } from "@plane/types";
import { TViewService } from "./types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class WorkspacePrivateViewService extends APIService implements TViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string): Promise<TView[]> {
return this.get(`/api/users/me/workspaces/${workspaceSlug}/views/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async fetchById(workspaceSlug: string, viewId: string): Promise<TView> {
return this.get(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async create(workspaceSlug: string, data: Partial<TView>): Promise<TView> {
return this.post(`/api/users/me/workspaces/${workspaceSlug}/views/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async update(workspaceSlug: string, viewId: string, data: Partial<TView>): Promise<TView> {
return this.patch(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async remove(workspaceSlug: string, viewId: string): Promise<void> {
return this.delete(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async lock(workspaceSlug: string, viewId: string): Promise<TView> {
return this.post(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async unlock(workspaceSlug: string, viewId: string): Promise<TView> {
return this.delete(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async duplicate(workspaceSlug: string, viewId: string): Promise<TView> {
return this.post(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/duplicate/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async makeFavorite(workspaceSlug: string, viewId: string): Promise<TView> {
return this.post(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async removeFavorite(workspaceSlug: string, viewId: string): Promise<TView> {
return this.delete(`/api/users/me/workspaces/${workspaceSlug}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}

View File

@@ -0,0 +1,92 @@
import { APIService } from "services/api.service";
// types
import { TView } from "@plane/types";
import { TViewService } from "./types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
export class WorkspacePublicViewService extends APIService implements TViewService {
constructor() {
super(API_BASE_URL);
}
async fetch(workspaceSlug: string): Promise<TView[]> {
return this.get(`/api/workspaces/${workspaceSlug}/views/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async fetchById(workspaceSlug: string, viewId: string): Promise<TView> {
return this.get(`/api/workspaces/${workspaceSlug}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async create(workspaceSlug: string, data: Partial<TView>): Promise<TView> {
return this.post(`/api/workspaces/${workspaceSlug}/views/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async update(workspaceSlug: string, viewId: string, data: Partial<TView>): Promise<TView> {
return this.patch(`/api/workspaces/${workspaceSlug}/views/${viewId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async remove(workspaceSlug: string, viewId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/views/${viewId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async lock(workspaceSlug: string, viewId: string): Promise<TView> {
return this.post(`/api/workspaces/${workspaceSlug}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async unlock(workspaceSlug: string, viewId: string): Promise<TView> {
return this.delete(`/api/workspaces/${workspaceSlug}/views/${viewId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async duplicate(workspaceSlug: string, viewId: string): Promise<TView> {
return this.post(`/api/workspaces/${workspaceSlug}/views/${viewId}/duplicate/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async makeFavorite(workspaceSlug: string, viewId: string): Promise<TView> {
return this.post(`/api/workspaces/${workspaceSlug}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async removeFavorite(workspaceSlug: string, viewId: string): Promise<TView> {
return this.delete(`/api/workspaces/${workspaceSlug}/views/${viewId}/favorite/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}

View File

@@ -2,7 +2,7 @@ import { observable, action, makeObservable, runInAction, computed } from "mobx"
import set from "lodash/set";
import isEmpty from "lodash/isEmpty";
// services
import { InboxService } from "services/inbox.service";
import { InboxService } from "services/inbox/inbox.service";
// types
import { RootStore } from "store/root.store";
import { TInboxIssueFilterOptions, TInboxIssueFilters, TInboxIssueQueryParams, TInbox } from "@plane/types";

View File

@@ -7,7 +7,8 @@ import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
// types
import { IIssueRootStore } from "../root.store";
import { TIssue, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types";
import { TIssue, TLoader, TUnGroupedIssues, TViewDisplayFilters, ViewFlags } from "@plane/types";
import { VIEW_TYPES, generateViewStoreKey } from "constants/view";
export interface IWorkspaceIssues {
// observable
@@ -17,7 +18,7 @@ export interface IWorkspaceIssues {
// computed
groupedIssueIds: { dataViewId: string; issueIds: TUnGroupedIssues | undefined };
// actions
fetchIssues: (workspaceSlug: string, viewId: string, loadType: TLoader) => Promise<TIssue[]>;
fetchIssues: (workspaceSlug: string, viewId: string, loadType: TLoader, query?: any) => Promise<TIssue[]>;
createIssue: (
workspaceSlug: string,
projectId: string,
@@ -76,39 +77,52 @@ export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssue
}
get groupedIssueIds() {
const viewId = this.rootIssueStore.globalViewId;
const workspaceSlug = this.rootIssueStore.workspaceSlug;
if (!workspaceSlug || !viewId) return { dataViewId: "", issueIds: undefined };
const { workspaceSlug, projectId, currentViewType, currentViewId } = this.rootIssueStore.rootStore.view;
if (!workspaceSlug || !currentViewId || !currentViewType) return { dataViewId: "", issueIds: undefined };
const uniqueViewId = `${workspaceSlug}_${viewId}`;
const viewRootKey = generateViewStoreKey(workspaceSlug, projectId, currentViewType);
let displayFilters: TViewDisplayFilters | undefined = undefined;
const displayFilters = this.rootIssueStore?.workspaceIssuesFilter?.filters?.[viewId]?.displayFilters;
if (!displayFilters) return { dataViewId: viewId, issueIds: undefined };
if (currentViewType === VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS)
displayFilters =
this.rootIssueStore?.rootStore?.view?.workspacePrivateViewStore?.viewMap?.[viewRootKey]?.[currentViewId]
?.appliedFilters?.display_filters;
else if (currentViewType === VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS)
displayFilters =
this.rootIssueStore?.rootStore?.view?.workspacePublicViewStore?.viewMap?.[viewRootKey]?.[currentViewId]
?.appliedFilters?.display_filters;
if (!displayFilters) return { dataViewId: currentViewId, issueIds: undefined };
const orderBy = displayFilters?.order_by;
const uniqueViewId = `${workspaceSlug}_${currentViewId}`;
const viewIssueIds = this.issues[uniqueViewId];
if (!viewIssueIds) return { dataViewId: viewId, issueIds: undefined };
if (!viewIssueIds) return { dataViewId: currentViewId, issueIds: undefined };
const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds);
if (!_issues) return { dataViewId: viewId, issueIds: [] };
if (!_issues) return { dataViewId: currentViewId, issueIds: [] };
let issueIds: TIssue | TUnGroupedIssues | undefined = undefined;
issueIds = this.unGroupedIssues(orderBy ?? "-created_at", _issues);
return { dataViewId: viewId, issueIds };
return { dataViewId: currentViewId, issueIds };
}
fetchIssues = async (workspaceSlug: string, viewId: string, loadType: TLoader = "init-loader") => {
fetchIssues = async (
workspaceSlug: string,
viewId: string,
loadType: TLoader = "init-loader",
query: any = undefined
) => {
try {
this.loader = loadType;
const uniqueViewId = `${workspaceSlug}_${viewId}`;
const params = this.rootIssueStore?.workspaceIssuesFilter?.getAppliedFilters(viewId);
const response = await this.workspaceService.getViewIssues(workspaceSlug, params);
const response = await this.workspaceService.getViewIssues(workspaceSlug, query || {});
runInAction(() => {
set(

View File

@@ -18,6 +18,8 @@ import { IMentionStore, MentionStore } from "./mention.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store";
import { IProjectPageStore, ProjectPageStore } from "./project-page.store";
import { ILabelStore, LabelStore } from "./label.store";
// new stores
import { GlobalViewRootStore } from "./view/root.store";
enableStaticRendering(typeof window === "undefined");
@@ -40,8 +42,10 @@ export class RootStore {
mention: IMentionStore;
dashboard: IDashboardStore;
projectPages: IProjectPageStore;
view: GlobalViewRootStore;
constructor() {
// old store structure
this.app = new AppRootStore(this);
this.eventTracker = new EventTrackerStore(this);
this.user = new UserRootStore(this);
@@ -61,6 +65,7 @@ export class RootStore {
this.mention = new MentionStore(this);
this.projectPages = new ProjectPageStore(this);
this.dashboard = new DashboardStore(this);
this.view = new GlobalViewRootStore(this);
}
resetOnSignout() {
@@ -80,5 +85,6 @@ export class RootStore {
this.mention = new MentionStore(this);
this.projectPages = new ProjectPageStore(this);
this.dashboard = new DashboardStore(this);
this.view = new GlobalViewRootStore(this);
}
}

View File

@@ -0,0 +1,122 @@
import isEmpty from "lodash/isEmpty";
import get from "lodash/get";
// types
import {
TViewFilters,
TViewDisplayFilters,
TViewDisplayProperties,
TViewFilterProps,
TViewFilterQueryParams,
} from "@plane/types";
// constants
import { EViewPageType, viewPageDefaultLayoutsByPageType } from "constants/view";
export class FiltersHelper {
// computed filters
computedFilters = (filters: TViewFilters, defaultValues?: Partial<TViewFilters>): TViewFilters => ({
project: defaultValues?.project || filters?.project || [],
module: defaultValues?.module || filters?.module || [],
cycle: defaultValues?.cycle || filters?.cycle || [],
priority: defaultValues?.priority || filters?.priority || [],
state: defaultValues?.state || filters?.state || [],
state_group: defaultValues?.state_group || filters?.state_group || [],
assignees: defaultValues?.assignees || filters?.assignees || [],
mentions: defaultValues?.mentions || filters?.mentions || [],
subscriber: defaultValues?.subscriber || filters?.subscriber || [],
created_by: defaultValues?.created_by || filters?.created_by || [],
labels: defaultValues?.labels || filters?.labels || [],
start_date: defaultValues?.start_date || filters?.start_date || [],
target_date: defaultValues?.target_date || filters?.target_date || [],
});
// computed display filters
computedDisplayFilters = (
viewPageType: EViewPageType,
displayFilters: TViewDisplayFilters,
defaultValues?: Partial<TViewDisplayFilters>
): TViewDisplayFilters => {
const viewPageDefaultLayout = viewPageDefaultLayoutsByPageType(viewPageType)?.[0] || "list";
return {
layout: defaultValues?.layout || displayFilters?.layout || viewPageDefaultLayout,
group_by: defaultValues?.group_by || displayFilters?.group_by || undefined,
sub_group_by: defaultValues?.sub_group_by || displayFilters?.sub_group_by || undefined,
order_by: defaultValues?.order_by || displayFilters?.order_by || "sort_order",
type: defaultValues?.type || displayFilters?.type || undefined,
sub_issue: defaultValues?.sub_issue || displayFilters?.sub_issue || false,
show_empty_groups: defaultValues?.show_empty_groups || displayFilters?.show_empty_groups || false,
calendar: {
show_weekends: defaultValues?.calendar?.show_weekends || displayFilters?.calendar?.show_weekends || false,
layout: defaultValues?.calendar?.layout || displayFilters?.calendar?.layout || "month",
},
};
};
// computed display properties
computedDisplayProperties = (
displayProperties: TViewDisplayProperties,
defaultValues?: Partial<TViewDisplayProperties>
): TViewDisplayProperties => ({
assignee: get(defaultValues, "assignee", get(displayProperties, "assignee", true)),
start_date: get(defaultValues, "start_date", get(displayProperties, "start_date", true)),
due_date: get(defaultValues, "due_date", get(displayProperties, "due_date", true)),
labels: get(defaultValues, "labels", get(displayProperties, "labels", true)),
priority: get(defaultValues, "priority", get(displayProperties, "priority", true)),
state: get(defaultValues, "state", get(displayProperties, "state", true)),
sub_issue_count: get(defaultValues, "sub_issue_count", get(displayProperties, "sub_issue_count", true)),
attachment_count: get(defaultValues, "attachment_count", get(displayProperties, "attachment_count", true)),
link: get(defaultValues, "link", get(displayProperties, "link", true)),
estimate: get(defaultValues, "estimate", get(displayProperties, "estimate", true)),
key: get(defaultValues, "key", get(displayProperties, "key", true)),
created_on: get(defaultValues, "created_on", get(displayProperties, "created_on", true)),
updated_on: get(defaultValues, "updated_on", get(displayProperties, "updated_on", true)),
});
// compute filters and display_filters issue query parameters
computeAppliedFiltersQueryParameters = (
filters: TViewFilterProps,
acceptableParamsByLayout: string[]
): { params: any; query: string } => {
const paramsObject: Partial<Record<TViewFilterQueryParams, string | boolean>> = {};
let paramsString = "";
const filteredParams: Partial<Record<TViewFilterQueryParams, undefined | string[] | boolean | string>> = {
// issue filters
priority: filters.filters?.priority || undefined,
state_group: filters.filters?.state_group || undefined,
state: filters.filters?.state || undefined,
assignees: filters.filters?.assignees || undefined,
mentions: filters.filters?.mentions || undefined,
created_by: filters.filters?.created_by || undefined,
labels: filters.filters?.labels || undefined,
start_date: filters.filters?.start_date || undefined,
target_date: filters.filters?.target_date || undefined,
project: filters.filters?.project || undefined,
subscriber: filters.filters?.subscriber || undefined,
// display filters
type: filters?.display_filters?.type || undefined,
sub_issue: filters?.display_filters?.sub_issue || true,
};
Object.keys(filteredParams).forEach((key) => {
const _key = key as TViewFilterQueryParams;
const _value: string | boolean | string[] | undefined = filteredParams[_key];
if (_value != undefined && acceptableParamsByLayout.includes(_key)) {
if (Array.isArray(_value)) _value.length > 0 && (paramsObject[_key] = _value.join(","));
else paramsObject[_key] = _value;
}
});
if (paramsObject && !isEmpty(paramsObject)) {
paramsString = Object.keys(paramsObject)
.map((key) => {
const _key = key as TViewFilterQueryParams;
const _value: string | boolean | undefined = paramsObject[_key];
if (_value) return `${_key}=${_value}`;
})
.join("&");
}
return { params: paramsObject, query: paramsString };
};
}

View File

@@ -0,0 +1,116 @@
import { action, autorun, makeObservable, observable, runInAction } from "mobx";
// stores
import { ViewRootStore } from "./view-root.store";
// services
import {
WorkspacePrivateViewService,
WorkspacePublicViewService,
ProjectPublicViewService,
ProjectPrivateViewService,
WorkspaceFiltersService,
ProjectFiltersService,
} from "services/view";
// types
import { RootStore } from "store/root.store";
// constants
import { EViewPageType, VIEW_TYPES } from "constants/view";
import { TViewTypes } from "@plane/types";
export class GlobalViewRootStore {
workspaceSlug: string | undefined = undefined;
projectId: string | undefined = undefined;
currentViewId: string | undefined = undefined;
currentViewType: TViewTypes | undefined = undefined;
currentUserId: string | undefined = undefined;
workspacePrivateViewStore: ViewRootStore;
workspacePublicViewStore: ViewRootStore;
projectPrivateViewStore: ViewRootStore;
projectPublicViewStore: ViewRootStore;
constructor(private store: RootStore) {
makeObservable(this, {
workspaceSlug: observable.ref,
projectId: observable.ref,
currentViewId: observable.ref,
currentViewType: observable.ref,
currentUserId: observable.ref,
// actions
setWorkspaceSlug: action,
setProjectId: action,
setCurrentViewId: action,
setCurrentViewType: action,
setCurrentUserId: action,
});
autorun(() => {
this.currentUserId = store.user.currentUser?.id;
});
const workspacePrivateDefaultViews: any[] = [
{
id: "assigned",
name: "Assigned",
is_local_view: true,
},
{
id: "created",
name: "Created",
is_local_view: true,
},
{
id: "subscribed",
name: "Subscribed",
is_local_view: true,
},
];
const workspacePublicDefaultViews: any[] = [
{
id: "all-issues",
name: "All Issues",
is_local_view: true,
},
];
this.workspacePrivateViewStore = new ViewRootStore(
this.store,
workspacePrivateDefaultViews,
new WorkspacePrivateViewService(),
new WorkspaceFiltersService(),
EViewPageType.ALL,
VIEW_TYPES.WORKSPACE_PRIVATE_VIEWS
);
this.workspacePublicViewStore = new ViewRootStore(
this.store,
workspacePublicDefaultViews,
new WorkspacePublicViewService(),
new WorkspaceFiltersService(),
EViewPageType.ALL,
VIEW_TYPES.WORKSPACE_PUBLIC_VIEWS
);
this.projectPrivateViewStore = new ViewRootStore(
this.store,
undefined,
new ProjectPrivateViewService(),
new ProjectFiltersService(),
EViewPageType.PROJECT,
VIEW_TYPES.PROJECT_PRIVATE_VIEWS
);
this.projectPublicViewStore = new ViewRootStore(
this.store,
undefined,
new ProjectPublicViewService(),
new ProjectFiltersService(),
EViewPageType.PROJECT,
VIEW_TYPES.PROJECT_PUBLIC_VIEWS
);
}
// helper actions
setWorkspaceSlug = (workspaceSlug: string | undefined) => runInAction(() => (this.workspaceSlug = workspaceSlug));
setProjectId = (projectId: string | undefined) => runInAction(() => (this.projectId = projectId));
setCurrentViewId = (viewId: string | undefined) => runInAction(() => (this.currentViewId = viewId));
setCurrentViewType = (viewType: TViewTypes | undefined) => runInAction(() => (this.currentViewType = viewType));
setCurrentUserId = (userId: string | undefined) => runInAction(() => (this.currentUserId = userId));
}

View File

@@ -0,0 +1,342 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import set from "lodash/set";
import sortBy from "lodash/sortBy";
import reverse from "lodash/reverse";
import cloneDeep from "lodash/cloneDeep";
// stores
import { RootStore } from "store/root.store";
import { ViewStore } from "./view.store";
// types
import { TUserViewService, TViewService } from "services/view/types";
import { TView, TViewFilters, TViewTypes } from "@plane/types";
// constants
import { EViewPageType, TViewCRUD, generateViewStoreKey, viewLocalPayload } from "constants/view";
export type TLoader =
| "view-loader"
| "view-mutation-loader"
| "view-detail-loader"
| "create-submitting"
| "edit-submitting"
| "delete-submitting"
| "duplicate-submitting"
| undefined;
type TViewRootStore = {
// observables
loader: TLoader;
viewMapCEN: ViewStore | undefined; // view map Create, Edit, and save as New
viewMap: Record<string, Record<string, ViewStore>>; // workspaceSlug/projectId/TViewType.toString(), viewId -> ViewStore
// computed
viewIds: string[];
viewById: (viewId: string) => ViewStore | undefined;
localView: () => ViewStore | undefined;
// actions
fetch: (workspaceSlug: string, projectId: string | undefined, _loader?: TLoader) => Promise<void>;
fetchById: (
workspaceSlug: string,
projectId: string | undefined,
viewId: string,
defaultFilters?: Partial<Record<keyof TViewFilters, string[]>> | undefined
) => Promise<void>;
remove: (viewId: string) => Promise<void>;
localViewHandler: (viewId: string | undefined, status: TViewCRUD) => void;
create: () => Promise<void>;
update: () => Promise<void>;
duplicate: (viewId: string) => Promise<void>;
};
export class ViewRootStore implements TViewRootStore {
// observables
loader: TLoader = "view-loader";
viewMapCEN: ViewStore | undefined = undefined;
viewMap: Record<string, Record<string, ViewStore>> = {};
constructor(
private store: RootStore,
private defaultViews: TView[] = [],
private service: TViewService,
private userService: TUserViewService,
private viewPageType: EViewPageType,
private viewType: TViewTypes
) {
makeObservable(this, {
// observables
loader: observable.ref,
viewMapCEN: observable,
viewMap: observable,
// computed
viewIds: computed,
// actions
localViewHandler: action,
fetch: action,
fetchById: action,
create: action,
update: action,
remove: action,
duplicate: action,
});
}
// computed
get viewIds() {
const { workspaceSlug, projectId, currentViewType } = this.store.view;
if (!workspaceSlug || !currentViewType) return [];
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, currentViewType);
const views = this.viewMap?.[viewRootSlug] ? Object.values(this.viewMap?.[viewRootSlug]) : [];
const localViews = views.filter((view) => view.is_local_view);
let apiViews = views.filter((view) => !view.is_local_view);
apiViews = reverse(sortBy(apiViews, "sort_order"));
const _viewIds = [...localViews.map((view) => view.id), ...apiViews.map((view) => view.id)];
return _viewIds.filter((view) => view !== undefined) as string[];
}
viewById = computedFn((viewId: string) => {
const { workspaceSlug, projectId, currentViewType } = this.store.view;
if (!workspaceSlug || !currentViewType) return undefined;
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, currentViewType);
return this.viewMap?.[viewRootSlug]?.[viewId] || undefined;
});
localView = computedFn(() => this.viewMapCEN);
// actions
fetch = async (workspaceSlug: string, projectId: string | undefined, _loader: TLoader = "view-loader") => {
try {
runInAction(() => (this.loader = _loader));
this.store.view.setWorkspaceSlug(workspaceSlug);
this.store.view.setProjectId(projectId);
this.store.view.setCurrentViewType(this.viewType);
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, this.viewType);
if (this.defaultViews && this.defaultViews.length > 0)
runInAction(() => {
this.defaultViews?.forEach((view) => {
if (view.id)
set(
this.viewMap,
[viewRootSlug, view.id],
new ViewStore(this.store, view, this.service, this.userService, this.viewPageType)
);
});
});
const views = await this.service.fetch(workspaceSlug, projectId);
if (!views) return;
runInAction(() => {
views.forEach((view) => {
if (view.id)
set(
this.viewMap,
[viewRootSlug, view.id],
new ViewStore(this.store, view, this.service, this.userService, this.viewPageType)
);
});
this.loader = undefined;
});
} catch {
runInAction(() => (this.loader = undefined));
}
};
fetchById = async (
workspaceSlug: string,
projectId: string | undefined,
viewId: string,
defaultFilters: Partial<Record<keyof TViewFilters, string[]>> | undefined = undefined
) => {
try {
runInAction(() => (this.loader = "view-detail-loader"));
this.store.view.setWorkspaceSlug(workspaceSlug);
this.store.view.setProjectId(projectId);
this.store.view.setCurrentViewId(viewId);
this.store.view.setCurrentViewType(this.viewType);
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, this.viewType);
const userView = await this.userService.fetch(workspaceSlug, projectId);
if (!userView) return;
let view: TView | undefined = undefined;
if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) {
const currentView = { ...this.viewById(viewId) } as TView;
if (!currentView) return;
view = currentView;
defaultFilters && (view.filters = defaultFilters as TViewFilters);
} else {
const currentView = await this.service.fetchById(workspaceSlug, viewId, projectId);
if (!currentView) return;
view = currentView;
}
view?.display_filters && (view.display_filters = userView.display_filters);
view?.display_properties && (view.display_properties = userView.display_properties);
if (!view) return;
runInAction(() => {
if (view?.id)
set(
this.viewMap,
[viewRootSlug, view.id],
new ViewStore(this.store, view, this.service, this.userService, this.viewPageType)
);
});
// fetching the issues
const filterParams = this.viewMap?.[viewRootSlug]?.[viewId]?.appliedFiltersQueryParams?.params;
this.store.issue.workspaceIssues.fetchIssues(workspaceSlug, viewId, "init-loader", filterParams);
runInAction(() => (this.loader = undefined));
} catch {
runInAction(() => (this.loader = undefined));
}
};
remove = async (viewId: string) => {
try {
const { workspaceSlug, projectId, currentViewType } = this.store.view;
if (!workspaceSlug || !currentViewType) return undefined;
runInAction(() => (this.loader = "delete-submitting"));
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, this.viewType);
await this.service.remove?.(workspaceSlug, viewId, projectId);
runInAction(() => {
delete this.viewMap?.[viewRootSlug]?.[viewId];
this.loader = undefined;
});
} catch {
runInAction(() => (this.loader = undefined));
}
};
localViewHandler = (viewId: string | undefined, status: TViewCRUD) => {
const { workspaceSlug, projectId, currentViewType } = this.store.view;
if (!workspaceSlug || !currentViewType) return undefined;
if (status === "CLEAR") {
runInAction(() => (this.viewMapCEN = undefined));
return;
}
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, currentViewType);
let _view: Partial<TView> = {};
if (status === "CREATE") _view = cloneDeep(viewLocalPayload);
else if (status === "EDIT") {
if (!viewId) return;
_view = cloneDeep(this.viewMap?.[viewRootSlug]?.[viewId]);
} else if (status === "SAVE_AS_NEW") {
if (!viewId) return;
const clonedView = cloneDeep(this.viewMap?.[viewRootSlug]?.[viewId]);
_view = {
id: "create",
name: clonedView?.name,
filters: clonedView?.filtersToUpdate?.filters,
display_filters: clonedView?.filtersToUpdate?.display_filters,
display_properties: clonedView?.filtersToUpdate?.display_properties,
};
} else return;
runInAction(() => {
if (_view.id)
set(
this,
["viewMapCEN"],
new ViewStore(this.store, _view as TView, this.service, this.userService, this.viewPageType)
);
});
};
create = async () => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.viewMapCEN) return;
runInAction(() => (this.loader = "create-submitting"));
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, this.viewType);
const view = await this.service.create(workspaceSlug, this.viewMapCEN.filtersToUpdate, projectId);
if (!view) return;
runInAction(() => {
if (view.id)
set(
this.viewMap,
[viewRootSlug, view.id],
new ViewStore(this.store, view, this.service, this.userService, this.viewPageType)
);
this.viewMapCEN = undefined;
this.loader = undefined;
});
} catch {
runInAction(() => (this.loader = undefined));
}
};
update = async () => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.viewMapCEN || !this.viewMapCEN.id) return;
runInAction(() => (this.loader = "edit-submitting"));
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, this.viewType);
const view = await this.service.update(
workspaceSlug,
this.viewMapCEN.id,
this.viewMapCEN.filtersToUpdate,
projectId
);
if (!view) return;
runInAction(() => {
if (view.id)
set(
this.viewMap,
[viewRootSlug, view.id],
new ViewStore(this.store, view, this.service, this.userService, this.viewPageType)
);
this.viewMapCEN = undefined;
this.loader = undefined;
});
} catch {
runInAction(() => (this.loader = undefined));
}
};
duplicate = async (viewId: string) => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.service.duplicate) return;
runInAction(() => (this.loader = "duplicate-submitting"));
const viewRootSlug = generateViewStoreKey(workspaceSlug, projectId, this.viewType);
const view = await this.service.duplicate(workspaceSlug, viewId, projectId);
if (!view) return;
runInAction(() => {
if (view.id)
set(
this.viewMap,
[viewRootSlug, view.id],
new ViewStore(this.store, view, this.service, this.userService, this.viewPageType)
);
this.loader = undefined;
});
} catch {
runInAction(() => (this.loader = undefined));
}
};
}

View File

@@ -0,0 +1,459 @@
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import set from "lodash/set";
import update from "lodash/update";
import concat from "lodash/concat";
import pull from "lodash/pull";
import isEqual from "lodash/isEqual";
import cloneDeep from "lodash/cloneDeep";
// store
import { RootStore } from "store/root.store";
// types
import { TUserViewService, TViewService } from "services/view/types";
import {
TView,
TUpdateView,
TViewFilters,
TViewDisplayFilters,
TViewDisplayProperties,
TViewFilterProps,
TViewAccess,
} from "@plane/types";
// helpers
import { FiltersHelper } from "./helpers/filters_helpers";
// constants
import {
EViewLayouts,
EViewPageType,
EFilterTypes,
viewDefaultFilterParametersByViewTypeAndLayout,
} from "constants/view";
type TLoader = "updating" | undefined;
export type TViewStore = TView & {
// observables
loader: TLoader;
filtersToUpdate: TUpdateView;
// computed
appliedFilters: TViewFilterProps | undefined;
appliedFiltersQueryParams: { params: Object; query: string } | undefined;
isFiltersApplied: boolean;
isFiltersUpdateEnabled: boolean;
// helper actions
setName: (name: string) => void;
setDescription: (description: string) => void;
setFilters: (filterKey: keyof TViewFilters | undefined, filterValue: "clear_all" | string) => void;
setDisplayFilters: (display_filters: Partial<TViewDisplayFilters>) => void;
setDisplayProperties: (displayPropertyKey: keyof TViewDisplayProperties) => void;
resetChanges: () => void;
saveChanges: () => Promise<void>;
// actions
update: (viewData: TUpdateView) => Promise<void>;
lockView: () => Promise<void>;
unlockView: () => Promise<void>;
makeFavorite: () => Promise<void>;
removeFavorite: () => Promise<void>;
};
export class ViewStore extends FiltersHelper implements TViewStore {
id: string | undefined;
workspace: string | undefined;
project: string | undefined;
name: string | undefined;
description: string | undefined;
query: string | undefined;
filters: TViewFilters;
display_filters: TViewDisplayFilters;
display_properties: TViewDisplayProperties;
access: TViewAccess | undefined;
owned_by: string | undefined;
sort_order: number | undefined;
is_locked: boolean = false;
is_pinned: boolean = false;
is_favorite: boolean = false;
created_by: string | undefined;
updated_by: string | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
is_local_view: boolean = false;
loader: TLoader = undefined;
filtersToUpdate: TUpdateView;
constructor(
private store: RootStore,
_view: TView,
private service: TViewService,
private userService: TUserViewService,
private viewPageType: EViewPageType
) {
super();
this.id = _view.id;
this.workspace = _view.workspace;
this.project = _view.project;
this.name = _view.name;
this.description = _view.description;
this.query = _view.query;
this.filters = this.computedFilters(_view.filters);
this.display_filters = this.computedDisplayFilters(this.viewPageType, _view.display_filters);
this.display_properties = this.computedDisplayProperties(_view.display_properties);
this.access = _view.access;
this.owned_by = _view.owned_by;
this.sort_order = _view.sort_order;
this.is_locked = _view.is_locked;
this.is_pinned = _view.is_pinned;
this.is_favorite = _view.is_favorite;
this.created_by = _view.created_by;
this.updated_by = _view.updated_by;
this.created_at = _view.created_at;
this.updated_at = _view.updated_at;
this.is_local_view = _view.is_local_view;
this.filtersToUpdate = {
name: this.name,
description: this.description,
filters: this.computedFilters(_view.filters),
display_filters: this.computedDisplayFilters(this.viewPageType, _view.display_filters),
display_properties: this.computedDisplayProperties(_view.display_properties),
};
makeObservable(this, {
// observables
id: observable.ref,
workspace: observable.ref,
project: observable.ref,
name: observable.ref,
description: observable.ref,
query: observable.ref,
filters: observable,
display_filters: observable,
display_properties: observable,
access: observable.ref,
owned_by: observable.ref,
sort_order: observable.ref,
is_locked: observable.ref,
is_pinned: observable.ref,
is_favorite: observable.ref,
created_by: observable.ref,
updated_by: observable.ref,
created_at: observable.ref,
updated_at: observable.ref,
is_local_view: observable.ref,
loader: observable.ref,
filtersToUpdate: observable,
// computed
appliedFilters: computed,
appliedFiltersQueryParams: computed,
isFiltersApplied: computed,
isFiltersUpdateEnabled: computed,
// helper actions
setName: action,
setFilters: action,
setDisplayFilters: action,
setDisplayProperties: action,
resetChanges: action,
saveChanges: action,
// actions
update: action,
lockView: action,
unlockView: action,
makeFavorite: action,
removeFavorite: action,
updateUserFilters: action,
updateUserDisplayFilters: action,
updateUserDisplayProperties: action,
});
}
// computed
get appliedFilters() {
return {
filters: this.computedFilters(this.filters, this.filtersToUpdate.filters),
display_filters: this.computedDisplayFilters(
this.viewPageType,
this.display_filters,
this.filtersToUpdate.display_filters
),
display_properties: this.computedDisplayProperties(
this.display_properties,
this.filtersToUpdate.display_properties
),
};
}
get appliedFiltersQueryParams() {
const appliedFilters = this.appliedFilters;
if (!appliedFilters) return undefined;
const layout = appliedFilters?.display_filters?.layout;
const requiredFilterProperties = viewDefaultFilterParametersByViewTypeAndLayout(
this.viewPageType,
layout,
EFilterTypes.FILTERS
);
return this.computeAppliedFiltersQueryParameters(appliedFilters, requiredFilterProperties) || undefined;
}
get isFiltersApplied() {
const filters = this.appliedFilters?.filters;
let isFiltersApplied = false;
Object.keys(filters).forEach((key) => {
const _key = key as keyof TViewFilters;
if (filters[_key]?.length > 0) isFiltersApplied = true;
});
return isFiltersApplied;
}
get isFiltersUpdateEnabled() {
const _filters = this.filters;
const _appliedFilters = this.appliedFilters?.filters;
let isFiltersUpdateEnabled = false;
Object.keys(_appliedFilters).forEach((key) => {
const _key = key as keyof TViewFilters;
if (!isEqual(_appliedFilters[_key].slice().sort(), _filters[_key].slice().sort())) isFiltersUpdateEnabled = true;
});
return isFiltersUpdateEnabled;
}
// helper actions
setName = (name: string) => {
runInAction(() => {
this.filtersToUpdate.name = name;
});
};
setDescription = (description: string) => {
runInAction(() => {
this.filtersToUpdate.description = description;
});
};
setFilters = (filterKey: keyof TViewFilters | undefined = undefined, filterValue: "clear_all" | string) => {
runInAction(() => {
if (filterKey === undefined) {
if (filterValue === "clear_all") set(this.filtersToUpdate, [EFilterTypes.FILTERS], {});
} else
update(this.filtersToUpdate, [EFilterTypes.FILTERS, filterKey], (_values = []) => {
if (filterValue === "clear_all") return [];
if (_values.includes(filterValue)) return pull(_values, filterValue);
return concat(_values, filterValue);
});
});
const { workspaceSlug } = this.store.view;
if (workspaceSlug && this.id) {
const filterParams = this.appliedFiltersQueryParams?.params;
this.store.issue.workspaceIssues.fetchIssues(workspaceSlug, this.id, "mutation", filterParams);
}
};
setDisplayFilters = async (display_filters: Partial<TViewDisplayFilters>) => {
const appliedFilters = this.appliedFilters;
const layout = appliedFilters?.display_filters?.layout;
const sub_group_by = appliedFilters?.display_filters?.sub_group_by;
const group_by = appliedFilters?.display_filters?.group_by;
const sub_issue = appliedFilters?.display_filters?.sub_issue;
if (group_by === undefined && display_filters.sub_group_by) display_filters.sub_group_by = undefined;
if (layout === EViewLayouts.KANBAN) {
if (sub_group_by === group_by) display_filters.group_by = undefined;
if (group_by === null) display_filters.group_by = "state";
}
if (layout === EViewLayouts.SPREADSHEET && sub_issue === true) display_filters.sub_issue = false;
runInAction(() => {
Object.keys(display_filters).forEach((key) => {
const _key = key as keyof TViewDisplayFilters;
set(this.filtersToUpdate, [EFilterTypes.DISPLAY_FILTERS, _key], display_filters[_key]);
});
});
// update display filters globally
this.updateUserDisplayFilters({ [EFilterTypes.DISPLAY_FILTERS]: this.filtersToUpdate.display_filters });
};
setDisplayProperties = async (displayPropertyKey: keyof TViewDisplayProperties) => {
runInAction(() => {
update(
this.filtersToUpdate,
[EFilterTypes.DISPLAY_PROPERTIES, displayPropertyKey],
(_value: boolean = true) => !_value
);
});
// update display properties globally
this.updateUserDisplayProperties({ [EFilterTypes.DISPLAY_PROPERTIES]: this.filtersToUpdate.display_properties });
};
resetChanges = () => {
runInAction(() => {
const _view = cloneDeep(this);
this.filtersToUpdate = {
name: _view.name,
description: _view.description,
filters: _view.filters,
display_filters: _view.display_filters,
display_properties: _view.display_properties,
};
});
};
saveChanges = async () => {
try {
if (!this.id) return;
await this.update(this.filtersToUpdate);
} catch {
Object.keys(this.filtersToUpdate).forEach((key) => {
const _key = key as keyof TUpdateView;
set(this, _key, this.filtersToUpdate[_key]);
});
}
};
// actions
update = async (viewData: TUpdateView) => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.id) return;
runInAction(() => {
this.loader = "updating";
});
const view = await this.service.update(workspaceSlug, this.id, viewData, projectId);
if (!view) return;
runInAction(() => {
Object.keys(view).forEach((key) => {
const _key = key as keyof TView;
set(this, _key, view[_key]);
});
this.loader = undefined;
});
} catch {
this.resetChanges();
}
};
lockView = async () => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.id || !this.service.lock) return;
const view = await this.service.lock(workspaceSlug, this.id, projectId);
if (!view) return;
runInAction(() => {
this.is_locked = view.is_locked;
});
} catch {
this.is_locked = this.is_locked;
}
};
unlockView = async () => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.id || !this.service.unlock) return;
const view = await this.service.unlock(workspaceSlug, this.id, projectId);
if (!view) return;
runInAction(() => {
this.is_locked = view.is_locked;
});
} catch {
this.is_locked = this.is_locked;
}
};
makeFavorite = async () => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.id || !this.service.makeFavorite) return;
const view = await this.service.makeFavorite(workspaceSlug, this.id, projectId);
if (!view) return;
runInAction(() => {
this.is_favorite = view.is_locked;
});
} catch {
this.is_favorite = this.is_favorite;
}
};
removeFavorite = async () => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug || !this.id || !this.service.removeFavorite) return;
const view = await this.service.removeFavorite(workspaceSlug, this.id, projectId);
if (!view) return;
runInAction(() => {
this.is_favorite = view.is_locked;
});
} catch {
this.is_favorite = this.is_favorite;
}
};
// updating the user specific filters, display filters, and display properties
updateUserFilters = async (filters: { [EFilterTypes.FILTERS]: TViewFilters }) => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug) return;
const userView = await this.userService.update(workspaceSlug, filters, projectId);
if (!userView) return;
runInAction(() => {
this.filters = userView.filters;
});
} catch {
runInAction(() => {
this.filters = this.filters;
});
}
};
updateUserDisplayFilters = async (display_filters: { [EFilterTypes.DISPLAY_FILTERS]: TViewDisplayFilters }) => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug) return;
const userView = await this.userService.update(workspaceSlug, display_filters, projectId);
if (!userView) return;
runInAction(() => {
this.display_filters = userView.display_filters;
});
} catch {
runInAction(() => {
this.display_filters = this.display_filters;
});
}
};
updateUserDisplayProperties = async (display_properties: {
[EFilterTypes.DISPLAY_PROPERTIES]: TViewDisplayProperties;
}) => {
try {
const { workspaceSlug, projectId } = this.store.view;
if (!workspaceSlug) return;
const userView = await this.userService.update(workspaceSlug, display_properties, projectId);
if (!userView) return;
runInAction(() => {
this.display_properties = userView.display_properties;
});
} catch {
runInAction(() => {
this.display_properties = this.display_properties;
});
}
};
}