diff --git a/.github/workflows/Build_Test_Pull_Request.yml b/.github/workflows/build-test-pull-request.yml similarity index 100% rename from .github/workflows/Build_Test_Pull_Request.yml rename to .github/workflows/build-test-pull-request.yml diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml new file mode 100644 index 000000000..28e47a0d6 --- /dev/null +++ b/.github/workflows/create-sync-pr.yml @@ -0,0 +1,77 @@ +name: Create PR in Plane EE Repository to sync the changes + +on: + pull_request: + types: + - closed + +jobs: + create_pr: + # Only run the job when a PR is merged + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - name: Check SOURCE_REPO + id: check_repo + env: + SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }} + run: | + echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)" + + - name: Checkout Code + if: steps.check_repo.outputs.is_correct_repo == 'true' + uses: actions/checkout@v2 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Branch Name + if: steps.check_repo.outputs.is_correct_repo == 'true' + run: | + echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV + + - name: Setup GH CLI + if: steps.check_repo.outputs.is_correct_repo == 'true' + run: | + type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + + - name: Create Pull Request + if: steps.check_repo.outputs.is_correct_repo == 'true' + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: | + TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}" + TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}" + SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" + + git checkout $SOURCE_BRANCH + git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target $SOURCE_BRANCH:$SOURCE_BRANCH + + PR_TITLE="${{ github.event.pull_request.title }}" + PR_BODY="${{ github.event.pull_request.body }}" + + # Remove double quotes + PR_TITLE_CLEANED="${PR_TITLE//\"/}" + PR_BODY_CLEANED="${PR_BODY//\"/}" + + # Construct PR_BODY_CONTENT using a here-document + PR_BODY_CONTENT=$(cat < If running in a cloud env replace localhost with public facing IP address of the VM diff --git a/apiserver/.env.example b/apiserver/.env.example index 4969f1766..8193b5e77 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,7 +1,7 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" +DJANGO_SETTINGS_MODULE="plane.settings.production" # Error logs SENTRY_DSN="" @@ -59,3 +59,14 @@ DEFAULT_PASSWORD="password123" # SignUps ENABLE_SIGNUP="1" + + +# Enable Email/Password Signup +ENABLE_EMAIL_PASSWORD="1" + +# Enable Magic link Login +ENABLE_MAGIC_LINK_LOGIN="0" + +# Email redirections and minio domain settings +WEB_URL="http://localhost" + diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index c10c4a745..2213c0d9d 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -70,6 +70,7 @@ from plane.api.views import ( ProjectIdentifierEndpoint, ProjectFavoritesViewSet, LeaveProjectEndpoint, + ProjectPublicCoverImagesEndpoint, ## End Projects # Issues IssueViewSet, @@ -150,12 +151,11 @@ from plane.api.views import ( GlobalSearchEndpoint, IssueSearchEndpoint, ## End Search - # Gpt + # External GPTIntegrationEndpoint, - ## End Gpt - # Release Notes ReleaseNotesEndpoint, - ## End Release Notes + UnsplashEndpoint, + ## End External # Inbox InboxViewSet, InboxIssueViewSet, @@ -186,6 +186,9 @@ from plane.api.views import ( ## Exporter ExportIssuesEndpoint, ## End Exporter + # Configuration + ConfigurationEndpoint, + ## End Configuration ) @@ -573,6 +576,11 @@ urlpatterns = [ LeaveProjectEndpoint.as_view(), name="project", ), + path( + "project-covers/", + ProjectPublicCoverImagesEndpoint.as_view(), + name="project-covers", + ), # End Projects # States path( @@ -1446,20 +1454,23 @@ urlpatterns = [ name="project-issue-search", ), ## End Search - # Gpt + # External path( "workspaces//projects//ai-assistant/", GPTIntegrationEndpoint.as_view(), name="importer", ), - ## End Gpt - # Release Notes path( "release-notes/", ReleaseNotesEndpoint.as_view(), name="release-notes", ), - ## End Release Notes + path( + "unsplash/", + UnsplashEndpoint.as_view(), + name="release-notes", + ), + ## End External # Inbox path( "workspaces//projects//inboxes/", @@ -1728,4 +1739,11 @@ urlpatterns = [ name="workspace-project-boards", ), ## End Public Boards + # Configuration + path( + "configs/", + ConfigurationEndpoint.as_view(), + name="configuration", + ), + ## End Configuration ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index c03d6d5b7..f7ad735c1 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -17,6 +17,7 @@ from .project import ( ProjectMemberEndpoint, WorkspaceProjectDeployBoardEndpoint, LeaveProjectEndpoint, + ProjectPublicCoverImagesEndpoint, ) from .user import ( UserEndpoint, @@ -147,16 +148,13 @@ from .page import ( from .search import GlobalSearchEndpoint, IssueSearchEndpoint -from .gpt import GPTIntegrationEndpoint +from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint from .estimate import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ) - -from .release import ReleaseNotesEndpoint - from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet from .analytic import ( @@ -169,4 +167,6 @@ from .analytic import ( from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet -from .exporter import ExportIssuesEndpoint \ No newline at end of file +from .exporter import ExportIssuesEndpoint + +from .config import ConfigurationEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py new file mode 100644 index 000000000..ea1b39d9c --- /dev/null +++ b/apiserver/plane/api/views/config.py @@ -0,0 +1,40 @@ +# Python imports +import os + +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework.permissions import AllowAny +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseAPIView + + +class ConfigurationEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + try: + data = {} + data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None) + data["github"] = os.environ.get("GITHUB_CLIENT_ID", None) + data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) + data["magic_login"] = ( + bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) + ) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1" + data["email_password_login"] = ( + os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1" + ) + return Response(data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/external.py similarity index 62% rename from apiserver/plane/api/views/gpt.py rename to apiserver/plane/api/views/external.py index 63c3f4f18..00a0270e4 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/external.py @@ -2,9 +2,10 @@ import requests # Third party imports +import openai from rest_framework.response import Response from rest_framework import status -import openai +from rest_framework.permissions import AllowAny from sentry_sdk import capture_exception # Django imports @@ -15,6 +16,7 @@ from .base import BaseAPIView from plane.api.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +from plane.utils.integrations.github import get_release_notes class GPTIntegrationEndpoint(BaseAPIView): @@ -73,3 +75,44 @@ class GPTIntegrationEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ReleaseNotesEndpoint(BaseAPIView): + def get(self, request): + try: + release_notes = get_release_notes() + return Response(release_notes, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class UnsplashEndpoint(BaseAPIView): + + def get(self, request): + try: + query = request.GET.get("query", False) + page = request.GET.get("page", 1) + per_page = request.GET.get("per_page", 20) + + url = ( + f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" + if query + else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" + ) + + headers = { + "Content-Type": "application/json", + } + + resp = requests.get(url=url, headers=headers) + return Response(resp.json(), status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 29f14e437..b5a62dd5d 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -712,10 +712,18 @@ class LabelViewSet(BaseViewSet): ProjectMemberPermission, ] - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - ) + def create(self, request, slug, project_id): + try: + serializer = LabelSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response({"error": "Label with the same name already exists in the project"}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST) def get_queryset(self): return self.filter_queryset( diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index c72b8d423..1ba227177 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,5 +1,6 @@ # Python imports import jwt +import boto3 from datetime import datetime # Django imports @@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet): serializer_class = ProjectMemberAdminSerializer model = ProjectMember permission_classes = [ - ProjectBasePermission, + ProjectMemberPermission, ] search_fields = [ @@ -617,7 +618,8 @@ class ProjectMemberViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) except ProjectMember.DoesNotExist: return Response( - {"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Project Member does not exist"}, + status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: capture_exception(e) @@ -1209,3 +1211,38 @@ class LeaveProjectEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ProjectPublicCoverImagesEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + try: + files = [] + s3 = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + params = { + "Bucket": settings.AWS_S3_BUCKET_NAME, + "Prefix": "static/project-cover/", + } + + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) + + return Response(files, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response([], status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/release.py b/apiserver/plane/api/views/release.py deleted file mode 100644 index de827c896..000000000 --- a/apiserver/plane/api/views/release.py +++ /dev/null @@ -1,21 +0,0 @@ -# Third party imports -from rest_framework.response import Response -from rest_framework import status -from sentry_sdk import capture_exception - -# Module imports -from .base import BaseAPIView -from plane.utils.integrations.github import get_release_notes - - -class ReleaseNotesEndpoint(BaseAPIView): - def get(self, request): - try: - release_notes = get_release_notes() - return Response(release_notes, status=status.HTTP_200_OK) - except Exception as e: - capture_exception(e) - return Response( - {"error": "Something went wrong please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 753fd861b..8d518b160 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): projects = request.query_params.getlist("project", []) queryset = IssueActivity.objects.filter( - ~Q(field__in=["comment", "vote", "reaction"]), + ~Q(field__in=["comment", "vote", "reaction", "draft"]), workspace__slug=slug, project__project_projectmember__member=request.user, actor=user_id, diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py index 950189c55..5a806c704 100644 --- a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -33,9 +33,8 @@ def create_issue_relation(apps, schema_editor): def update_issue_priority_choice(apps, schema_editor): IssueModel = apps.get_model("db", "Issue") updated_issues = [] - for obj in IssueModel.objects.all(): - if obj.priority is None: - obj.priority = "none" + for obj in IssueModel.objects.filter(priority=None): + obj.priority = "none" updated_issues.append(obj) IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100) diff --git a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py deleted file mode 100644 index cd9aa6902..000000000 --- a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 4.2.3 on 2023-09-15 06:55 - -from django.db import migrations, models -from django.conf import settings -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - dependencies = [ - ("db", "0044_auto_20230913_0709"), - ] - - operations = [ - migrations.CreateModel( - name="GlobalView", - fields=[ - ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At"),), - ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),), - ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True,),), - ("name", models.CharField(max_length=255, verbose_name="View Name")), - ("description", models.TextField(blank=True, verbose_name="View Description"),), - ("query", models.JSONField(verbose_name="View Query")), - ("access", models.PositiveSmallIntegerField(choices=[(0, "Private"), (1, "Public")], default=1),), - ("query_data", models.JSONField(default=dict)), - ("created_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="%(class)s_created_by", to=settings.AUTH_USER_MODEL, verbose_name="Created By",),), - ("updated_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="%(class)s_updated_by", to=settings.AUTH_USER_MODEL, verbose_name="Last Modified By",),), - ("workspace", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="global_views", to="db.workspace",),), - ], - options={ - "verbose_name": "Global View", - "verbose_name_plural": "Global Views", - "db_table": "global_views", - "ordering": ("-created_at",), - }, - ), - migrations.AddField( - model_name="issueactivity", - name="epoch", - field=models.FloatField(null=True), - ), - ] diff --git a/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py new file mode 100644 index 000000000..4b9c1b1eb --- /dev/null +++ b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.5 on 2023-09-29 10:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.workspace +import uuid + + +def update_issue_activity_priority(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.filter(field="priority"): + # Set the old and new value to none if it is empty for Priority + obj.new_value = obj.new_value or "none" + obj.old_value = obj.old_value or "none" + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value"], + batch_size=2000, + ) + +def update_issue_activity_blocked(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.filter(field="blocks"): + # Set the field to blocked_by + obj.field = "blocked_by" + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["field"], + batch_size=1000, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0044_auto_20230913_0709'), + ] + + operations = [ + migrations.CreateModel( + name='GlobalView', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255, verbose_name='View Name')), + ('description', models.TextField(blank=True, verbose_name='View Description')), + ('query', models.JSONField(verbose_name='View Query')), + ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), + ('query_data', models.JSONField(default=dict)), + ('sort_order', models.FloatField(default=65535)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')), + ], + options={ + 'verbose_name': 'Global View', + 'verbose_name_plural': 'Global Views', + 'db_table': 'global_views', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='workspacemember', + name='issue_props', + field=models.JSONField(default=plane.db.models.workspace.get_issue_props), + ), + migrations.AddField( + model_name='issueactivity', + name='epoch', + field=models.FloatField(null=True), + ), + migrations.RunPython(update_issue_activity_priority), + migrations.RunPython(update_issue_activity_blocked), + ] diff --git a/apiserver/plane/db/migrations/0046_auto_20230926_1015.py b/apiserver/plane/db/migrations/0046_auto_20230926_1015.py deleted file mode 100644 index 8bce37d95..000000000 --- a/apiserver/plane/db/migrations/0046_auto_20230926_1015.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.5 on 2023-09-26 10:15 - -from django.db import migrations - - -def update_issue_activity(apps, schema_editor): - IssueActivity = apps.get_model("db", "IssueActivity") - updated_issue_activity = [] - for obj in IssueActivity.objects.all(): - obj.epoch = int(obj.created_at.timestamp()) - updated_issue_activity.append(obj) - IssueActivity.objects.bulk_update( - updated_issue_activity, - ["epoch"], - batch_size=5000, - ) - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0045_auto_20230915_0655'), - ] - - operations = [ - migrations.RunPython(update_issue_activity), - ] diff --git a/apiserver/plane/db/migrations/0047_auto_20230926_1029.py b/apiserver/plane/db/migrations/0047_auto_20230926_1029.py deleted file mode 100644 index da64e11c8..000000000 --- a/apiserver/plane/db/migrations/0047_auto_20230926_1029.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 4.2.5 on 2023-09-26 10:29 - -from django.db import migrations - - -def update_issue_activity_priority(apps, schema_editor): - IssueActivity = apps.get_model("db", "IssueActivity") - updated_issue_activity = [] - for obj in IssueActivity.objects.filter(field="priority"): - # Set the old and new value to none if it is empty for Priority - obj.new_value = obj.new_value or "none" - obj.old_value = obj.old_value or "none" - updated_issue_activity.append(obj) - IssueActivity.objects.bulk_update( - updated_issue_activity, - ["new_value", "old_value"], - batch_size=1000, - ) - -def update_issue_activity_blocked(apps, schema_editor): - IssueActivity = apps.get_model("db", "IssueActivity") - updated_issue_activity = [] - for obj in IssueActivity.objects.filter(field="blocks"): - # Set the field to blocked_by - obj.field = "blocked_by" - updated_issue_activity.append(obj) - IssueActivity.objects.bulk_update( - updated_issue_activity, - ["field"], - batch_size=1000, - ) - - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0046_auto_20230926_1015'), - ] - - operations = [ - migrations.RunPython(update_issue_activity_priority), - migrations.RunPython(update_issue_activity_blocked), - ] diff --git a/apiserver/plane/db/migrations/0048_globalview_sort_order_workspacemember_issue_props_and_more.py b/apiserver/plane/db/migrations/0048_globalview_sort_order_workspacemember_issue_props_and_more.py deleted file mode 100644 index 3084ef637..000000000 --- a/apiserver/plane/db/migrations/0048_globalview_sort_order_workspacemember_issue_props_and_more.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.5 on 2023-09-27 11:18 - -from django.db import migrations, models -import plane.db.models.workspace - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0047_auto_20230926_1029'), - ] - - operations = [ - migrations.AddField( - model_name='globalview', - name='sort_order', - field=models.FloatField(default=65535), - ), - migrations.AddField( - model_name='workspacemember', - name='issue_props', - field=models.JSONField(default=plane.db.models.workspace.get_issue_props), - ), - ] diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 9d293c019..6f4833a6c 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -114,3 +114,6 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL") GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index e434f9742..9c6bd95a9 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -7,6 +7,7 @@ import dj_database_url import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration +from urllib.parse import urlparse from .common import * # noqa @@ -89,90 +90,112 @@ if bool(os.environ.get("SENTRY_DSN", False)): profiles_sample_rate=1.0, ) -# The AWS region to connect to. -AWS_REGION = os.environ.get("AWS_REGION", "") +if DOCKERIZED and USE_MINIO: + INSTALLED_APPS += ("storages",) + STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} + # The AWS access key to use. + AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") + # The AWS secret access key to use. + AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") + # The name of the bucket to store files in. + AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") + # The full URL to the S3 endpoint. Leave blank to use the default region URL. + AWS_S3_ENDPOINT_URL = os.environ.get( + "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" + ) + # Default permissions + AWS_DEFAULT_ACL = "public-read" + AWS_QUERYSTRING_AUTH = False + AWS_S3_FILE_OVERWRITE = False -# The AWS access key to use. -AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") + # Custom Domain settings + parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) + AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" + AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" +else: + # The AWS region to connect to. + AWS_REGION = os.environ.get("AWS_REGION", "") -# The AWS secret access key to use. -AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") + # The AWS access key to use. + AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") -# The optional AWS session token to use. -# AWS_SESSION_TOKEN = "" + # The AWS secret access key to use. + AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") -# The name of the bucket to store files in. -AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") + # The optional AWS session token to use. + # AWS_SESSION_TOKEN = "" -# How to construct S3 URLs ("auto", "path", "virtual"). -AWS_S3_ADDRESSING_STYLE = "auto" + # The name of the bucket to store files in. + AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") -# The full URL to the S3 endpoint. Leave blank to use the default region URL. -AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") + # How to construct S3 URLs ("auto", "path", "virtual"). + AWS_S3_ADDRESSING_STYLE = "auto" -# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. -AWS_S3_KEY_PREFIX = "" + # The full URL to the S3 endpoint. Leave blank to use the default region URL. + AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") -# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication -# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, -# and their permissions will be set to "public-read". -AWS_S3_BUCKET_AUTH = False + # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. + AWS_S3_KEY_PREFIX = "" -# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` -# is True. It also affects the "Cache-Control" header of the files. -# Important: Changing this setting will not affect existing files. -AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. + # Whether to enable authentication for stored files. If True, then generated URLs will include an authentication + # token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, + # and their permissions will be set to "public-read". + AWS_S3_BUCKET_AUTH = False -# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting -# cannot be used with `AWS_S3_BUCKET_AUTH`. -AWS_S3_PUBLIC_URL = "" + # How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` + # is True. It also affects the "Cache-Control" header of the files. + # Important: Changing this setting will not affect existing files. + AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. -# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you -# understand the consequences before enabling. -# Important: Changing this setting will not affect existing files. -AWS_S3_REDUCED_REDUNDANCY = False + # A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting + # cannot be used with `AWS_S3_BUCKET_AUTH`. + AWS_S3_PUBLIC_URL = "" -# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_CONTENT_DISPOSITION = "" + # If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you + # understand the consequences before enabling. + # Important: Changing this setting will not affect existing files. + AWS_S3_REDUCED_REDUNDANCY = False -# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_CONTENT_LANGUAGE = "" + # The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_CONTENT_DISPOSITION = "" -# A mapping of custom metadata for each file. Each value can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_METADATA = {} + # The Content-Language header used when the file is downloaded. This can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_CONTENT_LANGUAGE = "" -# If True, then files will be stored using AES256 server-side encryption. -# If this is a string value (e.g., "aws:kms"), that encryption type will be used. -# Otherwise, server-side encryption is not be enabled. -# Important: Changing this setting will not affect existing files. -AWS_S3_ENCRYPT_KEY = False + # A mapping of custom metadata for each file. Each value can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_METADATA = {} -# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. -# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). -# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" + # If True, then files will be stored using AES256 server-side encryption. + # If this is a string value (e.g., "aws:kms"), that encryption type will be used. + # Otherwise, server-side encryption is not be enabled. + # Important: Changing this setting will not affect existing files. + AWS_S3_ENCRYPT_KEY = False -# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their -# compressed size is smaller than their uncompressed size. -# Important: Changing this setting will not affect existing files. -AWS_S3_GZIP = True + # The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. + # This is only relevant if AWS S3 KMS server-side encryption is enabled (above). + # AWS_S3_KMS_ENCRYPTION_KEY_ID = "" -# The signature version to use for S3 requests. -AWS_S3_SIGNATURE_VERSION = None + # If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their + # compressed size is smaller than their uncompressed size. + # Important: Changing this setting will not affect existing files. + AWS_S3_GZIP = True -# If True, then files with the same name will overwrite each other. By default it's set to False to have -# extra characters appended. -AWS_S3_FILE_OVERWRITE = False + # The signature version to use for S3 requests. + AWS_S3_SIGNATURE_VERSION = None -STORAGES["default"] = { - "BACKEND": "django_s3_storage.storage.S3Storage", -} + # If True, then files with the same name will overwrite each other. By default it's set to False to have + # extra characters appended. + AWS_S3_FILE_OVERWRITE = False + STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", + } # AWS Settings End # Enable Connection Pooling (if desired) @@ -193,16 +216,27 @@ CSRF_COOKIE_SECURE = True REDIS_URL = os.environ.get("REDIS_URL") -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, - }, +if DOCKERIZED: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } + } +else: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } } -} WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so") @@ -225,8 +259,12 @@ broker_url = ( f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" ) -CELERY_RESULT_BACKEND = broker_url -CELERY_BROKER_URL = broker_url +if DOCKERIZED: + CELERY_BROKER_URL = REDIS_URL + CELERY_RESULT_BACKEND = REDIS_URL +else: + CELERY_BROKER_URL = broker_url + CELERY_RESULT_BACKEND = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) @@ -238,3 +276,6 @@ SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") + diff --git a/apiserver/plane/settings/selfhosted.py b/apiserver/plane/settings/selfhosted.py index 948ba22da..ee529a7c3 100644 --- a/apiserver/plane/settings/selfhosted.py +++ b/apiserver/plane/settings/selfhosted.py @@ -126,3 +126,4 @@ ANALYTICS_BASE_API = False OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") + diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 5e274f8f3..f776afd91 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -218,3 +218,7 @@ CELERY_BROKER_URL = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/package.json b/package.json index a0e58018a..ba0fd9a61 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", + "version": "0.13.2", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/editor/core/src/ui/menus/table-menu/index.tsx b/packages/editor/core/src/ui/menus/table-menu/index.tsx index fc84fd069..c115196db 100644 --- a/packages/editor/core/src/ui/menus/table-menu/index.tsx +++ b/packages/editor/core/src/ui/menus/table-menu/index.tsx @@ -75,8 +75,6 @@ export const TableMenu = ({ editor }: { editor: any }) => { const range = selection.getRangeAt(0); const tableNode = findTableAncestor(range.startContainer); - let parent = tableNode?.parentElement; - if (tableNode) { const tableRect = tableNode.getBoundingClientRect(); const tableCenter = tableRect.left + tableRect.width / 2; @@ -85,18 +83,6 @@ export const TableMenu = ({ editor }: { editor: any }) => { const tableBottom = tableRect.bottom; setTableLocation({ bottom: tableBottom, left: menuLeft }); - - while (parent) { - if (!parent.classList.contains("disable-scroll")) - parent.classList.add("disable-scroll"); - parent = parent.parentElement; - } - } else { - const scrollDisabledContainers = document.querySelectorAll(".disable-scroll"); - - scrollDisabledContainers.forEach((container) => { - container.classList.remove("disable-scroll"); - }); } } }; @@ -110,13 +96,9 @@ export const TableMenu = ({ editor }: { editor: any }) => { return (
{items.map((item, index) => ( diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 16fed7a78..12a7ab8c8 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-custom", - "version": "0.0.0", + "version": "0.13.2", "main": "index.js", "license": "MIT", "dependencies": { diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index b6c3ec614..1336379b7 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.0.1", + "version": "0.13.2", "description": "common tailwind configuration across monorepo", "main": "index.js", "devDependencies": { diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index f4810fc3f..58bfb8451 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.0.0", + "version": "0.13.2", "private": true, "files": [ "base.json", diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 000000000..ce447bf1c --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1 @@ +# UI Package diff --git a/packages/ui/package.json b/packages/ui/package.json index 6a9132fca..d107e711c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "0.0.0", + "version": "0.13.2", "main": "./index.tsx", "types": "./index.tsx", "license": "MIT", diff --git a/space/components/accounts/github-login-button.tsx b/space/components/accounts/github-login-button.tsx index e9b30ab73..b1bd586fe 100644 --- a/space/components/accounts/github-login-button.tsx +++ b/space/components/accounts/github-login-button.tsx @@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg"; export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); @@ -38,7 +41,7 @@ export const GithubLoginButton: FC = ({ handleSignIn })
- {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( - <> -

- Sign in to Plane -

-
-
- -
-
- - {/* */} -
+

Sign in to Plane

+ {data?.email_password_login && } + + {data?.magic_login && ( +
+
+
- - ) : ( - +
)} - {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

- ) : null} +
+ {data?.google && } +
+ +

+ By signing up, you agree to the{" "} + + Terms & Conditions + +

diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 509d676b7..03f082f33 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -44,19 +44,43 @@ const IssueNavbar = observer(() => { }, [projectStore, workspace_slug, project_slug]); useEffect(() => { - if (workspace_slug && project_slug) { - if (!board) { - router.push({ - pathname: `/${workspace_slug}/${project_slug}`, - query: { - board: "list", - }, - }); - return projectStore.setActiveBoard("list"); + if (workspace_slug && project_slug && projectStore?.deploySettings) { + const viewsAcceptable: string[] = []; + let currentBoard: string | null = null; + + if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list"); + if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban"); + if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar"); + if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt"); + if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); + + if (board) { + if (viewsAcceptable.includes(board.toString())) { + currentBoard = board.toString(); + } else { + if (viewsAcceptable && viewsAcceptable.length > 0) { + currentBoard = viewsAcceptable[0]; + } + } + } else { + if (viewsAcceptable && viewsAcceptable.length > 0) { + currentBoard = viewsAcceptable[0]; + } + } + + if (currentBoard) { + if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) { + projectStore.setActiveBoard(currentBoard); + router.push({ + pathname: `/${workspace_slug}/${project_slug}`, + query: { + board: currentBoard, + }, + }); + } } - projectStore.setActiveBoard(board.toString()); } - }, [board, workspace_slug, project_slug]); + }, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]); return (
@@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
) : (
- + Sign in diff --git a/space/components/views/login.tsx b/space/components/views/login.tsx index d01a22681..406d6be98 100644 --- a/space/components/views/login.tsx +++ b/space/components/views/login.tsx @@ -7,7 +7,13 @@ import { SignInView, UserLoggedIn } from "components/accounts"; export const LoginView = observer(() => { const { user: userStore } = useMobxStore(); - if (!userStore.currentUser) return ; - - return ; + return ( + <> + {userStore?.loader ? ( +
Loading
+ ) : ( + <>{userStore.currentUser ? : } + )} + + ); }); diff --git a/space/lib/mobx/store-init.tsx b/space/lib/mobx/store-init.tsx index 6e38d9c6d..4fc761ad1 100644 --- a/space/lib/mobx/store-init.tsx +++ b/space/lib/mobx/store-init.tsx @@ -3,12 +3,14 @@ import { useEffect } from "react"; // next imports import { useRouter } from "next/router"; +// js cookie +import Cookie from "js-cookie"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; const MobxStoreInit = () => { - const store: RootStore = useMobxStore(); + const { user: userStore }: RootStore = useMobxStore(); const router = useRouter(); const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] }; @@ -19,6 +21,11 @@ const MobxStoreInit = () => { // store.issue.userSelectedStates = states || []; // }, [store.issue]); + useEffect(() => { + const authToken = Cookie.get("accessToken") || null; + if (authToken) userStore.fetchCurrentUser(); + }, [userStore]); + return <>; }; diff --git a/space/package.json b/space/package.json index da1dfac8b..86248e2ac 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.0.1", + "version": "0.13.2", "private": true, "scripts": { "dev": "turbo run develop", diff --git a/space/pages/index.tsx b/space/pages/index.tsx new file mode 100644 index 000000000..1ff239253 --- /dev/null +++ b/space/pages/index.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +// next +import { NextPage } from "next"; +import { useRouter } from "next/router"; + +const Index: NextPage = () => { + const router = useRouter(); + const { next_path } = router.query as { next_path: string }; + + useEffect(() => { + if (next_path) router.push(`/login?next_path=${next_path}`); + else router.push(`/login`); + }, [router, next_path]); + + return null; +}; + +export default Index; diff --git a/space/pages/login/index.tsx b/space/pages/login/index.tsx index a80eff873..9f20f099f 100644 --- a/space/pages/login/index.tsx +++ b/space/pages/login/index.tsx @@ -5,4 +5,4 @@ import { LoginView } from "components/views"; const LoginPage = () => ; -export default LoginPage; \ No newline at end of file +export default LoginPage; diff --git a/space/services/app-config.service.ts b/space/services/app-config.service.ts new file mode 100644 index 000000000..713cda3da --- /dev/null +++ b/space/services/app-config.service.ts @@ -0,0 +1,30 @@ +// services +import APIService from "services/api.service"; +// helper +import { API_BASE_URL } from "helpers/common.helper"; + +export interface IEnvConfig { + github: string; + google: string; + github_app_name: string | null; + email_password_login: boolean; + magic_login: boolean; +} + +export class AppConfigService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async envConfig(): Promise { + return this.get("/api/configs/", { + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/services/file.service.ts b/space/services/file.service.ts index ccc7fab15..1ba4cd4d2 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -92,24 +92,6 @@ class FileService extends APIService { throw error?.response?.data; }); } - - async getUnsplashImages(page: number = 1, query?: string): Promise { - const url = "/api/unsplash"; - - return this.request({ - method: "get", - url, - params: { - page, - per_page: 20, - query, - }, - }) - .then((response) => response?.data?.results ?? response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } const fileService = new FileService(); diff --git a/space/store/user.ts b/space/store/user.ts index 3a76c2111..cc7334e87 100644 --- a/space/store/user.ts +++ b/space/store/user.ts @@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue"; import { IUser } from "types/user"; export interface IUserStore { + loader: boolean; + error: any | null; currentUser: any | null; fetchCurrentUser: () => void; currentActor: () => any; } class UserStore implements IUserStore { + loader: boolean = false; + error: any | null = null; + currentUser: IUser | null = null; // root store rootStore; @@ -73,14 +78,19 @@ class UserStore implements IUserStore { fetchCurrentUser = async () => { try { + this.loader = true; + this.error = null; const response = await this.userService.currentUser(); if (response) { runInAction(() => { + this.loader = false; this.currentUser = response; }); } } catch (error) { console.error("Failed to fetch current user", error); + this.loader = false; + this.error = error; } }; } diff --git a/turbo.json b/turbo.json index 2661f48e7..4b64d6231 100644 --- a/turbo.json +++ b/turbo.json @@ -1,8 +1,6 @@ { "$schema": "https://turbo.build/schema.json", "globalEnv": [ - "NEXT_PUBLIC_GITHUB_ID", - "NEXT_PUBLIC_GOOGLE_CLIENTID", "NEXT_PUBLIC_API_BASE_URL", "NEXT_PUBLIC_DEPLOY_URL", "API_BASE_URL", @@ -12,8 +10,6 @@ "NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH", - "NEXT_PUBLIC_UNSPLASH_ACCESS", - "NEXT_PUBLIC_UNSPLASH_ENABLED", "NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", diff --git a/web/components/account/email-password-form.tsx b/web/components/account/email-password-form.tsx index bb341b371..7a95095ee 100644 --- a/web/components/account/email-password-form.tsx +++ b/web/components/account/email-password-form.tsx @@ -1,12 +1,5 @@ -import React, { useState } from "react"; - -import { useRouter } from "next/router"; -import Link from "next/link"; - -// react hook form +import React from "react"; import { useForm } from "react-hook-form"; -// components -import { EmailResetPasswordForm } from "components/account"; // ui import { Input, PrimaryButton } from "components/ui"; // types @@ -18,14 +11,12 @@ type EmailPasswordFormValues = { type Props = { onSubmit: (formData: EmailPasswordFormValues) => Promise; + setIsResettingPassword: (value: boolean) => void; }; -export const EmailPasswordForm: React.FC = ({ onSubmit }) => { - const [isResettingPassword, setIsResettingPassword] = useState(false); - - const router = useRouter(); - const isSignUpPage = router.pathname === "/sign-up"; - +export const EmailPasswordForm: React.FC = (props) => { + const { onSubmit, setIsResettingPassword } = props; + // form info const { register, handleSubmit, @@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { return ( <> -

- {isResettingPassword - ? "Reset your password" - : isSignUpPage - ? "Sign up on Plane" - : "Sign in to Plane"} -

- {isResettingPassword ? ( - - ) : ( -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - }} - error={errors.email} - placeholder="Enter your email address..." - className="border-custom-border-300 h-[46px]" - /> -
-
- -
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
-
- - {isSignUpPage - ? isSubmitting - ? "Signing up..." - : "Sign up" - : isSubmitting - ? "Signing in..." - : "Sign in"} - - {!isSignUpPage && ( - - - Don{"'"}t have an account? Sign up. - - - )} -
-
- )} +
+
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + error={errors.email} + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" + /> +
+
+ +
+
+ +
+
+ + {isSubmitting ? "Signing in..." : "Sign in"} + +
+
); }; diff --git a/web/components/account/email-signup-form.tsx b/web/components/account/email-signup-form.tsx new file mode 100644 index 000000000..0a219741f --- /dev/null +++ b/web/components/account/email-signup-form.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// ui +import { Input, PrimaryButton } from "components/ui"; +// types +type EmailPasswordFormValues = { + email: string; + password?: string; + confirm_password: string; + medium?: string; +}; + +type Props = { + onSubmit: (formData: EmailPasswordFormValues) => Promise; +}; + +export const EmailSignUpForm: React.FC = (props) => { + const { onSubmit } = props; + + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + email: "", + password: "", + confirm_password: "", + medium: "email", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + return ( + <> +
+
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + error={errors.email} + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" + /> +
+
+ +
+
+ { + if (watch("password") != val) { + return "Your passwords do no match"; + } + }, + }} + error={errors.confirm_password} + placeholder="Confirm your password..." + className="border-custom-border-300 h-[46px]" + /> +
+ +
+ + {isSubmitting ? "Signing up..." : "Sign up"} + +
+
+ + ); +}; diff --git a/web/components/account/github-login-button.tsx b/web/components/account/github-login-button.tsx index 2f4fcbc4d..9ea5b7df2 100644 --- a/web/components/account/github-login-button.tsx +++ b/web/components/account/github-login-button.tsx @@ -1,29 +1,27 @@ import { useEffect, useState, FC } from "react"; - import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; - -// next-themes import { useTheme } from "next-themes"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; -const { NEXT_PUBLIC_GITHUB_ID } = process.env; - export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); - + // router const { query: { code }, } = useRouter(); - + // theme const { theme } = useTheme(); useEffect(() => { @@ -42,7 +40,7 @@ export const GithubLoginButton: FC = ({ handleSignIn }) return (
@@ -70,6 +76,7 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC input verticalPosition="bottom" width="w-full" + disabled={disabled} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 868d64557..063501036 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -24,9 +24,14 @@ import { getStatesList } from "helpers/state.helper"; type Props = { projectDetails: IProject | undefined; handleChange: (formData: Partial) => Promise; + disabled?: boolean; }; -export const AutoCloseAutomation: React.FC = ({ projectDetails, handleChange }) => { +export const AutoCloseAutomation: React.FC = ({ + projectDetails, + handleChange, + disabled = false, +}) => { const [monthModal, setmonthModal] = useState(false); const router = useRouter(); @@ -98,6 +103,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha : handleChange({ close_in: 0, default_state: null }) } size="sm" + disabled={disabled} />
@@ -119,6 +125,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha }} input width="w-full" + disabled={disabled} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/command-palette/command-pallette.tsx b/web/components/command-palette/command-pallette.tsx index 8e17cbafd..f183de9c6 100644 --- a/web/components/command-palette/command-pallette.tsx +++ b/web/components/command-palette/command-pallette.tsx @@ -161,7 +161,6 @@ export const CommandPalette: React.FC = observer(() => { /> setIsCreateViewModalOpen(false)} - viewType="project" isOpen={isCreateViewModalOpen} user={user} /> diff --git a/web/components/core/filters/filters-list.tsx b/web/components/core/filters/filters-list.tsx index 50493c64f..697844a15 100644 --- a/web/components/core/filters/filters-list.tsx +++ b/web/components/core/filters/filters-list.tsx @@ -10,14 +10,7 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // helpers import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; // types -import { - IIssueFilterOptions, - IIssueLabels, - IProject, - IState, - IUserLite, - TStateGroups, -} from "types"; +import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types"; // constants import { STATE_GROUP_COLORS } from "constants/state"; @@ -27,9 +20,7 @@ type Props = { clearAllFilters: (...args: any) => void; labels: IIssueLabels[] | undefined; members: IUserLite[] | undefined; - states?: IState[] | undefined; - stateGroup?: string[] | undefined; - project?: IProject[] | undefined; + states: IState[] | undefined; }; export const FiltersList: React.FC = ({ @@ -39,7 +30,6 @@ export const FiltersList: React.FC = ({ labels, members, states, - project, }) => { if (!filters) return <>; @@ -165,29 +155,6 @@ export const FiltersList: React.FC = ({ : key === "assignees" ? filters.assignees?.map((memberId: string) => { const member = members?.find((m) => m.id === memberId); - return ( -
- - {member?.display_name} - - setFilters({ - assignees: filters.assignees?.filter((p: any) => p !== memberId), - }) - } - > - - -
- ); - }) - : key === "subscriber" - ? filters.subscriber?.map((memberId: string) => { - const member = members?.find((m) => m.id === memberId); return (
= ({
); }) - : key === "project" - ? filters.project?.map((projectId) => { - const currentProject = project?.find((p) => p.id === projectId); - console.log("currentProject", currentProject); - console.log("currentProject", projectId); - return ( -

- {currentProject?.name} - - setFilters({ - project: filters.project?.filter((p) => p !== projectId), - }) - } - > - - -

- ); - }) : (filters[key] as any)?.join(", ")} + + + ) : ( +
+ {filters[key as keyof typeof filters]} + +
+ )} + + ); + })} + {Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && ( + + )} + + ); +}; diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index 957f1131c..cfe18cd97 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -1,32 +1,23 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; - -// next import Image from "next/image"; import { useRouter } from "next/router"; - -// swr import useSWR from "swr"; - -// react-dropdown import { useDropzone } from "react-dropzone"; - -// headless ui import { Tab, Transition, Popover } from "@headlessui/react"; // services import fileService from "services/file.service"; - -// components -import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui"; // hooks import useWorkspaceDetails from "hooks/use-workspace-details"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; - -const unsplashEnabled = - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1"; +// components +import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui"; const tabOptions = [ + { + key: "unsplash", + title: "Unsplash", + }, { key: "images", title: "Images", @@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC = ({ search: "", }); - const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () => - fileService.getUnsplashImages(1, searchParams) + const { data: unsplashImages, error: unsplashError } = useSWR( + `UNSPLASH_IMAGES_${searchParams}`, + () => fileService.getUnsplashImages(searchParams), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + const { data: projectCoverImages } = useSWR( + `PROJECT_COVER_IMAGES`, + () => fileService.getProjectCoverImages(), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } ); const imagePickerRef = useRef(null); @@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC = ({ }; useEffect(() => { - if (!images || value !== null) return; - onChange(images[0].urls.regular); - }, [value, onChange, images]); + if (!unsplashImages || value !== null) return; + + onChange(unsplashImages[0].urls.regular); + }, [value, onChange, unsplashImages]); useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); - if (!unsplashEnabled) return null; - return ( setIsOpen((prev) => !prev)} disabled={disabled} > @@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
-
- - {tabOptions.map((tab) => ( + + {tabOptions.map((tab) => { + if (!unsplashImages && unsplashError && tab.key === "unsplash") return null; + if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") + return null; + + return ( @@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC = ({ > {tab.title} - ))} - -
+ ); + })} + - -
- setFormData({ ...formData, search: e.target.value })} - placeholder="Search for images" - /> - setSearchParams(formData.search)} size="sm"> - Search - -
- {images ? ( -
- {images.map((image) => ( -
- {image.alt_description} { - setIsOpen(false); - onChange(image.urls.regular); - }} - /> + {(unsplashImages || !unsplashError) && ( + +
+ setFormData({ ...formData, search: e.target.value })} + placeholder="Search for images" + /> + setSearchParams(formData.search)} size="sm"> + Search + +
+ {unsplashImages ? ( + unsplashImages.length > 0 ? ( +
+ {unsplashImages.map((image) => ( +
{ + setIsOpen(false); + onChange(image.urls.regular); + }} + > + {image.alt_description} +
+ ))}
- ))} -
- ) : ( -
- -
- )} - - + ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} + {(!projectCoverImages || projectCoverImages.length !== 0) && ( + + {projectCoverImages ? ( + projectCoverImages.length > 0 ? ( +
+ {projectCoverImages.map((image, index) => ( +
{ + setIsOpen(false); + onChange(image); + }} + > + {`Default +
+ ))} +
+ ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} +
= (props) => { const [isCollapsed, setIsCollapsed] = useState(true); const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); + const [isCreateDraftIssueModalOpen, setIsCreateDraftIssueModalOpen] = useState(false); const { displayFilters, groupedIssues } = viewProps; @@ -96,10 +98,27 @@ export const SingleBoard: React.FC = (props) => { scrollToBottom(); }; + const handleAddIssueToGroup = () => { + if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true); + else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup(); + else onCreateClick(); + }; + return (
+ setIsCreateDraftIssueModalOpen(false)} + prePopulateData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + /> + = (props) => { {displayFilters?.group_by !== "created_by" && (
{type === "issue" - ? !disableAddIssueOption && ( + ? !disableAddIssueOption && + !isDraftIssuesPage && ( ) - : !disableUserActions && ( + : !disableUserActions && + !isDraftIssuesPage && ( = (props) => { position="left" noBorder > - onCreateClick()}> + { + if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true); + else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup(); + else onCreateClick(); + }} + > Create new {openIssuesListModal && ( diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index 62bcd5e58..3c123c2ac 100644 --- a/web/components/core/views/issues-view.tsx +++ b/web/components/core/views/issues-view.tsx @@ -483,7 +483,6 @@ export const IssuesView: React.FC = ({ setCreateViewModal(null)} - viewType="project" preLoadedData={createViewModal} user={user} /> diff --git a/web/components/core/views/list-view/single-list.tsx b/web/components/core/views/list-view/single-list.tsx index 9d3e9cb96..8cba67112 100644 --- a/web/components/core/views/list-view/single-list.tsx +++ b/web/components/core/views/list-view/single-list.tsx @@ -13,6 +13,7 @@ import projectService from "services/project.service"; // hooks import useProjects from "hooks/use-projects"; // components +import { CreateUpdateDraftIssueModal } from "components/issues"; import { SingleListIssue, ListInlineCreateIssueForm } from "components/core"; // ui import { Avatar, CustomMenu } from "components/ui"; @@ -75,6 +76,7 @@ export const SingleList: React.FC = (props) => { const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + const [isDraftIssuesModalOpen, setIsDraftIssuesModalOpen] = useState(false); const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues"; const isProfileIssuesPage = router.pathname.split("/")[2] === "profile"; @@ -208,154 +210,169 @@ export const SingleList: React.FC = (props) => { if (!groupedIssues) return null; return ( - - {({ open }) => ( -
-
- -
- {displayFilters?.group_by !== null && ( -
{getGroupIcon()}
- )} - {displayFilters?.group_by !== null ? ( -

- {getGroupTitle()} -

- ) : ( -

All Issues

- )} - - {groupedIssues[groupTitle as keyof IIssue].length} - -
-
- {isArchivedIssues ? ( - "" - ) : type === "issue" ? ( - !disableAddIssueOption && ( - - ) - ) : disableUserActions ? ( - "" - ) : ( - - -
- } - position="right" - noBorder - > - setIsCreateIssueFormOpen(true)}> - Create new - - {openIssuesListModal && ( - - Add an existing issue - - )} - - )} -
- - - {groupedIssues[groupTitle] ? ( - groupedIssues[groupTitle].length > 0 ? ( - groupedIssues[groupTitle].map((issue, index) => ( - handleIssueAction(issue, "edit")} - makeIssueCopy={() => handleIssueAction(issue, "copy")} - handleDeleteIssue={() => handleIssueAction(issue, "delete")} - handleDraftIssueSelect={ - handleDraftIssueAction - ? () => handleDraftIssueAction(issue, "edit") - : undefined - } - handleDraftIssueDelete={ - handleDraftIssueAction - ? () => handleDraftIssueAction(issue, "delete") - : undefined - } - handleMyIssueOpen={handleMyIssueOpen} - removeIssue={() => { - if (removeIssue !== null && issue.bridge_id) - removeIssue(issue.bridge_id, issue.id); - }} - disableUserActions={disableUserActions} - user={user} - userAuth={userAuth} - viewProps={viewProps} - /> - )) - ) : ( -

- No issues. -

- ) - ) : ( -
Loading...
- )} + <> + setIsDraftIssuesModalOpen(false)} + prePopulateData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + /> - setIsCreateIssueFormOpen(false)} - prePopulatedData={{ - ...(cycleId && { cycle: cycleId.toString() }), - ...(moduleId && { module: moduleId.toString() }), - [displayFilters?.group_by!]: groupTitle, - }} - /> - - {!disableAddIssueOption && !isCreateIssueFormOpen && ( - // TODO: add border here -
+ + {({ open }) => ( +
+
+ +
+ {displayFilters?.group_by !== null && ( +
{getGroupIcon()}
+ )} + {displayFilters?.group_by !== null ? ( +

+ {getGroupTitle()} +

+ ) : ( +

All Issues

+ )} + + {groupedIssues[groupTitle as keyof IIssue].length} + +
+
+ {isArchivedIssues ? ( + "" + ) : type === "issue" ? ( + !disableAddIssueOption && ( -
+ ) + ) : disableUserActions ? ( + "" + ) : ( + + +
+ } + position="right" + noBorder + > + setIsCreateIssueFormOpen(true)}> + Create new + + {openIssuesListModal && ( + + Add an existing issue + + )} + )} - - -
- )} -
+
+ + + {groupedIssues[groupTitle] ? ( + groupedIssues[groupTitle].length > 0 ? ( + groupedIssues[groupTitle].map((issue, index) => ( + handleIssueAction(issue, "edit")} + makeIssueCopy={() => handleIssueAction(issue, "copy")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} + handleDraftIssueSelect={ + handleDraftIssueAction + ? () => handleDraftIssueAction(issue, "edit") + : undefined + } + handleDraftIssueDelete={ + handleDraftIssueAction + ? () => handleDraftIssueAction(issue, "delete") + : undefined + } + handleMyIssueOpen={handleMyIssueOpen} + removeIssue={() => { + if (removeIssue !== null && issue.bridge_id) + removeIssue(issue.bridge_id, issue.id); + }} + disableUserActions={disableUserActions} + user={user} + userAuth={userAuth} + viewProps={viewProps} + /> + )) + ) : ( +

+ No issues. +

+ ) + ) : ( +
Loading...
+ )} + + setIsCreateIssueFormOpen(false)} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" + ? "labels_list" + : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + /> + + {!disableAddIssueOption && !isCreateIssueFormOpen && !isDraftIssuesPage && ( +
+ +
+ )} +
+
+
+ )} + + ); }; diff --git a/web/components/core/views/spreadsheet-view/index.ts b/web/components/core/views/spreadsheet-view/index.ts index e72819dad..9bf8ed1b0 100644 --- a/web/components/core/views/spreadsheet-view/index.ts +++ b/web/components/core/views/spreadsheet-view/index.ts @@ -10,5 +10,4 @@ export * from "./state-column"; export * from "./updated-on-column"; export * from "./spreadsheet-view"; export * from "./issue-column/issue-column"; -export * from "./spreadsheet-columns"; export * from "./issue-column/spreadsheet-issue-column"; diff --git a/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx b/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx index bb36c0e60..c00f085b2 100644 --- a/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx +++ b/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx @@ -83,88 +83,89 @@ export const IssueColumn: React.FC = ({ return (
-
-
- {properties.key && ( - + {properties.key && ( +
+
+ {issue.project_detail?.identifier}-{issue.sequence_id} - )} - {!isNotAllowed && !disableUserActions && ( -
- setIsOpen(nextOpenState)} - content={ -
- - + - -
- } - placement="bottom-start" + + + +
+ } + placement="bottom-start" + > + + +
+ )} +
+ + {issue.sub_issues_count > 0 && ( +
+
)}
- - {issue.sub_issues_count > 0 && ( -
- -
- )} -
+ )}
- ); -}; diff --git a/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx b/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx index b17ce8d2d..0d9214a36 100644 --- a/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx +++ b/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; // next import { useRouter } from "next/router"; @@ -19,13 +19,20 @@ import { SpreadsheetStateColumn, SpreadsheetUpdatedOnColumn, } from "components/core"; -import { CustomMenu, Spinner } from "components/ui"; +import { CustomMenu, Icon, Spinner } from "components/ui"; import { IssuePeekOverview } from "components/issues"; // hooks import useIssuesProperties from "hooks/use-issue-properties"; +import useLocalStorage from "hooks/use-local-storage"; +import { useWorkspaceView } from "hooks/use-workspace-view"; // types -import { ICurrentUserResponse, IIssue, ISubIssueResponse, UserAuth } from "types"; -import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter"; +import { + ICurrentUserResponse, + IIssue, + ISubIssueResponse, + TIssueOrderByOptions, + UserAuth, +} from "types"; import { CYCLE_DETAILS, CYCLE_ISSUES_WITH_PARAMS, @@ -39,7 +46,7 @@ import { import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import projectIssuesServices from "services/issues.service"; // icon -import { PlusIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon, PlusIcon } from "lucide-react"; type Props = { spreadsheetIssues: IIssue[]; @@ -70,13 +77,24 @@ export const SpreadsheetView: React.FC = ({ const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + + const containerRef = useRef(null); + const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId, workspaceViewId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId } = router.query; const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); + const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( + "spreadsheetViewSorting", + "" + ); + const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = + useLocalStorage("spreadsheetViewActiveSortingProperty", ""); + const workspaceIssuesPath = [ { params: { @@ -111,12 +129,19 @@ export const SpreadsheetView: React.FC = ({ router.pathname.includes(path.path) ); - const { params: workspaceViewParams } = useWorkspaceIssuesFilters( - workspaceSlug?.toString(), - workspaceViewId?.toString() - ); + const { + params: workspaceViewParams, + filters: workspaceViewFilters, + handleFilters, + } = useWorkspaceView(); - const { params } = useSpreadsheetIssuesView(); + const workspaceViewProperties = workspaceViewFilters.display_properties; + + const isWorkspaceView = globalViewId || currentWorkspaceIssuePath; + + const currentViewProperties = isWorkspaceView ? workspaceViewProperties : properties; + + const { params, displayFilters, setDisplayFilters } = useSpreadsheetIssuesView(); const partialUpdateIssue = useCallback( (formData: Partial, issue: IIssue) => { @@ -128,8 +153,8 @@ export const SpreadsheetView: React.FC = ({ ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) : viewId ? VIEW_ISSUES(viewId.toString(), params) - : workspaceViewId - ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), workspaceViewParams) + : globalViewId + ? WORKSPACE_VIEW_ISSUES(globalViewId.toString(), workspaceViewParams) : currentWorkspaceIssuePath ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params) : PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project_detail.id, params); @@ -198,9 +223,9 @@ export const SpreadsheetView: React.FC = ({ cycleId, moduleId, viewId, - workspaceViewId, - currentWorkspaceIssuePath, + globalViewId, workspaceViewParams, + currentWorkspaceIssuePath, params, user, ] @@ -208,10 +233,219 @@ export const SpreadsheetView: React.FC = ({ const isNotAllowed = userAuth.isGuest || userAuth.isViewer; - const renderColumn = (header: string, Component: React.ComponentType) => ( -
-
- {header} + const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { + if (globalViewId) handleFilters("display_filters", { order_by: order }); + else setDisplayFilters({ order_by: order }); + setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(order === "-created_at" ? "" : itemKey); + }; + + const renderColumn = ( + header: string, + propertyName: string, + Component: React.ComponentType, + ascendingOrder: TIssueOrderByOptions, + descendingOrder: TIssueOrderByOptions + ) => ( +
+
+ {currentWorkspaceIssuePath ? ( + {header} + ) : ( + + {activeSortingProperty === propertyName && ( +
+ +
+ )} + + {header} +
+ } + width="xl" + > + { + handleOrderBy(ascendingOrder, propertyName); + }} + > +
+
+ {propertyName === "assignee" || propertyName === "labels" ? ( + <> + + + + + A + + Z + + ) : propertyName === "due_date" || + propertyName === "created_on" || + propertyName === "updated_on" ? ( + <> + + + + + New + + Old + + ) : ( + <> + + + + + First + + Last + + )} +
+ + +
+
+ { + handleOrderBy(descendingOrder, propertyName); + }} + > +
+
+ {propertyName === "assignee" || propertyName === "labels" ? ( + <> + + + + + Z + + A + + ) : propertyName === "due_date" ? ( + <> + + + + + Old + + New + + ) : ( + <> + + + + + Last + + First + + )} +
+ + +
+
+ {selectedMenuItem && + selectedMenuItem !== "" && + displayFilters?.order_by !== "-created_at" && + selectedMenuItem.includes(propertyName) && ( + { + handleOrderBy("-created_at", propertyName); + }} + > +
+
+ + + + + Clear sorting +
+
+
+ )} + + )}
{spreadsheetIssues.map((issue: IIssue, index) => ( @@ -221,7 +455,7 @@ export const SpreadsheetView: React.FC = ({ projectId={issue.project_detail.id} partialUpdateIssue={partialUpdateIssue} expandedIssues={expandedIssues} - properties={properties} + properties={currentViewProperties} user={user} isNotAllowed={isNotAllowed} /> @@ -230,6 +464,27 @@ export const SpreadsheetView: React.FC = ({
); + const handleScroll = () => { + if (containerRef.current) { + const scrollLeft = containerRef.current.scrollLeft; + setIsScrolled(scrollLeft > 0); + } + }; + + useEffect(() => { + const currentContainerRef = containerRef.current; + + if (currentContainerRef) { + currentContainerRef.addEventListener("scroll", handleScroll); + } + + return () => { + if (currentContainerRef) { + currentContainerRef.removeEventListener("scroll", handleScroll); + } + }; + }, []); + return ( <> = ({ workspaceSlug={workspaceSlug?.toString() ?? ""} readOnly={disableUserActions} /> -
+
-
+
{spreadsheetIssues ? ( <>
-
-
- - ID - +
+
+ {currentViewProperties.key && ( + + ID + + )} Issue @@ -262,7 +524,7 @@ export const SpreadsheetView: React.FC = ({ expandedIssues={expandedIssues} setExpandedIssues={setExpandedIssues} setCurrentProjectId={setCurrentProjectId} - properties={properties} + properties={currentViewProperties} handleIssueAction={handleIssueAction} disableUserActions={disableUserActions} userAuth={userAuth} @@ -270,15 +532,79 @@ export const SpreadsheetView: React.FC = ({ ))}
- {renderColumn("State", SpreadsheetStateColumn)} - {renderColumn("Priority", SpreadsheetPriorityColumn)} - {renderColumn("Assignees", SpreadsheetAssigneeColumn)} - {renderColumn("Label", SpreadsheetLabelColumn)} - {renderColumn("Start Date", SpreadsheetStartDateColumn)} - {renderColumn("Due Date", SpreadsheetDueDateColumn)} - {renderColumn("Estimate", SpreadsheetEstimateColumn)} - {renderColumn("Created On", SpreadsheetCreatedOnColumn)} - {renderColumn("Updated On", SpreadsheetUpdatedOnColumn)} + {currentViewProperties.state && + renderColumn( + "State", + "state", + SpreadsheetStateColumn, + "state__name", + "-state__name" + )} + + {currentViewProperties.priority && + renderColumn( + "Priority", + "priority", + SpreadsheetPriorityColumn, + "priority", + "-priority" + )} + {currentViewProperties.assignee && + renderColumn( + "Assignees", + "assignee", + SpreadsheetAssigneeColumn, + "assignees__first_name", + "-assignees__first_name" + )} + {currentViewProperties.labels && + renderColumn( + "Label", + "labels", + SpreadsheetLabelColumn, + "labels__name", + "-labels__name" + )} + {currentViewProperties.start_date && + renderColumn( + "Start Date", + "start_date", + SpreadsheetStartDateColumn, + "-start_date", + "start_date" + )} + {currentViewProperties.due_date && + renderColumn( + "Due Date", + "due_date", + SpreadsheetDueDateColumn, + "-target_date", + "target_date" + )} + {currentViewProperties.estimate && + renderColumn( + "Estimate", + "estimate", + SpreadsheetEstimateColumn, + "estimate_point", + "-estimate_point" + )} + {currentViewProperties.created_on && + renderColumn( + "Created On", + "created_on", + SpreadsheetCreatedOnColumn, + "-created_at", + "created_at" + )} + {currentViewProperties.updated_on && + renderColumn( + "Updated On", + "updated_on", + SpreadsheetUpdatedOnColumn, + "-updated_at", + "updated_at" + )} ) : (
diff --git a/web/components/core/views/spreadsheet-view/state-column/state-column.tsx b/web/components/core/views/spreadsheet-view/state-column/state-column.tsx index a31345f3d..04c833b1d 100644 --- a/web/components/core/views/spreadsheet-view/state-column/state-column.tsx +++ b/web/components/core/views/spreadsheet-view/state-column/state-column.tsx @@ -76,7 +76,7 @@ export const StateColumn: React.FC = ({ value={issue.state_detail} projectId={projectId} onChange={handleStateChange} - buttonClassName="!p-0 !rounded-none !shadow-none !border-0" + buttonClassName="!shadow-none !border-0" hideDropdownArrow disabled={isNotAllowed} /> diff --git a/web/components/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx index ab4eb022e..cc42dbec8 100644 --- a/web/components/emoji-icon-picker/index.tsx +++ b/web/components/emoji-icon-picker/index.tsx @@ -4,6 +4,7 @@ import { Tab, Transition, Popover } from "@headlessui/react"; // react colors import { TwitterPicker } from "react-color"; // hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // types import { Props } from "./types"; @@ -38,6 +39,7 @@ const EmojiIconPicker: React.FC = ({ const [recentEmojis, setRecentEmojis] = useState([]); + const buttonRef = useRef(null); const emojiPickerRef = useRef(null); useEffect(() => { @@ -49,10 +51,12 @@ const EmojiIconPicker: React.FC = ({ }, [value, onChange]); useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false)); + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, emojiPickerRef); return ( setIsOpen((prev) => !prev)} className="outline-none" disabled={disabled} @@ -61,6 +65,8 @@ const EmojiIconPicker: React.FC = ({ = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - -
+ +
{tabOptions.map((tab) => ( diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx index 61e3078d6..e21e803de 100644 --- a/web/components/gantt-chart/chart/index.tsx +++ b/web/components/gantt-chart/chart/index.tsx @@ -80,6 +80,9 @@ export const ChartViewRoot: FC = ({ const router = useRouter(); const { cycleId, moduleId } = router.query; + const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId; + const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId; + const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => blocks && blocks.length > 0 ? blocks.map((block: any) => ({ @@ -317,7 +320,7 @@ export const ChartViewRoot: FC = ({ SidebarBlockRender={SidebarBlockRender} enableReorder={enableReorder} /> - {chartBlocks && ( + {chartBlocks && !(isCyclePage || isModulePage) && (
Promise; data?: Partial | null; + isOpen: boolean; prePopulatedData?: Partial | null; projectId: string; setActiveProject: React.Dispatch>; @@ -92,6 +94,7 @@ export const DraftIssueForm: FC = (props) => { const { handleFormSubmit, data, + isOpen, prePopulatedData, projectId, setActiveProject, @@ -112,6 +115,8 @@ export const DraftIssueForm: FC = (props) => { const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); + const editorRef = useRef(null); const router = useRouter(); @@ -136,6 +141,33 @@ export const DraftIssueForm: FC = (props) => { const issueName = watch("name"); + const payload: Partial = { + name: watch("name"), + description: watch("description"), + description_html: watch("description_html"), + state: watch("state"), + priority: watch("priority"), + assignees: watch("assignees"), + labels: watch("labels"), + start_date: watch("start_date"), + target_date: watch("target_date"), + project: watch("project"), + parent: watch("parent"), + cycle: watch("cycle"), + module: watch("module"), + }; + + useEffect(() => { + if (!isOpen || data) return; + + setLocalStorageValue( + JSON.stringify({ + ...payload, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(payload), isOpen, data]); + const onClose = () => { handleClose(); }; @@ -276,7 +308,7 @@ export const DraftIssueForm: FC = (props) => { )}
- handleCreateUpdateIssue(formData, "convertToNewIssue") + handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft") )} >
diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index b6479d067..ca37ae03f 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -385,6 +385,7 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = > = (props) => { const issueName = watch("name"); const payload: Partial = { - name: getValues("name"), - description: getValues("description"), - state: getValues("state"), - priority: getValues("priority"), - assignees: getValues("assignees"), - labels: getValues("labels"), - start_date: getValues("start_date"), - target_date: getValues("target_date"), - project: getValues("project"), - parent: getValues("parent"), - cycle: getValues("cycle"), - module: getValues("module"), + name: watch("name"), + description: watch("description"), + description_html: watch("description_html"), + state: watch("state"), + priority: watch("priority"), + assignees: watch("assignees"), + labels: watch("labels"), + start_date: watch("start_date"), + target_date: watch("target_date"), + project: watch("project"), + parent: watch("parent"), + cycle: watch("cycle"), + module: watch("module"), }; useEffect(() => { diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx index 608cf4fd1..cf6f811f6 100644 --- a/web/components/issues/modal.tsx +++ b/web/components/issues/modal.tsx @@ -20,6 +20,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useProjects from "hooks/use-projects"; import useMyIssues from "hooks/my-issues/use-my-issues"; import useLocalStorage from "hooks/use-local-storage"; +import { useWorkspaceView } from "hooks/use-workspace-view"; // components import { IssueForm, ConfirmIssueDiscard } from "components/issues"; // types @@ -37,6 +38,7 @@ import { VIEW_ISSUES, INBOX_ISSUES, PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, + WORKSPACE_VIEW_ISSUES, } from "constants/fetch-keys"; // constants import { INBOX_ISSUE_SOURCE } from "constants/inbox"; @@ -81,7 +83,8 @@ export const CreateUpdateIssueModal: React.FC = ({ const [prePopulateData, setPreloadedData] = useState>({}); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId, inboxId } = + router.query; const { displayFilters, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); @@ -94,6 +97,8 @@ export const CreateUpdateIssueModal: React.FC = ({ const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { params: globalViewParams } = useWorkspaceView(); + const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } = useLocalStorage("draftedIssue", {}); @@ -276,6 +281,40 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }; + const workspaceIssuesPath = [ + { + params: { + sub_issue: false, + }, + path: "workspace-views/all-issues", + }, + { + params: { + assignees: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/assigned", + }, + { + params: { + created_by: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/created", + }, + { + params: { + subscriber: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/subscribed", + }, + ]; + + const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) => + router.pathname.includes(path.path) + ); + const calendarFetchKey = cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) : moduleId @@ -332,6 +371,14 @@ export const CreateUpdateIssueModal: React.FC = ({ mutate(USER_ISSUE(workspaceSlug as string)); if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + + if (globalViewId) + mutate(WORKSPACE_VIEW_ISSUES(globalViewId.toString(), globalViewParams)); + + if (currentWorkspaceIssuePath) + mutate( + WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params) + ); }) .catch(() => { setToastAlert({ diff --git a/web/components/issues/my-issues/my-issues-select-filters.tsx b/web/components/issues/my-issues/my-issues-select-filters.tsx index 8085b5e78..ce8e03797 100644 --- a/web/components/issues/my-issues/my-issues-select-filters.tsx +++ b/web/components/issues/my-issues/my-issues-select-filters.tsx @@ -4,21 +4,18 @@ import { useRouter } from "next/router"; import useSWR from "swr"; -// hook -import useProjects from "hooks/use-projects"; -import useWorkspaceMembers from "hooks/use-workspace-members"; // services import issuesService from "services/issues.service"; // components import { DateFilterModal } from "components/core"; // ui -import { Avatar, MultiLevelDropdown } from "components/ui"; +import { MultiLevelDropdown } from "components/ui"; // icons import { PriorityIcon, StateGroupIcon } from "components/icons"; // helpers import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { IIssueFilterOptions, TStateGroups } from "types"; +import { IIssueFilterOptions, IQuery, TStateGroups } from "types"; // fetch-keys import { WORKSPACE_LABELS } from "constants/fetch-keys"; // constants @@ -26,7 +23,7 @@ import { GROUP_CHOICES, PRIORITIES } from "constants/project"; import { DATE_FILTER_OPTIONS } from "constants/filters"; type Props = { - filters: Partial; + filters: Partial | IQuery; onSelect: (option: any) => void; direction?: "left" | "right"; height?: "sm" | "md" | "rg" | "lg"; @@ -58,11 +55,6 @@ export const MyIssuesSelectFilters: React.FC = ({ : null ); - const { projects: allProjects } = useProjects(); - const joinedProjects = allProjects?.filter((p) => p.is_member); - - const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); - return ( <> {isDateFilterModalOpen && ( @@ -82,19 +74,25 @@ export const MyIssuesSelectFilters: React.FC = ({ height={height} options={[ { - id: "project", - label: "Project", - value: joinedProjects, + id: "priority", + label: "Priority", + value: PRIORITIES, hasChildren: true, - children: joinedProjects?.map((project) => ({ - id: project.id, - label:
{project.name}
, - value: { - key: "project", - value: project.id, - }, - selected: filters?.project?.includes(project.id), - })), + children: [ + ...PRIORITIES.map((priority) => ({ + id: priority === null ? "null" : priority, + label: ( +
+ {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), + ], }, { id: "state_group", @@ -144,87 +142,6 @@ export const MyIssuesSelectFilters: React.FC = ({ selected: filters?.labels?.includes(label.id), })), }, - { - id: "priority", - label: "Priority", - value: PRIORITIES, - hasChildren: true, - children: [ - ...PRIORITIES.map((priority) => ({ - id: priority === null ? "null" : priority, - label: ( -
- {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, - }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - ], - }, - { - id: "created_by", - label: "Created by", - value: workspaceMembers, - hasChildren: true, - children: workspaceMembers?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "created_by", - value: member.member.id, - }, - selected: filters?.created_by?.includes(member.member.id), - })), - }, - { - id: "assignees", - label: "Assignees", - value: workspaceMembers, - hasChildren: true, - children: workspaceMembers?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "assignees", - value: member.member.id, - }, - selected: filters?.assignees?.includes(member.member.id), - })), - }, - { - id: "subscriber", - label: "Subscriber", - value: workspaceMembers, - hasChildren: true, - children: workspaceMembers?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "subscriber", - value: member.member.id, - }, - selected: filters?.subscriber?.includes(member.member.id), - })), - }, { id: "start_date", label: "Start date", diff --git a/web/components/issues/my-issues/my-issues-view-options.tsx b/web/components/issues/my-issues/my-issues-view-options.tsx index 1cbc467a8..69cfa1909 100644 --- a/web/components/issues/my-issues/my-issues-view-options.tsx +++ b/web/components/issues/my-issues/my-issues-view-options.tsx @@ -30,7 +30,7 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ export const MyIssuesViewOptions: React.FC = () => { const router = useRouter(); - const { workspaceSlug, workspaceViewId } = router.query; + const { workspaceSlug, globalViewId } = router.query; const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters( workspaceSlug?.toString() @@ -42,7 +42,7 @@ export const MyIssuesViewOptions: React.FC = () => { router.pathname.includes(pathname) ); - const showFilters = isWorkspaceViewPath || workspaceViewId; + const showFilters = isWorkspaceViewPath || globalViewId; return (
diff --git a/web/components/issues/sidebar-select/blocked.tsx b/web/components/issues/sidebar-select/blocked.tsx index d7e448377..a4ebe0108 100644 --- a/web/components/issues/sidebar-select/blocked.tsx +++ b/web/components/issues/sidebar-select/blocked.tsx @@ -14,7 +14,7 @@ import { ExistingIssuesListModal } from "components/core"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { BlockedIcon } from "components/icons"; // types -import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types"; +import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types"; type Props = { issueId?: string; @@ -41,6 +41,9 @@ export const SidebarBlockedSelect: React.FC = ({ setIsBlockedModalOpen(false); }; + const blockedByIssue = + watch("related_issues")?.filter((i) => i.relation_type === "blocked_by") || []; + const onSubmit = async (data: ISearchIssueResponse[]) => { if (data.length === 0) { setToastAlert({ @@ -80,18 +83,13 @@ export const SidebarBlockedSelect: React.FC = ({ }) .then((response) => { submitChanges({ - related_issues: [ - ...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"), - ...response, - ], + related_issues: [...watch("related_issues"), ...response], }); }); handleClose(); }; - const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by"); - return ( <> = (props) => { })), ], }) - .then((response) => { - submitChanges({ - related_issues: [...watch("related_issues"), ...(response ?? [])], - }); + .then(() => { + submitChanges(); }); handleClose(); diff --git a/web/components/issues/sidebar-select/relates-to.tsx b/web/components/issues/sidebar-select/relates-to.tsx index deadf4d20..b9e56dbeb 100644 --- a/web/components/issues/sidebar-select/relates-to.tsx +++ b/web/components/issues/sidebar-select/relates-to.tsx @@ -75,10 +75,8 @@ export const SidebarRelatesSelect: React.FC = (props) => { })), ], }) - .then((response) => { - submitChanges({ - related_issues: [...watch("related_issues"), ...(response ?? [])], - }); + .then(() => { + submitChanges(); }); handleClose(); diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index 5455192fb..e0c370eaf 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -53,7 +53,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; // types import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types"; // fetch-keys -import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { ContrastIcon } from "components/icons"; type Props = { @@ -480,6 +480,7 @@ export const IssueDetailsSidebar: React.FC = ({ }, false ); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -500,6 +501,7 @@ export const IssueDetailsSidebar: React.FC = ({ }, false ); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -517,6 +519,7 @@ export const IssueDetailsSidebar: React.FC = ({ ...data, }; }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -534,6 +537,7 @@ export const IssueDetailsSidebar: React.FC = ({ ...data, }; }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx index 466f21141..4bb8ea234 100644 --- a/web/components/issues/sub-issues/issue.tsx +++ b/web/components/issues/sub-issues/issue.tsx @@ -1,7 +1,8 @@ import React from "react"; // next imports -import Link from "next/link"; import { useRouter } from "next/router"; +// swr +import { mutate } from "swr"; // lucide icons import { ChevronDown, @@ -13,6 +14,7 @@ import { Loader, } from "lucide-react"; // components +import { IssuePeekOverview } from "components/issues/peek-overview"; import { SubIssuesRootList } from "./issues-list"; import { IssueProperty } from "./properties"; // ui @@ -21,6 +23,8 @@ import { Tooltip, CustomMenu } from "components/ui"; // types import { ICurrentUserResponse, IIssue } from "types"; import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; +// fetch keys +import { SUB_ISSUES } from "constants/fetch-keys"; export interface ISubIssues { workspaceSlug: string; @@ -39,7 +43,6 @@ export interface ISubIssues { issueId: string, issue?: IIssue | null ) => void; - setPeekParentId: (id: string) => void; } export const SubIssues: React.FC = ({ @@ -55,14 +58,12 @@ export const SubIssues: React.FC = ({ handleIssuesLoader, copyText, handleIssueCrudOperation, - setPeekParentId, }) => { const router = useRouter(); + const { query } = router; + const { peekIssue } = query as { peekIssue: string }; const openPeekOverview = (issue_id: string) => { - const { query } = router; - - setPeekParentId(parentIssue?.id); router.push({ pathname: router.pathname, query: { ...query, peekIssue: issue_id }, @@ -200,7 +201,17 @@ export const SubIssues: React.FC = ({ handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} - setPeekParentId={setPeekParentId} + /> + )} + + {peekIssue && peekIssue === issue?.id && ( + + parentIssue && parentIssue?.id && mutate(SUB_ISSUES(parentIssue?.id)) + } + projectId={issue?.project ?? ""} + workspaceSlug={workspaceSlug ?? ""} + readOnly={!editable} /> )}
diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx index dc5053a86..c79e64e91 100644 --- a/web/components/issues/sub-issues/issues-list.tsx +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -28,7 +28,6 @@ export interface ISubIssuesRootList { issueId: string, issue?: IIssue | null ) => void; - setPeekParentId: (id: string) => void; } export const SubIssuesRootList: React.FC = ({ @@ -43,7 +42,6 @@ export const SubIssuesRootList: React.FC = ({ handleIssuesLoader, copyText, handleIssueCrudOperation, - setPeekParentId, }) => { const { data: issues, isLoading } = useSWR( workspaceSlug && projectId && parentIssue && parentIssue?.id @@ -84,7 +82,6 @@ export const SubIssuesRootList: React.FC = ({ handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} - setPeekParentId={setPeekParentId} /> ))} diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 43594d900..4119a8345 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -10,7 +10,6 @@ import { ExistingIssuesListModal } from "components/core"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { SubIssuesRootList } from "./issues-list"; import { ProgressBar } from "./progressbar"; -import { IssuePeekOverview } from "components/issues/peek-overview"; // ui import { CustomMenu } from "components/ui"; // hooks @@ -63,8 +62,6 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = : null ); - const [peekParentId, setPeekParentId] = React.useState(""); - const [issuesLoader, setIssuesLoader] = React.useState({ visibility: [parentIssue?.id], delete: [], @@ -241,7 +238,6 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} - setPeekParentId={setPeekParentId} />
)} @@ -367,13 +363,6 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user }) = )} )} - - peekParentId && peekIssue && mutateSubIssues(peekParentId)} - projectId={projectId ?? ""} - workspaceSlug={workspaceSlug ?? ""} - readOnly={!isEditable} - />
); }; diff --git a/web/pages/[workspaceSlug]/workspace-views/[workspaceViewId].tsx b/web/components/issues/workspace-views/workpace-view-issues.tsx similarity index 59% rename from web/pages/[workspaceSlug]/workspace-views/[workspaceViewId].tsx rename to web/components/issues/workspace-views/workpace-view-issues.tsx index cba4d802b..78a12f807 100644 --- a/web/pages/[workspaceSlug]/workspace-views/[workspaceViewId].tsx +++ b/web/components/issues/workspace-views/workpace-view-issues.tsx @@ -1,48 +1,49 @@ -import { useCallback, useState } from "react"; +import React, { useCallback, useState } from "react"; + +import useSWR from "swr"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; - -// hook -import useToast from "hooks/use-toast"; -import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter"; -import useProjects from "hooks/use-projects"; -import useUser from "hooks/use-user"; -import useWorkspaceMembers from "hooks/use-workspace-members"; // context import { useProjectMyMembership } from "contexts/project-member.context"; -// services -import workspaceService from "services/workspace.service"; +// service import projectIssuesServices from "services/issues.service"; -// layouts -import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +// hooks +import useProjects from "hooks/use-projects"; +import useUser from "hooks/use-user"; +import { useWorkspaceView } from "hooks/use-workspace-view"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +import useToast from "hooks/use-toast"; // components -import { FiltersList, SpreadsheetView } from "components/core"; import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; -import { WorkspaceIssuesViewOptions } from "components/issues/workspace-views/workspace-issue-view-option"; -import { CreateUpdateViewModal } from "components/views"; -import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; -// ui import { EmptyState, PrimaryButton } from "components/ui"; -// icons -import { PlusIcon } from "@heroicons/react/24/outline"; -import { CheckCircle } from "lucide-react"; -// images +import { SpreadsheetView, WorkspaceFiltersList } from "components/core"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// icon +import { PlusIcon } from "components/icons"; +// image import emptyView from "public/empty-state/view.svg"; -// fetch-keys -import { - WORKSPACE_LABELS, - WORKSPACE_VIEWS_LIST, - WORKSPACE_VIEW_DETAILS, - WORKSPACE_VIEW_ISSUES, -} from "constants/fetch-keys"; -// constant +// constants +import { WORKSPACE_LABELS } from "constants/fetch-keys"; import { STATE_GROUP } from "constants/project"; // types -import { IIssue, IIssueFilterOptions, IView } from "types"; +import { IIssue, IWorkspaceIssueFilterOptions } from "types"; + +export const WorkspaceViewIssues = () => { + const router = useRouter(); + const { workspaceSlug, globalViewId } = router.query; + + const { setToastAlert } = useToast(); + + const { memberRole } = useProjectMyMembership(); + const { user } = useUser(); + const { isGuest, isViewer } = useWorkspaceMembers( + workspaceSlug?.toString(), + Boolean(workspaceSlug) + ); + const { filters, viewIssues, mutateViewIssues, handleFilters } = useWorkspaceView(); -const WorkspaceView: React.FC = () => { const [createViewModal, setCreateViewModal] = useState(null); // create issue modal @@ -61,38 +62,6 @@ const WorkspaceView: React.FC = () => { const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [issueToDelete, setIssueToDelete] = useState(null); - const router = useRouter(); - const { workspaceSlug, workspaceViewId } = router.query; - - const { memberRole } = useProjectMyMembership(); - - const { user } = useUser(); - const { setToastAlert } = useToast(); - - const { data: viewDetails, error } = useSWR( - workspaceSlug && workspaceViewId ? WORKSPACE_VIEW_DETAILS(workspaceViewId.toString()) : null, - workspaceSlug && workspaceViewId - ? () => workspaceService.getViewDetails(workspaceSlug.toString(), workspaceViewId.toString()) - : null - ); - - const { params, filters, setFilters } = useWorkspaceIssuesFilters( - workspaceSlug?.toString(), - workspaceViewId?.toString() - ); - - const { isGuest, isViewer } = useWorkspaceMembers( - workspaceSlug?.toString(), - Boolean(workspaceSlug) - ); - - const { data: viewIssues, mutate: mutateIssues } = useSWR( - workspaceSlug && viewDetails ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, - workspaceSlug && viewDetails - ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) - : null - ); - const { projects: allProjects } = useProjects(); const joinedProjects = allProjects?.filter((p) => p.is_member); @@ -103,39 +72,6 @@ const WorkspaceView: React.FC = () => { const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); - const updateView = async (payload: IIssueFilterOptions) => { - const payloadData = { - query_data: payload, - }; - - await workspaceService - .updateView(workspaceSlug as string, workspaceViewId as string, payloadData) - .then((res) => { - mutate( - WORKSPACE_VIEWS_LIST(workspaceSlug as string), - (prevData) => - prevData?.map((p) => { - if (p.id === res.id) return { ...p, ...payloadData }; - - return p; - }), - false - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "View updated successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be updated. Please try again.", - }); - }); - }; - const makeIssueCopy = useCallback( (issue: IIssue) => { setCreateIssueModal(true); @@ -176,80 +112,51 @@ const WorkspaceView: React.FC = () => { ); const nullFilters = - filters && - Object.keys(filters).filter((key) => filters[key as keyof IIssueFilterOptions] === null); + filters.filters && + Object.keys(filters.filters).filter( + (key) => + filters.filters[key as keyof IWorkspaceIssueFilterOptions] === null || + (filters.filters[key as keyof IWorkspaceIssueFilterOptions]?.length ?? 0) <= 0 + ); const areFiltersApplied = - filters && - Object.keys(filters).length > 0 && - nullFilters.length !== Object.keys(filters).length; + filters.filters && + Object.keys(filters.filters).length > 0 && + nullFilters.length !== Object.keys(filters.filters).length; const isNotAllowed = isGuest || isViewer; - return ( - - - - {viewDetails ? `${viewDetails.name} Issues` : "Workspace Issues"} - -
- } - right={ -
- - { - const e = new KeyboardEvent("keydown", { key: "c" }); - document.dispatchEvent(e); - }} - > - - Add Issue - -
- } - > + <> setCreateIssueModal(false)} prePopulateData={{ ...preloadedData, }} - onSubmit={async () => { - mutateIssues(); - }} + onSubmit={async () => mutateViewIssues()} /> setEditIssueModal(false)} data={issueToEdit} - onSubmit={async () => { - mutateIssues(); - }} + onSubmit={async () => mutateViewIssues()} /> setDeleteIssueModal(false)} isOpen={deleteIssueModal} data={issueToDelete} user={user} - onSubmit={async () => { - mutateIssues(); - }} + onSubmit={async () => mutateViewIssues()} /> - setCreateViewModal(null)} - viewType="workspace" preLoadedData={createViewModal} - user={user} />
setCreateViewModal(true)} /> - {error ? ( + {false ? ( { {areFiltersApplied && ( <>
- setFilters(updatedFilter)} + handleFilters("filters", updatedFilter)} labels={workspaceLabels} members={workspaceMembers?.map((m) => m.member)} stateGroup={STATE_GROUP} project={joinedProjects} clearAllFilters={() => - setFilters({ + handleFilters("filters", { assignees: null, created_by: null, labels: null, @@ -287,17 +194,22 @@ const WorkspaceView: React.FC = () => { /> { - if (workspaceViewId) { - updateView(filters); + if (globalViewId) { + handleFilters("filters", filters.filters, true); + setToastAlert({ + title: "View updated", + message: "Your view has been updated", + type: "success", + }); } else setCreateViewModal({ - query: filters, + query: filters.filters, }); }} className="flex items-center gap-2 text-sm" > - {!workspaceViewId && } - {workspaceViewId ? "Update" : "Save"} view + {!globalViewId && } + {globalViewId ? "Update" : "Save"} view
{
} @@ -305,7 +217,7 @@ const WorkspaceView: React.FC = () => { )} { )}
- + ); }; - -export default WorkspaceView; diff --git a/web/components/issues/workspace-views/workspace-all-issue.tsx b/web/components/issues/workspace-views/workspace-all-issue.tsx new file mode 100644 index 000000000..4618c331d --- /dev/null +++ b/web/components/issues/workspace-views/workspace-all-issue.tsx @@ -0,0 +1,236 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +import useProjects from "hooks/use-projects"; +import { useWorkspaceView } from "hooks/use-workspace-view"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +import projectIssuesServices from "services/issues.service"; +// components +import { SpreadsheetView, WorkspaceFiltersList } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// ui +import { PrimaryButton } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +// fetch-keys +import { WORKSPACE_LABELS, WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// constants +import { STATE_GROUP } from "constants/project"; +// types +import { IIssue, IWorkspaceIssueFilterOptions } from "types"; + +export const WorkspaceAllIssue = () => { + const router = useRouter(); + const { workspaceSlug, globalViewId } = router.query; + + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const { data: workspaceLabels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => projectIssuesServices.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const { filters, handleFilters } = useWorkspaceView(); + + const params: any = { + assignees: filters?.filters?.assignees ? filters?.filters?.assignees.join(",") : undefined, + subscriber: filters?.filters?.subscriber ? filters?.filters?.subscriber.join(",") : undefined, + state_group: filters?.filters?.state_group + ? filters?.filters?.state_group.join(",") + : undefined, + priority: filters?.filters?.priority ? filters?.filters?.priority.join(",") : undefined, + labels: filters?.filters?.labels ? filters?.filters?.labels.join(",") : undefined, + created_by: filters?.filters?.created_by ? filters?.filters?.created_by.join(",") : undefined, + start_date: filters?.filters?.start_date ? filters?.filters?.start_date.join(",") : undefined, + target_date: filters?.filters?.target_date + ? filters?.filters?.target_date.join(",") + : undefined, + project: filters?.filters?.project ? filters?.filters?.project.join(",") : undefined, + sub_issue: false, + type: undefined, + }; + + const { data: viewIssues, mutate: mutateViewIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + const nullFilters = + filters.filters && + Object.keys(filters.filters).filter( + (key) => + filters.filters[key as keyof IWorkspaceIssueFilterOptions] === null || + (filters.filters[key as keyof IWorkspaceIssueFilterOptions]?.length ?? 0) <= 0 + ); + + const areFiltersApplied = + filters.filters && + Object.keys(filters.filters).length > 0 && + nullFilters.length !== Object.keys(filters.filters).length; + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateViewIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateViewIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateViewIssues(); + }} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> +
+ {areFiltersApplied && ( + <> +
+ handleFilters("filters", updatedFilter)} + labels={workspaceLabels} + members={workspaceMembers?.map((m) => m.member)} + stateGroup={STATE_GROUP} + project={joinedProjects} + clearAllFilters={() => + handleFilters("filters", { + assignees: null, + created_by: null, + labels: null, + priority: null, + state_group: null, + start_date: null, + target_date: null, + subscriber: null, + project: null, + }) + } + /> + { + if (globalViewId) handleFilters("filters", filters.filters, true); + else + setCreateViewModal({ + query: filters.filters, + }); + }} + className="flex items-center gap-2 text-sm" + > + {!globalViewId && } + {globalViewId ? "Update" : "Save"} view + +
+ {
} + + )} + +
+
+
+ + ); +}; diff --git a/web/components/issues/workspace-views/workspace-assigned-issue.tsx b/web/components/issues/workspace-views/workspace-assigned-issue.tsx new file mode 100644 index 000000000..4469804ac --- /dev/null +++ b/web/components/issues/workspace-views/workspace-assigned-issue.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useState } from "react"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// fetch-keys +import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +export const WorkspaceAssignedIssue = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUser(); + + const { memberRole } = useProjectMyMembership(); + + const params: any = { + assignees: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> + +
+ +
+
+
+ + ); +}; diff --git a/web/components/issues/workspace-views/workspace-created-issues.tsx b/web/components/issues/workspace-views/workspace-created-issues.tsx new file mode 100644 index 000000000..bcc83c38b --- /dev/null +++ b/web/components/issues/workspace-views/workspace-created-issues.tsx @@ -0,0 +1,147 @@ +import React, { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// fetch-keys +import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +export const WorkspaceCreatedIssues = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const params: any = { + created_by: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> +
+ +
+
+
+ + ); +}; diff --git a/web/components/issues/workspace-views/workspace-issue-view-option.tsx b/web/components/issues/workspace-views/workspace-issue-view-option.tsx index 4e98cce92..25ddc338a 100644 --- a/web/components/issues/workspace-views/workspace-issue-view-option.tsx +++ b/web/components/issues/workspace-views/workspace-issue-view-option.tsx @@ -3,10 +3,9 @@ import React from "react"; import { useRouter } from "next/router"; // hooks -import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; -import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter"; +import { useWorkspaceView } from "hooks/use-workspace-view"; // components -import { MyIssuesSelectFilters } from "components/issues"; +import { GlobalSelectFilters } from "components/workspace/views/global-select-filters"; // ui import { Tooltip } from "components/ui"; // icons @@ -31,18 +30,13 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ export const WorkspaceIssuesViewOptions: React.FC = () => { const router = useRouter(); - const { workspaceSlug, workspaceViewId } = router.query; + const { workspaceSlug, globalViewId } = router.query; - const { displayFilters, setDisplayFilters } = useMyIssuesFilters(workspaceSlug?.toString()); - - const { filters, setFilters } = useWorkspaceIssuesFilters( - workspaceSlug?.toString(), - workspaceViewId?.toString() - ); + const { filters, handleFilters } = useWorkspaceView(); const isWorkspaceViewPath = router.pathname.includes("workspace-views/all-issues"); - const showFilters = isWorkspaceViewPath || workspaceViewId; + const showFilters = isWorkspaceViewPath || globalViewId; return (
@@ -58,12 +52,12 @@ export const WorkspaceIssuesViewOptions: React.FC = () => { - ); - })} + return ( + + ); + })}
diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index b6a2be8bb..5593d8c7c 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -393,7 +393,7 @@ export const CreateProjectModal: React.FC = ({ value={value} onChange={onChange} options={options} - buttonClassName="!px-2 shadow-md" + buttonClassName="border-[0.5px] !px-2 shadow-md" label={
{value ? ( diff --git a/web/components/project/member-select.tsx b/web/components/project/member-select.tsx index 64baa945d..4fcb04268 100644 --- a/web/components/project/member-select.tsx +++ b/web/components/project/member-select.tsx @@ -16,9 +16,10 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { value: any; onChange: (val: string) => void; + isDisabled?: boolean; }; -export const MemberSelect: React.FC = ({ value, onChange }) => { +export const MemberSelect: React.FC = ({ value, onChange, isDisabled = false }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -79,6 +80,7 @@ export const MemberSelect: React.FC = ({ value, onChange }) => { position="right" width="w-full" onChange={onChange} + disabled={isDisabled} /> ); }; diff --git a/web/components/tiptap/index.tsx b/web/components/tiptap/index.tsx new file mode 100644 index 000000000..44076234e --- /dev/null +++ b/web/components/tiptap/index.tsx @@ -0,0 +1,110 @@ +import { useImperativeHandle, useRef, forwardRef, useEffect } from "react"; +import { useEditor, EditorContent, Editor } from "@tiptap/react"; +import { useDebouncedCallback } from "use-debounce"; +// components +import { EditorBubbleMenu } from "./bubble-menu"; +import { TiptapExtensions } from "./extensions"; +import { TiptapEditorProps } from "./props"; +import { ImageResizer } from "./extensions/image-resize"; +import { TableMenu } from "./table-menu"; + +export interface ITipTapRichTextEditor { + value: string; + noBorder?: boolean; + borderOnFocus?: boolean; + customClassName?: string; + editorContentCustomClassNames?: string; + onChange?: (json: any, html: string) => void; + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; + setShouldShowAlert?: (showAlert: boolean) => void; + workspaceSlug: string; + editable?: boolean; + forwardedRef?: any; + debouncedUpdatesEnabled?: boolean; +} + +const Tiptap = (props: ITipTapRichTextEditor) => { + const { + onChange, + debouncedUpdatesEnabled, + forwardedRef, + editable, + setIsSubmitting, + setShouldShowAlert, + editorContentCustomClassNames, + value, + noBorder, + workspaceSlug, + borderOnFocus, + customClassName, + } = props; + + const editor = useEditor({ + editable: editable ?? true, + editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting), + extensions: TiptapExtensions(workspaceSlug, setIsSubmitting), + content: value, + onUpdate: async ({ editor }) => { + // for instant feedback loop + setIsSubmitting?.("submitting"); + setShouldShowAlert?.(true); + if (debouncedUpdatesEnabled) { + debouncedUpdates({ onChange, editor }); + } else { + onChange?.(editor.getJSON(), editor.getHTML()); + } + }, + }); + + const editorRef: React.MutableRefObject = useRef(null); + + useImperativeHandle(forwardedRef, () => ({ + clearEditor: () => { + editorRef.current?.commands.clearContent(); + }, + setEditorValue: (content: string) => { + editorRef.current?.commands.setContent(content); + }, + })); + + const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => { + setTimeout(async () => { + if (onChange) { + onChange(editor.getJSON(), editor.getHTML()); + } + }, 500); + }, 1000); + + const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md + ${noBorder ? "" : "border border-custom-border-200"} ${ + borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0" + } ${customClassName}`; + + if (!editor) return null; + editorRef.current = editor; + + return ( +
{ + editor?.chain().focus().run(); + }} + className={`tiptap-editor-container relative cursor-text ${editorClassNames}`} + > + {editor && } +
+ + + {editor?.isActive("image") && } +
+
+ ); +}; + +const TipTapEditor = forwardRef((props, ref) => ( + +)); + +TipTapEditor.displayName = "TipTapEditor"; + +export { TipTapEditor }; diff --git a/web/components/ui/dropdowns/custom-menu.tsx b/web/components/ui/dropdowns/custom-menu.tsx index c451d4432..f456804f0 100644 --- a/web/components/ui/dropdowns/custom-menu.tsx +++ b/web/components/ui/dropdowns/custom-menu.tsx @@ -19,6 +19,7 @@ export type CustomMenuProps = DropdownProps & { const CustomMenu = ({ buttonClassName = "", + customButtonClassName = "", children, className = "", customButton, @@ -40,7 +41,13 @@ const CustomMenu = ({ {({ open }) => ( <> {customButton ? ( - + {customButton} ) : ( diff --git a/web/components/ui/dropdowns/types.d.ts b/web/components/ui/dropdowns/types.d.ts index aace1858a..b368a7ed8 100644 --- a/web/components/ui/dropdowns/types.d.ts +++ b/web/components/ui/dropdowns/types.d.ts @@ -1,5 +1,6 @@ export type DropdownProps = { buttonClassName?: string; + customButtonClassName?: string; className?: string; customButton?: JSX.Element; disabled?: boolean; diff --git a/web/components/ui/empty-state.tsx b/web/components/ui/empty-state.tsx index 098c3f152..e39b10801 100644 --- a/web/components/ui/empty-state.tsx +++ b/web/components/ui/empty-state.tsx @@ -16,6 +16,7 @@ type Props = { }; secondaryButton?: React.ReactNode; isFullScreen?: boolean; + disabled?: boolean; }; export const EmptyState: React.FC = ({ @@ -25,6 +26,7 @@ export const EmptyState: React.FC = ({ primaryButton, secondaryButton, isFullScreen = true, + disabled = false, }) => (
= ({ {description &&

{description}

}
{primaryButton && ( - + {primaryButton.icon} {primaryButton.text} diff --git a/web/components/ui/toggle-switch.tsx b/web/components/ui/toggle-switch.tsx index d6c512ad7..5ad9377de 100644 --- a/web/components/ui/toggle-switch.tsx +++ b/web/components/ui/toggle-switch.tsx @@ -21,7 +21,7 @@ export const ToggleSwitch: React.FC = (props) => { size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10" } flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ value ? "bg-custom-primary-100" : "bg-gray-700" - } ${className || ""}`} + } ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`} > {label} = (props) => { ? "translate-x-4" : "translate-x-5") + " bg-white" : "translate-x-0.5 bg-custom-background-90" - }`} + } ${disabled ? "cursor-not-allowed" : ""}`} /> ); diff --git a/web/components/views/delete-view-modal.tsx b/web/components/views/delete-view-modal.tsx index 0d49c62cc..61c627430 100644 --- a/web/components/views/delete-view-modal.tsx +++ b/web/components/views/delete-view-modal.tsx @@ -8,7 +8,6 @@ import { mutate } from "swr"; import { Dialog, Transition } from "@headlessui/react"; // services import viewsService from "services/views.service"; -import workspaceService from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; // ui @@ -18,17 +17,16 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // types import type { ICurrentUserResponse, IView } from "types"; // fetch-keys -import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; +import { VIEWS_LIST } from "constants/fetch-keys"; type Props = { isOpen: boolean; - viewType: "project" | "workspace"; setIsOpen: React.Dispatch>; data: IView | null; user: ICurrentUserResponse | undefined; }; -export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, viewType, user }) => { +export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, user }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const router = useRouter(); @@ -43,64 +41,33 @@ export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, view const handleDeletion = async () => { setIsDeleteLoading(true); + if (!workspaceSlug || !data || !projectId) return; - if (viewType === "project") { - if (!workspaceSlug || !data || !projectId) return; + await viewsService + .deleteView(workspaceSlug as string, projectId as string, data.id, user) + .then(() => { + mutate(VIEWS_LIST(projectId as string), (views) => + views?.filter((view) => view.id !== data.id) + ); - await viewsService - .deleteView(workspaceSlug as string, projectId as string, data.id, user) - .then(() => { - mutate(VIEWS_LIST(projectId as string), (views) => - views?.filter((view) => view.id !== data.id) - ); + handleClose(); - handleClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "View deleted successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be deleted. Please try again.", - }); - }) - .finally(() => { - setIsDeleteLoading(false); + setToastAlert({ + type: "success", + title: "Success!", + message: "View deleted successfully.", }); - } else { - if (!workspaceSlug || !data) return; - - await workspaceService - .deleteView(workspaceSlug as string, data.id) - .then(() => { - mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string), (views) => - views?.filter((view) => view.id !== data.id) - ); - - handleClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "View deleted successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be deleted. Please try again.", - }); - }) - .finally(() => { - setIsDeleteLoading(false); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be deleted. Please try again.", }); - } + }) + .finally(() => { + setIsDeleteLoading(false); + }); }; return ( diff --git a/web/components/views/form.tsx b/web/components/views/form.tsx index 29d16ca9c..0c57a9542 100644 --- a/web/components/views/form.tsx +++ b/web/components/views/form.tsx @@ -10,8 +10,6 @@ import { useForm } from "react-hook-form"; import stateService from "services/state.service"; // hooks import useProjectMembers from "hooks/use-project-members"; -import useProjects from "hooks/use-projects"; -import useWorkspaceMembers from "hooks/use-workspace-members"; // components import { FiltersList } from "components/core"; import { SelectFilters } from "components/views"; @@ -24,14 +22,13 @@ import { getStatesList } from "helpers/state.helper"; import { IQuery, IView } from "types"; import issuesService from "services/issues.service"; // fetch-keys -import { PROJECT_ISSUE_LABELS, STATES_LIST, WORKSPACE_LABELS } from "constants/fetch-keys"; +import { PROJECT_ISSUE_LABELS, STATES_LIST } from "constants/fetch-keys"; type Props = { handleFormSubmit: (values: IView) => Promise; handleClose: () => void; status: boolean; data?: IView | null; - viewType?: "workspace" | "project"; preLoadedData?: Partial | null; }; @@ -45,7 +42,6 @@ export const ViewForm: React.FC = ({ handleClose, status, data, - viewType, preLoadedData, }) => { const router = useRouter(); @@ -81,26 +77,8 @@ export const ViewForm: React.FC = ({ ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) : null ); - - const { data: workspaceLabels } = useSWR( - workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, - workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null - ); - - const labelOptions = viewType === "workspace" ? workspaceLabels : labels; - const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString()); - const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); - - const memberOptions = - viewType === "workspace" - ? workspaceMembers?.map((m) => m.member) - : members?.map((m) => m.member); - - const { projects: allProjects } = useProjects(); - const joinedProjects = allProjects?.filter((p) => p.is_member); - const handleCreateUpdateView = async (formData: IView) => { await handleFormSubmit(formData); @@ -113,14 +91,12 @@ export const ViewForm: React.FC = ({ setValue("query", { assignees: null, created_by: null, - subscriber: null, labels: null, priority: null, state: null, - state_group: null, start_date: null, target_date: null, - project: null, + type: null, }); }; @@ -209,10 +185,9 @@ export const ViewForm: React.FC = ({
m.member)} states={states} - project={joinedProjects} clearAllFilters={clearAllFilters} setFilters={(query: any) => { setValue("query", { diff --git a/web/components/views/modal.tsx b/web/components/views/modal.tsx index 03f4f0b60..c1ff54231 100644 --- a/web/components/views/modal.tsx +++ b/web/components/views/modal.tsx @@ -8,7 +8,6 @@ import { mutate } from "swr"; import { Dialog, Transition } from "@headlessui/react"; // services import viewsService from "services/views.service"; -import workspaceService from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; // components @@ -16,11 +15,10 @@ import { ViewForm } from "components/views"; // types import { ICurrentUserResponse, IView } from "types"; // fetch-keys -import { VIEWS_LIST, WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; +import { VIEWS_LIST } from "constants/fetch-keys"; type Props = { isOpen: boolean; - viewType: "project" | "workspace"; handleClose: () => void; data?: IView | null; preLoadedData?: Partial | null; @@ -29,7 +27,6 @@ type Props = { export const CreateUpdateViewModal: React.FC = ({ isOpen, - viewType, handleClose, data, preLoadedData, @@ -49,48 +46,25 @@ export const CreateUpdateViewModal: React.FC = ({ ...payload, query_data: payload.query, }; + await viewsService + .createView(workspaceSlug as string, projectId as string, payload, user) + .then(() => { + mutate(VIEWS_LIST(projectId as string)); + handleClose(); - if (viewType === "project") { - await viewsService - .createView(workspaceSlug as string, projectId as string, payload, user) - .then(() => { - mutate(VIEWS_LIST(projectId as string)); - handleClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "View created successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be created. Please try again.", - }); + setToastAlert({ + type: "success", + title: "Success!", + message: "View created successfully.", }); - } else { - await workspaceService - .createView(workspaceSlug as string, payload) - .then(() => { - mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string)); - handleClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "View created successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be created. Please try again.", - }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be created. Please try again.", }); - } + }); }; const updateView = async (payload: IView) => { @@ -98,79 +72,41 @@ export const CreateUpdateViewModal: React.FC = ({ ...payload, query_data: payload.query, }; - if (viewType === "project") { - await viewsService - .updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user) - .then((res) => { - mutate( - VIEWS_LIST(projectId as string), - (prevData) => - prevData?.map((p) => { - if (p.id === res.id) return { ...p, ...payloadData }; + await viewsService + .updateView(workspaceSlug as string, projectId as string, data?.id ?? "", payloadData, user) + .then((res) => { + mutate( + VIEWS_LIST(projectId as string), + (prevData) => + prevData?.map((p) => { + if (p.id === res.id) return { ...p, ...payloadData }; - return p; - }), - false - ); - onClose(); + return p; + }), + false + ); + onClose(); - setToastAlert({ - type: "success", - title: "Success!", - message: "View updated successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be updated. Please try again.", - }); + setToastAlert({ + type: "success", + title: "Success!", + message: "View updated successfully.", }); - } else { - await workspaceService - .updateView(workspaceSlug as string, data?.id ?? "", payloadData) - .then((res) => { - mutate( - WORKSPACE_VIEWS_LIST(workspaceSlug as string), - (prevData) => - prevData?.map((p) => { - if (p.id === res.id) return { ...p, ...payloadData }; - - return p; - }), - false - ); - onClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "View updated successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "View could not be updated. Please try again.", - }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be updated. Please try again.", }); - } + }); }; const handleFormSubmit = async (formData: IView) => { - if (viewType === "project") { - if (!workspaceSlug || !projectId) return; + if (!workspaceSlug || !projectId) return; - if (!data) await createView(formData); - else await updateView(formData); - } else { - if (!workspaceSlug) return; - - if (!data) await createView(formData); - else await updateView(formData); - } + if (!data) await createView(formData); + else await updateView(formData); }; return ( @@ -205,7 +141,6 @@ export const CreateUpdateViewModal: React.FC = ({ handleClose={handleClose} status={data ? true : false} data={data} - viewType={viewType} preLoadedData={preLoadedData} /> diff --git a/web/components/views/select-filters.tsx b/web/components/views/select-filters.tsx index 7b1324f5f..c3aadc33d 100644 --- a/web/components/views/select-filters.tsx +++ b/web/components/views/select-filters.tsx @@ -4,9 +4,6 @@ import { useRouter } from "next/router"; import useSWR from "swr"; -// hook -import useProjects from "hooks/use-projects"; -import useWorkspaceMembers from "hooks/use-workspace-members"; // services import stateService from "services/state.service"; import projectService from "services/project.service"; @@ -21,16 +18,11 @@ import { PriorityIcon, StateGroupIcon } from "components/icons"; import { getStatesList } from "helpers/state.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { IIssueFilterOptions, TStateGroups } from "types"; +import { IIssueFilterOptions } from "types"; // fetch-keys -import { - PROJECT_ISSUE_LABELS, - PROJECT_MEMBERS, - STATES_LIST, - WORKSPACE_LABELS, -} from "constants/fetch-keys"; +import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys"; // constants -import { GROUP_CHOICES, PRIORITIES } from "constants/project"; +import { PRIORITIES } from "constants/project"; import { DATE_FILTER_OPTIONS } from "constants/filters"; type Props = { @@ -56,7 +48,7 @@ export const SelectFilters: React.FC = ({ }); const router = useRouter(); - const { workspaceSlug, projectId, workspaceViewId } = router.query; + const { workspaceSlug, projectId } = router.query; const { data: states } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, @@ -66,20 +58,6 @@ export const SelectFilters: React.FC = ({ ); const statesList = getStatesList(states); - const workspaceViewPathName = [ - "workspace-views", - "workspace-views/all-issues", - "workspace-views/assigned", - "workspace-views/created", - "workspace-views/subscribed", - ]; - - const isWorkspaceViewPath = workspaceViewPathName.some((pathname) => - router.pathname.includes(pathname) - ); - - const isWorkspaceView = isWorkspaceViewPath || workspaceViewId; - const { data: members } = useSWR( projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug && projectId @@ -87,8 +65,6 @@ export const SelectFilters: React.FC = ({ : null ); - const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); - const { data: issueLabels } = useSWR( projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, workspaceSlug && projectId @@ -96,14 +72,6 @@ export const SelectFilters: React.FC = ({ : null ); - const { data: workspaceLabels } = useSWR( - workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, - workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null - ); - - const { projects: allProjects } = useProjects(); - const joinedProjects = allProjects?.filter((p) => p.is_member); - const projectFilterOption = [ { id: "priority", @@ -283,226 +251,6 @@ export const SelectFilters: React.FC = ({ ], }, ]; - - const workspaceFilterOption = [ - { - id: "project", - label: "Project", - value: joinedProjects, - hasChildren: true, - children: joinedProjects?.map((project) => ({ - id: project.id, - label:
{project.name}
, - value: { - key: "project", - value: project.id, - }, - selected: filters?.project?.includes(project.id), - })), - }, - { - id: "state_group", - label: "State groups", - value: GROUP_CHOICES, - hasChildren: true, - children: [ - ...Object.keys(GROUP_CHOICES).map((key) => ({ - id: key, - label: ( -
- - {GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]} -
- ), - value: { - key: "state_group", - value: key, - }, - selected: filters?.state?.includes(key), - })), - ], - }, - { - id: "labels", - label: "Labels", - value: workspaceLabels, - hasChildren: true, - children: workspaceLabels?.map((label) => ({ - id: label.id, - label: ( -
-
- {label.name} -
- ), - value: { - key: "labels", - value: label.id, - }, - selected: filters?.labels?.includes(label.id), - })), - }, - { - id: "priority", - label: "Priority", - value: PRIORITIES, - hasChildren: true, - children: PRIORITIES.map((priority) => ({ - id: priority === null ? "null" : priority, - label: ( -
- - {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, - }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - }, - { - id: "created_by", - label: "Created by", - value: workspaceMembers, - hasChildren: true, - children: workspaceMembers?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "created_by", - value: member.member.id, - }, - selected: filters?.created_by?.includes(member.member.id), - })), - }, - { - id: "assignees", - label: "Assignees", - value: workspaceMembers, - hasChildren: true, - children: workspaceMembers?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "assignees", - value: member.member.id, - }, - selected: filters?.assignees?.includes(member.member.id), - })), - }, - { - id: "subscriber", - label: "Subscriber", - value: workspaceMembers, - hasChildren: true, - children: workspaceMembers?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "subscriber", - value: member.member.id, - }, - selected: filters?.subscriber?.includes(member.member.id), - })), - }, - { - id: "start_date", - label: "Start date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...DATE_FILTER_OPTIONS.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "start_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value), - })), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - { - id: "target_date", - label: "Due date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...DATE_FILTER_OPTIONS.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "target_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), - })), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - ]; - - const filterOption = isWorkspaceView ? workspaceFilterOption : projectFilterOption; - return ( <> {isDateFilterModalOpen && ( @@ -520,7 +268,7 @@ export const SelectFilters: React.FC = ({ onSelect={onSelect} direction={direction} height={height} - options={filterOption} + options={projectFilterOption} /> ); diff --git a/web/components/views/single-view-item.tsx b/web/components/views/single-view-item.tsx index d27eb3cf1..70f07a416 100644 --- a/web/components/views/single-view-item.tsx +++ b/web/components/views/single-view-item.tsx @@ -21,17 +21,11 @@ import { truncateText } from "helpers/string.helper"; type Props = { view: IView; - viewType: "project" | "workspace"; handleEditView: () => void; handleDeleteView: () => void; }; -export const SingleViewItem: React.FC = ({ - view, - viewType, - handleEditView, - handleDeleteView, -}) => { +export const SingleViewItem: React.FC = ({ view, handleEditView, handleDeleteView }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -87,10 +81,7 @@ export const SingleViewItem: React.FC = ({ }); }; - const viewRedirectionUrl = - viewType === "project" - ? `/${workspaceSlug}/projects/${projectId}/views/${view.id}` - : `/${workspaceSlug}/workspace-views/${view.id}`; + const viewRedirectionUrl = `/${workspaceSlug}/projects/${projectId}/views/${view.id}`; return (
@@ -125,31 +116,29 @@ export const SingleViewItem: React.FC = ({ filters

- {viewType === "project" ? ( - view.is_favorite ? ( - - ) : ( - - ) - ) : null} + {view.is_favorite ? ( + + ) : ( + + )} { diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index 965a9e8a3..9f35f337c 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -1,24 +1,23 @@ import React, { useRef, useState } from "react"; - import Link from "next/link"; - -// headless ui import { Transition } from "@headlessui/react"; + +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // hooks -import useTheme from "hooks/use-theme"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material"; -import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline"; -import { DocumentIcon, DiscordIcon, GithubIcon } from "components/icons"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { DiscordIcon } from "components/icons"; +import { FileText, Github, MessagesSquare } from "lucide-react"; +// assets +import packageJson from "package.json"; const helpOptions = [ { name: "Documentation", href: "https://docs.plane.so/", - Icon: DocumentIcon, + Icon: FileText, }, { name: "Join our Discord", @@ -28,13 +27,13 @@ const helpOptions = [ { name: "Report a bug", href: "https://github.com/makeplane/plane/issues/new/choose", - Icon: GithubIcon, + Icon: Github, }, { name: "Chat with us", href: null, onClick: () => (window as any).$crisp.push(["do", "chat:show"]), - Icon: ChatBubbleOvalLeftEllipsisIcon, + Icon: MessagesSquare, }, ]; @@ -123,37 +122,44 @@ export const WorkspaceHelpSection: React.FC = ({ setS leaveTo="transform opacity-0 scale-95" >
- {helpOptions.map(({ name, Icon, href, onClick }) => { - if (href) - return ( - - + {helpOptions.map(({ name, Icon, href, onClick }) => { + if (href) + return ( + + +
+ +
+ {name} +
+ + ); + else + return ( + - ); - })} +
+ +
+ {name} + + ); + })} +
+
Version: v{packageJson.version}
diff --git a/web/components/workspace/sidebar-menu.tsx b/web/components/workspace/sidebar-menu.tsx index df38ea8af..d6c6ee3f9 100644 --- a/web/components/workspace/sidebar-menu.tsx +++ b/web/components/workspace/sidebar-menu.tsx @@ -34,7 +34,7 @@ const workspaceLinks = (workspaceSlug: string) => [ }, { Icon: TaskAltOutlined, - name: "Issues", + name: "All Issues", href: `/${workspaceSlug}/workspace-views/all-issues`, }, ]; diff --git a/web/components/workspace/views/delete-workspace-view-modal.tsx b/web/components/workspace/views/delete-workspace-view-modal.tsx new file mode 100644 index 000000000..6030f630f --- /dev/null +++ b/web/components/workspace/views/delete-workspace-view-modal.tsx @@ -0,0 +1,141 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import workspaceService from "services/workspace.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { DangerButton, SecondaryButton } from "components/ui"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// types +import { IWorkspaceView } from "types/workspace-views"; +// fetch-keys +import { WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + setIsOpen: React.Dispatch>; + data: IWorkspaceView | null; +}; + +export const DeleteWorkspaceViewModal: React.FC = ({ isOpen, data, setIsOpen }) => { + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const handleClose = () => { + setIsOpen(false); + setIsDeleteLoading(false); + }; + + const handleDeletion = async () => { + setIsDeleteLoading(true); + + if (!workspaceSlug || !data) return; + + await workspaceService + .deleteView(workspaceSlug as string, data.id) + .then(() => { + mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string), (views) => + views?.filter((view) => view.id !== data.id) + ); + + handleClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "View deleted successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be deleted. Please try again.", + }); + }) + .finally(() => { + setIsDeleteLoading(false); + }); + }; + + return ( + + + +
+ + +
+
+ + +
+
+
+
+
+ + Delete View + +
+

+ Are you sure you want to delete view-{" "} + + {data?.name} + + ? All of the data related to the view will be permanently removed. This + action cannot be undone. +

+
+
+
+
+
+ Cancel + + {isDeleteLoading ? "Deleting..." : "Delete"} + +
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/workspace/views/form.tsx b/web/components/workspace/views/form.tsx new file mode 100644 index 000000000..b16d61399 --- /dev/null +++ b/web/components/workspace/views/form.tsx @@ -0,0 +1,213 @@ +import { useEffect } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// react-hook-form +import { useForm } from "react-hook-form"; +// services +import issuesService from "services/issues.service"; + +// hooks +import useProjects from "hooks/use-projects"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +// components +import { WorkspaceFiltersList } from "components/core"; +import { GlobalSelectFilters } from "components/workspace/views/global-select-filters"; + +// ui +import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui"; +// helpers +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; +// types +import { IQuery } from "types"; +import { IWorkspaceView } from "types/workspace-views"; +// fetch-keys +import { WORKSPACE_LABELS } from "constants/fetch-keys"; +import { STATE_GROUP } from "constants/project"; + +type Props = { + handleFormSubmit: (values: IWorkspaceView) => Promise; + handleClose: () => void; + status: boolean; + data?: IWorkspaceView | null; + preLoadedData?: Partial | null; +}; + +const defaultValues: Partial = { + name: "", + description: "", +}; + +export const WorkspaceViewForm: React.FC = ({ + handleFormSubmit, + handleClose, + status, + data, + preLoadedData, +}) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + setValue, + } = useForm({ + defaultValues, + }); + const filters = watch("query"); + + const { data: labelOptions } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const memberOptions = workspaceMembers?.map((m) => m.member); + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + const handleCreateUpdateView = async (formData: IWorkspaceView) => { + await handleFormSubmit(formData); + + reset({ + ...defaultValues, + }); + }; + + const clearAllFilters = () => { + setValue("query", { + assignees: null, + created_by: null, + subscriber: null, + labels: null, + priority: null, + state_group: null, + start_date: null, + target_date: null, + project: null, + }); + }; + + useEffect(() => { + reset({ + ...defaultValues, + ...preLoadedData, + ...data, + }); + }, [data, preLoadedData, reset]); + + useEffect(() => { + if (status && data) { + setValue("query", data.query_data); + } + }, [data, status, setValue]); + + return ( + +
+

+ {status ? "Update" : "Create"} View +

+
+
+ +
+
+