dev: feature flagging implementation (#443)

* dev: initialize feature flagging

* dev: feature flagging workspace active cycles

* dev: update feature flag implementation

* dev: add `FEATURE_FLAG_SERVER_AUTH_TOKEN` env

* dev: add feature flagging for backend apis

* dev: setup feature flags store and hooks. (#558)

* dev: setup feature flags store and hooks.

* minor improvements for swr key and flags enum.

* dev: workspace active cycles feature flag. (#562)

* dev: add task for cancelling the workspace subscription when the workspace is deleted

* dev: rename feaure flagging component

* dev: update feature flagging function for spaces

* dev: add feature flags for bulk ops, issue embeds and page publish. (#589)

* dev: add logging for member sync task

* dev: restrict workspace from deleting if the subscription is active

* dev: workspace delete check endpoint

* dev: subscription endpoint check

* dev: update subscriptions

* chore: plane pro billing and plans page updates.

* dev: update pro pill display logic.

* dev: fix feature flagging

* chore: minor improvement in cloud-badge to avoid API calls to `products` endpoint if user has PRO subscription.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Nikhil
2024-07-11 15:12:54 +05:30
committed by sriram veeraghanta
parent ed567f77e6
commit 4e8bfe0024
59 changed files with 2425 additions and 1076 deletions

View File

@@ -2,8 +2,10 @@
import csv
import io
from datetime import date
import requests
from dateutil.relativedelta import relativedelta
# Django imports
from django.db import IntegrityError
from django.db.models import (
Count,
@@ -15,8 +17,6 @@ from django.db.models import (
)
from django.db.models.fields import DateField
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
# Django imports
from django.http import HttpResponse
from django.utils import timezone
@@ -49,6 +49,7 @@ from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.payment.bgtasks.member_sync_task import member_sync_task
from django.conf import settings
class WorkSpaceViewSet(BaseViewSet):
@@ -167,6 +168,33 @@ class WorkSpaceViewSet(BaseViewSet):
path="/api/users/me/settings/", multiple=True, user=False
)
def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
# Fetch the workspace subcription
if settings.PAYMENT_SERVER_BASE_URL:
# Make a cancel request to the payment server
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/subscriptions/check/",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
json={"workspace_id": str(workspace.id)},
)
# Check if the response is successful
response.raise_for_status()
# Return the response
response = response.json()
# Check if the response contains the product key
if response.get("subscription_exists"):
return Response(
{"error": "workspace has active subscription"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# Delete the workspace
return super().destroy(request, *args, **kwargs)
return super().destroy(request, *args, **kwargs)

View File

@@ -146,6 +146,8 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# Sync workspace members
member_sync_task.delay(slug)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -1,7 +1,7 @@
# Django imports
from django.urls import path
from plane.ee.views import RephraseGrammarEndpoint
from plane.ee.views.app import RephraseGrammarEndpoint
urlpatterns = [

View File

@@ -1,6 +1,6 @@
from django.urls import path
from plane.ee.views import (
from plane.ee.views.app import (
WorkspaceActiveCycleEndpoint,
)

View File

@@ -1,18 +1,18 @@
from django.urls import path
from plane.ee.views import (
from plane.ee.views.app import (
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
BulkSubscribeIssuesEndpoint
BulkSubscribeIssuesEndpoint,
)
urlpatterns = [
path(
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
BulkIssueOperationsEndpoint.as_view(),
name="bulk-operations-issues",
),
path(
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-archive-issues/",
BulkArchiveIssuesEndpoint.as_view(),
name="bulk-archive-issues",

View File

@@ -0,0 +1,7 @@
from plane.ee.views.app.ai import RephraseGrammarEndpoint
from plane.ee.views.app.cycle import WorkspaceActiveCycleEndpoint
from plane.ee.views.app.issue import (
BulkIssueOperationsEndpoint,
BulkArchiveIssuesEndpoint,
BulkSubscribeIssuesEndpoint,
)

View File

@@ -21,6 +21,10 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Module imports
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
from plane.utils.analytics_plot import burndown_plot
# ee imports
from plane.ee.views.base import BaseAPIView
from plane.ee.permissions import (
WorkspaceUserPermission,
@@ -28,8 +32,8 @@ from plane.ee.permissions import (
from plane.ee.serializers import (
WorkspaceActiveCycleSerializer,
)
from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project
from plane.utils.analytics_plot import burndown_plot
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
class WorkspaceActiveCycleEndpoint(BaseAPIView):
@@ -232,6 +236,7 @@ class WorkspaceActiveCycleEndpoint(BaseAPIView):
)
return results
@check_feature_flag(FeatureFlag.WORKSPACE_ACTIVE_CYCLES)
def get(self, request, slug):
favorite_subquery = UserFavorite.objects.filter(

View File

@@ -22,10 +22,12 @@ from plane.db.models import (
IssueLabel,
IssueAssignee,
Workspace,
IssueSubscriber
IssueSubscriber,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.ee.bgtasks import bulk_issue_activity
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
class BulkIssueOperationsEndpoint(BaseAPIView):
@@ -33,6 +35,7 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
ProjectEntityPermission,
]
@check_feature_flag(FeatureFlag.BULK_OPS)
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
@@ -271,7 +274,13 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
# Bulk update all the objects
Issue.objects.bulk_update(
bulk_update_issues,
["priority", "start_date", "target_date", "state_id", "completed_at"],
[
"priority",
"start_date",
"target_date",
"state_id",
"completed_at",
],
batch_size=100,
)
@@ -303,6 +312,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
ProjectEntityPermission,
]
@check_feature_flag(FeatureFlag.BULK_OPS)
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
@@ -358,6 +368,7 @@ class BulkSubscribeIssuesEndpoint(BaseAPIView):
ProjectEntityPermission,
]
@check_feature_flag(FeatureFlag.BULK_OPS)
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
workspace = Workspace.objects.filter(slug=slug).first()

View File

@@ -10,6 +10,8 @@ from plane.ee.permissions import (
)
from plane.db.models import DeployBoard, Workspace, Page
from plane.app.serializers import DeployBoardSerializer
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
class ProjectPagePublishEndpoint(BaseAPIView):
@@ -18,6 +20,7 @@ class ProjectPagePublishEndpoint(BaseAPIView):
ProjectMemberPermission,
]
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
def post(self, request, slug, project_id, page_id):
workspace = Workspace.objects.get(slug=slug)
# Fetch the page
@@ -62,6 +65,7 @@ class ProjectPagePublishEndpoint(BaseAPIView):
serializer = DeployBoardSerializer(deploy_board)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
def patch(self, request, slug, project_id, page_id):
# Get the deploy board
deploy_board = DeployBoard.objects.get(
@@ -96,6 +100,7 @@ class ProjectPagePublishEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
def get(self, request, slug, project_id, page_id):
# Get the deploy board
deploy_board = DeployBoard.objects.get(
@@ -123,6 +128,7 @@ class WorkspacePagePublishEndpoint(BaseAPIView):
WorkSpaceAdminPermission,
]
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
def post(self, request, slug, page_id):
workspace = Workspace.objects.get(slug=slug)
# Fetch the page
@@ -165,6 +171,7 @@ class WorkspacePagePublishEndpoint(BaseAPIView):
serializer = DeployBoardSerializer(deploy_board)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
def patch(self, request, slug, page_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=page_id, entity_name="page", workspace__slug=slug
@@ -194,6 +201,7 @@ class WorkspacePagePublishEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@check_feature_flag(FeatureFlag.PAGE_PUBLISH)
def get(self, request, slug, page_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=page_id, entity_name="page", workspace__slug=slug

View File

@@ -31,6 +31,8 @@ from plane.db.models import (
from plane.ee.views.base import BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -90,6 +92,7 @@ class WorkspacePageViewSet(BaseViewSet):
.distinct()
)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = WorkspacePageSerializer(
@@ -112,6 +115,7 @@ class WorkspacePageViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def partial_update(self, request, slug, pk):
try:
page = Page.objects.get(
@@ -175,6 +179,7 @@ class WorkspacePageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def retrieve(self, request, slug, pk=None):
page = self.get_queryset().filter(pk=pk).first()
if page is None:
@@ -188,6 +193,7 @@ class WorkspacePageViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def lock(self, request, slug, pk):
page = Page.objects.filter(
pk=pk,
@@ -198,6 +204,7 @@ class WorkspacePageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def unlock(self, request, slug, pk):
page = Page.objects.filter(
pk=pk,
@@ -209,11 +216,13 @@ class WorkspacePageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def list(self, request, slug):
queryset = self.get_queryset()
pages = WorkspacePageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def archive(self, request, slug, pk):
page = Page.objects.get(
pk=pk,
@@ -241,6 +250,7 @@ class WorkspacePageViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def unarchive(self, request, slug, pk):
page = Page.objects.get(
pk=pk,
@@ -270,6 +280,7 @@ class WorkspacePageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def destroy(self, request, slug, pk):
page = Page.objects.get(
pk=pk,
@@ -310,6 +321,7 @@ class WorkspacePagesDescriptionViewSet(BaseViewSet):
WorkspaceEntityPermission,
]
@check_feature_flag(FeatureFlag.WORKSPACE_PAGES)
def retrieve(self, request, slug, pk):
page = Page.objects.get(
pk=pk,

View File

@@ -13,6 +13,8 @@ from plane.db.models import (
IssueView,
)
from plane.ee.views.base import BaseViewSet
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
class IssueViewEEViewSet(BaseViewSet):
@@ -41,6 +43,7 @@ class IssueViewEEViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@check_feature_flag(FeatureFlag.VIEW_ACCESS_PRIVATE)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 1)

View File

@@ -10,6 +10,8 @@ from plane.ee.permissions import (
)
from plane.db.models import DeployBoard, Workspace, IssueView
from plane.app.serializers import DeployBoardSerializer
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
class WorkspaceViewsPublishEndpoint(BaseAPIView):
@@ -18,6 +20,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
WorkSpaceAdminPermission,
]
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def post(self, request, slug, view_id):
workspace = Workspace.objects.get(slug=slug)
# Fetch the view
@@ -63,6 +66,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
serializer = DeployBoardSerializer(deploy_board)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def patch(self, request, slug, view_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=view_id, entity_name="view", workspace__slug=slug
@@ -92,6 +96,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def get(self, request, slug, view_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=view_id, entity_name="view", workspace__slug=slug
@@ -99,6 +104,7 @@ class WorkspaceViewsPublishEndpoint(BaseAPIView):
serializer = DeployBoardSerializer(deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def delete(self, request, slug, view_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=view_id, entity_name="view", workspace__slug=slug
@@ -113,6 +119,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
ProjectMemberPermission,
]
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def post(self, request, slug, project_id, view_id):
# Fetch the view
issue_view = IssueView.objects.get(
@@ -157,6 +164,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
serializer = DeployBoardSerializer(deploy_board)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def patch(self, request, slug, project_id, view_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=view_id,
@@ -189,6 +197,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def get(self, request, slug, project_id, view_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=view_id,
@@ -199,6 +208,7 @@ class IssueViewsPublishEndpoint(BaseAPIView):
serializer = DeployBoardSerializer(deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
@check_feature_flag(FeatureFlag.VIEW_PUBLISH)
def delete(self, request, slug, project_id, view_id):
deploy_board = DeployBoard.objects.get(
entity_identifier=view_id,

View File

@@ -13,6 +13,8 @@ from plane.db.models import (
IssueView,
)
from plane.ee.views.base import BaseViewSet
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
class WorkspaceViewEEViewSet(BaseViewSet):
@@ -53,6 +55,7 @@ class WorkspaceViewEEViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@check_feature_flag(FeatureFlag.VIEW_ACCESS_PRIVATE)
def access(self, request, slug, pk):
access = request.data.get("access", 1)

View File

@@ -21,6 +21,9 @@ from plane.ee.serializers import (
from plane.app.serializers import (
IssuePublicSerializer,
)
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import ErrorCodes
class PagePublicEndpoint(BaseAPIView):
@@ -34,10 +37,24 @@ class PagePublicEndpoint(BaseAPIView):
deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="page"
)
# Get the page object
page = Page.objects.get(pk=deploy_board.entity_identifier)
serializer = PagePublicSerializer(page)
return Response(serializer.data, status=status.HTTP_200_OK)
# Check if the workspace has access to feature
if check_workspace_feature_flag(
feature_key=FeatureFlag.PAGE_PUBLISH,
slug=deploy_board.workspace.slug,
):
# Get the page object
page = Page.objects.get(pk=deploy_board.entity_identifier)
serializer = PagePublicSerializer(page)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
return Response(
{
"error": "Payment required",
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
class PagePublicIssuesEndpoint(BaseAPIView):
@@ -52,35 +69,48 @@ class PagePublicIssuesEndpoint(BaseAPIView):
anchor=anchor, entity_name="page"
)
# Get the issue's embedded inside the page
page_issues = PageLog.objects.filter(
page_id=deploy_board.entity_identifier, entity_name="issue"
).values_list("entity_identifier", flat=True)
if check_workspace_feature_flag(
feature_key=FeatureFlag.PAGE_PUBLISH,
slug=deploy_board.workspace.slug,
):
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
# Get the issue's embedded inside the page
page_issues = PageLog.objects.filter(
page_id=deploy_board.entity_identifier, entity_name="issue"
).values_list("entity_identifier", flat=True)
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(pk__in=page_issues)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
.filter(pk__in=page_issues)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.prefetch_related(
Prefetch(
"votes",
queryset=IssueVote.objects.select_related("actor"),
)
)
)
.prefetch_related(
Prefetch(
"votes",
queryset=IssueVote.objects.select_related("actor"),
)
serializer = IssuePublicSerializer(issue_queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
return Response(
{
"error": "Payment required",
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
)
serializer = IssuePublicSerializer(issue_queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -12,6 +12,9 @@ from plane.db.models import (
from plane.ee.serializers import (
ViewsPublicSerializer,
)
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.payment.flags.flag import FeatureFlag
from plane.payment.flags.flag_decorator import ErrorCodes
class ViewsPublicEndpoint(BaseAPIView):
@@ -25,7 +28,22 @@ class ViewsPublicEndpoint(BaseAPIView):
deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="view"
)
# Get the views object
views = IssueView.objects.get(pk=deploy_board.entity_identifier)
serializer = ViewsPublicSerializer(views)
return Response(serializer.data, status=status.HTTP_200_OK)
# Check if the workspace has access to feature
if check_workspace_feature_flag(
feature_key=FeatureFlag.PAGE_PUBLISH,
slug=deploy_board.workspace.slug,
):
# Get the views object
views = IssueView.objects.get(pk=deploy_board.entity_identifier)
serializer = ViewsPublicSerializer(views)
return Response(serializer.data, status=status.HTTP_200_OK)
# Check if the workspace has access to feature
else:
return Response(
{
"error": "Payment required",
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
)

View File

@@ -10,48 +10,65 @@ from celery import shared_task
# Module imports
from plane.db.models import WorkspaceMember, Workspace
from plane.utils.exception_logger import log_exception
@shared_task
def member_sync_task(slug):
# Do not run this task if payment server base url is not set
if settings.PAYMENT_SERVER_BASE_URL:
# workspace from slug
workspace = Workspace.objects.filter(slug=slug).first()
workspace_id = str(workspace.id)
try:
# Do not run this task if payment server base url is not set
if settings.PAYMENT_SERVER_BASE_URL:
# workspace from slug
workspace = Workspace.objects.filter(slug=slug).first()
workspace_id = str(workspace.id)
# Get all active workspace members
workspace_members = (
WorkspaceMember.objects.filter(
workspace_id=workspace_id, is_active=True, member__is_bot=False
# Get all active workspace members
workspace_members = (
WorkspaceMember.objects.filter(
workspace_id=workspace_id,
is_active=True,
member__is_bot=False,
)
.annotate(
user_email=F("member__email"),
user_id=F("member__id"),
user_role=F("role"),
)
.values("user_email", "user_id", "user_role")
)
.annotate(user_email=F("member__email"), user_id=F("member__id"))
.values("user_email", "user_id")
)
# Convert user_id to string
for member in workspace_members:
member["user_id"] = str(member["user_id"])
# Convert user_id to string
for member in workspace_members:
member["user_id"] = str(member["user_id"])
# Send request to payment server to sync workspace members
response = requests.patch(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/workspaces/{workspace_id}/subscriptions/",
json={
"slug": slug,
"workspace_id": str(workspace_id),
"members_list": list(workspace_members),
},
headers={"content-type": "application/json"},
)
# Send request to payment server to sync workspace members
response = requests.patch(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/workspaces/{workspace_id}/subscriptions/",
json={
"slug": slug,
"workspace_id": str(workspace_id),
"members_list": list(workspace_members),
},
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
)
# Check if response is successful
if response.status_code == 200:
return
# Workspace does not have a subscription
elif response.status_code == 404:
return
# Invalid request
# Check if response is successful
if response.status_code == 200:
return
# Workspace does not have a subscription
elif response.status_code == 404:
return
# Invalid request
else:
return
else:
return
else:
except requests.exceptions.RequestException as e:
log_exception(e)
return
except Exception as e:
log_exception(e)
return

View File

@@ -0,0 +1,30 @@
from enum import Enum
class FeatureFlag(Enum):
# Workspace level active cycles
WORKSPACE_ACTIVE_CYCLES = "WORKSPACE_ACTIVE_CYCLES"
# Bulk operations on issues
BULK_OPS = "BULK_OPS"
# Make views public or private
VIEW_ACCESS_PRIVATE = "VIEW_ACCESS_PRIVATE"
# View publish
VIEW_PUBLISH = "VIEW_PUBLISH"
# View Locking and unlocking
VIEW_LOCKING = "VIEW_LOCKING"
# Workspace level pages
WORKSPACE_PAGES = "WORKSPACE_PAGES"
# OIDC SAML Auth
OIDC_SAML_AUTH = "OIDC_SAML_AUTH"
# Page level issue embeds
PAGE_ISSUE_EMBEDS = "PAGE_ISSUE_EMBEDS"
# Page Publish
PAGE_PUBLISH = "PAGE_PUBLISH"
# Work logging
WORK_LOG = "WORK_LOG"
# Estimate with time
ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME"
# Issue list
ISSUE_TYPE_DISPLAY = "ISSUE_TYPE_DISPLAY"
# Settings
ISSUE_TYPE_SETTINGS = "ISSUE_TYPE_SETTINGS"

View File

@@ -0,0 +1,73 @@
# Python imports
from functools import wraps
from enum import Enum
# Django imports
# Third party imports
import openfeature.api
from openfeature.evaluation_context import EvaluationContext
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .provider import FlagProvider
class ErrorCodes(Enum):
PAYMENT_REQUIRED = 1999
def check_feature_flag(feature_key, default_value=True):
"""decorator to feature flag"""
def decorator(view_func):
@wraps(view_func)
def _wrapped_view(instance, request, *args, **kwargs):
# Function to generate cache key
openfeature.api.set_provider(FlagProvider())
client = openfeature.api.get_client()
if client.get_boolean_value(
flag_key=feature_key.value,
default_value=default_value,
evaluation_context=EvaluationContext(
str(request.user.id),
{"slug": kwargs.get("slug")},
),
):
response = view_func(instance, request, *args, **kwargs)
else:
response = Response(
{
"error": "Payment required",
"error_code": ErrorCodes.PAYMENT_REQUIRED.value,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
return response
return _wrapped_view
return decorator
def check_workspace_feature_flag(
feature_key, slug, user_id=None, default_value=True
):
"""Function to check workspace feature flag"""
# Function to generate cache key
openfeature.api.set_provider(FlagProvider())
client = openfeature.api.get_client()
# Function to check if the feature flag is enabled
flag = client.get_boolean_value(
flag_key=feature_key.value,
default_value=default_value,
evaluation_context=EvaluationContext(
user_id,
{"slug": slug},
),
)
# Return the flag
return flag

View File

@@ -0,0 +1,98 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from openfeature.provider.provider import AbstractProvider
from openfeature.provider.metadata import Metadata
from openfeature.flag_evaluation import FlagResolutionDetails
# Module imports
from plane.utils.exception_logger import log_exception
class FlagProvider(AbstractProvider):
def get_metadata(self) -> Metadata:
return Metadata(name="PlaneProvider")
def make_request(self, slug, user_id, feature_key, default_value):
# Make a request to the feature flag server to get the value of the feature flag
if settings.FEATURE_FLAG_SERVER_BASE_URL:
try:
# Make a request to the feature flag server
response = requests.post(
f"{settings.FEATURE_FLAG_SERVER_BASE_URL}/api/feature-flags/",
headers={
"x-api-key": settings.FEATURE_FLAG_SERVER_AUTH_TOKEN,
"Content-Type": "application/json",
},
json={
"workspace_slug": slug,
"user_id": user_id,
"flag_key": feature_key,
},
)
# If the request is successful, return the value of the feature flag
response.raise_for_status()
# Get the value of the feature flag from the response
resp = response.json()
return resp.get("value", default_value)
# If the request fails, log the exception and return the default value
except requests.exceptions.RequestException as e:
log_exception(e)
return default_value
return default_value
def resolve_boolean_details(
self,
flag_key,
default_value,
evaluation_context,
):
# Get the targeting key and attributes from the evaluation context
targetting_key = evaluation_context.targeting_key
attributes = evaluation_context.attributes
slug = attributes.get("slug")
# Get the value of the feature flag
value = self.make_request(
user_id=targetting_key,
slug=slug,
feature_key=flag_key,
default_value=default_value,
)
# Return the value of the feature flag
return FlagResolutionDetails(value=value)
def resolve_string_details(
self,
flag_key,
default_value,
evaluation_context,
):
pass
def resolve_integer_details(
self,
flag_key,
default_value,
evaluation_context,
):
pass
def resolve_float_details(
self,
flag_key,
default_value,
evaluation_context,
):
pass
def resolve_object_details(
self,
flag_key,
default_value,
evaluation_context,
):
pass

View File

@@ -6,6 +6,7 @@ from .views import (
WorkspaceProductEndpoint,
WebsitePaymentLinkEndpoint,
WebsiteUserWorkspaceEndpoint,
SubscriptionEndpoint,
)
urlpatterns = [
@@ -34,4 +35,9 @@ urlpatterns = [
WebsiteUserWorkspaceEndpoint.as_view(),
name="website-workspaces",
),
path(
"workspaces/<str:slug>/subscriptions/",
SubscriptionEndpoint.as_view(),
name="subscription",
),
]

View File

@@ -4,3 +4,4 @@ from .product import (
WebsiteUserWorkspaceEndpoint,
)
from .payment import PaymentLinkEndpoint, WebsitePaymentLinkEndpoint
from .subscription import SubscriptionEndpoint

View File

@@ -30,7 +30,9 @@ class PaymentLinkEndpoint(BaseAPIView):
workspace__slug=slug, is_active=True, member__is_bot=False
)
.annotate(
user_email=F("member__email"), user_id=F("member__id")
user_email=F("member__email"),
user_id=F("member__id"),
user_role=F("role"),
)
.values("user_email", "user_id")
)
@@ -114,7 +116,9 @@ class WebsitePaymentLinkEndpoint(BaseAPIView):
workspace__slug=slug, is_active=True, member__is_bot=False
)
.annotate(
user_email=F("member__email"), user_id=F("member__id")
user_email=F("member__email"),
user_id=F("member__id"),
user_role=F("role"),
)
.values("user_email", "user_id")
)

View File

@@ -27,17 +27,40 @@ class ProductEndpoint(BaseAPIView):
def get(self, request, slug):
try:
if settings.PAYMENT_SERVER_BASE_URL:
count = WorkspaceMember.objects.filter(
workspace__slug=slug
# Get all the paid users in the workspace
paid_count = WorkspaceMember.objects.filter(
workspace__slug=slug,
is_active=True,
member__is_bot=False,
role__gt=10,
).count()
# Get all the viewers and guests in the workspace
free_count = WorkspaceMember.objects.filter(
workspace__slug=slug,
is_active=True,
member__is_bot=False,
role__lte=10,
).count()
# If paid users are currently the pay workspace count
workspace_count = paid_count
# If free users are more than 5 times the paid users, then workspace count is free users - 5 * paid users
if free_count > 5 * paid_count:
workspace_count = free_count - 5 * paid_count
# Fetch the products from the payment server
response = requests.get(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/products/?quantity={count}",
f"{settings.PAYMENT_SERVER_BASE_URL}/api/products/?quantity={workspace_count}",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
)
# Check if the response is successful
response.raise_for_status()
# Convert the response to json
response = response.json()
return Response(response, status=status.HTTP_200_OK)
else:
@@ -96,7 +119,12 @@ class WebsiteUserWorkspaceEndpoint(BaseAPIView):
role=20,
)
.annotate(uuid_str=Cast("workspace_id", CharField()))
.values("uuid_str", "workspace__slug", "workspace__name", "workspace__logo")
.values(
"uuid_str",
"workspace__slug",
"workspace__name",
"workspace__logo",
)
)
workspaces = [

View File

@@ -0,0 +1,47 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.app.permissions.workspace import WorkspaceOwnerPermission
from plane.db.models import Workspace
class SubscriptionEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# Fetch the workspace subcription
if settings.PAYMENT_SERVER_BASE_URL:
# Make a cancel request to the payment server
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/subscriptions/check/",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
json={"workspace_id": str(workspace.id)},
)
# Check if the response is successful
response.raise_for_status()
# Return the response
response = response.json()
# Check if the response contains the product key
return Response(response, status=status.HTTP_200_OK)
return Response(
{"error": "error in checking workspace subscription"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -366,6 +366,14 @@ ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL")
# Cloud server base url
# payment server base url
PAYMENT_SERVER_BASE_URL = os.environ.get("PAYMENT_SERVER_BASE_URL", False)
PAYMENT_SERVER_AUTH_TOKEN = os.environ.get("PAYMENT_SERVER_AUTH_TOKEN", "")
# feature flag server base urls
FEATURE_FLAG_SERVER_BASE_URL = os.environ.get(
"FEATURE_FLAG_SERVER_BASE_URL", False
)
FEATURE_FLAG_SERVER_AUTH_TOKEN = os.environ.get(
"FEATURE_FLAG_SERVER_AUTH_TOKEN", ""
)

View File

@@ -65,5 +65,7 @@ pytz==2024.1
PyJWT==2.8.0
# SAML
python3-saml==1.16.0
# feature flag
openfeature-sdk==0.7.0
# graphql
strawberry-graphql-django==0.43.0

View File

@@ -39,13 +39,19 @@ export const DocumentEditorAdditionalExtensions = (props: Props) => {
},
];
const extensions = [SlashCommand(fileHandler.upload, slashCommandAdditionalOptions)];
let extensions = [];
// If searchCallback is provided, then add the slash command for issue embed. This check is required as the searchCallback is optional.
if (issueEmbedConfig?.searchCallback) {
extensions = [SlashCommand(fileHandler.upload, slashCommandAdditionalOptions)];
} else {
extensions = [SlashCommand(fileHandler.upload)];
}
if (issueEmbedConfig)
if (issueEmbedConfig && issueEmbedConfig.searchCallback)
extensions.push(
IssueEmbedSuggestions.configure({
suggestion: {
render: () => IssueListRenderer(issueEmbedConfig?.searchCallback),
render: () => issueEmbedConfig.searchCallback && IssueListRenderer(issueEmbedConfig.searchCallback),
},
})
);

View File

@@ -10,7 +10,7 @@ export type TReadOnlyEmbedConfig = {
};
export type TIssueEmbedConfig = {
searchCallback: (searchQuery: string) => Promise<TEmbedItem[]>;
searchCallback?: (searchQuery: string) => Promise<TEmbedItem[]>;
widgetCallback: ({
issueId,
projectId,

View File

@@ -19,5 +19,5 @@ export type IPaymentProduct = {
export type IWorkspaceProductSubscription = {
product: TProductSubscriptionType;
expiry_date: string | null;
current_period_end_date: string | null;
};

View File

@@ -27,7 +27,8 @@
"NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL",
"NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL",
"NEXT_PUBLIC_DISCO_BASE_URL",
"NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL"
"NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL",
"NEXT_PUBLIC_FEATURE_FLAG_SERVER_BASE_URL"
],
"tasks": {
"build": {

View File

@@ -0,0 +1,7 @@
"use client";
import { CustomError } from "@/components/common";
export default function ActiveCyclesError() {
return <CustomError />;
}

View File

@@ -0,0 +1,43 @@
"use client";
import { Button } from "@plane/ui";
export const CustomError = () => {
const handleRefresh = () => {
window.location.reload();
};
return (
<div className={`h-full w-full overflow-hidden bg-custom-background-100`}>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">Yikes! That doesn{"'"}t look good.</h3>
<p className="mx-auto md:w-1/2 text-sm text-custom-text-200">
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
details, please write to{" "}
<a href="mailto:support@plane.so" className="text-custom-primary">
support@plane.so
</a>{" "}
or on our{" "}
<a
href="https://discord.com/invite/A92xrEGCge"
target="_blank"
className="text-custom-primary"
rel="noopener noreferrer"
>
Discord
</a>
.
</p>
</div>
<div className="flex items-center justify-center gap-2">
<Button variant="primary" size="md" onClick={handleRefresh}>
Refresh
</Button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -4,4 +4,5 @@ export * from "./latest-feature-block";
export * from "./breadcrumb-link";
export * from "./logo-spinner";
export * from "./logo";
export * from "./custom-error";
export * from "./count-chip";

View File

@@ -13,6 +13,8 @@ import { Button, TOAST_TYPE, setToast, Tooltip } from "@plane/ui";
import { LogoSpinner } from "@/components/common";
import { useMember, useProject, useUser, useWorkspace } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web hooks
import { useFeatureFlags } from "@/plane-web/hooks/store/use-feature-flags";
// images
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
@@ -35,6 +37,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
workspace: { fetchWorkspaceMembers },
} = useMember();
const { workspaces } = useWorkspace();
const { loader: featureFlagsLoader, fetchFeatureFlags } = useFeatureFlags();
const { isMobile } = usePlatformOS();
const planeLogo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
@@ -42,6 +45,13 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
const currentWorkspace =
(allWorkspaces && allWorkspaces.find((workspace) => workspace?.slug === workspaceSlug)) || undefined;
// fetching feature flags
useSWR(
workspaceSlug && currentUser ? `WORKSPACE_FEATURE_FLAGS_${workspaceSlug}_${currentUser.id}` : null,
workspaceSlug && currentUser ? () => fetchFeatureFlags(workspaceSlug.toString(), currentUser.id) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching user workspace information
useSWR(
workspaceSlug && currentWorkspace ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null,
@@ -80,7 +90,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
};
// if list of workspaces are not there then we have to render the spinner
if (allWorkspaces === undefined) {
if (featureFlagsLoader || allWorkspaces === undefined) {
return (
<div className="grid h-screen place-items-center bg-custom-background-100 p-4">
<div className="flex flex-col items-center gap-3 text-center">

View File

@@ -1,4 +1,9 @@
// plane web components
import { WorkspaceActiveCyclesList } from "@/plane-web/components/active-cycles";
import { WorkspaceActiveCyclesList, WorkspaceActiveCyclesUpgrade } from "@/plane-web/components/active-cycles";
import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags";
export const WorkspaceActiveCyclesRoot = () => <WorkspaceActiveCyclesList />;
export const WorkspaceActiveCyclesRoot = () => (
<WithFeatureFlagHOC flag="WORKSPACE_ACTIVE_CYCLES" fallback={<WorkspaceActiveCyclesUpgrade />}>
<WorkspaceActiveCyclesList />
</WithFeatureFlagHOC>
)

View File

@@ -1 +1,90 @@
export * from "ce/components/active-cycles/workspace-active-cycles-upgrade";
"use client";
import React from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// icons
import { Crown } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// constants
import { WORKSPACE_ACTIVE_CYCLES_DETAILS } from "@/constants/cycle";
// helper
import { cn } from "@/helpers/common.helper";
// hooks
import { useUser } from "@/hooks/store";
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
export const WorkspaceActiveCyclesUpgrade = observer(() => {
// store hooks
const {
userProfile: { data: userProfile },
} = useUser();
const { toggleProPlanModal } = useWorkspaceSubscription();
const isDarkMode = userProfile?.theme.theme === "dark";
return (
<div className="vertical-scrollbar scrollbar-lg flex h-full flex-col gap-10 rounded-xl px-8 pt-8">
<div
className={cn("item-center flex min-h-[25rem] justify-between rounded-xl", {
"bg-gradient-to-l from-[#CFCFCF] to-[#212121]": userProfile?.theme.theme === "dark",
"bg-gradient-to-l from-[#3b5ec6] to-[#f5f7fe]": userProfile?.theme.theme === "light",
})}
>
<div className="relative flex flex-col justify-center gap-7 px-14 lg:w-1/2">
<div className="flex max-w-64 flex-col gap-2">
<h2 className="text-2xl font-semibold">On-demand snapshots of all your cycles</h2>
<p className="text-base font-medium text-custom-text-300">
Monitor cycles across projects, track high-priority issues, and zoom in cycles that need attention.
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="primary" onClick={() => toggleProPlanModal(true)}>
<Crown className="h-3.5 w-3.5" />
Upgrade
</Button>
</div>
<span className="absolute left-0 top-0">
<Image
src={`/workspace-active-cycles/cta-l-1-${isDarkMode ? "dark" : "light"}.webp`}
height={125}
width={125}
className="rounded-xl"
alt="l-1"
/>
</span>
</div>
<div className="relative hidden w-1/2 lg:block">
<span className="absolute bottom-0 right-0">
<Image
src={`/workspace-active-cycles/cta-r-1-${isDarkMode ? "dark" : "light"}.webp`}
height={420}
width={500}
alt="r-1"
/>
</span>
<span className="absolute -bottom-16 right-1/2 rounded-xl">
<Image
src={`/workspace-active-cycles/cta-r-2-${isDarkMode ? "dark" : "light"}.webp`}
height={210}
width={280}
alt="r-2"
/>
</span>
</div>
</div>
<div className="grid h-full grid-cols-1 gap-5 pb-8 lg:grid-cols-2 xl:grid-cols-3">
{WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
<div key={item.title} className="flex min-h-32 w-full flex-col gap-2 rounded-md bg-custom-background-80 p-4">
<div className="flex items-center gap-2">
<h3 className="font-medium">{item.title}</h3>
<item.icon className="h-4 w-4 text-blue-500" />
</div>
<span className="text-sm text-custom-text-300">{item.description}</span>
</div>
))}
</div>
</div>
);
});

View File

@@ -5,6 +5,7 @@ import { Spinner } from "@plane/ui";
// components
import { WORKSPACE_ACTIVE_CYCLES_LIST } from "@/constants/fetch-keys";
// plane web components
import { WorkspaceActiveCyclesUpgrade } from "@/plane-web/components/active-cycles/";
import { ActiveCycleInfoCard } from "@/plane-web/components/cycles/active-cycles";
// constants
// services
@@ -24,7 +25,7 @@ export const ActiveCyclesListPage: FC<ActiveCyclesListPageProps> = (props) => {
const { workspaceSlug, cursor, perPage, updateTotalPages, updateResultsCount } = props;
// fetching active cycles in workspace
const { data: workspaceActiveCycles } = useSWR(
const { data: workspaceActiveCycles, error } = useSWR(
workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${perPage}`) : null,
workspaceSlug && cursor ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, perPage) : null
);
@@ -36,6 +37,11 @@ export const ActiveCyclesListPage: FC<ActiveCyclesListPageProps> = (props) => {
}
}, [updateTotalPages, updateResultsCount, workspaceActiveCycles]);
if (error) {
if (error.error_code === 1999) return <WorkspaceActiveCyclesUpgrade />;
else throw Error(error.error);
}
if (!workspaceActiveCycles) {
return (
<div className="flex items-center justify-center h-full w-full">

View File

@@ -0,0 +1 @@
export * from './with-feature-flag-hoc';

View File

@@ -0,0 +1,15 @@
import { ReactNode } from "react";
// plane web hooks
import { E_FEATURE_FLAGS, useFlag } from "@/plane-web/hooks/store/use-flag";
interface IWithFeatureFlagHOC {
flag: keyof typeof E_FEATURE_FLAGS;
fallback: ReactNode;
children: ReactNode;
}
export const WithFeatureFlagHOC = ({ flag, fallback, children }: IWithFeatureFlagHOC) => {
const isFeatureEnabled = useFlag(flag);
return <>{isFeatureEnabled ? children : fallback}</>;
};

View File

@@ -17,11 +17,11 @@ export const CloudEditionBadge = observer(() => {
// params
const { workspaceSlug } = useParams();
// states
const [isProPlanModalOpen, setIsProPlanModalOpen] = useState(false);
const [isProPlanDetailsModalOpen, setProPlanDetailsModalOpen] = useState(false);
// hooks
const { captureEvent } = useEventTracker();
const { currentWorkspaceSubscribedPlan, fetchWorkspaceSubscribedPlan } = useWorkspaceSubscription();
const { isProPlanModalOpen, currentWorkspaceSubscribedPlanDetail, toggleProPlanModal, fetchWorkspaceSubscribedPlan } =
useWorkspaceSubscription();
// fetch workspace current plane information
useSWR(
workspaceSlug && process.env.NEXT_PUBLIC_DISCO_BASE_URL ? `WORKSPACE_CURRENT_PLAN_${workspaceSlug}` : null,
@@ -36,7 +36,7 @@ export const CloudEditionBadge = observer(() => {
);
const handleProPlanPurchaseModalOpen = () => {
setIsProPlanModalOpen(true);
toggleProPlanModal(true);
captureEvent("pro_plan_modal_opened", {});
};
@@ -45,7 +45,7 @@ export const CloudEditionBadge = observer(() => {
captureEvent("pro_plan_details_modal_opened", {});
};
if (!currentWorkspaceSubscribedPlan)
if (!currentWorkspaceSubscribedPlanDetail)
return (
<Loader className="flex h-full">
<Loader.Item height="30px" width="95%" />
@@ -54,28 +54,35 @@ export const CloudEditionBadge = observer(() => {
return (
<>
<CloudProductsModal isOpen={isProPlanModalOpen} handleClose={() => setIsProPlanModalOpen(false)} />
<ProPlanDetailsModal isOpen={isProPlanDetailsModalOpen} handleClose={() => setProPlanDetailsModalOpen(false)} />
{currentWorkspaceSubscribedPlan === "FREE" && (
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanPurchaseModalOpen}
>
Upgrade to Pro
</Button>
{currentWorkspaceSubscribedPlanDetail.product === "FREE" && (
<>
<CloudProductsModal isOpen={isProPlanModalOpen} handleClose={() => toggleProPlanModal(false)} />
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanPurchaseModalOpen}
>
Upgrade to Pro
</Button>
</>
)}
{currentWorkspaceSubscribedPlan === "PRO" && (
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanDetailsModalOpen}
>
<Image src={PlaneLogo} alt="Plane Pro" width={14} height={14} />
{"Plane Pro"}
</Button>
{currentWorkspaceSubscribedPlanDetail.product === "PRO" && (
<>
<ProPlanDetailsModal
isOpen={isProPlanDetailsModalOpen}
handleClose={() => setProPlanDetailsModalOpen(false)}
/>
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleProPlanDetailsModalOpen}
>
<Image src={PlaneLogo} alt="Plane Pro" width={14} height={14} />
{"Plane Pro"}
</Button>
</>
)}
</>
);

View File

@@ -1,23 +1,122 @@
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { ExternalLink } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// constants
import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common";
import { Button, Loader, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
// store hooks
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
// services
import { PaymentService } from "@/plane-web/services/payment.service";
// assets
import PlaneLogo from "@/public/plane-logos/blue-without-text.png";
export const PlaneCloudBilling: React.FC = () => (
<section className="w-full overflow-y-auto md:pr-9 pr-4">
<div>
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">Billing & Plans</h3>
</div>
</div>
<div className="px-4 py-6">
const paymentService = new PaymentService();
export const PlaneCloudBilling: React.FC = observer(() => {
// params
const { workspaceSlug } = useParams();
// states
const [isLoading, setIsLoading] = useState<boolean>(false);
// hooks
const { currentWorkspaceSubscribedPlanDetail, toggleProPlanModal } = useWorkspaceSubscription();
const handleSubscriptionPageRedirection = () => {
setIsLoading(true);
paymentService
.getWorkspaceSubscriptionPageLink(workspaceSlug.toString())
.then((response) => {
if (response.url) {
window.open(response.url, "_blank");
}
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to redirect to subscription page. Please try again.",
});
})
.finally(() => {
setIsLoading(false);
});
};
return (
<section className="w-full overflow-y-auto md:pr-9 pr-4">
<div>
<h4 className="text-md mb-1 leading-6">Current plan</h4>
<p className="mb-3 text-sm text-custom-text-200">You are currently using the free plan</p>
<a href={MARKETING_PRICING_PAGE_LINK} target="_blank" rel="noreferrer">
<Button variant="neutral-primary">View Plans</Button>
</a>
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium flex gap-4">
Billing and plans{" "}
<a
href="https://plane.so/pricing"
className={cn(
getButtonStyling("neutral-primary", "sm"),
"cursor-pointer rounded-2xl px-3 py-1 text-center text-xs font-medium outline-none"
)}
target="_blank"
rel="noreferrer noopener"
>
{"View all plans"}
<ExternalLink className="h-3 w-3" strokeWidth={2} />
</a>
</h3>
</div>
</div>
</div>
</section>
);
<div className="py-6">
{!currentWorkspaceSubscribedPlanDetail && (
<Loader className="flex w-full justify-between">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="30px" width="20%" />
</Loader>
)}
{currentWorkspaceSubscribedPlanDetail?.product === "FREE" && (
<div>
<div className="flex gap-2 font-medium justify-between">
<div className="flex items-center gap-2">
<Image src={PlaneLogo} alt="Plane" width={24} height={24} />
<h4 className="text-xl mb-1 leading-6 font-bold">Free plan</h4>
</div>
<div>
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded-2xl px-4 py-1.5 text-center text-sm font-medium outline-none"
onClick={() => toggleProPlanModal(true)}
>
Upgrade to Pro
</Button>
</div>
</div>
</div>
)}
{currentWorkspaceSubscribedPlanDetail?.product === "PRO" && (
<div>
<div className="flex flex-col sm:flex-row gap-4 text-lg font-medium justify-between">
<div className="flex items-center gap-2">
<Image src={PlaneLogo} alt="Plane pro" width={24} height={24} />
<h4 className="text-2xl mb-1 leading-6 font-bold">Plane Pro</h4>
<div className="text-center text-sm text-custom-text-200 font-medium">
(Renew on: {renderFormattedDate(currentWorkspaceSubscribedPlanDetail.current_period_end_date)})
</div>
</div>
<div>
<Button
variant="neutral-primary"
className="cursor-pointer rounded-2xl px-3 py-1.5 text-center text-sm font-medium outline-none"
onClick={handleSubscriptionPageRedirection}
>
{isLoading ? "Redirecting to Stripe..." : "Manage your subscriptions"}
<ExternalLink className="h-3 w-3" strokeWidth={2} />
</Button>
</div>
</div>
</div>
)}
</div>
</section>
);
});

View File

@@ -1 +0,0 @@
export * from "ce/components/pages/editor/embed/issue-embed-upgrade-card";

View File

@@ -0,0 +1,33 @@
import { Crown } from "lucide-react";
// ui
import { Button } from "@plane/ui";
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
export const IssueEmbedUpgradeCard: React.FC<any> = (props) => {
const { toggleProPlanModal } = useWorkspaceSubscription();
return (
<div
className={`${props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full h-[100px] cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs`}
>
<h5 className="h-[20%] text-xs text-custom-text-300 p-2">
{props.node?.attrs?.project_identifier}-{props?.node?.attrs?.sequence_id}
</h5>
<div className="relative h-[71%]">
<div className="h-full backdrop-filter backdrop-blur-[30px] bg-custom-background-80 bg-opacity-30 flex items-center w-full justify-between gap-5 mt-2.5 pl-4 pr-5 py-3 max-md:max-w-full max-md:flex-wrap relative">
<div className="flex gap-2 items-center">
<div className="rounded">
<Crown className="m-2" size={16} color="#FFBA18" />
</div>
<div className="text-custom-text text-sm">
Embed and access issues in pages seamlessly, upgrade to plane pro now.
</div>
</div>
<Button variant="primary" onClick={() => toggleProPlanModal(true)}>
Upgrade
</Button>
</div>
</div>
</div>
);
};

View File

@@ -11,6 +11,7 @@ import { usePage } from "@/hooks/store";
import { PublishPageModal } from "@/plane-web/components/pages";
// plane web hooks
import { usePublishPage } from "@/plane-web/hooks/store";
import { useFlag } from "@/plane-web/hooks/store/use-flag";
export const PageDetailsHeaderExtraActions = observer(() => {
// states
@@ -21,12 +22,15 @@ export const PageDetailsHeaderExtraActions = observer(() => {
const { anchor, isCurrentUserOwner } = usePage(pageId.toString());
const { fetchProjectPagePublishSettings, getPagePublishSettings, publishProjectPage, unpublishProjectPage } =
usePublishPage();
const isPagePublishEnabled = useFlag("PAGE_PUBLISH");
// derived values
const isDeployed = !!anchor;
const pagePublishSettings = getPagePublishSettings(pageId.toString());
const publishLink = `${SPACE_BASE_URL}/pages/${anchor}`;
if (!isPagePublishEnabled) return null;
return (
<>
<PublishPageModal

View File

@@ -1 +1,3 @@
export * from "./root";
export * from "./root";
export * from "./plane-cloud-plans";
export * from "./plan-card";

View File

@@ -0,0 +1,93 @@
import { FC } from "react";
import { BadgeCheck } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
type TPlaneCardVariant = "free" | "one" | "pro" | "enterprise";
type TButtonCallToAction = {
variant: "button";
label: string;
onClick: () => void;
};
type TLinkCallToAction = {
variant: "link";
label: string;
url: string;
};
export type TPlanCard = {
variant: TPlaneCardVariant;
planName: string;
isActive: boolean;
priceDetails: {
price: string;
user?: string;
duration?: string;
};
callToAction: TButtonCallToAction | TLinkCallToAction;
baseFeature?: string;
features: string[];
};
export const PlanCard: FC<TPlanCard> = (props) => {
const { variant, planName, isActive, priceDetails, callToAction, baseFeature, features } = props;
return (
<div className="py-2 px-4">
<div className="p-4 border border-custom-border-200 rounded-xl">
<div className="flex gap-4 items-center justify-between">
<div className="text-xl font-bold">{planName}</div>
{isActive && (
<div className="border-[0.5px] border-custom-primary-100 p-0.5 bg-custom-primary-100/10 text-custom-primary-300 text-xs font-medium rounded">
Current plan
</div>
)}
</div>
<div className="flex gap-4 items-center my-4">
<div className="text-2xl font-semibold">{priceDetails.price}</div>
<div className="flex flex-col w-full text-xs text-custom-text-300 font-medium">
<span className="line-clamp-1">{priceDetails.user}</span>
<span className="line-clamp-2">{priceDetails.duration}</span>
</div>
</div>
<div>
{callToAction.variant === "button" ? (
<button
onClick={callToAction.onClick}
className={cn(
"w-full text-center px-4 py-2 text-base font-semibold text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100 rounded-lg"
)}
>
{callToAction.label}
</button>
) : (
<a
href={callToAction.url}
target="_blank"
className={cn(
"block w-full text-center px-4 py-2 text-base font-semibold text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100 rounded-lg"
)}
>
{callToAction.label}
</a>
)}
</div>
</div>
<div className="py-4">
<div className="text-sm font-semibold pt-2 h-8">{baseFeature}</div>
<ul className="text-sm font-medium">
{features.map((feature) => (
<li key={feature} className="flex gap-2 items-start py-3 w-full h-12">
<span className="flex-shrink-0 pt-0.5">
<BadgeCheck size={14} />
</span>
<span className="line-clamp-2">{feature}</span>
</li>
))}
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,144 @@
import { FC } from "react";
import { observer } from "mobx-react";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme } from "@/hooks/store";
// plane web components
import { PlanCard, TPlanCard } from "@/plane-web/components/workspace/billing";
// plane web hooks
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
const freePlanFeatures = [
"Unlimited projects",
"Unlimited issues",
"Cycles and Modules",
"Layouts + Views",
"Pages",
"Intake",
];
const onePlanFeatures = [
"OIDC and SAML",
"Active Cycles",
"Real-time collab",
"Limited time tracking",
"Linked pages",
"Docker, Kubernetes + more",
];
const proPlanFeatures = [
"Active Cycles + other Cycles features",
"Bulk ops",
"Time tracking",
"Customizable dashboards",
"On-demand reports",
"Shared and public views",
];
const enterprisePlanFeatures = [
"Unlimited Issues",
"Unlimited file upload",
"Priority support",
"Custom Theming",
"Access to Roadmap",
"Plane AI",
];
export const PlaneCloudPlans: FC = observer(() => {
// hooks
const { sidebarCollapsed } = useAppTheme();
const { toggleProPlanModal } = useWorkspaceSubscription();
const planePlans: TPlanCard[] = [
{
variant: "free",
planName: "Free",
isActive: true,
priceDetails: {
price: "$0",
user: "per user",
duration: "per month",
},
callToAction: {
variant: "link",
label: "Start for free",
url: "https://plane.so/pricing",
},
features: freePlanFeatures,
},
{
variant: "one",
planName: "One",
isActive: false,
priceDetails: {
price: "$799",
user: "100 users",
duration: "Two years' support",
},
callToAction: {
variant: "link",
label: "Get One",
url: "https://plane.so/one",
},
baseFeature: "Everything in Free +",
features: onePlanFeatures,
},
{
variant: "pro",
planName: "Pro",
isActive: false,
priceDetails: {
price: "$7",
user: "per user",
duration: "per month",
},
callToAction: {
variant: "button",
label: "Get Pro",
onClick: () => toggleProPlanModal(true),
},
baseFeature: "Everything in One +",
features: proPlanFeatures,
},
{
variant: "enterprise",
planName: "Enterprise",
isActive: false,
priceDetails: {
price: "Custom",
},
callToAction: {
variant: "link",
label: "Talk to Sales",
url: "https://plane.so/contact",
},
baseFeature: "Everything in Pro +",
features: enterprisePlanFeatures,
},
];
return (
<div className="grid grid-cols-12 w-full py-8">
{planePlans.map((plan) => (
<div
key={plan.variant}
className={cn("col-span-12 sm:col-span-6 md:col-span-12 lg:col-span-6", {
"lg:col-span-6 xl:col-span-3": sidebarCollapsed,
"lg:col-span-12 xl:col-span-3": !sidebarCollapsed,
})}
>
<PlanCard
variant={plan.variant}
planName={plan.planName}
isActive={plan.isActive}
priceDetails={plan.priceDetails}
callToAction={plan.callToAction}
baseFeature={plan.baseFeature}
features={plan.features}
/>
</div>
))}
</div>
);
});

View File

@@ -1 +1,38 @@
export * from "ce/components/workspace/upgrade-badge";
import { FC } from "react";
import { observer } from "mobx-react";
// helpers
import { cn } from "@/helpers/common.helper";
// store
import { useWorkspaceSubscription } from "@/plane-web/hooks/store";
type TUpgradeBadge = {
className?: string;
size?: "sm" | "md";
};
export const UpgradeBadge: FC<TUpgradeBadge> = observer((props) => {
const { className, size = "sm" } = props;
// store hooks
const { currentWorkspaceSubscribedPlanDetail } = useWorkspaceSubscription();
// derived values
const isSubscribedToPro = currentWorkspaceSubscribedPlanDetail?.product === "PRO";
if (!currentWorkspaceSubscribedPlanDetail || isSubscribedToPro) {
return null;
}
return (
<div
className={cn(
"w-fit cursor-pointer rounded-2xl text-custom-primary-200 bg-custom-primary-100/20 text-center font-medium outline-none",
{
"text-sm px-3": size === "md",
"text-xs px-2": size === "sm",
},
className
)}
>
Pro
</div>
);
});

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// plane web stores
import { IFeatureFlagsStore } from "@/plane-web/store/feature-flags/feature-flags.store";
export const useFeatureFlags = (): IFeatureFlagsStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useFeatureFlags must be used within StoreProvider");
return context.featureFlags;
};

View File

@@ -0,0 +1,25 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
export enum E_FEATURE_FLAGS {
BULK_OPS = "BULK_OPS",
ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME",
ISSUE_TYPE_DISPLAY = "ISSUE_TYPE_DISPLAY",
ISSUE_TYPE_SETTINGS = "ISSUE_TYPE_SETTINGS",
OIDC_SAML_AUTH = "OIDC_SAML_AUTH",
PAGE_ISSUE_EMBEDS = "PAGE_ISSUE_EMBEDS",
PAGE_PUBLISH = "PAGE_PUBLISH",
VIEW_ACCESS_PRIVATE = "VIEW_ACCESS_PRIVATE",
VIEW_LOCK = "VIEW_LOCK",
VIEW_PUBLISH = "VIEW_PUBLISH",
WORKSPACE_ACTIVE_CYCLES = "WORKSPACE_ACTIVE_CYCLES",
WORKSPACE_PAGES = "WORKSPACE_PAGES",
WORK_LOGS = "WORK_LOGS",
}
export const useFlag = (flag: keyof typeof E_FEATURE_FLAGS, defaultValue: boolean = false): boolean => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useFlag must be used within StoreProvider");
return context.featureFlags.flags[E_FEATURE_FLAGS[flag]] ?? defaultValue;
};

View File

@@ -1 +1,8 @@
export const useBulkOperationStatus = () => true;
// plane web hooks
import { useFlag } from "@/plane-web/hooks/store/use-flag";
export const useBulkOperationStatus = () => {
// store hooks
const isBulkOpsEnabled = useFlag("BULK_OPS");
return isBulkOpsEnabled;
};

View File

@@ -5,13 +5,18 @@ import { TPageEmbedResponse, TPageEmbedType } from "@plane/types";
// ui
import { PriorityIcon } from "@plane/ui";
// plane web components
import { IssueEmbedCard } from "@/plane-web/components/pages";
import { IssueEmbedCard, IssueEmbedUpgradeCard } from "@/plane-web/components/pages";
// plane web hooks
import { useFlag } from "@/plane-web/hooks/store/use-flag";
// services
import { ProjectPageService } from "@/services/page";
const pageService = new ProjectPageService();
export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => {
// store hooks
const isIssueEmbedEnabled = useFlag("PAGE_ISSUE_EMBEDS");
const fetchIssues = async (searchQuery: string): Promise<TEmbedItem[]> => {
const response = await pageService.searchEmbed<TPageEmbedResponse[]>(workspaceSlug, projectId, {
query_type: queryType,
@@ -52,6 +57,8 @@ export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryTyp
return <IssueEmbedCard issueId={issueId} projectId={resolvedProjectId} workspaceSlug={resolvedWorkspaceSlug} />;
};
const upgradeCallback = () => <IssueEmbedUpgradeCard />;
const issueEmbedProps: TEmbedConfig["issue"] = {
searchCallback,
widgetCallback,
@@ -61,8 +68,23 @@ export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryTyp
widgetCallback,
};
const issueEmbedUpgradeProps: TEmbedConfig["issue"] = {
widgetCallback: upgradeCallback,
};
const issueEmbedReadOnlyUpgradeProps: TReadOnlyEmbedConfig["issue"] = {
widgetCallback: upgradeCallback,
};
if (isIssueEmbedEnabled) {
return {
issueEmbedProps,
issueEmbedReadOnlyProps,
};
}
return {
issueEmbedProps,
issueEmbedReadOnlyProps,
issueEmbedProps: issueEmbedUpgradeProps,
issueEmbedReadOnlyProps: issueEmbedReadOnlyUpgradeProps,
};
};

View File

@@ -0,0 +1,22 @@
// services
import { APIService } from "@/services/api.service";
export type TFeatureFlagsResponse = {
values: {
[featureFlag: string]: boolean;
};
};
export class FeatureFlagService extends APIService {
constructor() {
super("");
}
async getFeatureFlags(data = {}): Promise<TFeatureFlagsResponse> {
return this.post(`/flags/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -9,7 +9,7 @@ export class PaymentService extends APIService {
super(API_BASE_URL);
}
listProducts(workspaceSlug: string): Promise<IPaymentProduct[]> {
async listProducts(workspaceSlug: string): Promise<IPaymentProduct[]> {
return this.get(`/api/payments/workspaces/${workspaceSlug}/products/`)
.then((response) => response?.data)
.catch((error) => {
@@ -17,7 +17,7 @@ export class PaymentService extends APIService {
});
}
getCurrentWorkspacePaymentLink(workspaceSlug: string, data = {}) {
async getCurrentWorkspacePaymentLink(workspaceSlug: string, data = {}) {
return this.post(`/api/payments/workspaces/${workspaceSlug}/payment-link/`, data)
.then((response) => response?.data)
.catch((error) => {
@@ -25,7 +25,7 @@ export class PaymentService extends APIService {
});
}
getWorkspaceCurrentPlane(workspaceSlug: string): Promise<IWorkspaceProductSubscription> {
async getWorkspaceCurrentPlan(workspaceSlug: string): Promise<IWorkspaceProductSubscription> {
return this.get(`/api/payments/workspaces/${workspaceSlug}/current-plan/`)
.then((response) => response?.data)
.catch((error) => {
@@ -33,6 +33,14 @@ export class PaymentService extends APIService {
});
}
async getWorkspaceSubscriptionPageLink(workspaceSlug: string) {
return this.post(`/api/payments/workspaces/${workspaceSlug}/subscriptions/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getPaymentLink(data = {}) {
return this.post(`/api/payments/website/payment-link/`, data)
.then((response) => response?.data)

View File

@@ -0,0 +1,50 @@
import { set } from "lodash";
import { action, makeObservable, observable, runInAction } from "mobx";
// services
import { FeatureFlagService, TFeatureFlagsResponse } from "@/plane-web/services/feature-flag.service";
// plane web store
const featureFlagService = new FeatureFlagService();
type TFeatureFlagsMaps = {
[featureFlag: string]: boolean;
};
export interface IFeatureFlagsStore {
loader: boolean;
flags: TFeatureFlagsMaps;
fetchFeatureFlags: (workspaceSlug: string, userId: string) => Promise<TFeatureFlagsResponse>;
}
export class FeatureFlagsStore implements IFeatureFlagsStore {
loader = false;
flags: TFeatureFlagsMaps = {};
constructor() {
makeObservable(this, {
loader: observable.ref,
flags: observable,
fetchFeatureFlags: action,
});
}
fetchFeatureFlags = async (workspaceSlug: string, userId: string) => {
try {
set(this, "loader", true);
const response = await featureFlagService.getFeatureFlags({ workspace_slug: workspaceSlug, user_id: userId });
runInAction(() => {
if (response.values) {
Object.keys(response.values).forEach((key) => {
set(this.flags, key, response.values[key]);
});
}
set(this, "loader", false);
});
return response;
} catch (error) {
set(this, "loader", false);
console.error("Error fetching feature flags", error);
throw error;
}
};
}

View File

@@ -1,23 +1,26 @@
// plane web store
import { FeatureFlagsStore, IFeatureFlagsStore } from "@/plane-web/store/feature-flags/feature-flags.store";
import { IPublishPageStore, PublishPageStore } from "@/plane-web/store/pages/publish-page.store";
import { IWorkspacePageStore, WorkspacePageStore } from "@/plane-web/store/pages/workspace-page.store";
// store
import {
IWorkspaceSubscriptionStore,
WorkspaceSubscriptionStore,
} from "@/plane-web/store/subscription/subscription.store";
// store
import { CoreRootStore } from "@/store/root.store";
export class RootStore extends CoreRootStore {
workspacePages: IWorkspacePageStore;
publishPage: IPublishPageStore;
workspaceSubscription: IWorkspaceSubscriptionStore;
featureFlags: IFeatureFlagsStore;
constructor() {
super();
this.workspacePages = new WorkspacePageStore(this);
this.publishPage = new PublishPageStore(this);
this.workspaceSubscription = new WorkspaceSubscriptionStore(this);
this.featureFlags = new FeatureFlagsStore();
}
resetOnSignOut() {
@@ -25,5 +28,6 @@ export class RootStore extends CoreRootStore {
this.workspacePages = new WorkspacePageStore(this);
this.publishPage = new PublishPageStore(this);
this.workspaceSubscription = new WorkspaceSubscriptionStore(this);
this.featureFlags = new FeatureFlagsStore();
}
}

View File

@@ -1,7 +1,7 @@
import { set } from "lodash";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
// types
import { IWorkspaceProductSubscription, TProductSubscriptionType } from "@plane/types";
import { IWorkspaceProductSubscription } from "@plane/types";
// services
import { PaymentService } from "@/plane-web/services/payment.service";
// plane web store
@@ -10,41 +10,56 @@ import { RootStore } from "@/plane-web/store/root.store";
const paymentService = new PaymentService();
type TWorkspaceSubscriptionMap = {
[workspaceSlug: string]: TProductSubscriptionType;
[workspaceSlug: string]: IWorkspaceProductSubscription;
};
export interface IWorkspaceSubscriptionStore {
subscribedPlan: TWorkspaceSubscriptionMap;
currentWorkspaceSubscribedPlan: TProductSubscriptionType | undefined;
isProPlanModalOpen: boolean;
currentWorkspaceSubscribedPlanDetail: IWorkspaceProductSubscription | undefined;
toggleProPlanModal: (value?: boolean) => void;
fetchWorkspaceSubscribedPlan: (workspaceSlug: string) => Promise<IWorkspaceProductSubscription>;
}
export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore {
subscribedPlan: TWorkspaceSubscriptionMap = {};
isProPlanModalOpen = false;
constructor(private rootStore: RootStore) {
makeObservable(this, {
subscribedPlan: observable,
currentWorkspaceSubscribedPlan: computed,
isProPlanModalOpen: observable.ref,
currentWorkspaceSubscribedPlanDetail: computed,
toggleProPlanModal: action,
fetchWorkspaceSubscribedPlan: action,
});
}
get currentWorkspaceSubscribedPlan() {
get currentWorkspaceSubscribedPlanDetail() {
if (!this.rootStore.router.workspaceSlug) return undefined;
return this.subscribedPlan[this.rootStore.router.workspaceSlug] || undefined;
}
toggleProPlanModal = (value?: boolean) => {
this.isProPlanModalOpen = value ?? !this.isProPlanModalOpen;
};
fetchWorkspaceSubscribedPlan = async (workspaceSlug: string) => {
try {
const response = await paymentService.getWorkspaceCurrentPlane(workspaceSlug);
const response = await paymentService.getWorkspaceCurrentPlan(workspaceSlug);
runInAction(() => {
set(this.subscribedPlan, workspaceSlug, response?.product || "FREE");
set(this.subscribedPlan, workspaceSlug, {
product: response?.product || "FREE",
current_period_end_date: response?.current_period_end_date,
});
});
return response;
} catch (error) {
runInAction(() => {
set(this.subscribedPlan, workspaceSlug, "FREE");
set(this.subscribedPlan, workspaceSlug, {
product: "FREE",
current_period_end_date: null,
});
});
throw error;
}

View File

@@ -81,6 +81,15 @@ const nextConfig = {
destination: `${GOD_MODE_BASE_URL}/:path*`,
});
}
if (process.env.NEXT_PUBLIC_FEATURE_FLAG_SERVER_BASE_URL) {
const FEATURE_FLAG_SERVER_BASE_URL = process.env.NEXT_PUBLIC_FEATURE_FLAG_SERVER_BASE_URL;
rewrites.push({
source: "/flags/",
destination: `${FEATURE_FLAG_SERVER_BASE_URL}/api/feature-flags/`,
});
}
return rewrites;
},
};

1951
yarn.lock

File diff suppressed because it is too large Load Diff