diff --git a/.env.example b/.env.example index 9fe0f47d9..082aa753b 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,3 @@ -# Frontend -# Extra image domains that need to be added for Next Image -NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= -# Google Client ID for Google OAuth -NEXT_PUBLIC_GOOGLE_CLIENTID="" -# Github ID for Github OAuth -NEXT_PUBLIC_GITHUB_ID="" -# Github App Name for GitHub Integration -NEXT_PUBLIC_GITHUB_APP_NAME="" -# Sentry DSN for error monitoring -NEXT_PUBLIC_SENTRY_DSN="" -# Enable/Disable OAUTH - default 0 for selfhosted instance -NEXT_PUBLIC_ENABLE_OAUTH=0 -# Enable/Disable sentry -NEXT_PUBLIC_ENABLE_SENTRY=0 -# Enable/Disable session recording -NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 -# Enable/Disable event tracking -NEXT_PUBLIC_TRACK_EVENTS=0 -# Slack for Slack Integration -NEXT_PUBLIC_SLACK_CLIENT_ID="" -# For Telemetry, set it to "app.plane.so" -NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" -# public boards deploy url -NEXT_PUBLIC_DEPLOY_URL="" -# plane deploy using nginx -NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 - -# Backend -# Debug value for api server use it as 0 for production use -DEBUG=0 - -# Error logs -SENTRY_DSN="" - # Database Settings PGUSER="plane" PGPASSWORD="plane" @@ -45,15 +10,6 @@ REDIS_HOST="plane-redis" REDIS_PORT="6379" REDIS_URL="redis://${REDIS_HOST}:6379/" -# Email Settings -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" -EMAIL_PORT=587 -EMAIL_FROM="Team Plane " -EMAIL_USE_TLS="1" -EMAIL_USE_SSL="0" - # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" @@ -69,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint OPENAI_API_KEY="sk-" # add your openai key here GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access -# Github -GITHUB_CLIENT_SECRET="" # For fetching release notes - # Settings related to Docker DOCKERIZED=1 # set to 1 If using the pre-configured minio setup @@ -80,10 +33,3 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" - -# SignUps -ENABLE_SIGNUP="1" -# Auto generated and Required that will be generated from setup.sh diff --git a/.github/workflows/Build_Test_Pull_Request.yml b/.github/workflows/Build_Test_Pull_Request.yml index 438bdbef3..6dc7ae1e5 100644 --- a/.github/workflows/Build_Test_Pull_Request.yml +++ b/.github/workflows/Build_Test_Pull_Request.yml @@ -33,14 +33,9 @@ jobs: deploy: - space/** - - name: Setup .npmrc for repository - run: | - echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc - - name: Build Plane's Main App if: steps.changed-files.outputs.web_any_changed == 'true' run: | - mv ./.npmrc ./web cd web yarn yarn build diff --git a/.github/workflows/Update_Docker_Images.yml b/.github/workflows/Update_Docker_Images.yml index 64b7eb085..30593b584 100644 --- a/.github/workflows/Update_Docker_Images.yml +++ b/.github/workflows/Update_Docker_Images.yml @@ -22,10 +22,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Setup .npmrc for repository - run: | - echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc - - name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release id: metaFrontend uses: docker/metadata-action@v4.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6baa0bb07..b25a791d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like: -- 3rd-party libraries being used and their versions -- a use-case that fails +- 3rd-party libraries being used and their versions +- a use-case that fails Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved. @@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla ### Requirements -- Node.js version v16.18.0 -- Python version 3.8+ -- Postgres version v14 -- Redis version v6.2.7 +- Node.js version v16.18.0 +- Python version 3.8+ +- Postgres version v14 +- Redis version v6.2.7 ### Setup the project @@ -30,6 +30,48 @@ The project is a monorepo, with backend api and frontend in a single repo. The backend is a django project which is kept inside apiserver +1. Clone the repo + +```bash +git clone https://github.com/makeplane/plane +cd plane +chmod +x setup.sh +``` + +2. Run setup.sh + +```bash +./setup.sh +``` + +3. Define `NEXT_PUBLIC_API_BASE_URL=http://localhost` in **web/.env** and **space/.env** file + +```bash +echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./web/.env +``` + +```bash +echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./space/.env +``` + +4. Run Docker compose up + +```bash +docker compose up -d +``` + +5. Install dependencies + +```bash +yarn install +``` + +6. Run the web app in development mode + +```bash +yarn dev +``` + ## Missing a Feature? If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. @@ -39,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt To ensure consistency throughout the source code, please keep these rules in mind as you are working: -- All features or bug fixes must be tested by one or more specs (unit-tests). -- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. +- All features or bug fixes must be tested by one or more specs (unit-tests). +- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. ## Need help? Questions and suggestions @@ -48,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in ## Ways to contribute -- Try Plane Cloud and the self hosting platform and give feedback -- Add new integrations -- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) -- Share your thoughts and suggestions with us -- Help create tutorials and blog posts -- Request a feature by submitting a proposal -- Report a bug -- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. +- Try Plane Cloud and the self hosting platform and give feedback +- Add new integrations +- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) +- Share your thoughts and suggestions with us +- Help create tutorials and blog posts +- Request a feature by submitting a proposal +- Report a bug +- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. diff --git a/README.md b/README.md index a5a7ddd87..3cbeed8c4 100644 --- a/README.md +++ b/README.md @@ -59,17 +59,6 @@ chmod +x setup.sh > If running in a cloud env replace localhost with public facing IP address of the VM -- Setup Tiptap Pro - - Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free). - - Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro. - -``` -@tiptap-pro:registry=https://registry.tiptap.dev/ -//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN -``` - - Run Docker compose up ```bash diff --git a/apiserver/.env.example b/apiserver/.env.example new file mode 100644 index 000000000..4969f1766 --- /dev/null +++ b/apiserver/.env.example @@ -0,0 +1,61 @@ +# Backend +# Debug value for api server use it as 0 for production use +DEBUG=0 +DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" + +# Error logs +SENTRY_DSN="" + +# Database Settings +PGUSER="plane" +PGPASSWORD="plane" +PGHOST="plane-db" +PGDATABASE="plane" +DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} + +# Redis Settings +REDIS_HOST="plane-redis" +REDIS_PORT="6379" +REDIS_URL="redis://${REDIS_HOST}:6379/" + +# Email Settings +EMAIL_HOST="" +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" +EMAIL_PORT=587 +EMAIL_FROM="Team Plane " +EMAIL_USE_TLS="1" +EMAIL_USE_SSL="0" + +# AWS Settings +AWS_REGION="" +AWS_ACCESS_KEY_ID="access-key" +AWS_SECRET_ACCESS_KEY="secret-key" +AWS_S3_ENDPOINT_URL="http://plane-minio:9000" +# Changing this requires change in the nginx.conf for uploads if using minio setup +AWS_S3_BUCKET_NAME="uploads" +# Maximum file upload limit +FILE_SIZE_LIMIT=5242880 + +# GPT settings +OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint +OPENAI_API_KEY="sk-" # add your openai key here +GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access + +# Github +GITHUB_CLIENT_SECRET="" # For fetching release notes + +# Settings related to Docker +DOCKERIZED=1 +# set to 1 If using the pre-configured minio setup +USE_MINIO=1 + +# Nginx Configuration +NGINX_PORT=80 + +# Default Creds +DEFAULT_EMAIL="captain@plane.so" +DEFAULT_PASSWORD="password123" + +# SignUps +ENABLE_SIGNUP="1" diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2dc910caf..dbf7ca049 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -23,7 +23,7 @@ from .project import ( ProjectPublicMemberSerializer ) from .state import StateSerializer, StateLiteSerializer -from .view import IssueViewSerializer, IssueViewFavoriteSerializer +from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer from .asset import FileAssetSerializer from .issue import ( @@ -31,8 +31,6 @@ from .issue import ( IssueActivitySerializer, IssueCommentSerializer, IssuePropertySerializer, - BlockerIssueSerializer, - BlockedIssueSerializer, IssueAssigneeSerializer, LabelSerializer, IssueSerializer, @@ -45,6 +43,8 @@ from .issue import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, IssuePublicSerializer, ) diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 664368033..ad214c52a 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer): unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) assignees = serializers.SerializerMethodField(read_only=True) - labels = serializers.SerializerMethodField(read_only=True) total_estimates = serializers.IntegerField(read_only=True) completed_estimates = serializers.IntegerField(read_only=True) started_estimates = serializers.IntegerField(read_only=True) @@ -50,11 +49,10 @@ class CycleSerializer(BaseSerializer): members = [ { "avatar": assignee.avatar, - "first_name": assignee.first_name, "display_name": assignee.display_name, "id": assignee.id, } - for issue_cycle in obj.issue_cycle.all() + for issue_cycle in obj.issue_cycle.prefetch_related("issue__assignees").all() for assignee in issue_cycle.issue.assignees.all() ] # Use a set comprehension to return only the unique objects @@ -64,24 +62,6 @@ class CycleSerializer(BaseSerializer): unique_list = [dict(item) for item in unique_objects] return unique_list - - def get_labels(self, obj): - labels = [ - { - "name": label.name, - "color": label.color, - "id": label.id, - } - for issue_cycle in obj.issue_cycle.all() - for label in issue_cycle.issue.labels.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in labels} - - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] - - return unique_list class Meta: model = Cycle diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 938c7cab4..57539f24c 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -17,12 +17,10 @@ from plane.db.models import ( IssueActivity, IssueComment, IssueProperty, - IssueBlocker, IssueAssignee, IssueSubscriber, IssueLabel, Label, - IssueBlocker, CycleIssue, Cycle, Module, @@ -32,6 +30,7 @@ from plane.db.models import ( IssueReaction, CommentReaction, IssueVote, + IssueRelation, ) @@ -50,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer): "target_date", "sequence_id", "sort_order", + "is_draft", ] @@ -81,25 +81,12 @@ class IssueCreateSerializer(BaseSerializer): required=False, ) - # List of issues that are blocking this issue - blockers_list = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), - write_only=True, - required=False, - ) labels_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) - # List of issues that are blocked by this issue - blocks_list = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), - write_only=True, - required=False, - ) - class Meta: model = Issue fields = "__all__" @@ -122,10 +109,8 @@ class IssueCreateSerializer(BaseSerializer): return data def create(self, validated_data): - blockers = validated_data.pop("blockers_list", None) assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) - blocks = validated_data.pop("blocks_list", None) project_id = self.context["project_id"] workspace_id = self.context["workspace_id"] @@ -137,22 +122,6 @@ class IssueCreateSerializer(BaseSerializer): created_by_id = issue.created_by_id updated_by_id = issue.updated_by_id - if blockers is not None and len(blockers): - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=issue, - blocked_by=blocker, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for blocker in blockers - ], - batch_size=10, - ) - if assignees is not None and len(assignees): IssueAssignee.objects.bulk_create( [ @@ -196,29 +165,11 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if blocks is not None and len(blocks): - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=block, - blocked_by=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for block in blocks - ], - batch_size=10, - ) - return issue def update(self, instance, validated_data): - blockers = validated_data.pop("blockers_list", None) assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) - blocks = validated_data.pop("blocks_list", None) # Related models project_id = instance.project_id @@ -226,23 +177,6 @@ class IssueCreateSerializer(BaseSerializer): created_by_id = instance.created_by_id updated_by_id = instance.updated_by_id - if blockers is not None: - IssueBlocker.objects.filter(block=instance).delete() - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=instance, - blocked_by=blocker, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for blocker in blockers - ], - batch_size=10, - ) - if assignees is not None: IssueAssignee.objects.filter(issue=instance).delete() IssueAssignee.objects.bulk_create( @@ -277,23 +211,6 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if blocks is not None: - IssueBlocker.objects.filter(blocked_by=instance).delete() - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=block, - blocked_by=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for block in blocks - ], - batch_size=10, - ) - # Time updation occues even when other related models are updated instance.updated_at = timezone.now() return super().update(instance, validated_data) @@ -375,32 +292,39 @@ class IssueLabelSerializer(BaseSerializer): ] -class BlockedIssueSerializer(BaseSerializer): - blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True) +class IssueRelationSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") class Meta: - model = IssueBlocker + model = IssueRelation fields = [ - "blocked_issue_detail", - "blocked_by", - "block", + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", ] - read_only_fields = fields - -class BlockerIssueSerializer(BaseSerializer): - blocker_issue_detail = IssueProjectLiteSerializer( - source="blocked_by", read_only=True - ) +class RelatedIssueSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") class Meta: - model = IssueBlocker + model = IssueRelation fields = [ - "blocker_issue_detail", - "blocked_by", - "block", + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", ] - read_only_fields = fields class IssueAssigneeSerializer(BaseSerializer): @@ -617,10 +541,8 @@ class IssueSerializer(BaseSerializer): parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - # List of issues blocked by this issue - blocked_issues = BlockedIssueSerializer(read_only=True, many=True) - # List of issues that block this issue - blocker_issues = BlockerIssueSerializer(read_only=True, many=True) + related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) + issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) issue_link = IssueLinkSerializer(read_only=True, many=True) diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py index 076228ae0..a3b6f48be 100644 --- a/apiserver/plane/api/serializers/view.py +++ b/apiserver/plane/api/serializers/view.py @@ -5,10 +5,39 @@ from rest_framework import serializers from .base import BaseSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer -from plane.db.models import IssueView, IssueViewFavorite +from plane.db.models import GlobalView, IssueView, IssueViewFavorite from plane.utils.issue_filters import issue_filters +class GlobalViewSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + + class Meta: + model = GlobalView + fields = "__all__" + read_only_fields = [ + "workspace", + "query", + ] + + def create(self, validated_data): + query_params = validated_data.get("query_data", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = dict() + return GlobalView.objects.create(**validated_data) + + def update(self, instance, validated_data): + query_params = validated_data.get("query_data", {}) + if bool(query_params): + validated_data["query"] = issue_filters(query_params, "POST") + else: + validated_data["query"] = dict() + validated_data["query"] = issue_filters(query_params, "PATCH") + return super().update(instance, validated_data) + + class IssueViewSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 558b7f059..c10c4a745 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -90,7 +90,9 @@ from plane.api.views import ( IssueSubscriberViewSet, IssueCommentPublicViewSet, IssueReactionViewSet, + IssueRelationViewSet, CommentReactionViewSet, + IssueDraftViewSet, ## End Issues # States StateViewSet, @@ -100,6 +102,8 @@ from plane.api.views import ( BulkEstimatePointEndpoint, ## End Estimates # Views + GlobalViewViewSet, + GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet, @@ -182,7 +186,6 @@ from plane.api.views import ( ## Exporter ExportIssuesEndpoint, ## End Exporter - ) @@ -239,7 +242,11 @@ urlpatterns = [ UpdateUserTourCompletedEndpoint.as_view(), name="user-tour", ), - path("users/workspaces//activities/", UserActivityEndpoint.as_view(), name="user-activities"), + path( + "users/workspaces//activities/", + UserActivityEndpoint.as_view(), + name="user-activities", + ), # user workspaces path( "users/me/workspaces/", @@ -647,6 +654,37 @@ urlpatterns = [ ViewIssuesEndpoint.as_view(), name="project-view-issues", ), + path( + "workspaces//views/", + GlobalViewViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="global-view", + ), + path( + "workspaces//views//", + GlobalViewViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="global-view", + ), + path( + "workspaces//issues/", + GlobalViewIssuesViewSet.as_view( + { + "get": "list", + } + ), + name="global-view-issues", + ), path( "workspaces//projects//user-favorite-views/", IssueViewFavoriteViewSet.as_view( @@ -765,11 +803,6 @@ urlpatterns = [ ), name="project-issue", ), - path( - "workspaces//issues/", - WorkSpaceIssuesEndpoint.as_view(), - name="workspace-issue", - ), path( "workspaces//projects//issue-labels/", LabelViewSet.as_view( @@ -1010,6 +1043,49 @@ urlpatterns = [ name="project-issue-archive", ), ## End Issue Archives + ## Issue Relation + path( + "workspaces//projects//issues//issue-relation/", + IssueRelationViewSet.as_view( + { + "post": "create", + } + ), + name="issue-relation", + ), + path( + "workspaces//projects//issues//issue-relation//", + IssueRelationViewSet.as_view( + { + "delete": "destroy", + } + ), + name="issue-relation", + ), + ## End Issue Relation + ## Issue Drafts + path( + "workspaces//projects//issue-drafts/", + IssueDraftViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-draft", + ), + path( + "workspaces//projects//issue-drafts//", + IssueDraftViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-draft", + ), + ## End Issue Drafts ## File Assets path( "workspaces//file-assets/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 71647bfea..c03d6d5b7 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -56,7 +56,7 @@ from .workspace import ( LeaveWorkspaceEndpoint, ) from .state import StateViewSet -from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet +from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .cycle import ( CycleViewSet, CycleIssueViewSet, @@ -86,8 +86,10 @@ from .issue import ( IssueReactionPublicViewSet, CommentReactionPublicViewSet, IssueVotePublicViewSet, + IssueRelationViewSet, IssueRetrievePublicEndpoint, ProjectIssuesPublicEndpoint, + IssueDraftViewSet, ) from .auth_extended import ( @@ -167,6 +169,4 @@ from .analytic import ( from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet -from .exporter import ( - ExportIssuesEndpoint, -) \ No newline at end of file +from .exporter import ExportIssuesEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 253da2c5b..e84b6dd0a 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -80,6 +80,7 @@ class CycleViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -101,48 +102,84 @@ class CycleViewSet(BaseViewSet): .select_related("workspace") .select_related("owned_by") .annotate(is_favorite=Exists(subquery)) - .annotate(total_issues=Count("issue_cycle")) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) .annotate( completed_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="completed"), + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( cancelled_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="cancelled"), + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( started_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="started"), + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( unstarted_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="unstarted"), + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( backlog_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="backlog"), + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) .annotate( completed_estimates=Sum( "issue_cycle__issue__estimate_point", - filter=Q(issue_cycle__issue__state__group="completed"), + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( started_estimates=Sum( "issue_cycle__issue__estimate_point", - filter=Q(issue_cycle__issue__state__group="started"), + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .prefetch_related( @@ -195,17 +232,30 @@ class CycleViewSet(BaseViewSet): .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) .values("display_name", "assignee_id", "avatar") - .annotate(total_issues=Count("assignee_id")) + .annotate( + total_issues=Count( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) .annotate( completed_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("display_name") @@ -221,17 +271,30 @@ class CycleViewSet(BaseViewSet): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_issues=Count("label_id")) + .annotate( + total_issues=Count( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) .annotate( completed_issues=Count( "label_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "label_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("label_name") @@ -384,17 +447,30 @@ class CycleViewSet(BaseViewSet): .values( "first_name", "last_name", "assignee_id", "avatar", "display_name" ) - .annotate(total_issues=Count("assignee_id")) + .annotate( + total_issues=Count( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) .annotate( completed_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("first_name", "last_name") @@ -411,17 +487,30 @@ class CycleViewSet(BaseViewSet): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_issues=Count("label_id")) + .annotate( + total_issues=Count( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) .annotate( completed_issues=Count( "label_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "label_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("label_name") @@ -487,6 +576,7 @@ class CycleIssueViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -517,6 +607,7 @@ class CycleIssueViewSet(BaseViewSet): try: order_by = request.GET.get("order_by", "created_at") group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) @@ -555,9 +646,15 @@ class CycleIssueViewSet(BaseViewSet): issues_data = IssueStateSerializer(issues, many=True).data + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues_data, group_by), + group_results(issues_data, group_by, sub_group_by), status=status.HTTP_200_OK, ) @@ -655,6 +752,7 @@ class CycleIssueViewSet(BaseViewSet): ), } ), + epoch=int(timezone.now().timestamp()) ) # Return all Cycle Issues diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py index 0a92b3850..18d9a1d69 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -384,7 +384,7 @@ class BulkImportIssuesEndpoint(BaseAPIView): sort_order=largest_sort_order, start_date=issue_data.get("start_date", None), target_date=issue_data.get("target_date", None), - priority=issue_data.get("priority", None), + priority=issue_data.get("priority", "none"), created_by=request.user, ) ) diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 4fbea5f87..79294275e 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -173,12 +173,12 @@ class InboxIssueViewSet(BaseViewSet): ) # Check for valid priority - if not request.data.get("issue", {}).get("priority", None) in [ + if not request.data.get("issue", {}).get("priority", "none") in [ "low", "medium", "high", "urgent", - None, + "none", ]: return Response( {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST @@ -213,6 +213,7 @@ class InboxIssueViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, + epoch=int(timezone.now().timestamp()) ) # create an inbox issue InboxIssue.objects.create( @@ -277,6 +278,7 @@ class InboxIssueViewSet(BaseViewSet): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) issue_serializer.save() else: @@ -478,12 +480,12 @@ class InboxIssuePublicViewSet(BaseViewSet): ) # Check for valid priority - if not request.data.get("issue", {}).get("priority", None) in [ + if not request.data.get("issue", {}).get("priority", "none") in [ "low", "medium", "high", "urgent", - None, + "none", ]: return Response( {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST @@ -518,6 +520,7 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, + epoch=int(timezone.now().timestamp()) ) # create an inbox issue InboxIssue.objects.create( @@ -582,6 +585,7 @@ class InboxIssuePublicViewSet(BaseViewSet): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) issue_serializer.save() return Response(issue_serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 334ad2514..844095434 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -4,6 +4,7 @@ import random from itertools import chain # Django imports +from django.utils import timezone from django.db.models import ( Prefetch, OuterRef, @@ -24,6 +25,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import IntegrityError from django.conf import settings +from django.db import IntegrityError # Third Party imports from rest_framework.response import Response @@ -51,6 +53,8 @@ from plane.api.serializers import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, IssuePublicSerializer, ) from plane.api.permissions import ( @@ -76,6 +80,7 @@ from plane.db.models import ( CommentReaction, ProjectDeployBoard, IssueVote, + IssueRelation, ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity @@ -125,6 +130,7 @@ class IssueViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), + epoch=int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -145,6 +151,7 @@ class IssueViewSet(BaseViewSet): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -178,7 +185,7 @@ class IssueViewSet(BaseViewSet): filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", None] + priority_order = ["urgent", "high", "medium", "low", "none"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] order_by_param = request.GET.get("order_by", "-created_at") @@ -266,9 +273,16 @@ class IssueViewSet(BaseViewSet): ## Grouping the results group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues, group_by), status=status.HTTP_200_OK + group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK ) return Response(issues, status=status.HTTP_200_OK) @@ -304,6 +318,7 @@ class IssueViewSet(BaseViewSet): issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -315,7 +330,12 @@ class IssueViewSet(BaseViewSet): def retrieve(self, request, slug, project_id, pk=None): try: - issue = Issue.issue_objects.get( + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get( workspace__slug=slug, project_id=project_id, pk=pk ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -331,7 +351,7 @@ class UserWorkSpaceIssues(BaseAPIView): try: filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", None] + priority_order = ["urgent", "high", "medium", "low", "none"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] order_by_param = request.GET.get("order_by", "-created_at") @@ -443,9 +463,16 @@ class UserWorkSpaceIssues(BaseAPIView): ## Grouping the results group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues, group_by), status=status.HTTP_200_OK + group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK ) return Response(issues, status=status.HTTP_200_OK) @@ -491,7 +518,7 @@ class IssueActivityEndpoint(BaseAPIView): issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) .filter( - ~Q(field__in=["comment", "vote", "reaction"]), + ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, ) .select_related("actor", "workspace", "issue", "project") @@ -550,6 +577,7 @@ class IssueCommentViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, + epoch=int(timezone.now().timestamp()) ) def perform_update(self, serializer): @@ -568,6 +596,7 @@ class IssueCommentViewSet(BaseViewSet): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -589,6 +618,7 @@ class IssueCommentViewSet(BaseViewSet): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -872,6 +902,7 @@ class IssueLinkViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, + epoch=int(timezone.now().timestamp()) ) def perform_update(self, serializer): @@ -890,6 +921,7 @@ class IssueLinkViewSet(BaseViewSet): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -911,6 +943,7 @@ class IssueLinkViewSet(BaseViewSet): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -989,6 +1022,7 @@ class IssueAttachmentEndpoint(BaseAPIView): serializer.data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1011,6 +1045,7 @@ class IssueAttachmentEndpoint(BaseAPIView): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1068,7 +1103,7 @@ class IssueArchiveViewSet(BaseViewSet): show_sub_issues = request.GET.get("show_sub_issues", "true") # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", None] + priority_order = ["urgent", "high", "medium", "low", "none"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] order_by_param = request.GET.get("order_by", "-created_at") @@ -1213,6 +1248,7 @@ class IssueArchiveViewSet(BaseViewSet): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -1417,6 +1453,7 @@ class IssueReactionViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) def destroy(self, request, slug, project_id, issue_id, reaction_code): @@ -1440,6 +1477,7 @@ class IssueReactionViewSet(BaseViewSet): "identifier": str(issue_reaction.id), } ), + epoch=int(timezone.now().timestamp()) ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1488,6 +1526,7 @@ class CommentReactionViewSet(BaseViewSet): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) def destroy(self, request, slug, project_id, comment_id, reaction_code): @@ -1512,6 +1551,7 @@ class CommentReactionViewSet(BaseViewSet): "comment_id": str(comment_id), } ), + epoch=int(timezone.now().timestamp()) ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1608,6 +1648,7 @@ class IssueCommentPublicViewSet(BaseViewSet): issue_id=str(issue_id), project_id=str(project_id), current_instance=None, + epoch=int(timezone.now().timestamp()) ) if not ProjectMember.objects.filter( project_id=project_id, @@ -1657,6 +1698,7 @@ class IssueCommentPublicViewSet(BaseViewSet): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1690,6 +1732,7 @@ class IssueCommentPublicViewSet(BaseViewSet): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), + epoch=int(timezone.now().timestamp()) ) comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1764,6 +1807,7 @@ class IssueReactionPublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1808,6 +1852,7 @@ class IssueReactionPublicViewSet(BaseViewSet): "identifier": str(issue_reaction.id), } ), + epoch=int(timezone.now().timestamp()) ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1881,6 +1926,7 @@ class CommentReactionPublicViewSet(BaseViewSet): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1932,6 +1978,7 @@ class CommentReactionPublicViewSet(BaseViewSet): "comment_id": str(comment_id), } ), + epoch=int(timezone.now().timestamp()) ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1995,6 +2042,7 @@ class IssueVotePublicViewSet(BaseViewSet): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -2029,6 +2077,7 @@ class IssueVotePublicViewSet(BaseViewSet): "identifier": str(issue_vote.id), } ), + epoch=int(timezone.now().timestamp()) ) issue_vote.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -2040,6 +2089,109 @@ class IssueVotePublicViewSet(BaseViewSet): ) +class IssueRelationViewSet(BaseViewSet): + serializer_class = IssueRelationSerializer + model = IssueRelation + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + type="issue_relation.activity.deleted", + requested_data=json.dumps({"related_list": None}), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueRelationSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()) + ) + return super().perform_destroy(instance) + + def create(self, request, slug, project_id, issue_id): + try: + related_list = request.data.get("related_list", []) + relation = request.data.get("relation", None) + project = Project.objects.get(pk=project_id) + + issue_relation = IssueRelation.objects.bulk_create( + [ + IssueRelation( + issue_id=related_issue["issue"], + related_issue_id=related_issue["related_issue"], + relation_type=related_issue["relation_type"], + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for related_issue in related_list + ], + batch_size=10, + ignore_conflicts=True, + ) + + issue_activity.delay( + type="issue_relation.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()) + ) + + if relation == "blocking": + return Response( + RelatedIssueSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + else: + return Response( + IssueRelationSerializer(issue_relation, many=True).data, + status=status.HTTP_201_CREATED, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The issue is already taken"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + 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( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) + + class IssueRetrievePublicEndpoint(BaseAPIView): permission_classes = [ AllowAny, @@ -2078,7 +2230,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", None] + priority_order = ["urgent", "high", "medium", "low", "none"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] order_by_param = request.GET.get("order_by", "-created_at") @@ -2240,3 +2392,256 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueDraftViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = IssueFlatSerializer + model = Issue + + + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + type="issue_draft.activity.deleted", + requested_data=json.dumps( + {"issue_id": str(self.kwargs.get("pk", None))} + ), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("pk", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueSerializer(current_instance).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()) + ) + return super().perform_destroy(instance) + + + def get_queryset(self): + return ( + Issue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(is_draft=True) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + ) + + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + try: + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssueLiteSerializer(issue_queryset, many=True).data + + ## Grouping the results + group_by = request.GET.get("group_by", False) + if group_by: + return Response( + group_results(issues, group_by), status=status.HTTP_200_OK + ) + + return Response(issues, 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, + ) + + + def create(self, request, slug, project_id): + try: + project = Project.objects.get(pk=project_id) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save(is_draft=True) + + # Track the issue + issue_activity.delay( + type="issue_draft.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()) + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Project.DoesNotExist: + return Response( + {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND + ) + + + def partial_update(self, request, slug, project_id, pk): + try: + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + serializer = IssueSerializer( + issue, data=request.data, partial=True + ) + + if serializer.is_valid(): + if(request.data.get("is_draft") is not None and not request.data.get("is_draft")): + serializer.save(created_at=timezone.now(), updated_at=timezone.now()) + else: + serializer.save() + issue_activity.delay( + type="issue_draft.activity.updated", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("pk", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueSerializer(issue).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()) + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Issue.DoesNotExist: + return Response( + {"error": "Issue does not exists"}, + 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 retrieve(self, request, slug, project_id, pk=None): + try: + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True + ) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + except Issue.DoesNotExist: + return Response( + {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 1cd741f84..1489edb2d 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -2,6 +2,7 @@ import json # Django Imports +from django.utils import timezone from django.db import IntegrityError from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.core import serializers @@ -39,6 +40,7 @@ from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot + class ModuleViewSet(BaseViewSet): model = Module permission_classes = [ @@ -77,35 +79,63 @@ class ModuleViewSet(BaseViewSet): queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) - .annotate(total_issues=Count("issue_module")) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) .annotate( completed_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="completed"), + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( cancelled_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="cancelled"), + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( started_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="started"), + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( unstarted_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="unstarted"), + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( backlog_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="backlog"), + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .order_by(order_by, "name") @@ -129,6 +159,7 @@ class ModuleViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -177,18 +208,36 @@ class ModuleViewSet(BaseViewSet): .annotate(assignee_id=F("assignees__id")) .annotate(display_name=F("assignees__display_name")) .annotate(avatar=F("assignees__avatar")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") - .annotate(total_issues=Count("assignee_id")) + .values( + "first_name", "last_name", "assignee_id", "avatar", "display_name" + ) + .annotate( + total_issues=Count( + "assignee_id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ) + ) .annotate( completed_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("first_name", "last_name") @@ -204,17 +253,33 @@ class ModuleViewSet(BaseViewSet): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_issues=Count("label_id")) + .annotate( + total_issues=Count( + "label_id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), + ) .annotate( completed_issues=Count( "label_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "label_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("label_name") @@ -277,6 +342,7 @@ class ModuleIssueViewSet(BaseViewSet): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -308,6 +374,7 @@ class ModuleIssueViewSet(BaseViewSet): try: order_by = request.GET.get("order_by", "created_at") group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_module__module_id=module_id) @@ -346,9 +413,15 @@ class ModuleIssueViewSet(BaseViewSet): issues_data = IssueStateSerializer(issues, many=True).data + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues_data, group_by), + group_results(issues_data, group_by, sub_group_by), status=status.HTTP_200_OK, ) @@ -437,6 +510,7 @@ class ModuleIssueViewSet(BaseViewSet): ), } ), + epoch=int(timezone.now().timestamp()) ) return Response( @@ -483,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet): class ModuleFavoriteViewSet(BaseViewSet): - serializer_class = ModuleFavoriteSerializer model = ModuleFavorite diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 093c8ff78..c72b8d423 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1094,7 +1094,7 @@ class ProjectMemberEndpoint(BaseAPIView): project_id=project_id, workspace__slug=slug, member__is_bot=False, - ).select_related("project", "member") + ).select_related("project", "member", "workspace") serializer = ProjectMemberSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py index 0a8c5c530..35b75ce67 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -220,7 +220,7 @@ class IssueSearchEndpoint(BaseAPIView): query = request.query_params.get("search", False) workspace_search = request.query_params.get("workspace_search", "false") parent = request.query_params.get("parent", "false") - blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false") + issue_relation = request.query_params.get("issue_relation", "false") cycle = request.query_params.get("cycle", "false") module = request.query_params.get("module", "false") sub_issue = request.query_params.get("sub_issue", "false") @@ -247,12 +247,12 @@ class IssueSearchEndpoint(BaseAPIView): "parent_id", flat=True ) ) - if blocker_blocked_by == "true" and issue_id: + if issue_relation == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( ~Q(pk=issue_id), - ~Q(blocked_issues__block=issue), - ~Q(blocker_issues__blocked_by=issue), + ~Q(issue_related__issue=issue), + ~Q(issue_relation__related_issue=issue), ) if sub_issue == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 32ba24c8b..b6f1d7c4b 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -1,4 +1,18 @@ # Django imports +from django.db.models import ( + Prefetch, + OuterRef, + Func, + F, + Case, + Value, + CharField, + When, + Exists, + Max, +) +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page from django.db import IntegrityError from django.db.models import Prefetch, OuterRef, Exists @@ -10,18 +24,192 @@ from sentry_sdk import capture_exception # Module imports from . import BaseViewSet, BaseAPIView from plane.api.serializers import ( + GlobalViewSerializer, IssueViewSerializer, IssueLiteSerializer, IssueViewFavoriteSerializer, ) -from plane.api.permissions import ProjectEntityPermission +from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission from plane.db.models import ( + Workspace, + GlobalView, IssueView, Issue, IssueViewFavorite, IssueReaction, + IssueLink, + IssueAttachment, ) from plane.utils.issue_filters import issue_filters +from plane.utils.grouper import group_results + + +class GlobalViewViewSet(BaseViewSet): + serializer_class = GlobalViewSerializer + model = GlobalView + permission_classes = [ + WorkspaceEntityPermission, + ] + + def perform_create(self, serializer): + workspace = Workspace.objects.get(slug=self.kwargs.get("slug")) + serializer.save(workspace_id=workspace.id) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace") + .order_by("-created_at") + .distinct() + ) + + +class GlobalViewIssuesViewSet(BaseViewSet): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + ) + + + @method_decorator(gzip_page) + def list(self, request, slug): + try: + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .filter(project__project_projectmember__member=self.request.user) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssueLiteSerializer(issue_queryset, many=True).data + + ## Grouping the results + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if group_by: + return Response( + group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK + ) + + return Response(issues, 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 IssueViewViewSet(BaseViewSet): diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 2ec3f324a..753fd861b 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1072,7 +1072,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): .order_by("state_group") ) - priority_order = ["urgent", "high", "medium", "low", None] + priority_order = ["urgent", "high", "medium", "low", "none"] priority_distribution = ( Issue.issue_objects.filter( @@ -1239,13 +1239,21 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): .annotate( created_issues=Count( "project_issue", - filter=Q(project_issue__created_by_id=user_id), + filter=Q( + project_issue__created_by_id=user_id, + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), ) ) .annotate( assigned_issues=Count( "project_issue", - filter=Q(project_issue__assignees__in=[user_id]), + filter=Q( + project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), ) ) .annotate( @@ -1254,6 +1262,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): filter=Q( project_issue__completed_at__isnull=False, project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, ), ) ) @@ -1267,6 +1277,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): "started", ], project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, ), ) ) @@ -1317,6 +1329,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): def get(self, request, slug, user_id): try: filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( Issue.issue_objects.filter( diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py index a77d68b4b..45c53eaca 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -32,7 +32,7 @@ def delete_old_s3_link(): else: s3 = boto3.client( "s3", - region_name="ap-south-1", + region_name=settings.AWS_REGION, aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, config=Config(signature_version="s3v4"), diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 0cadac553..6d33dfc4f 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -39,6 +39,7 @@ def track_name( project, actor, issue_activities, + epoch ): if current_instance.get("name") != requested_data.get("name"): issue_activities.append( @@ -52,6 +53,7 @@ def track_name( project=project, workspace=project.workspace, comment=f"updated the name to {requested_data.get('name')}", + epoch=epoch, ) ) @@ -64,6 +66,7 @@ def track_parent( project, actor, issue_activities, + epoch ): if current_instance.get("parent") != requested_data.get("parent"): if requested_data.get("parent") == None: @@ -81,6 +84,7 @@ def track_parent( comment=f"updated the parent issue to None", old_identifier=old_parent.id, new_identifier=None, + epoch=epoch, ) ) else: @@ -101,6 +105,7 @@ def track_parent( comment=f"updated the parent issue to {new_parent.name}", old_identifier=old_parent.id if old_parent is not None else None, new_identifier=new_parent.id, + epoch=epoch, ) ) @@ -113,36 +118,23 @@ def track_priority( project, actor, issue_activities, + epoch ): if current_instance.get("priority") != requested_data.get("priority"): - if requested_data.get("priority") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("priority"), - new_value=None, - field="priority", - project=project, - workspace=project.workspace, - comment=f"updated the priority to None", - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("priority"), - new_value=requested_data.get("priority"), - field="priority", - project=project, - workspace=project.workspace, - comment=f"updated the priority to {requested_data.get('priority')}", - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("priority"), + new_value=requested_data.get("priority"), + field="priority", + project=project, + workspace=project.workspace, + comment=f"updated the priority to {requested_data.get('priority')}", + epoch=epoch, ) + ) # Track chnages in state of the issue @@ -153,6 +145,7 @@ def track_state( project, actor, issue_activities, + epoch ): if current_instance.get("state") != requested_data.get("state"): new_state = State.objects.get(pk=requested_data.get("state", None)) @@ -171,6 +164,7 @@ def track_state( comment=f"updated the state to {new_state.name}", old_identifier=old_state.id, new_identifier=new_state.id, + epoch=epoch, ) ) @@ -183,6 +177,7 @@ def track_description( project, actor, issue_activities, + epoch ): if current_instance.get("description_html") != requested_data.get( "description_html" @@ -203,6 +198,7 @@ def track_description( project=project, workspace=project.workspace, comment=f"updated the description to {requested_data.get('description_html')}", + epoch=epoch, ) ) @@ -215,6 +211,7 @@ def track_target_date( project, actor, issue_activities, + epoch ): if current_instance.get("target_date") != requested_data.get("target_date"): if requested_data.get("target_date") == None: @@ -229,6 +226,7 @@ def track_target_date( project=project, workspace=project.workspace, comment=f"updated the target date to None", + epoch=epoch, ) ) else: @@ -243,6 +241,7 @@ def track_target_date( project=project, workspace=project.workspace, comment=f"updated the target date to {requested_data.get('target_date')}", + epoch=epoch, ) ) @@ -255,6 +254,7 @@ def track_start_date( project, actor, issue_activities, + epoch ): if current_instance.get("start_date") != requested_data.get("start_date"): if requested_data.get("start_date") == None: @@ -269,6 +269,7 @@ def track_start_date( project=project, workspace=project.workspace, comment=f"updated the start date to None", + epoch=epoch, ) ) else: @@ -283,6 +284,7 @@ def track_start_date( project=project, workspace=project.workspace, comment=f"updated the start date to {requested_data.get('start_date')}", + epoch=epoch, ) ) @@ -295,6 +297,7 @@ def track_labels( project, actor, issue_activities, + epoch ): # Label Addition if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): @@ -314,6 +317,7 @@ def track_labels( comment=f"added label {label.name}", new_identifier=label.id, old_identifier=None, + epoch=epoch, ) ) @@ -335,6 +339,7 @@ def track_labels( comment=f"removed label {label.name}", old_identifier=label.id, new_identifier=None, + epoch=epoch, ) ) @@ -347,6 +352,7 @@ def track_assignees( project, actor, issue_activities, + epoch ): # Assignee Addition if len(requested_data.get("assignees_list")) > len( @@ -367,6 +373,7 @@ def track_assignees( workspace=project.workspace, comment=f"added assignee {assignee.display_name}", new_identifier=assignee.id, + epoch=epoch, ) ) @@ -389,151 +396,29 @@ def track_assignees( workspace=project.workspace, comment=f"removed assignee {assignee.display_name}", old_identifier=assignee.id, - ) - ) - - -# Track changes in blocking issues -def track_blocks( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - if len(requested_data.get("blocks_list")) > len( - current_instance.get("blocked_issues") - ): - for block in requested_data.get("blocks_list"): - if ( - len( - [ - blocked - for blocked in current_instance.get("blocked_issues") - if blocked.get("block") == block - ] - ) - == 0 - ): - issue = Issue.objects.get(pk=block) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field="blocks", - project=project, - workspace=project.workspace, - comment=f"added blocking issue {project.identifier}-{issue.sequence_id}", - new_identifier=issue.id, - ) - ) - - # Blocked Issue Removal - if len(requested_data.get("blocks_list")) < len( - current_instance.get("blocked_issues") - ): - for blocked in current_instance.get("blocked_issues"): - if blocked.get("block") not in requested_data.get("blocks_list"): - issue = Issue.objects.get(pk=blocked.get("block")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field="blocks", - project=project, - workspace=project.workspace, - comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}", - old_identifier=issue.id, - ) - ) - - -# Track changes in blocked_by issues -def track_blockings( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - if len(requested_data.get("blockers_list")) > len( - current_instance.get("blocker_issues") - ): - for block in requested_data.get("blockers_list"): - if ( - len( - [ - blocked - for blocked in current_instance.get("blocker_issues") - if blocked.get("blocked_by") == block - ] - ) - == 0 - ): - issue = Issue.objects.get(pk=block) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field="blocking", - project=project, - workspace=project.workspace, - comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}", - new_identifier=issue.id, - ) - ) - - # Blocked Issue Removal - if len(requested_data.get("blockers_list")) < len( - current_instance.get("blocker_issues") - ): - for blocked in current_instance.get("blocker_issues"): - if blocked.get("blocked_by") not in requested_data.get("blockers_list"): - issue = Issue.objects.get(pk=blocked.get("blocked_by")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field="blocking", - project=project, - workspace=project.workspace, - comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}", - old_identifier=issue.id, + epoch=epoch, ) ) def create_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): - issue_activities.append( - IssueActivity( - issue_id=issue_id, - project=project, - workspace=project.workspace, - comment=f"created the issue", - verb="created", - actor=actor, + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"created the issue", + verb="created", + actor=actor, + epoch=epoch, + ) ) - ) def track_estimate_points( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): if current_instance.get("estimate_point") != requested_data.get("estimate_point"): if requested_data.get("estimate_point") == None: @@ -548,6 +433,7 @@ def track_estimate_points( project=project, workspace=project.workspace, comment=f"updated the estimate point to None", + epoch=epoch, ) ) else: @@ -562,12 +448,13 @@ def track_estimate_points( project=project, workspace=project.workspace, comment=f"updated the estimate point to {requested_data.get('estimate_point')}", + epoch=epoch, ) ) def track_archive_at( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): if requested_data.get("archived_at") is None: issue_activities.append( @@ -581,6 +468,7 @@ def track_archive_at( field="archived_at", old_value="archive", new_value="restore", + epoch=epoch, ) ) else: @@ -595,12 +483,13 @@ def track_archive_at( field="archived_at", old_value=None, new_value="archive", + epoch=epoch, ) ) def track_closed_to( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): if requested_data.get("closed_to") is not None: updated_state = State.objects.get( @@ -620,12 +509,13 @@ def track_closed_to( comment=f"Plane updated the state to {updated_state.name}", old_identifier=None, new_identifier=updated_state.id, + epoch=epoch, ) ) def update_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): ISSUE_ACTIVITY_MAPPER = { "name": track_name, @@ -637,8 +527,6 @@ def update_issue_activity( "start_date": track_start_date, "labels_list": track_labels, "assignees_list": track_assignees, - "blocks_list": track_blocks, - "blockers_list": track_blockings, "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, @@ -659,11 +547,12 @@ def update_issue_activity( project, actor, issue_activities, + epoch ) def delete_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -673,12 +562,13 @@ def delete_issue_activity( verb="deleted", actor=actor, field="issue", + epoch=epoch, ) ) def create_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -697,12 +587,13 @@ def create_comment_activity( new_value=requested_data.get("comment_html", ""), new_identifier=requested_data.get("id", None), issue_comment_id=requested_data.get("id", None), + epoch=epoch, ) ) def update_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -724,12 +615,13 @@ def update_comment_activity( new_value=requested_data.get("comment_html", ""), new_identifier=current_instance.get("id", None), issue_comment_id=current_instance.get("id", None), + epoch=epoch, ) ) def delete_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -740,12 +632,13 @@ def delete_comment_activity( verb="deleted", actor=actor, field="comment", + epoch=epoch, ) ) def create_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -777,6 +670,7 @@ def create_cycle_issue_activity( comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}", old_identifier=old_cycle.id, new_identifier=new_cycle.id, + epoch=epoch, ) ) @@ -797,12 +691,13 @@ def create_cycle_issue_activity( workspace=project.workspace, comment=f"added cycle {cycle.name}", new_identifier=cycle.id, + epoch=epoch, ) ) def delete_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -826,12 +721,13 @@ def delete_cycle_issue_activity( workspace=project.workspace, comment=f"removed this issue from {cycle.name if cycle is not None else None}", old_identifier=cycle.id if cycle is not None else None, + epoch=epoch, ) ) def create_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -863,6 +759,7 @@ def create_module_issue_activity( comment=f"updated module from {old_module.name} to {new_module.name}", old_identifier=old_module.id, new_identifier=new_module.id, + epoch=epoch, ) ) @@ -882,12 +779,13 @@ def create_module_issue_activity( workspace=project.workspace, comment=f"added module {module.name}", new_identifier=module.id, + epoch=epoch, ) ) def delete_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -911,12 +809,13 @@ def delete_module_issue_activity( workspace=project.workspace, comment=f"removed this issue from {module.name if module is not None else None}", old_identifier=module.id if module is not None else None, + epoch=epoch, ) ) def create_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -934,12 +833,13 @@ def create_link_activity( field="link", new_value=requested_data.get("url", ""), new_identifier=requested_data.get("id", None), + epoch=epoch, ) ) def update_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -960,12 +860,13 @@ def update_link_activity( old_identifier=current_instance.get("id"), new_value=requested_data.get("url", ""), new_identifier=current_instance.get("id", None), + epoch=epoch, ) ) def delete_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( @@ -982,13 +883,14 @@ def delete_link_activity( actor=actor, field="link", old_value=current_instance.get("url", ""), - new_value="" + new_value="", + epoch=epoch, ) ) def create_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1006,12 +908,13 @@ def create_attachment_activity( field="attachment", new_value=current_instance.get("asset", ""), new_identifier=current_instance.get("id", None), + epoch=epoch, ) ) def delete_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): issue_activities.append( IssueActivity( @@ -1022,11 +925,12 @@ def delete_attachment_activity( verb="deleted", actor=actor, field="attachment", + epoch=epoch, ) ) def create_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: @@ -1045,12 +949,13 @@ def create_issue_reaction_activity( comment="added the reaction", old_identifier=None, new_identifier=issue_reaction, + epoch=epoch, ) ) def delete_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -1069,12 +974,13 @@ def delete_issue_reaction_activity( comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, + epoch=epoch, ) ) def create_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: @@ -1094,12 +1000,13 @@ def create_comment_reaction_activity( comment="added the reaction", old_identifier=None, new_identifier=comment_reaction_id, + epoch=epoch, ) ) def delete_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -1120,12 +1027,13 @@ def delete_comment_reaction_activity( comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, + epoch=epoch, ) ) def create_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("vote") is not None: @@ -1142,12 +1050,13 @@ def create_issue_vote_activity( comment="added the vote", old_identifier=None, new_identifier=None, + epoch=epoch, ) ) def delete_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -1166,10 +1075,170 @@ def delete_issue_vote_activity( comment="removed the vote", old_identifier=current_instance.get("identifier"), new_identifier=None, + epoch=epoch, ) ) +def create_issue_relation_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance is None and requested_data.get("related_list") is not None: + for issue_relation in requested_data.get("related_list"): + if issue_relation.get("relation_type") == "blocked_by": + relation_type = "blocking" + else: + relation_type = issue_relation.get("relation_type") + issue = Issue.objects.get(pk=issue_relation.get("issue")) + issue_activities.append( + IssueActivity( + issue_id=issue_relation.get("related_issue"), + actor=actor, + verb="created", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field=relation_type, + project=project, + workspace=project.workspace, + comment=f'added {relation_type} relation', + old_identifier=issue_relation.get("issue"), + ) + ) + issue = Issue.objects.get(pk=issue_relation.get("related_issue")) + issue_activities.append( + IssueActivity( + issue_id=issue_relation.get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field=f'{issue_relation.get("relation_type")}', + project=project, + workspace=project.workspace, + comment=f'added {issue_relation.get("relation_type")} relation', + old_identifier=issue_relation.get("related_issue"), + epoch=epoch, + ) + ) + + +def delete_issue_relation_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance is not None and requested_data.get("related_list") is None: + if current_instance.get("relation_type") == "blocked_by": + relation_type = "blocking" + else: + relation_type = current_instance.get("relation_type") + issue = Issue.objects.get(pk=current_instance.get("issue")) + issue_activities.append( + IssueActivity( + issue_id=current_instance.get("related_issue"), + actor=actor, + verb="deleted", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field=relation_type, + project=project, + workspace=project.workspace, + comment=f'deleted {relation_type} relation', + old_identifier=current_instance.get("issue"), + epoch=epoch, + ) + ) + issue = Issue.objects.get(pk=current_instance.get("related_issue")) + issue_activities.append( + IssueActivity( + issue_id=current_instance.get("issue"), + actor=actor, + verb="deleted", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field=f'{current_instance.get("relation_type")}', + project=project, + workspace=project.workspace, + comment=f'deleted {current_instance.get("relation_type")} relation', + old_identifier=current_instance.get("related_issue"), + epoch=epoch, + ) + ) + + +def create_draft_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"drafted the issue", + field="draft", + verb="created", + actor=actor, + epoch=epoch, + ) + ) + + +def update_draft_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if requested_data.get("is_draft") is not None and requested_data.get("is_draft") == False: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"created the issue", + verb="updated", + actor=actor, + epoch=epoch, + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"updated the draft issue", + field="draft", + verb="updated", + actor=actor, + epoch=epoch, + ) + ) + + + +def delete_draft_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities, epoch +): + issue_activities.append( + IssueActivity( + project=project, + workspace=project.workspace, + comment=f"deleted the draft issue", + field="draft", + verb="deleted", + actor=actor, + epoch=epoch, + ) + ) + # Receive message from room group @shared_task def issue_activity( @@ -1179,6 +1248,7 @@ def issue_activity( issue_id, actor_id, project_id, + epoch, subscriber=True, ): try: @@ -1233,12 +1303,17 @@ def issue_activity( "link.activity.deleted": delete_link_activity, "attachment.activity.created": create_attachment_activity, "attachment.activity.deleted": delete_attachment_activity, + "issue_relation.activity.created": create_issue_relation_activity, + "issue_relation.activity.deleted": delete_issue_relation_activity, "issue_reaction.activity.created": create_issue_reaction_activity, "issue_reaction.activity.deleted": delete_issue_reaction_activity, "comment_reaction.activity.created": create_comment_reaction_activity, "comment_reaction.activity.deleted": delete_comment_reaction_activity, "issue_vote.activity.created": create_issue_vote_activity, "issue_vote.activity.deleted": delete_issue_vote_activity, + "issue_draft.activity.created": create_draft_issue_activity, + "issue_draft.activity.updated": update_draft_issue_activity, + "issue_draft.activity.deleted": delete_draft_issue_activity, } func = ACTIVITY_MAPPER.get(type) @@ -1250,6 +1325,7 @@ def issue_activity( project, actor, issue_activities, + epoch, ) # Save all the values to database diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 645772c94..68c64403a 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -58,27 +58,31 @@ def archive_old_issues(): # Check if Issues if issues: + # Set the archive time to current time + archive_at = timezone.now() + issues_to_update = [] for issue in issues: - issue.archived_at = timezone.now() + issue.archived_at = archive_at issues_to_update.append(issue) # Bulk Update the issues and log the activity if issues_to_update: - updated_issues = Issue.objects.bulk_update( + Issue.objects.bulk_update( issues_to_update, ["archived_at"], batch_size=100 ) [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"archived_at": str(issue.archived_at)}), + requested_data=json.dumps({"archived_at": str(archive_at)}), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, current_instance=None, subscriber=False, + epoch=int(timezone.now().timestamp()) ) - for issue in updated_issues + for issue in issues_to_update ] return except Exception as e: @@ -138,7 +142,7 @@ def close_old_issues(): # Bulk Update the issues and log the activity if issues_to_update: - updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) [ issue_activity.delay( type="issue.activity.updated", @@ -148,8 +152,9 @@ def close_old_issues(): project_id=project_id, current_instance=None, subscriber=False, + epoch=int(timezone.now().timestamp()) ) - for issue in updated_issues + for issue in issues_to_update ] return except Exception as e: 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 new file mode 100644 index 000000000..950189c55 --- /dev/null +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.3 on 2023-09-12 07:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +from plane.db.models import IssueRelation +from sentry_sdk import capture_exception +import uuid + + +def create_issue_relation(apps, schema_editor): + try: + IssueBlockerModel = apps.get_model("db", "IssueBlocker") + updated_issue_relation = [] + for blocked_issue in IssueBlockerModel.objects.all(): + updated_issue_relation.append( + IssueRelation( + issue_id=blocked_issue.block_id, + related_issue_id=blocked_issue.blocked_by_id, + relation_type="blocked_by", + project_id=blocked_issue.project_id, + workspace_id=blocked_issue.workspace_id, + created_by_id=blocked_issue.created_by_id, + updated_by_id=blocked_issue.updated_by_id, + ) + ) + IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100) + except Exception as e: + print(e) + capture_exception(e) + + +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" + updated_issues.append(obj) + IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0042_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='IssueRelation', + 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)), + ('relation_type', models.CharField(choices=[('duplicate', 'Duplicate'), ('relates_to', 'Relates To'), ('blocked_by', 'Blocked By')], default='blocked_by', max_length=20, verbose_name='Issue Relation Type')), + ('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')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('related_issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_related', to='db.issue')), + ('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='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Relation', + 'verbose_name_plural': 'Issue Relations', + 'db_table': 'issue_relations', + 'ordering': ('-created_at',), + 'unique_together': {('issue', 'related_issue')}, + }, + ), + migrations.AddField( + model_name='issue', + name='is_draft', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='issue', + name='priority', + field=models.CharField(choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], default='none', max_length=30, verbose_name='Issue Priority'), + ), + migrations.RunPython(create_issue_relation), + migrations.RunPython(update_issue_priority_choice), + ] diff --git a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py new file mode 100644 index 000000000..19a1449af --- /dev/null +++ b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py @@ -0,0 +1,138 @@ +# Generated by Django 4.2.3 on 2023-09-13 07:09 + +from django.db import migrations + + +def workspace_member_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get("state_group", None), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get("target_date", None), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + "display_filters": { + "group_by": old_props.get("groupByProperty", None), + "order_by": old_props.get("orderBy", "-created_at"), + "type": old_props.get("filters", {}).get("type", None), + "sub_issue": old_props.get("showSubIssues", True), + "show_empty_groups": old_props.get("showEmptyGroups", True), + "layout": old_props.get("issueView", "list"), + "calendar_date_range": old_props.get("calendarDateRange", ""), + }, + "display_properties": { + "assignee": old_props.get("properties", {}).get("assignee", True), + "attachment_count": old_props.get("properties", {}).get("attachment_count", True), + "created_on": old_props.get("properties", {}).get("created_on", True), + "due_date": old_props.get("properties", {}).get("due_date", True), + "estimate": old_props.get("properties", {}).get("estimate", True), + "key": old_props.get("properties", {}).get("key", True), + "labels": old_props.get("properties", {}).get("labels", True), + "link": old_props.get("properties", {}).get("link", True), + "priority": old_props.get("properties", {}).get("priority", True), + "start_date": old_props.get("properties", {}).get("start_date", True), + "state": old_props.get("properties", {}).get("state", True), + "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True), + "updated_on": old_props.get("properties", {}).get("updated_on", True), + }, + } + return new_props + + +def project_member_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get("state_group", None), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get("target_date", None), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + "display_filters": { + "group_by": old_props.get("groupByProperty", None), + "order_by": old_props.get("orderBy", "-created_at"), + "type": old_props.get("filters", {}).get("type", None), + "sub_issue": old_props.get("showSubIssues", True), + "show_empty_groups": old_props.get("showEmptyGroups", True), + "layout": old_props.get("issueView", "list"), + "calendar_date_range": old_props.get("calendarDateRange", ""), + }, + } + return new_props + + +def cycle_module_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get("state_group", None), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get("target_date", None), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + } + return new_props + + +def update_workspace_member_view_props(apps, schema_editor): + WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember") + updated_workspace_member = [] + for obj in WorkspaceMemberModel.objects.all(): + obj.view_props = workspace_member_props(obj.view_props) + obj.default_props = workspace_member_props(obj.default_props) + updated_workspace_member.append(obj) + WorkspaceMemberModel.objects.bulk_update(updated_workspace_member, ["view_props", "default_props"], batch_size=100) + +def update_project_member_view_props(apps, schema_editor): + ProjectMemberModel = apps.get_model("db", "ProjectMember") + updated_project_member = [] + for obj in ProjectMemberModel.objects.all(): + obj.view_props = project_member_props(obj.view_props) + obj.default_props = project_member_props(obj.default_props) + updated_project_member.append(obj) + ProjectMemberModel.objects.bulk_update(updated_project_member, ["view_props", "default_props"], batch_size=100) + +def update_cycle_props(apps, schema_editor): + CycleModel = apps.get_model("db", "Cycle") + updated_cycle = [] + for obj in CycleModel.objects.all(): + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_cycle.append(obj) + CycleModel.objects.bulk_update(updated_cycle, ["view_props"], batch_size=100) + +def update_module_props(apps, schema_editor): + ModuleModel = apps.get_model("db", "Module") + updated_module = [] + for obj in ModuleModel.objects.all(): + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_module.append(obj) + ModuleModel.objects.bulk_update(updated_module, ["view_props"], batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0043_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.RunPython(update_workspace_member_view_props), + migrations.RunPython(update_project_member_view_props), + migrations.RunPython(update_cycle_props), + migrations.RunPython(update_module_props), + ] diff --git a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py new file mode 100644 index 000000000..cd9aa6902 --- /dev/null +++ b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py @@ -0,0 +1,42 @@ +# 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/0046_auto_20230926_1015.py b/apiserver/plane/db/migrations/0046_auto_20230926_1015.py new file mode 100644 index 000000000..8bce37d95 --- /dev/null +++ b/apiserver/plane/db/migrations/0046_auto_20230926_1015.py @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 000000000..da64e11c8 --- /dev/null +++ b/apiserver/plane/db/migrations/0047_auto_20230926_1029.py @@ -0,0 +1,44 @@ +# 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/models/__init__.py b/apiserver/plane/db/models/__init__.py index 90532dc64..9496b5906 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -32,6 +32,7 @@ from .issue import ( IssueAssignee, Label, IssueBlocker, + IssueRelation, IssueLink, IssueSequence, IssueAttachment, @@ -49,7 +50,7 @@ from .state import State from .cycle import Cycle, CycleIssue, CycleFavorite -from .view import IssueView, IssueViewFavorite +from .view import GlobalView, IssueView, IssueViewFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 78e958380..3ba054d49 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -29,6 +29,7 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__isnull=True) ) .exclude(archived_at__isnull=False) + .exclude(is_draft=True) ) @@ -38,6 +39,7 @@ class Issue(ProjectBaseModel): ("high", "High"), ("medium", "Medium"), ("low", "Low"), + ("none", "None") ) parent = models.ForeignKey( "self", @@ -64,8 +66,7 @@ class Issue(ProjectBaseModel): max_length=30, choices=PRIORITY_CHOICES, verbose_name="Issue Priority", - null=True, - blank=True, + default="none", ) start_date = models.DateField(null=True, blank=True) target_date = models.DateField(null=True, blank=True) @@ -83,6 +84,7 @@ class Issue(ProjectBaseModel): sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) archived_at = models.DateField(null=True) + is_draft = models.BooleanField(default=False) objects = models.Manager() issue_objects = IssueManager() @@ -178,6 +180,37 @@ class IssueBlocker(ProjectBaseModel): return f"{self.block.name} {self.blocked_by.name}" +class IssueRelation(ProjectBaseModel): + RELATION_CHOICES = ( + ("duplicate", "Duplicate"), + ("relates_to", "Relates To"), + ("blocked_by", "Blocked By"), + ) + + issue = models.ForeignKey( + Issue, related_name="issue_relation", on_delete=models.CASCADE + ) + related_issue = models.ForeignKey( + Issue, related_name="issue_related", on_delete=models.CASCADE + ) + relation_type = models.CharField( + max_length=20, + choices=RELATION_CHOICES, + verbose_name="Issue Relation Type", + default="blocked_by", + ) + + class Meta: + unique_together = ["issue", "related_issue"] + verbose_name = "Issue Relation" + verbose_name_plural = "Issue Relations" + db_table = "issue_relations" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.related_issue.name}" + + class IssueAssignee(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_assignee" @@ -276,6 +309,7 @@ class IssueActivity(ProjectBaseModel): ) old_identifier = models.UUIDField(null=True) new_identifier = models.UUIDField(null=True) + epoch = models.FloatField(null=True) class Meta: verbose_name = "Issue Activity" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index da155af40..4cd2134ac 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -25,13 +25,26 @@ ROLE_CHOICES = ( def get_default_props(): return { - "filters": {"type": None}, - "orderBy": "-created_at", - "collapsed": True, - "issueView": "list", - "filterIssue": None, - "groupByProperty": None, - "showEmptyGroups": True, + "filters": { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + }, + "display_filters": { + "group_by": None, + "order_by": '-created_at', + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, } diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 6a968af53..6e0a47105 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -3,7 +3,30 @@ from django.db import models from django.conf import settings # Module import -from . import ProjectBaseModel +from . import ProjectBaseModel, BaseModel + + +class GlobalView(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="global_views" + ) + name = models.CharField(max_length=255, verbose_name="View Name") + description = models.TextField(verbose_name="View Description", blank=True) + query = models.JSONField(verbose_name="View Query") + access = models.PositiveSmallIntegerField( + default=1, choices=((0, "Private"), (1, "Public")) + ) + query_data = models.JSONField(default=dict) + + class Meta: + verbose_name = "Global View" + verbose_name_plural = "Global Views" + db_table = "global_views" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the View""" + return f"{self.name} <{self.workspace.name}>" class IssueView(ProjectBaseModel): diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 48d8c9f2d..c85268435 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -16,26 +16,41 @@ ROLE_CHOICES = ( def get_default_props(): return { - "filters": {"type": None}, - "groupByProperty": None, - "issueView": "list", - "orderBy": "-created_at", - "properties": { + "filters": { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + }, + "display_filters": { + "group_by": None, + "order_by": '-created_at', + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, + "display_properties": { "assignee": True, + "attachment_count": True, + "created_on": True, "due_date": True, + "estimate": True, "key": True, "labels": True, + "link": True, "priority": True, + "start_date": True, "state": True, "sub_issue_count": True, - "attachment_count": True, - "link": True, - "estimate": True, - "created_on": True, "updated_on": True, - "start_date": True, - }, - "showEmptyGroups": True, + } } diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index acc1f34fe..e434f9742 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -1,10 +1,8 @@ """Production settings and globals.""" -from urllib.parse import urlparse import ssl import certifi import dj_database_url -from urllib.parse import urlparse import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration @@ -91,112 +89,89 @@ if bool(os.environ.get("SENTRY_DSN", False)): profiles_sample_rate=1.0, ) -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 region to connect to. +AWS_REGION = os.environ.get("AWS_REGION", "") - # 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 access key to use. +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") - # The AWS access key to use. - AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") +# The AWS secret access key to use. +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") - # The AWS secret access key to use. - AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") +# The optional AWS session token to use. +# AWS_SESSION_TOKEN = "" - # The optional AWS session token to use. - # AWS_SESSION_TOKEN = "" +# The name of the bucket to store files in. +AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") - # The name of the bucket to store files in. - AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") +# How to construct S3 URLs ("auto", "path", "virtual"). +AWS_S3_ADDRESSING_STYLE = "auto" - # How to construct S3 URLs ("auto", "path", "virtual"). - AWS_S3_ADDRESSING_STYLE = "auto" +# 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", "") - # 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", "") +# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. +AWS_S3_KEY_PREFIX = "" - # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. - AWS_S3_KEY_PREFIX = "" +# 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 - # 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 +# 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. - # 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. +# 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 = "" - # 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 = "" +# 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 - # 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-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 = "" - # 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 = "" +# 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-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 = "" +# 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 = {} - # 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 = {} +# 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 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 +# 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 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 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 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 signature version to use for S3 requests. +AWS_S3_SIGNATURE_VERSION = None - # The signature version to use for S3 requests. - AWS_S3_SIGNATURE_VERSION = None +# 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 - # 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", - } +STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", +} # AWS Settings End @@ -218,27 +193,16 @@ CSRF_COOKIE_SECURE = True REDIS_URL = os.environ.get("REDIS_URL") -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}, - }, - } +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") @@ -261,19 +225,16 @@ broker_url = ( f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" ) -if DOCKERIZED: - CELERY_BROKER_URL = REDIS_URL - CELERY_RESULT_BACKEND = REDIS_URL -else: - CELERY_RESULT_BACKEND = broker_url - CELERY_BROKER_URL = broker_url +CELERY_RESULT_BACKEND = broker_url +CELERY_BROKER_URL = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) - +# Enable or Disable signups ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" # Scout Settings SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" + diff --git a/apiserver/plane/settings/selfhosted.py b/apiserver/plane/settings/selfhosted.py new file mode 100644 index 000000000..948ba22da --- /dev/null +++ b/apiserver/plane/settings/selfhosted.py @@ -0,0 +1,128 @@ +"""Self hosted settings and globals.""" +from urllib.parse import urlparse + +import dj_database_url +from urllib.parse import urlparse + + +from .common import * # noqa + +# Database +DEBUG = int(os.environ.get("DEBUG", 0)) == 1 + +# Docker configurations +DOCKERIZED = 1 +USE_MINIO = 1 + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "plane", + "USER": os.environ.get("PGUSER", ""), + "PASSWORD": os.environ.get("PGPASSWORD", ""), + "HOST": os.environ.get("PGHOST", ""), + } +} + +# Parse database configuration from $DATABASE_URL +DATABASES["default"] = dj_database_url.config() +SITE_ID = 1 + +# File size limit +FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + +CORS_ALLOW_METHODS = [ + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +] + +CORS_ALLOW_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +] + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = True + +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +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 + +# 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}:" + +# Honor the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow all host headers +ALLOWED_HOSTS = [ + "*", +] + +# Security settings +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Redis URL +REDIS_URL = os.environ.get("REDIS_URL") + +# Caches +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +# URL used for email redirects +WEB_URL = os.environ.get("WEB_URL", "http://localhost") + +# Celery settings +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL + +# Enable or Disable signups +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + +# Analytics +ANALYTICS_BASE_API = False + +# OPEN AI Settings +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/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 60e751459..bffbb4c2a 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -74,10 +74,10 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): sorted_data = grouped_data if temp_axis == "priority": - order = ["low", "medium", "high", "urgent", "None"] + order = ["low", "medium", "high", "urgent", "none"] sorted_data = {key: grouped_data[key] for key in order if key in grouped_data} else: - sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0]))) + sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "none", x[0]))) return sorted_data diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 535bf6eba..9e134042a 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -15,7 +15,7 @@ def resolve_keys(group_keys, value): return value -def group_results(results_data, group_by): +def group_results(results_data, group_by, sub_group_by=False): """group results data into certain group_by Args: @@ -25,38 +25,140 @@ def group_results(results_data, group_by): Returns: obj: grouped results """ - response_dict = dict() + if sub_group_by: + main_responsive_dict = dict() - if group_by == "priority": - response_dict = { - "urgent": [], - "high": [], - "medium": [], - "low": [], - "None": [], - } + if sub_group_by == "priority": + main_responsive_dict = { + "urgent": {}, + "high": {}, + "medium": {}, + "low": {}, + "none": {}, + } - for value in results_data: - group_attribute = resolve_keys(group_by, value) - if isinstance(group_attribute, list): - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in response_dict: - response_dict[str(attrib)].append(value) - else: - response_dict[str(attrib)] = [] - response_dict[str(attrib)].append(value) - else: - if str(None) in response_dict: - response_dict[str(None)].append(value) + for value in results_data: + main_group_attribute = resolve_keys(sub_group_by, value) + group_attribute = resolve_keys(group_by, value) + if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list): + if len(main_group_attribute): + for attrib in main_group_attribute: + if str(attrib) not in main_responsive_dict: + main_responsive_dict[str(attrib)] = {} + if str(group_attribute) in main_responsive_dict[str(attrib)]: + main_responsive_dict[str(attrib)][str(group_attribute)].append(value) + else: + main_responsive_dict[str(attrib)][str(group_attribute)] = [] + main_responsive_dict[str(attrib)][str(group_attribute)].append(value) else: - response_dict[str(None)] = [] - response_dict[str(None)].append(value) - else: - if str(group_attribute) in response_dict: - response_dict[str(group_attribute)].append(value) - else: - response_dict[str(group_attribute)] = [] - response_dict[str(group_attribute)].append(value) + if str(None) not in main_responsive_dict: + main_responsive_dict[str(None)] = {} - return response_dict + if str(group_attribute) in main_responsive_dict[str(None)]: + main_responsive_dict[str(None)][str(group_attribute)].append(value) + else: + main_responsive_dict[str(None)][str(group_attribute)] = [] + main_responsive_dict[str(None)][str(group_attribute)].append(value) + + elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list): + if str(main_group_attribute) not in main_responsive_dict: + main_responsive_dict[str(main_group_attribute)] = {} + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in main_responsive_dict[str(main_group_attribute)]: + main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + else: + main_responsive_dict[str(main_group_attribute)][str(attrib)] = [] + main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + else: + if str(None) in main_responsive_dict[str(main_group_attribute)]: + main_responsive_dict[str(main_group_attribute)][str(None)].append(value) + else: + main_responsive_dict[str(main_group_attribute)][str(None)] = [] + main_responsive_dict[str(main_group_attribute)][str(None)].append(value) + + elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list): + if len(main_group_attribute): + for main_attrib in main_group_attribute: + if str(main_attrib) not in main_responsive_dict: + main_responsive_dict[str(main_attrib)] = {} + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in main_responsive_dict[str(main_attrib)]: + main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + else: + main_responsive_dict[str(main_attrib)][str(attrib)] = [] + main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + else: + if str(None) in main_responsive_dict[str(main_attrib)]: + main_responsive_dict[str(main_attrib)][str(None)].append(value) + else: + main_responsive_dict[str(main_attrib)][str(None)] = [] + main_responsive_dict[str(main_attrib)][str(None)].append(value) + else: + if str(None) not in main_responsive_dict: + main_responsive_dict[str(None)] = {} + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in main_responsive_dict[str(None)]: + main_responsive_dict[str(None)][str(attrib)].append(value) + else: + main_responsive_dict[str(None)][str(attrib)] = [] + main_responsive_dict[str(None)][str(attrib)].append(value) + else: + if str(None) in main_responsive_dict[str(None)]: + main_responsive_dict[str(None)][str(None)].append(value) + else: + main_responsive_dict[str(None)][str(None)] = [] + main_responsive_dict[str(None)][str(None)].append(value) + else: + main_group_attribute = resolve_keys(sub_group_by, value) + group_attribute = resolve_keys(group_by, value) + + if str(main_group_attribute) not in main_responsive_dict: + main_responsive_dict[str(main_group_attribute)] = {} + + if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]: + main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + else: + main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = [] + main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + + return main_responsive_dict + + else: + response_dict = dict() + + if group_by == "priority": + response_dict = { + "urgent": [], + "high": [], + "medium": [], + "low": [], + "none": [], + } + + for value in results_data: + group_attribute = resolve_keys(group_by, value) + if isinstance(group_attribute, list): + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in response_dict: + response_dict[str(attrib)].append(value) + else: + response_dict[str(attrib)] = [] + response_dict[str(attrib)].append(value) + else: + if str(None) in response_dict: + response_dict[str(None)].append(value) + else: + response_dict[str(None)] = [] + response_dict[str(None)].append(value) + else: + if str(group_attribute) in response_dict: + response_dict[str(group_attribute)].append(value) + else: + response_dict[str(group_attribute)] = [] + response_dict[str(group_attribute)].append(value) + + return response_dict diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 34e1e8203..dae301c38 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -1,6 +1,7 @@ from django.utils.timezone import make_aware from django.utils.dateparse import parse_datetime + def filter_state(params, filter, method): if method == "GET": states = params.get("state").split(",") @@ -23,7 +24,6 @@ def filter_state_group(params, filter, method): return filter - def filter_estimate_point(params, filter, method): if method == "GET": estimate_points = params.get("estimate_point").split(",") @@ -39,25 +39,7 @@ def filter_priority(params, filter, method): if method == "GET": priorities = params.get("priority").split(",") if len(priorities) and "" not in priorities: - if len(priorities) == 1 and "null" in priorities: - filter["priority__isnull"] = True - elif len(priorities) > 1 and "null" in priorities: - filter["priority__isnull"] = True - filter["priority__in"] = [p for p in priorities if p != "null"] - else: - filter["priority__in"] = [p for p in priorities if p != "null"] - - else: - if params.get("priority", None) and len(params.get("priority")): - priorities = params.get("priority") - if len(priorities) == 1 and "null" in priorities: - filter["priority__isnull"] = True - elif len(priorities) > 1 and "null" in priorities: - filter["priority__isnull"] = True - filter["priority__in"] = [p for p in priorities if p != "null"] - else: - filter["priority__in"] = [p for p in priorities if p != "null"] - + filter["priority__in"] = priorities return filter @@ -181,17 +163,17 @@ def filter_target_date(params, filter, method): for query in target_dates: target_date_query = query.split(";") if len(target_date_query) == 2 and "after" in target_date_query: - filter["target_date__gt"] = target_date_query[0] + filter["target_date__gte"] = target_date_query[0] else: - filter["target_date__lt"] = target_date_query[0] + filter["target_date__lte"] = target_date_query[0] else: if params.get("target_date", None) and len(params.get("target_date")): for query in params.get("target_date"): target_date_query = query.split(";") if len(target_date_query) == 2 and "after" in target_date_query: - filter["target_date__gt"] = target_date_query[0] + filter["target_date__gte"] = target_date_query[0] else: - filter["target_date__lt"] = target_date_query[0] + filter["target_date__lte"] = target_date_query[0] return filter @@ -229,7 +211,6 @@ def filter_issue_state_type(params, filter, method): return filter - def filter_project(params, filter, method): if method == "GET": projects = params.get("project").split(",") @@ -329,7 +310,7 @@ def issue_filters(query_params, method): "module": filter_module, "inbox_status": filter_inbox_status, "sub_issue": filter_sub_issue_toggle, - "subscriber": filter_subscribed_issues, + "subscriber": filter_subscribed_issues, "start_target_date": filter_start_target_date_issues, } diff --git a/docker-compose-hub.yml b/docker-compose-hub.yml index 0e42c83a8..498f37b84 100644 --- a/docker-compose-hub.yml +++ b/docker-compose-hub.yml @@ -1,113 +1,61 @@ version: "3.8" -x-api-and-worker-env: - &api-and-worker-env - DEBUG: ${DEBUG} - SENTRY_DSN: ${SENTRY_DSN} - DJANGO_SETTINGS_MODULE: plane.settings.production - DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} - REDIS_URL: redis://plane-redis:6379/ - EMAIL_HOST: ${EMAIL_HOST} - EMAIL_HOST_USER: ${EMAIL_HOST_USER} - EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} - EMAIL_PORT: ${EMAIL_PORT} - EMAIL_FROM: ${EMAIL_FROM} - EMAIL_USE_TLS: ${EMAIL_USE_TLS} - EMAIL_USE_SSL: ${EMAIL_USE_SSL} - AWS_REGION: ${AWS_REGION} - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} - AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} - WEB_URL: ${WEB_URL} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - DISABLE_COLLECTSTATIC: 1 - DOCKERIZED: 1 - OPENAI_API_BASE: ${OPENAI_API_BASE} - OPENAI_API_KEY: ${OPENAI_API_KEY} - GPT_ENGINE: ${GPT_ENGINE} - SECRET_KEY: ${SECRET_KEY} - DEFAULT_EMAIL: ${DEFAULT_EMAIL} - DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} - USE_MINIO: ${USE_MINIO} - ENABLE_SIGNUP: ${ENABLE_SIGNUP} - services: - plane-web: - container_name: planefrontend + web: + container_name: web image: makeplane/plane-frontend:latest restart: always command: /usr/local/bin/start.sh web/server.js web env_file: - - .env - environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} - NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL} - NEXT_PUBLIC_GOOGLE_CLIENTID: "0" - NEXT_PUBLIC_GITHUB_APP_NAME: "0" - NEXT_PUBLIC_GITHUB_ID: "0" - NEXT_PUBLIC_SENTRY_DSN: "0" - NEXT_PUBLIC_ENABLE_OAUTH: "0" - NEXT_PUBLIC_ENABLE_SENTRY: "0" - NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0" - NEXT_PUBLIC_TRACK_EVENTS: "0" + - ./web/.env depends_on: - - plane-api - - plane-worker + - api + - worker - plane-deploy: - container_name: planedeploy - image: makeplane/plane-deploy:latest + space: + container_name: space + image: makeplane/plane-space:latest restart: always command: /usr/local/bin/start.sh space/server.js space env_file: - - .env - environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} + - ./space/.env depends_on: - - plane-api - - plane-worker - - plane-web + - api + - worker + - web - plane-api: - container_name: planebackend + api: + container_name: api image: makeplane/plane-backend:latest restart: always command: ./bin/takeoff env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - plane-db - plane-redis - plane-worker: - container_name: planebgworker + worker: + container_name: bgworker image: makeplane/plane-backend:latest restart: always command: ./bin/worker env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis - plane-beat-worker: - container_name: planebeatworker + beat-worker: + container_name: beatworker image: makeplane/plane-backend:latest restart: always command: ./bin/beat env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis @@ -157,8 +105,8 @@ services: - plane-minio # Comment this if you already have a reverse proxy running - plane-proxy: - container_name: planeproxy + proxy: + container_name: proxy image: makeplane/plane-proxy:latest ports: - ${NGINX_PORT}:80 @@ -168,8 +116,9 @@ services: FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} depends_on: - - plane-web - - plane-api + - web + - api + - space volumes: pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index cf631face..0895aa1ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,88 +1,35 @@ version: "3.8" -x-api-and-worker-env: &api-and-worker-env - DEBUG: ${DEBUG} - SENTRY_DSN: ${SENTRY_DSN} - DJANGO_SETTINGS_MODULE: plane.settings.production - DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} - REDIS_URL: redis://plane-redis:6379/ - EMAIL_HOST: ${EMAIL_HOST} - EMAIL_HOST_USER: ${EMAIL_HOST_USER} - EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} - EMAIL_PORT: ${EMAIL_PORT} - EMAIL_FROM: ${EMAIL_FROM} - EMAIL_USE_TLS: ${EMAIL_USE_TLS} - EMAIL_USE_SSL: ${EMAIL_USE_SSL} - AWS_REGION: ${AWS_REGION} - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} - AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} - WEB_URL: ${WEB_URL} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - DISABLE_COLLECTSTATIC: 1 - DOCKERIZED: 1 - OPENAI_API_BASE: ${OPENAI_API_BASE} - OPENAI_API_KEY: ${OPENAI_API_KEY} - GPT_ENGINE: ${GPT_ENGINE} - SECRET_KEY: ${SECRET_KEY} - DEFAULT_EMAIL: ${DEFAULT_EMAIL} - DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} - USE_MINIO: ${USE_MINIO} - ENABLE_SIGNUP: ${ENABLE_SIGNUP} - services: - plane-web: - container_name: planefrontend + web: + container_name: web build: context: . dockerfile: ./web/Dockerfile.web args: DOCKER_BUILDKIT: 1 - NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 - NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces restart: always command: /usr/local/bin/start.sh web/server.js web - env_file: - - .env - environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} - NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL} - NEXT_PUBLIC_GOOGLE_CLIENTID: "0" - NEXT_PUBLIC_GITHUB_APP_NAME: "0" - NEXT_PUBLIC_GITHUB_ID: "0" - NEXT_PUBLIC_SENTRY_DSN: "0" - NEXT_PUBLIC_ENABLE_OAUTH: "0" - NEXT_PUBLIC_ENABLE_SENTRY: "0" - NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0" - NEXT_PUBLIC_TRACK_EVENTS: "0" depends_on: - - plane-api - - plane-worker + - api + - worker - plane-deploy: - container_name: planedeploy + space: + container_name: space build: context: . dockerfile: ./space/Dockerfile.space args: DOCKER_BUILDKIT: 1 - NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1 - NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 restart: always command: /usr/local/bin/start.sh space/server.js space - env_file: - - .env - environment: - - NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} depends_on: - - plane-api - - plane-worker - - plane-web + - api + - worker + - web - plane-api: - container_name: planebackend + api: + container_name: api build: context: ./apiserver dockerfile: Dockerfile.api @@ -91,15 +38,13 @@ services: restart: always command: ./bin/takeoff env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - plane-db - plane-redis - plane-worker: - container_name: planebgworker + worker: + container_name: bgworker build: context: ./apiserver dockerfile: Dockerfile.api @@ -108,16 +53,14 @@ services: restart: always command: ./bin/worker env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis - plane-beat-worker: - container_name: planebeatworker + beat-worker: + container_name: beatworker build: context: ./apiserver dockerfile: Dockerfile.api @@ -126,11 +69,9 @@ services: restart: always command: ./bin/beat env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis @@ -163,8 +104,6 @@ services: command: server /export --console-address ":9090" volumes: - uploads:/export - env_file: - - .env environment: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} @@ -179,22 +118,21 @@ services: - plane-minio # Comment this if you already have a reverse proxy running - plane-proxy: - container_name: planeproxy + proxy: + container_name: proxy build: context: ./nginx dockerfile: Dockerfile restart: always ports: - ${NGINX_PORT}:80 - env_file: - - .env environment: FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} depends_on: - - plane-web - - plane-api + - web + - api + - space volumes: pgdata: diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index 974f4907d..4775dcbfa 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -1,30 +1,36 @@ -events { } - +events { +} http { - sendfile on; + sendfile on; -server { - listen 80; - root /www/data/; - access_log /var/log/nginx/access.log; + server { + listen 80; + root /www/data/; + access_log /var/log/nginx/access.log; - client_max_body_size ${FILE_SIZE_LIMIT}; + client_max_body_size ${FILE_SIZE_LIMIT}; - location / { - proxy_pass http://planefrontend:3000/; - } + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Permissions-Policy "interest-cohort=()" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - location /api/ { - proxy_pass http://planebackend:8000/api/; - } + location / { + proxy_pass http://web:3000/; + } - location /spaces/ { - proxy_pass http://planedeploy:3000/spaces/; - } + location /api/ { + proxy_pass http://api:8000/api/; + } - location /${BUCKET_NAME}/ { - proxy_pass http://plane-minio:9000/uploads/; + location /spaces/ { + rewrite ^/spaces/?$ /spaces/login break; + proxy_pass http://space:3000/spaces/; + } + + location /${BUCKET_NAME}/ { + proxy_pass http://plane-minio:9000/uploads/; + } } } -} \ No newline at end of file diff --git a/package.json b/package.json index 793a1922f..de09c6ee9 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,12 @@ "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "devDependencies": { + "autoprefixer": "^10.4.15", "eslint-config-custom": "*", + "postcss": "^8.4.29", "prettier": "latest", + "prettier-plugin-tailwindcss": "^0.5.4", + "tailwindcss": "^3.3.3", "turbo": "latest" }, "packageManager": "yarn@1.22.19" diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index d31a76406..82be65376 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -16,5 +16,7 @@ module.exports = { "no-duplicate-imports": "error", "arrow-body-style": ["error", "as-needed"], "react/self-closing-comp": ["error", { component: true, html: true }], + "@next/next/no-img-element": "off", + "@typescript-eslint/no-unused-vars": ["warn"], }, }; diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json new file mode 100644 index 000000000..1bd5a0e1c --- /dev/null +++ b/packages/tailwind-config-custom/package.json @@ -0,0 +1,10 @@ +{ + "name": "tailwind-config-custom", + "version": "0.0.1", + "description": "common tailwind configuration across monorepo", + "main": "index.js", + "devDependencies": { + "@tailwindcss/typography": "^0.5.10", + "tailwindcss-animate": "^1.0.7" + } +} diff --git a/packages/tailwind-config-custom/postcss.config.js b/packages/tailwind-config-custom/postcss.config.js new file mode 100644 index 000000000..cbfea5ea2 --- /dev/null +++ b/packages/tailwind-config-custom/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + "tailwindcss/nesting": {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js new file mode 100644 index 000000000..061168c4f --- /dev/null +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -0,0 +1,212 @@ +const convertToRGB = (variableName) => `rgba(var(${variableName}))`; + +module.exports = { + darkMode: "class", + content: [ + "./components/**/*.tsx", + "./constants/**/*.{js,ts,jsx,tsx}", + "./layouts/**/*.tsx", + "./pages/**/*.tsx", + "./ui/**/*.tsx", + ], + theme: { + extend: { + boxShadow: { + "custom-shadow-2xs": "var(--color-shadow-2xs)", + "custom-shadow-xs": "var(--color-shadow-xs)", + "custom-shadow-sm": "var(--color-shadow-sm)", + "custom-shadow-rg": "var(--color-shadow-rg)", + "custom-shadow-md": "var(--color-shadow-md)", + "custom-shadow-lg": "var(--color-shadow-lg)", + "custom-shadow-xl": "var(--color-shadow-xl)", + "custom-shadow-2xl": "var(--color-shadow-2xl)", + "custom-shadow-3xl": "var(--color-shadow-3xl)", + "custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)", + "custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)", + "custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)", + "custom-sidebar-shadow-rg": "var(--color-sidebar-shadow-rg)", + "custom-sidebar-shadow-md": "var(--color-sidebar-shadow-md)", + "custom-sidebar-shadow-lg": "var(--color-sidebar-shadow-lg)", + "custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)", + "custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)", + "custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)", + }, + colors: { + custom: { + primary: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-primary-10"), + 20: convertToRGB("--color-primary-20"), + 30: convertToRGB("--color-primary-30"), + 40: convertToRGB("--color-primary-40"), + 50: convertToRGB("--color-primary-50"), + 60: convertToRGB("--color-primary-60"), + 70: convertToRGB("--color-primary-70"), + 80: convertToRGB("--color-primary-80"), + 90: convertToRGB("--color-primary-90"), + 100: convertToRGB("--color-primary-100"), + 200: convertToRGB("--color-primary-200"), + 300: convertToRGB("--color-primary-300"), + 400: convertToRGB("--color-primary-400"), + 500: convertToRGB("--color-primary-500"), + 600: convertToRGB("--color-primary-600"), + 700: convertToRGB("--color-primary-700"), + 800: convertToRGB("--color-primary-800"), + 900: convertToRGB("--color-primary-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-primary-100"), + }, + background: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-background-10"), + 20: convertToRGB("--color-background-20"), + 30: convertToRGB("--color-background-30"), + 40: convertToRGB("--color-background-40"), + 50: convertToRGB("--color-background-50"), + 60: convertToRGB("--color-background-60"), + 70: convertToRGB("--color-background-70"), + 80: convertToRGB("--color-background-80"), + 90: convertToRGB("--color-background-90"), + 100: convertToRGB("--color-background-100"), + 200: convertToRGB("--color-background-200"), + 300: convertToRGB("--color-background-300"), + 400: convertToRGB("--color-background-400"), + 500: convertToRGB("--color-background-500"), + 600: convertToRGB("--color-background-600"), + 700: convertToRGB("--color-background-700"), + 800: convertToRGB("--color-background-800"), + 900: convertToRGB("--color-background-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-background-100"), + }, + text: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-text-10"), + 20: convertToRGB("--color-text-20"), + 30: convertToRGB("--color-text-30"), + 40: convertToRGB("--color-text-40"), + 50: convertToRGB("--color-text-50"), + 60: convertToRGB("--color-text-60"), + 70: convertToRGB("--color-text-70"), + 80: convertToRGB("--color-text-80"), + 90: convertToRGB("--color-text-90"), + 100: convertToRGB("--color-text-100"), + 200: convertToRGB("--color-text-200"), + 300: convertToRGB("--color-text-300"), + 400: convertToRGB("--color-text-400"), + 500: convertToRGB("--color-text-500"), + 600: convertToRGB("--color-text-600"), + 700: convertToRGB("--color-text-700"), + 800: convertToRGB("--color-text-800"), + 900: convertToRGB("--color-text-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-text-100"), + }, + border: { + 0: "rgb(255, 255, 255)", + 100: convertToRGB("--color-border-100"), + 200: convertToRGB("--color-border-200"), + 300: convertToRGB("--color-border-300"), + 400: convertToRGB("--color-border-400"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-border-200"), + }, + sidebar: { + background: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-sidebar-background-10"), + 20: convertToRGB("--color-sidebar-background-20"), + 30: convertToRGB("--color-sidebar-background-30"), + 40: convertToRGB("--color-sidebar-background-40"), + 50: convertToRGB("--color-sidebar-background-50"), + 60: convertToRGB("--color-sidebar-background-60"), + 70: convertToRGB("--color-sidebar-background-70"), + 80: convertToRGB("--color-sidebar-background-80"), + 90: convertToRGB("--color-sidebar-background-90"), + 100: convertToRGB("--color-sidebar-background-100"), + 200: convertToRGB("--color-sidebar-background-200"), + 300: convertToRGB("--color-sidebar-background-300"), + 400: convertToRGB("--color-sidebar-background-400"), + 500: convertToRGB("--color-sidebar-background-500"), + 600: convertToRGB("--color-sidebar-background-600"), + 700: convertToRGB("--color-sidebar-background-700"), + 800: convertToRGB("--color-sidebar-background-800"), + 900: convertToRGB("--color-sidebar-background-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-sidebar-background-100"), + }, + text: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-sidebar-text-10"), + 20: convertToRGB("--color-sidebar-text-20"), + 30: convertToRGB("--color-sidebar-text-30"), + 40: convertToRGB("--color-sidebar-text-40"), + 50: convertToRGB("--color-sidebar-text-50"), + 60: convertToRGB("--color-sidebar-text-60"), + 70: convertToRGB("--color-sidebar-text-70"), + 80: convertToRGB("--color-sidebar-text-80"), + 90: convertToRGB("--color-sidebar-text-90"), + 100: convertToRGB("--color-sidebar-text-100"), + 200: convertToRGB("--color-sidebar-text-200"), + 300: convertToRGB("--color-sidebar-text-300"), + 400: convertToRGB("--color-sidebar-text-400"), + 500: convertToRGB("--color-sidebar-text-500"), + 600: convertToRGB("--color-sidebar-text-600"), + 700: convertToRGB("--color-sidebar-text-700"), + 800: convertToRGB("--color-sidebar-text-800"), + 900: convertToRGB("--color-sidebar-text-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-sidebar-text-100"), + }, + border: { + 0: "rgb(255, 255, 255)", + 100: convertToRGB("--color-sidebar-border-100"), + 200: convertToRGB("--color-sidebar-border-200"), + 300: convertToRGB("--color-sidebar-border-300"), + 400: convertToRGB("--color-sidebar-border-400"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-sidebar-border-200"), + }, + }, + backdrop: "#131313", + }, + }, + keyframes: { + leftToaster: { + "0%": { left: "-20rem" }, + "100%": { left: "0" }, + }, + rightToaster: { + "0%": { right: "-20rem" }, + "100%": { right: "0" }, + }, + }, + typography: ({ theme }) => ({ + brand: { + css: { + "--tw-prose-body": convertToRGB("--color-text-100"), + "--tw-prose-p": convertToRGB("--color-text-100"), + "--tw-prose-headings": convertToRGB("--color-text-100"), + "--tw-prose-lead": convertToRGB("--color-text-100"), + "--tw-prose-links": convertToRGB("--color-primary-100"), + "--tw-prose-bold": convertToRGB("--color-text-100"), + "--tw-prose-counters": convertToRGB("--color-text-100"), + "--tw-prose-bullets": convertToRGB("--color-text-100"), + "--tw-prose-hr": convertToRGB("--color-text-100"), + "--tw-prose-quotes": convertToRGB("--color-text-100"), + "--tw-prose-quote-borders": convertToRGB("--color-border"), + "--tw-prose-code": convertToRGB("--color-text-100"), + "--tw-prose-pre-code": convertToRGB("--color-text-100"), + "--tw-prose-pre-bg": convertToRGB("--color-background-100"), + "--tw-prose-th-borders": convertToRGB("--color-border"), + "--tw-prose-td-borders": convertToRGB("--color-border"), + }, + }, + }), + }, + fontFamily: { + custom: ["Inter", "sans-serif"], + }, + }, + plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 63e41b917..6a9132fca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,7 @@ "next": "12.3.2", "react": "^18.2.0", "tsconfig": "*", + "tailwind-config-custom": "*", "typescript": "4.7.4" } } diff --git a/packages/ui/postcss.config.js b/packages/ui/postcss.config.js new file mode 100644 index 000000000..129aa7f59 --- /dev/null +++ b/packages/ui/postcss.config.js @@ -0,0 +1 @@ +module.exports = require("tailwind-config-custom/postcss.config"); diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js new file mode 100644 index 000000000..1e1e59826 --- /dev/null +++ b/packages/ui/tailwind.config.js @@ -0,0 +1 @@ +module.exports = require("tailwind-config-custom/tailwind.config"); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 8c357fac6..cd6c94d6e 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,9 +1,5 @@ { - "extends": "../tsconfig/nextjs.json", + "extends": "tsconfig/react-library.json", "include": ["."], - "exclude": ["dist", "build", "node_modules"], - "compilerOptions": { - "jsx": "react-jsx", - "lib": ["DOM"] - } + "exclude": ["dist", "build", "node_modules"] } diff --git a/replace-env-vars.sh b/replace-env-vars.sh deleted file mode 100644 index 949ffd7d7..000000000 --- a/replace-env-vars.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -FROM=$1 -TO=$2 -DIRECTORY=$3 - -if [ "${FROM}" = "${TO}" ]; then - echo "Nothing to replace, the value is already set to ${TO}." - - exit 0 -fi - -# Only perform action if $FROM and $TO are different. -echo "Replacing all statically built instances of $FROM with this string $TO ." - -grep -R -la "${FROM}" $DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}" diff --git a/setup.sh b/setup.sh index 235e1a977..e028cc407 100755 --- a/setup.sh +++ b/setup.sh @@ -5,25 +5,9 @@ cp ./.env.example ./.env export LC_ALL=C export LC_CTYPE=C - -# Generate the NEXT_PUBLIC_API_BASE_URL with given IP -echo -e "\nNEXT_PUBLIC_API_BASE_URL=$1" >> ./.env +cp ./web/.env.example ./web/.env +cp ./space/.env.example ./space/.env +cp ./apiserver/.env.example ./apiserver/.env # Generate the SECRET_KEY that will be used by django -echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.env - -# WEB_URL for email redirection and image saving -echo -e "WEB_URL=$1" >> ./.env - -# Generate Prompt for taking tiptap auth key -echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n" - -echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m" -echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n" - -read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken - - -echo "@tiptap-pro:registry=https://registry.tiptap.dev/ -//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc - +echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env \ No newline at end of file diff --git a/space/.env.example b/space/.env.example index 238f70854..7700ec946 100644 --- a/space/.env.example +++ b/space/.env.example @@ -1,8 +1,2 @@ -# Base url for the API requests -NEXT_PUBLIC_API_BASE_URL="" -# Public boards deploy URL -NEXT_PUBLIC_DEPLOY_URL="" -# Google Client ID for Google OAuth -NEXT_PUBLIC_GOOGLE_CLIENTID="" # Flag to toggle OAuth -NEXT_PUBLIC_ENABLE_OAUTH=1 \ No newline at end of file +NEXT_PUBLIC_ENABLE_OAUTH=0 \ No newline at end of file diff --git a/space/.eslintrc.js b/space/.eslintrc.js index 38e6a5f4c..c8df60750 100644 --- a/space/.eslintrc.js +++ b/space/.eslintrc.js @@ -1,7 +1,4 @@ module.exports = { root: true, extends: ["custom"], - rules: { - "@next/next/no-img-element": "off", - }, }; diff --git a/space/Dockerfile.space b/space/Dockerfile.space index 963dad136..12c309134 100644 --- a/space/Dockerfile.space +++ b/space/Dockerfile.space @@ -1,7 +1,6 @@ FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat WORKDIR /app -ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER RUN yarn global add turbo COPY . . @@ -20,19 +19,16 @@ RUN yarn install --network-timeout 500000 COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json -COPY replace-env-vars.sh /usr/local/bin/ USER root -RUN chmod +x /usr/local/bin/replace-env-vars.sh -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +ARG NEXT_PUBLIC_API_BASE_URL="" ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX RUN yarn turbo run build --filter=space -RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space - FROM node:18-alpine AS runner WORKDIR /app @@ -48,14 +44,14 @@ COPY --from=installer --chown=captain:plane /app/space/.next/standalone ./ COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next COPY --from=installer --chown=captain:plane /app/space/public ./space/public -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +ARG NEXT_PUBLIC_API_BASE_URL="" ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX + +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX USER root -COPY replace-env-vars.sh /usr/local/bin/ COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/replace-env-vars.sh RUN chmod +x /usr/local/bin/start.sh USER captain diff --git a/space/components/accounts/email-password-form.tsx b/space/components/accounts/email-password-form.tsx index 23742eefe..b00740a15 100644 --- a/space/components/accounts/email-password-form.tsx +++ b/space/components/accounts/email-password-form.tsx @@ -1,9 +1,6 @@ import React, { useState } from "react"; - import { useRouter } from "next/router"; import Link from "next/link"; - -// react hook form import { useForm } from "react-hook-form"; // components import { EmailResetPasswordForm } from "./email-reset-password-form"; diff --git a/space/components/accounts/sign-in.tsx b/space/components/accounts/sign-in.tsx index ed55f7697..c6a151d44 100644 --- a/space/components/accounts/sign-in.tsx +++ b/space/components/accounts/sign-in.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; @@ -13,7 +13,7 @@ import useToast from "hooks/use-toast"; // components import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; // images -const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; +const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; export const SignInView = observer(() => { const { user: userStore } = useMobxStore(); @@ -33,7 +33,7 @@ export const SignInView = observer(() => { const onSignInSuccess = (response: any) => { const isOnboarded = response?.user?.onboarding_step?.profile_complete || false; - const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/"; + const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login"; userStore.setCurrentUser(response?.user); @@ -41,7 +41,7 @@ export const SignInView = observer(() => { router.push(`/onboarding?next_path=${nextPath}`); return; } - router.push((nextPath ?? "/").toString()); + router.push((nextPath ?? "/login").toString()); }; const handleGoogleSignIn = async ({ clientId, credential }: any) => { diff --git a/space/components/tiptap/extensions/index.tsx b/space/components/tiptap/extensions/index.tsx index f5dc11384..8ad4e07b4 100644 --- a/space/components/tiptap/extensions/index.tsx +++ b/space/components/tiptap/extensions/index.tsx @@ -18,7 +18,6 @@ import Gapcursor from "@tiptap/extension-gapcursor"; import ts from "highlight.js/lib/languages/typescript"; import "highlight.js/styles/github-dark.css"; -import UniqueID from "@tiptap-pro/extension-unique-id"; import UpdatedImage from "./updated-image"; import isValidHttpUrl from "../bubble-menu/utils/link-validator"; import { CustomTableCell } from "./table/table-cell"; @@ -121,9 +120,6 @@ export const TiptapExtensions = ( }, includeChildren: true, }), - UniqueID.configure({ - types: ["image"], - }), SlashCommand(workspaceSlug, setIsSubmitting), TiptapUnderline, TextStyle, diff --git a/space/components/views/index.ts b/space/components/views/index.ts index 84d36cd29..f54d11bdd 100644 --- a/space/components/views/index.ts +++ b/space/components/views/index.ts @@ -1 +1 @@ -export * from "./home"; +export * from "./login"; diff --git a/space/components/views/home.tsx b/space/components/views/login.tsx similarity index 88% rename from space/components/views/home.tsx rename to space/components/views/login.tsx index 999fce073..d01a22681 100644 --- a/space/components/views/home.tsx +++ b/space/components/views/login.tsx @@ -4,7 +4,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // components import { SignInView, UserLoggedIn } from "components/accounts"; -export const HomeView = observer(() => { +export const LoginView = observer(() => { const { user: userStore } = useMobxStore(); if (!userStore.currentUser) return ; diff --git a/space/helpers/common.helper.ts b/space/helpers/common.helper.ts new file mode 100644 index 000000000..758d7c370 --- /dev/null +++ b/space/helpers/common.helper.ts @@ -0,0 +1 @@ +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : ""; diff --git a/space/package.json b/space/package.json index f2bb39df6..6ce9ecefe 100644 --- a/space/package.json +++ b/space/package.json @@ -17,8 +17,6 @@ "@heroicons/react": "^2.0.12", "@mui/icons-material": "^5.14.1", "@mui/material": "^5.14.1", - "@tailwindcss/typography": "^0.5.9", - "@tiptap-pro/extension-unique-id": "^2.1.0", "@tiptap/extension-code-block-lowlight": "^2.0.4", "@tiptap/extension-color": "^2.0.4", "@tiptap/extension-gapcursor": "^2.1.7", @@ -62,7 +60,6 @@ "uuid": "^9.0.0" }, "devDependencies": { - "@tailwindcss/typography": "^0.5.9", "@types/js-cookie": "^3.0.3", "@types/node": "18.14.1", "@types/nprogress": "^0.2.0", @@ -70,12 +67,10 @@ "@types/react-dom": "18.0.11", "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.48.2", - "autoprefixer": "^10.4.13", "eslint": "8.34.0", "eslint-config-custom": "*", "eslint-config-next": "13.2.1", - "postcss": "^8.4.21", "tsconfig": "*", - "tailwindcss": "^3.2.7" + "tailwind-config-custom": "*" } } diff --git a/space/pages/index.tsx b/space/pages/index.tsx deleted file mode 100644 index fe0b7d33a..000000000 --- a/space/pages/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; - -// components -import { HomeView } from "components/views"; - -const HomePage = () => ; - -export default HomePage; diff --git a/space/pages/login/index.tsx b/space/pages/login/index.tsx new file mode 100644 index 000000000..a80eff873 --- /dev/null +++ b/space/pages/login/index.tsx @@ -0,0 +1,8 @@ +import React from "react"; + +// components +import { LoginView } from "components/views"; + +const LoginPage = () => ; + +export default LoginPage; \ No newline at end of file diff --git a/space/pages/onboarding/index.tsx b/space/pages/onboarding/index.tsx index 5cb168d38..12b09641b 100644 --- a/space/pages/onboarding/index.tsx +++ b/space/pages/onboarding/index.tsx @@ -2,22 +2,16 @@ import React, { useEffect } from "react"; // mobx import { observer } from "mobx-react-lite"; import { useMobxStore } from "lib/mobx/store-provider"; -// services -import authenticationService from "services/authentication.service"; -// hooks -import useToast from "hooks/use-toast"; // components import { OnBoardingForm } from "components/accounts/onboarding-form"; -const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; +const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; const OnBoardingPage = () => { const { user: userStore } = useMobxStore(); const user = userStore?.currentUser; - const { setToastAlert } = useToast(); - useEffect(() => { const user = userStore?.currentUser; diff --git a/space/postcss.config.js b/space/postcss.config.js index cbfea5ea2..129aa7f59 100644 --- a/space/postcss.config.js +++ b/space/postcss.config.js @@ -1,7 +1 @@ -module.exports = { - plugins: { - "tailwindcss/nesting": {}, - tailwindcss: {}, - autoprefixer: {}, - }, -}; +module.exports = require("tailwind-config-custom/postcss.config"); diff --git a/space/services/authentication.service.ts b/space/services/authentication.service.ts index a6f1ec90f..4d861994f 100644 --- a/space/services/authentication.service.ts +++ b/space/services/authentication.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class AuthService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async emailLogin(data: any) { diff --git a/space/services/file.service.ts b/space/services/file.service.ts index 5ef34fc76..d9783d29c 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -1,7 +1,5 @@ -// services import APIService from "services/api.service"; - -const { NEXT_PUBLIC_API_BASE_URL } = process.env; +import { API_BASE_URL } from "helpers/common.helper"; interface UnSplashImage { id: string; @@ -29,7 +27,7 @@ interface UnSplashImageUrls { class FileServices extends APIService { constructor() { - super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async uploadFile(workspaceSlug: string, file: FormData): Promise { diff --git a/space/services/issue.service.ts b/space/services/issue.service.ts index 835778fb2..5feb1b00b 100644 --- a/space/services/issue.service.ts +++ b/space/services/issue.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class IssueService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise { diff --git a/space/services/project.service.ts b/space/services/project.service.ts index 291a5f323..0d6eca951 100644 --- a/space/services/project.service.ts +++ b/space/services/project.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class ProjectService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async getProjectSettings(workspace_slug: string, project_slug: string): Promise { diff --git a/space/services/user.service.ts b/space/services/user.service.ts index 9a324bb95..21e9f941e 100644 --- a/space/services/user.service.ts +++ b/space/services/user.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class UserService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async currentUser(): Promise { diff --git a/space/tailwind.config.js b/space/tailwind.config.js index 0347ad9f9..1e1e59826 100644 --- a/space/tailwind.config.js +++ b/space/tailwind.config.js @@ -1,203 +1 @@ -/** @type {import('tailwindcss').Config} */ - -const convertToRGB = (variableName) => `rgba(var(${variableName}))`; - -module.exports = { - content: [ - "./app/**/*.{js,ts,jsx,tsx}", - "./pages/**/*.{js,ts,jsx,tsx}", - "./layouts/**/*.tsx", - "./components/**/*.{js,ts,jsx,tsx}", - "./constants/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: { - boxShadow: { - "custom-shadow-2xs": "var(--color-shadow-2xs)", - "custom-shadow-xs": "var(--color-shadow-xs)", - "custom-shadow-sm": "var(--color-shadow-sm)", - "custom-shadow-rg": "var(--color-shadow-rg)", - "custom-shadow-md": "var(--color-shadow-md)", - "custom-shadow-lg": "var(--color-shadow-lg)", - "custom-shadow-xl": "var(--color-shadow-xl)", - "custom-shadow-2xl": "var(--color-shadow-2xl)", - "custom-shadow-3xl": "var(--color-shadow-3xl)", - "custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)", - "custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)", - "custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)", - "custom-sidebar-shadow-rg": "var(--color-sidebar-shadow-rg)", - "custom-sidebar-shadow-md": "var(--color-sidebar-shadow-md)", - "custom-sidebar-shadow-lg": "var(--color-sidebar-shadow-lg)", - "custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)", - "custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)", - "custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)", - }, - colors: { - custom: { - primary: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-primary-10"), - 20: convertToRGB("--color-primary-20"), - 30: convertToRGB("--color-primary-30"), - 40: convertToRGB("--color-primary-40"), - 50: convertToRGB("--color-primary-50"), - 60: convertToRGB("--color-primary-60"), - 70: convertToRGB("--color-primary-70"), - 80: convertToRGB("--color-primary-80"), - 90: convertToRGB("--color-primary-90"), - 100: convertToRGB("--color-primary-100"), - 200: convertToRGB("--color-primary-200"), - 300: convertToRGB("--color-primary-300"), - 400: convertToRGB("--color-primary-400"), - 500: convertToRGB("--color-primary-500"), - 600: convertToRGB("--color-primary-600"), - 700: convertToRGB("--color-primary-700"), - 800: convertToRGB("--color-primary-800"), - 900: convertToRGB("--color-primary-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-primary-100"), - }, - background: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-background-10"), - 20: convertToRGB("--color-background-20"), - 30: convertToRGB("--color-background-30"), - 40: convertToRGB("--color-background-40"), - 50: convertToRGB("--color-background-50"), - 60: convertToRGB("--color-background-60"), - 70: convertToRGB("--color-background-70"), - 80: convertToRGB("--color-background-80"), - 90: convertToRGB("--color-background-90"), - 100: convertToRGB("--color-background-100"), - 200: convertToRGB("--color-background-200"), - 300: convertToRGB("--color-background-300"), - 400: convertToRGB("--color-background-400"), - 500: convertToRGB("--color-background-500"), - 600: convertToRGB("--color-background-600"), - 700: convertToRGB("--color-background-700"), - 800: convertToRGB("--color-background-800"), - 900: convertToRGB("--color-background-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-background-100"), - }, - text: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-text-10"), - 20: convertToRGB("--color-text-20"), - 30: convertToRGB("--color-text-30"), - 40: convertToRGB("--color-text-40"), - 50: convertToRGB("--color-text-50"), - 60: convertToRGB("--color-text-60"), - 70: convertToRGB("--color-text-70"), - 80: convertToRGB("--color-text-80"), - 90: convertToRGB("--color-text-90"), - 100: convertToRGB("--color-text-100"), - 200: convertToRGB("--color-text-200"), - 300: convertToRGB("--color-text-300"), - 400: convertToRGB("--color-text-400"), - 500: convertToRGB("--color-text-500"), - 600: convertToRGB("--color-text-600"), - 700: convertToRGB("--color-text-700"), - 800: convertToRGB("--color-text-800"), - 900: convertToRGB("--color-text-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-text-100"), - }, - border: { - 0: "rgb(255, 255, 255)", - 100: convertToRGB("--color-border-100"), - 200: convertToRGB("--color-border-200"), - 300: convertToRGB("--color-border-300"), - 400: convertToRGB("--color-border-400"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-border-200"), - }, - sidebar: { - background: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-sidebar-background-10"), - 20: convertToRGB("--color-sidebar-background-20"), - 30: convertToRGB("--color-sidebar-background-30"), - 40: convertToRGB("--color-sidebar-background-40"), - 50: convertToRGB("--color-sidebar-background-50"), - 60: convertToRGB("--color-sidebar-background-60"), - 70: convertToRGB("--color-sidebar-background-70"), - 80: convertToRGB("--color-sidebar-background-80"), - 90: convertToRGB("--color-sidebar-background-90"), - 100: convertToRGB("--color-sidebar-background-100"), - 200: convertToRGB("--color-sidebar-background-200"), - 300: convertToRGB("--color-sidebar-background-300"), - 400: convertToRGB("--color-sidebar-background-400"), - 500: convertToRGB("--color-sidebar-background-500"), - 600: convertToRGB("--color-sidebar-background-600"), - 700: convertToRGB("--color-sidebar-background-700"), - 800: convertToRGB("--color-sidebar-background-800"), - 900: convertToRGB("--color-sidebar-background-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-sidebar-background-100"), - }, - text: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-sidebar-text-10"), - 20: convertToRGB("--color-sidebar-text-20"), - 30: convertToRGB("--color-sidebar-text-30"), - 40: convertToRGB("--color-sidebar-text-40"), - 50: convertToRGB("--color-sidebar-text-50"), - 60: convertToRGB("--color-sidebar-text-60"), - 70: convertToRGB("--color-sidebar-text-70"), - 80: convertToRGB("--color-sidebar-text-80"), - 90: convertToRGB("--color-sidebar-text-90"), - 100: convertToRGB("--color-sidebar-text-100"), - 200: convertToRGB("--color-sidebar-text-200"), - 300: convertToRGB("--color-sidebar-text-300"), - 400: convertToRGB("--color-sidebar-text-400"), - 500: convertToRGB("--color-sidebar-text-500"), - 600: convertToRGB("--color-sidebar-text-600"), - 700: convertToRGB("--color-sidebar-text-700"), - 800: convertToRGB("--color-sidebar-text-800"), - 900: convertToRGB("--color-sidebar-text-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-sidebar-text-100"), - }, - border: { - 0: "rgb(255, 255, 255)", - 100: convertToRGB("--color-sidebar-border-100"), - 200: convertToRGB("--color-sidebar-border-200"), - 300: convertToRGB("--color-sidebar-border-300"), - 400: convertToRGB("--color-sidebar-border-400"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-sidebar-border-200"), - }, - }, - backdrop: "#131313", - }, - }, - typography: ({ theme }) => ({ - brand: { - css: { - "--tw-prose-body": convertToRGB("--color-text-100"), - "--tw-prose-p": convertToRGB("--color-text-100"), - "--tw-prose-headings": convertToRGB("--color-text-100"), - "--tw-prose-lead": convertToRGB("--color-text-100"), - "--tw-prose-links": convertToRGB("--color-primary-100"), - "--tw-prose-bold": convertToRGB("--color-text-100"), - "--tw-prose-counters": convertToRGB("--color-text-100"), - "--tw-prose-bullets": convertToRGB("--color-text-100"), - "--tw-prose-hr": convertToRGB("--color-text-100"), - "--tw-prose-quotes": convertToRGB("--color-text-100"), - "--tw-prose-quote-borders": convertToRGB("--color-border"), - "--tw-prose-code": convertToRGB("--color-text-100"), - "--tw-prose-pre-code": convertToRGB("--color-text-100"), - "--tw-prose-pre-bg": convertToRGB("--color-background-100"), - "--tw-prose-th-borders": convertToRGB("--color-border"), - "--tw-prose-td-borders": convertToRGB("--color-border"), - }, - }, - }), - }, - fontFamily: { - custom: ["Inter", "sans-serif"], - }, - }, - plugins: [require("@tailwindcss/typography")], -}; +module.exports = require("tailwind-config-custom/tailwind.config"); diff --git a/start.sh b/start.sh index dcb97db6d..2685c3826 100644 --- a/start.sh +++ b/start.sh @@ -1,9 +1,5 @@ #!/bin/sh set -x -# Replace the statically built BUILT_NEXT_PUBLIC_API_BASE_URL with run-time NEXT_PUBLIC_API_BASE_URL -# NOTE: if these values are the same, this will be skipped. -/usr/local/bin/replace-env-vars.sh "$BUILT_NEXT_PUBLIC_API_BASE_URL" "$NEXT_PUBLIC_API_BASE_URL" $2 - echo "Starting Plane Frontend.." node $1 diff --git a/turbo.json b/turbo.json index 47b92f0db..59bbe741f 100644 --- a/turbo.json +++ b/turbo.json @@ -15,17 +15,20 @@ "NEXT_PUBLIC_UNSPLASH_ACCESS", "NEXT_PUBLIC_UNSPLASH_ENABLED", "NEXT_PUBLIC_TRACK_EVENTS", - "TRACKER_ACCESS_KEY", + "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER", "NEXT_PUBLIC_SESSION_RECORDER_KEY", "NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS", - "NEXT_PUBLIC_SLACK_CLIENT_ID", - "NEXT_PUBLIC_SLACK_CLIENT_SECRET", - "NEXT_PUBLIC_SUPABASE_URL", - "NEXT_PUBLIC_SUPABASE_ANON_KEY", - "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", - "NEXT_PUBLIC_DEPLOY_WITH_NGINX" + "NEXT_PUBLIC_DEPLOY_WITH_NGINX", + "NEXT_PUBLIC_POSTHOG_KEY", + "NEXT_PUBLIC_POSTHOG_HOST", + "SLACK_OAUTH_URL", + "SLACK_CLIENT_ID", + "SLACK_CLIENT_SECRET", + "JITSU_TRACKER_ACCESS_KEY", + "JITSU_TRACKER_HOST", + "UNSPLASH_ACCESS_KEY" ], "pipeline": { "build": { diff --git a/web/.env.example b/web/.env.example index 50a6209b2..3868cd834 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,26 +1,4 @@ -# Base url for the API requests -NEXT_PUBLIC_API_BASE_URL="" -# Extra image domains that need to be added for Next Image -NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= -# Google Client ID for Google OAuth -NEXT_PUBLIC_GOOGLE_CLIENTID="" -# GitHub App ID for GitHub OAuth -NEXT_PUBLIC_GITHUB_ID="" -# GitHub App Name for GitHub Integration -NEXT_PUBLIC_GITHUB_APP_NAME="" -# Sentry DSN for error monitoring -NEXT_PUBLIC_SENTRY_DSN="" # Enable/Disable OAUTH - default 0 for selfhosted instance NEXT_PUBLIC_ENABLE_OAUTH=0 -# Enable/Disable Sentry -NEXT_PUBLIC_ENABLE_SENTRY=0 -# Enable/Disable session recording -NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 -# Enable/Disable event tracking -NEXT_PUBLIC_TRACK_EVENTS=0 -# Slack Client ID for Slack Integration -NEXT_PUBLIC_SLACK_CLIENT_ID="" -# For Telemetry, set it to "app.plane.so" -NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" # Public boards deploy URL -NEXT_PUBLIC_DEPLOY_URL="" \ No newline at end of file +NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces" \ No newline at end of file diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 38e6a5f4c..c8df60750 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,7 +1,4 @@ module.exports = { root: true, extends: ["custom"], - rules: { - "@next/next/no-img-element": "off", - }, }; diff --git a/web/Dockerfile.web b/web/Dockerfile.web index 40946fa2d..d9260e61d 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -2,7 +2,6 @@ FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app -ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER RUN yarn global add turbo COPY . . @@ -14,8 +13,8 @@ FROM node:18-alpine AS installer RUN apk add --no-cache libc6-compat WORKDIR /app -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces +ARG NEXT_PUBLIC_API_BASE_URL="" +ARG NEXT_PUBLIC_DEPLOY_URL="" # First install the dependencies (as they change less often) COPY .gitignore .gitignore @@ -26,18 +25,12 @@ RUN yarn install --network-timeout 500000 # Build the project COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json -COPY replace-env-vars.sh /usr/local/bin/ USER root -RUN chmod +x /usr/local/bin/replace-env-vars.sh - -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL RUN yarn turbo run build --filter=web -RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} web - FROM node:18-alpine AS runner WORKDIR /app @@ -52,20 +45,15 @@ COPY --from=installer /app/web/package.json . # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./ - COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces - -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL +ARG NEXT_PUBLIC_API_BASE_URL="" +ARG NEXT_PUBLIC_DEPLOY_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL USER root -COPY replace-env-vars.sh /usr/local/bin/ COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/replace-env-vars.sh RUN chmod +x /usr/local/bin/start.sh USER captain diff --git a/web/components/analytics/custom-analytics/graph/index.tsx b/web/components/analytics/custom-analytics/graph/index.tsx index 349f9884d..733d17437 100644 --- a/web/components/analytics/custom-analytics/graph/index.tsx +++ b/web/components/analytics/custom-analytics/graph/index.tsx @@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper"; import { generateBarColor } from "helpers/analytics.helper"; // types import { IAnalyticsParams, IAnalyticsResponse } from "types"; -// constants type Props = { analytics: IAnalyticsResponse; diff --git a/web/components/analytics/scope-and-demand/scope.tsx b/web/components/analytics/scope-and-demand/scope.tsx index b01354b93..9231947bd 100644 --- a/web/components/analytics/scope-and-demand/scope.tsx +++ b/web/components/analytics/scope-and-demand/scope.tsx @@ -15,17 +15,19 @@ export const AnalyticsScope: React.FC = ({ defaultAnalytics }) => (
Pending issues
- {defaultAnalytics.pending_issue_user.length > 0 ? ( + {defaultAnalytics.pending_issue_user && defaultAnalytics.pending_issue_user.length > 0 ? ( `#f97316`} - customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)} + customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => + d.count > 0 ? d.count : 50 + )} tooltip={(datum) => { const assignee = defaultAnalytics.pending_issue_user.find( - (a) => a.assignees__display_name === `${datum.indexValue}` + (a) => a.assignees__id === `${datum.indexValue}` ); return ( @@ -39,10 +41,9 @@ export const AnalyticsScope: React.FC = ({ defaultAnalytics }) => ( }} axisBottom={{ renderTick: (datum) => { - const avatar = - defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? ""; + const assignee = defaultAnalytics.pending_issue_user[datum.tickIndex] ?? ""; - if (avatar && avatar !== "") + if (assignee && assignee?.assignees__avatar && assignee?.assignees__avatar !== "") return ( = ({ defaultAnalytics }) => ( y={10} width={16} height={16} - xlinkHref={avatar} + xlinkHref={assignee?.assignees__avatar} style={{ clipPath: "circle(50%)" }} /> @@ -60,7 +61,7 @@ export const AnalyticsScope: React.FC = ({ defaultAnalytics }) => ( - {datum.value ? `${datum.value}`.toUpperCase()[0] : "?"} + {datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"} ); diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 07ac86460..bb4e72e0c 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -3,8 +3,8 @@ import React, { useState } from "react"; // component import { CustomSelect, ToggleSwitch } from "components/ui"; import { SelectMonthModal } from "components/automation"; -// icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; +// icon +import { ArchiveRestore } from "lucide-react"; // constants import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; // types @@ -28,14 +28,18 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
-
-
-

Auto-archive closed issues

-

- Plane will automatically archive issues that have been completed or cancelled for the - configured time period. -

+
+
+
+
+ +
+
+

Auto-archive closed issues

+

+ Plane will auto archive issues that have been completed or canceled. +

+
= ({ projectDetails, handleC size="sm" />
- {projectDetails?.archive_in !== 0 && ( -
-
- Auto-archive issues that are closed for -
-
- { - handleChange({ archive_in: val }); - }} - input - verticalPosition="top" - width="w-full" - > - <> - {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} - - ))} - - - + {projectDetails?.archive_in !== 0 && ( +
+
+
+ Auto-archive issues that are closed for +
+
+ { + handleChange({ archive_in: val }); + }} + input + verticalPosition="bottom" + width="w-full" + > + <> + {PROJECT_AUTOMATION_MONTHS.map((month) => ( + + {month.label} + + ))} + + + + +
)} diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index ad65714aa..868d64557 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -5,11 +5,12 @@ import useSWR from "swr"; import { useRouter } from "next/router"; // component -import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui"; +import { CustomSearchSelect, CustomSelect, Icon, ToggleSwitch } from "components/ui"; import { SelectMonthModal } from "components/automation"; // icons -import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; +import { Squares2X2Icon } from "@heroicons/react/24/outline"; import { StateGroupIcon } from "components/icons"; +import { ArchiveX } from "lucide-react"; // services import stateService from "services/state.service"; // constants @@ -76,14 +77,18 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha handleChange={handleChange} /> -
-
-
-

Auto-close inactive issues

-

- Plane will automatically close the issues that have not been updated for the - configured time period. -

+
+
+
+
+ +
+
+

Auto-close issues

+

+ Plane will automatically close issue that haven’t been completed or canceled. +

+
= ({ projectDetails, handleCha size="sm" />
+ {projectDetails?.close_in !== 0 && ( -
-
-
- Auto-close issues that are inactive for +
+
+
+
+ Auto-close issues that are inactive for +
+
+ { + handleChange({ close_in: val }); + }} + input + width="w-full" + > + <> + {PROJECT_AUTOMATION_MONTHS.map((month) => ( + + {month.label} + + ))} + + + +
-
- { - handleChange({ close_in: val }); - }} - input - width="w-full" - > - <> - {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} - - ))} - - - -
-
-
-
Auto-close Status
-
- - {selectedOption ? ( - - ) : currentDefaultState ? ( - - ) : ( - - )} - {selectedOption?.name - ? selectedOption.name - : currentDefaultState?.name ?? ( - State - )} -
- } - onChange={(val: string) => { - handleChange({ default_state: val }); - }} - options={options} - disabled={!multipleOptions} - width="w-full" - input - /> + +
+
Auto-close Status
+
+ + {selectedOption ? ( + + ) : currentDefaultState ? ( + + ) : ( + + )} + {selectedOption?.name + ? selectedOption.name + : currentDefaultState?.name ?? ( + State + )} +
+ } + onChange={(val: string) => { + handleChange({ default_state: val }); + }} + options={options} + disabled={!multipleOptions} + width="w-full" + input + /> +
diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index b91c03391..18239d62b 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -104,7 +104,7 @@ export const SelectMonthModal: React.FC = ({ as="h3" className="text-lg font-medium leading-6 text-custom-text-100" > - Customize Time Range + Customise Time Range
diff --git a/web/components/command-palette/command-pallette.tsx b/web/components/command-palette/command-pallette.tsx index 507d8a49c..8e17cbafd 100644 --- a/web/components/command-palette/command-pallette.tsx +++ b/web/components/command-palette/command-pallette.tsx @@ -41,7 +41,7 @@ export const CommandPalette: React.FC = observer(() => { const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId, issueId, inboxId } = router.query; + const { workspaceSlug, projectId, issueId, inboxId, cycleId, moduleId } = router.query; const { user } = useUser(); @@ -161,6 +161,7 @@ export const CommandPalette: React.FC = observer(() => { /> setIsCreateViewModalOpen(false)} + viewType="project" isOpen={isCreateViewModalOpen} user={user} /> @@ -183,6 +184,13 @@ export const CommandPalette: React.FC = observer(() => { isOpen={isIssueModalOpen} handleClose={() => setIsIssueModalOpen(false)} fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]} + prePopulateData={ + cycleId + ? { cycle: cycleId.toString() } + : moduleId + ? { module: moduleId.toString() } + : undefined + } /> { const router = useRouter(); @@ -51,6 +58,26 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => { ); }; +const LabelPill = ({ labelId }: { labelId: string }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { data: labels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + return ( + l.id === labelId)?.color ?? "#000000", + }} + aria-hidden="true" + /> + ); +}; + const activityDetails: { [key: string]: { message: ( @@ -90,14 +117,14 @@ const activityDetails: { ); }, - icon:
+ )} + {isDraftIssues && ( +
+ {issueViewForDraftIssues.map((option) => ( {
- {issueView !== "gantt_chart" && ( + {displayFilters.layout !== "gantt_chart" && (

Display Properties

@@ -310,7 +362,7 @@ export const IssuesFilterView: React.FC = () => { if (key === "estimate" && !isEstimateActive) return null; if ( - issueView === "spreadsheet" && + displayFilters.layout === "spreadsheet" && (key === "attachment_count" || key === "link" || key === "sub_issue_count") @@ -318,7 +370,7 @@ export const IssuesFilterView: React.FC = () => { return null; if ( - issueView !== "spreadsheet" && + displayFilters.layout !== "spreadsheet" && (key === "created_on" || key === "updated_on") ) return null; diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index 5f13d960e..957f1131c 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -20,6 +20,7 @@ import fileService from "services/file.service"; 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" || @@ -67,6 +68,8 @@ export const ImagePickerPopover: React.FC = ({ fileService.getUnsplashImages(1, searchParams) ); + const imagePickerRef = useRef(null); + const { workspaceDetails } = useWorkspaceDetails(); const onDrop = useCallback((acceptedFiles: File[]) => { @@ -116,12 +119,14 @@ export const ImagePickerPopover: React.FC = ({ onChange(images[0].urls.regular); }, [value, onChange, images]); + useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); + if (!unsplashEnabled) return null; return ( setIsOpen((prev) => !prev)} disabled={disabled} > @@ -137,7 +142,10 @@ export const ImagePickerPopover: React.FC = ({ leaveTo="transform opacity-0 scale-95" > -
+
diff --git a/web/components/core/modals/bulk-delete-issues-modal.tsx b/web/components/core/modals/bulk-delete-issues-modal.tsx index 59e7c8a84..375e155a4 100644 --- a/web/components/core/modals/bulk-delete-issues-modal.tsx +++ b/web/components/core/modals/bulk-delete-issues-modal.tsx @@ -58,7 +58,7 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user ); const { setToastAlert } = useToast(); - const { issueView, params } = useIssuesView(); + const { displayFilters, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); const { order_by, group_by, ...viewGanttParams } = params; @@ -126,8 +126,8 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user message: "Issues deleted successfully!", }); - if (issueView === "calendar") mutate(calendarFetchKey); - else if (issueView === "gantt_chart") mutate(ganttFetchKey); + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + else if (displayFilters.layout === "gantt_chart") mutate(ganttFetchKey); else { if (cycleId) { mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)); diff --git a/web/components/core/modals/image-upload-modal.tsx b/web/components/core/modals/image-upload-modal.tsx index 14e4844de..df4f21e12 100644 --- a/web/components/core/modals/image-upload-modal.tsx +++ b/web/components/core/modals/image-upload-modal.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useState } from "react"; -import NextImage from "next/image"; import { useRouter } from "next/router"; // react-dropzone @@ -12,7 +11,7 @@ import fileServices from "services/file.service"; // hooks import useWorkspaceDetails from "hooks/use-workspace-details"; // ui -import { PrimaryButton, SecondaryButton } from "components/ui"; +import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui"; // icons import { UserCircleIcon } from "components/icons"; @@ -21,6 +20,8 @@ type Props = { onClose: () => void; isOpen: boolean; onSuccess: (url: string) => void; + isRemoving: boolean; + handleDelete: () => void; userImage?: boolean; }; @@ -29,6 +30,8 @@ export const ImageUploadModal: React.FC = ({ onSuccess, isOpen, onClose, + isRemoving, + handleDelete, userImage, }) => { const [image, setImage] = useState(null); @@ -148,12 +151,10 @@ export const ImageUploadModal: React.FC = ({ > Edit - ) : ( @@ -182,15 +183,22 @@ export const ImageUploadModal: React.FC = ({

File formats supported- .jpeg, .jpg, .png, .webp, .svg

-
- Cancel - - {isImageUploading ? "Uploading..." : "Upload & Save"} - +
+
+ + {isRemoving ? "Removing..." : "Remove"} + +
+
+ Cancel + + {isImageUploading ? "Uploading..." : "Upload & Save"} + +
diff --git a/web/components/core/views/all-views.tsx b/web/components/core/views/all-views.tsx index eb54ccb2a..75038f57b 100644 --- a/web/components/core/views/all-views.tsx +++ b/web/components/core/views/all-views.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import { useRouter } from "next/router"; @@ -12,6 +12,7 @@ import stateService from "services/state.service"; // hooks import useUser from "hooks/use-user"; import { useProjectMyMembership } from "contexts/project-member.context"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; // components import { AllLists, @@ -50,6 +51,7 @@ type Props = { secondaryButton?: React.ReactNode; }; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void; handleOnDragEnd: (result: DropResult) => Promise; openIssuesListModal: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; @@ -66,6 +68,7 @@ export const AllViews: React.FC = ({ dragDisabled = false, emptyState, handleIssueAction, + handleDraftIssueAction, handleOnDragEnd, openIssuesListModal, removeIssue, @@ -77,10 +80,14 @@ export const AllViews: React.FC = ({ const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const [myIssueProjectId, setMyIssueProjectId] = useState(null); + const { user } = useUser(); const { memberRole } = useProjectMyMembership(); - const { groupedIssues, isEmpty, issueView } = viewProps; + const { groupedIssues, isEmpty, displayFilters } = viewProps; + + const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView(); const { data: stateGroups } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, @@ -90,6 +97,10 @@ export const AllViews: React.FC = ({ ); const states = getStatesList(stateGroups); + const handleMyIssueOpen = (issue: IIssue) => { + setMyIssueProjectId(issue.project); + }; + const handleTrashBox = useCallback( (isDragging: boolean) => { if (isDragging && !trashBox) setTrashBox(true); @@ -117,39 +128,45 @@ export const AllViews: React.FC = ({ {groupedIssues ? ( !isEmpty || - issueView === "kanban" || - issueView === "calendar" || - issueView === "gantt_chart" ? ( + displayFilters?.layout === "kanban" || + displayFilters?.layout === "calendar" || + displayFilters?.layout === "gantt_chart" ? ( <> - {issueView === "list" ? ( + {displayFilters?.layout === "list" ? ( - ) : issueView === "kanban" ? ( + ) : displayFilters?.layout === "kanban" ? ( - ) : issueView === "calendar" ? ( + ) : displayFilters?.layout === "calendar" ? ( = ({ user={user} userAuth={memberRole} /> - ) : issueView === "spreadsheet" ? ( + ) : displayFilters?.layout === "spreadsheet" ? ( ) : ( - issueView === "gantt_chart" && + displayFilters?.layout === "gantt_chart" && ( + + ) )} ) : router.pathname.includes("archived-issues") ? ( diff --git a/web/components/core/views/board-view/all-boards.tsx b/web/components/core/views/board-view/all-boards.tsx index ea0f64ace..37503043a 100644 --- a/web/components/core/views/board-view/all-boards.tsx +++ b/web/components/core/views/board-view/all-boards.tsx @@ -1,5 +1,12 @@ +import { useRouter } from "next/router"; + +//hook +import useMyIssues from "hooks/my-issues/use-my-issues"; +import useIssuesView from "hooks/use-issues-view"; +import useProfileIssues from "hooks/use-profile-issues"; // components import { SingleBoard } from "components/core/views/board-view/single-board"; +import { IssuePeekOverview } from "components/issues"; // icons import { StateGroupIcon } from "components/icons"; // helpers @@ -13,9 +20,12 @@ type Props = { disableAddIssueOption?: boolean; dragDisabled: boolean; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void; handleTrashBox: (isDragging: boolean) => void; openIssuesListModal?: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; + myIssueProjectId?: string | null; + handleMyIssueOpen?: (issue: IIssue) => void; states: IState[] | undefined; user: ICurrentUserResponse | undefined; userAuth: UserAuth; @@ -28,25 +38,53 @@ export const AllBoards: React.FC = ({ disableAddIssueOption = false, dragDisabled, handleIssueAction, + handleDraftIssueAction, handleTrashBox, openIssuesListModal, + myIssueProjectId, + handleMyIssueOpen, removeIssue, states, user, userAuth, viewProps, }) => { - const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps; + const router = useRouter(); + const { workspaceSlug, projectId, userId } = router.query; + + const isProfileIssue = + router.pathname.includes("assigned") || + router.pathname.includes("created") || + router.pathname.includes("subscribed"); + + const isMyIssue = router.pathname.includes("my-issues"); + + const { mutateIssues } = useIssuesView(); + const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString()); + + const { displayFilters, groupedIssues } = viewProps; return ( <> + + isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues() + } + projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> {groupedIssues ? ( -
+
{Object.keys(groupedIssues).map((singleGroup, index) => { const currentState = - selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; + displayFilters?.group_by === "state" + ? states?.find((s) => s.id === singleGroup) + : null; - if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null; + if (!displayFilters?.show_empty_groups && groupedIssues[singleGroup].length === 0) + return null; return ( = ({ dragDisabled={dragDisabled} groupTitle={singleGroup} handleIssueAction={handleIssueAction} + handleDraftIssueAction={handleDraftIssueAction} handleTrashBox={handleTrashBox} openIssuesListModal={openIssuesListModal ?? null} + handleMyIssueOpen={handleMyIssueOpen} removeIssue={removeIssue} user={user} userAuth={userAuth} @@ -67,13 +107,15 @@ export const AllBoards: React.FC = ({ /> ); })} - {!showEmptyGroups && ( + {!displayFilters?.show_empty_groups && (

Hidden groups

{Object.keys(groupedIssues).map((singleGroup, index) => { const currentState = - selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; + displayFilters?.group_by === "state" + ? states?.find((s) => s.id === singleGroup) + : null; if (groupedIssues[singleGroup].length === 0) return ( @@ -91,7 +133,7 @@ export const AllBoards: React.FC = ({ /> )}

- {selectedGroup === "state" + {displayFilters?.group_by === "state" ? addSpaceIfCamelCase(currentState?.name ?? "") : addSpaceIfCamelCase(singleGroup)}

diff --git a/web/components/core/views/board-view/board-header.tsx b/web/components/core/views/board-view/board-header.tsx index 144bdb826..1c2bef60f 100644 --- a/web/components/core/views/board-view/board-header.tsx +++ b/web/components/core/views/board-view/board-header.tsx @@ -20,7 +20,7 @@ import { renderEmoji } from "helpers/emoji.helper"; // types import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types"; // fetch-keys -import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; +import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys"; // constants import { STATE_GROUP_COLORS } from "constants/state"; @@ -48,22 +48,35 @@ export const BoardHeader: React.FC = ({ const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { groupedIssues, groupByProperty: selectedGroup } = viewProps; + const { displayFilters, groupedIssues } = viewProps; const { data: issueLabels } = useSWR( - workspaceSlug && projectId && selectedGroup === "labels" + workspaceSlug && projectId && displayFilters?.group_by === "labels" ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, - workspaceSlug && projectId && selectedGroup === "labels" + workspaceSlug && projectId && displayFilters?.group_by === "labels" ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) : null ); + const { data: workspaceLabels } = useSWR( + workspaceSlug && displayFilters?.group_by === "labels" + ? WORKSPACE_LABELS(workspaceSlug.toString()) + : null, + workspaceSlug && displayFilters?.group_by === "labels" + ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) + : null + ); + const { data: members } = useSWR( - workspaceSlug && projectId && (selectedGroup === "created_by" || selectedGroup === "assignees") + workspaceSlug && + projectId && + (displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees") ? PROJECT_MEMBERS(projectId.toString()) : null, - workspaceSlug && projectId && (selectedGroup === "created_by" || selectedGroup === "assignees") + workspaceSlug && + projectId && + (displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees") ? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString()) : null ); @@ -73,12 +86,15 @@ export const BoardHeader: React.FC = ({ const getGroupTitle = () => { let title = addSpaceIfCamelCase(groupTitle); - switch (selectedGroup) { + switch (displayFilters?.group_by) { case "state": title = addSpaceIfCamelCase(currentState?.name ?? ""); break; case "labels": - title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; + title = + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find( + (label) => label.id === groupTitle + )?.name ?? "None"; break; case "project": title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; @@ -97,7 +113,7 @@ export const BoardHeader: React.FC = ({ const getGroupIcon = () => { let icon; - switch (selectedGroup) { + switch (displayFilters?.group_by) { case "state": icon = currentState && ( = ({ break; case "labels": const labelColor = - issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find( + (label) => label.id === groupTitle + )?.color ?? "#000000"; icon = ( = ({ {getGroupIcon()}

= ({ )} - {!disableAddIssue && !disableUserActions && selectedGroup !== "created_by" && ( + {!disableAddIssue && !disableUserActions && displayFilters?.group_by !== "created_by" && ( } > - + { + if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit(); + else editIssue(); + }} + >
Edit issue
- {type !== "issue" && removeIssue && ( + {type !== "issue" && removeIssue && !isDraftIssue && (
@@ -280,53 +396,67 @@ export const SingleBoardIssue: React.FC = ({
)} - handleDeleteIssue(issue)}> + { + if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete(); + else handleDeleteIssue(issue); + }} + >
Delete issue
- -
- - Copy issue Link -
-
+ {!isDraftIssue && ( + +
+ + Copy issue Link +
+
+ )} )}

)} - - - {properties.key && ( -
- {issue.project_detail.identifier}-{issue.sequence_id} -
- )} -
{issue.name}
-
- + +
+ {properties.key && ( +
+ {issue.project_detail.identifier}-{issue.sequence_id} +
+ )} + +
+
{properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -350,16 +480,24 @@ export const SingleBoardIssue: React.FC = ({ /> )} {properties.labels && issue.labels.length > 0 && ( - + )} {properties.assignee && ( - )} {properties.estimate && issue.estimate_point !== null && ( diff --git a/web/components/core/views/calendar-view/calendar-header.tsx b/web/components/core/views/calendar-view/calendar-header.tsx index 271423cd4..fd69ed443 100644 --- a/web/components/core/views/calendar-view/calendar-header.tsx +++ b/web/components/core/views/calendar-view/calendar-header.tsx @@ -5,25 +5,12 @@ import { Popover, Transition } from "@headlessui/react"; // ui import { CustomMenu, ToggleSwitch } from "components/ui"; // icons -import { - CheckIcon, - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, -} from "@heroicons/react/24/outline"; +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; // helpers import { - addMonths, - addSevenDaysToDate, formatDate, - getCurrentWeekEndDate, - getCurrentWeekStartDate, isSameMonth, isSameYear, - lastDayOfWeek, - startOfWeek, - subtract7DaysToDate, - subtractMonths, updateDateWithMonth, updateDateWithYear, } from "helpers/calendar.helper"; @@ -31,190 +18,136 @@ import { import { MONTHS_LIST, YEARS_LIST } from "constants/calendar"; type Props = { - isMonthlyView: boolean; - setIsMonthlyView: React.Dispatch>; currentDate: Date; setCurrentDate: React.Dispatch>; showWeekEnds: boolean; setShowWeekEnds: React.Dispatch>; - changeDateRange: (startDate: Date, endDate: Date) => void; }; export const CalendarHeader: React.FC = ({ - setIsMonthlyView, - isMonthlyView, currentDate, setCurrentDate, showWeekEnds, setShowWeekEnds, - changeDateRange, -}) => { - const updateDate = (date: Date) => { - setCurrentDate(date); +}) => ( +
+
+ + {({ open }) => ( + <> + +
+ {formatDate(currentDate, "Month")}{" "} + {formatDate(currentDate, "yyyy")} +
+
- changeDateRange(startOfWeek(date), lastDayOfWeek(date)); - }; - - return ( -
-
- - {({ open }) => ( - <> - -
- {formatDate(currentDate, "Month")}{" "} - {formatDate(currentDate, "yyyy")} + + +
+ {YEARS_LIST.map((year) => ( + + ))}
- +
+ {MONTHS_LIST.map((month) => ( + + ))} +
+
+
+ + )} + - - -
- {YEARS_LIST.map((year) => ( - - ))} -
-
- {MONTHS_LIST.map((month) => ( - - ))} -
-
-
- - )} - - -
- - -
-
- -
+
+
- } + const nextMonthFirstDate = new Date(nextMonthYear, nextMonthMonth, 1); + + setCurrentDate(nextMonthFirstDate); + }} > - { - setIsMonthlyView(true); - changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate)); - }} - className="w-52 text-sm text-custom-text-200" - > -
- Monthly View - -
-
- { - setIsMonthlyView(false); - changeDateRange( - getCurrentWeekStartDate(currentDate), - getCurrentWeekEndDate(currentDate) - ); - }} - className="w-52 text-sm text-custom-text-200" - > -
- Weekly View - -
-
-
-

Show weekends

- setShowWeekEnds(!showWeekEnds)} /> -
- + +
- ); -}; + +
+ + + + Options +
+ } + > +
+

Show weekends

+ setShowWeekEnds(!showWeekEnds)} /> +
+ +
+
+); export default CalendarHeader; diff --git a/web/components/core/views/calendar-view/calendar.tsx b/web/components/core/views/calendar-view/calendar.tsx index 5a8a07260..7758f64cd 100644 --- a/web/components/core/views/calendar-view/calendar.tsx +++ b/web/components/core/views/calendar-view/calendar.tsx @@ -1,10 +1,6 @@ import React, { useEffect, useState } from "react"; - import { useRouter } from "next/router"; - import { mutate } from "swr"; - -// react-beautiful-dnd import { DragDropContext, DropResult } from "react-beautiful-dnd"; // services import issuesService from "services/issues.service"; @@ -12,6 +8,7 @@ import issuesService from "services/issues.service"; import useCalendarIssuesView from "hooks/use-calendar-issues-view"; // components import { SingleCalendarDate, CalendarHeader } from "components/core"; +import { IssuePeekOverview } from "components/issues"; // ui import { Spinner } from "components/ui"; // helpers @@ -49,30 +46,27 @@ export const CalendarView: React.FC = ({ userAuth, }) => { const [showWeekEnds, setShowWeekEnds] = useState(false); - const [currentDate, setCurrentDate] = useState(new Date()); - const [isMonthlyView, setIsMonthlyView] = useState(true); + + const { calendarIssues, mutateIssues, params, activeMonthDate, setActiveMonthDate } = + useCalendarIssuesView(); const [calendarDates, setCalendarDates] = useState({ - startDate: startOfWeek(currentDate), - endDate: lastDayOfWeek(currentDate), + startDate: startOfWeek(activeMonthDate), + endDate: lastDayOfWeek(activeMonthDate), }); const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; - const { calendarIssues, params, setCalendarDateRange } = useCalendarIssuesView(); - - const totalDate = eachDayOfInterval({ - start: calendarDates.startDate, - end: calendarDates.endDate, - }); - - const onlyWeekDays = weekDayInterval({ - start: calendarDates.startDate, - end: calendarDates.endDate, - }); - - const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays; + const currentViewDays = showWeekEnds + ? eachDayOfInterval({ + start: calendarDates.startDate, + end: calendarDates.endDate, + }) + : weekDayInterval({ + start: calendarDates.startDate, + end: calendarDates.endDate, + }); const currentViewDaysData = currentViewDays.map((date: Date) => { const filterIssue = @@ -146,96 +140,78 @@ export const CalendarView: React.FC = ({ .then(() => mutate(fetchKey)); }; - const changeDateRange = (startDate: Date, endDate: Date) => { - setCalendarDates({ - startDate, - endDate, - }); - - setCalendarDateRange( - `${renderDateFormat(startDate)};after,${renderDateFormat(endDate)};before` - ); - }; - useEffect(() => { - setCalendarDateRange( - `${renderDateFormat(startOfWeek(currentDate))};after,${renderDateFormat( - lastDayOfWeek(currentDate) - )};before` - ); - }, [currentDate]); + setCalendarDates({ + startDate: startOfWeek(activeMonthDate), + endDate: lastDayOfWeek(activeMonthDate), + }); + }, [activeMonthDate]); const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; - return calendarIssues ? ( -
- -
- + return ( + <> + mutateIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> + {calendarIssues ? ( +
+ +
+ -
- {weeks.map((date, index) => (
- - {isMonthlyView - ? formatDate(date, "eee").substring(0, 3) - : formatDate(date, "eee")} - - {!isMonthlyView && {formatDate(date, "d")}} + {weeks.map((date, index) => ( +
+ {formatDate(date, "eee").substring(0, 3)} +
+ ))}
- ))} -
-
- {currentViewDaysData.map((date, index) => ( - - ))} -
+
+ {currentViewDaysData.map((date, index) => ( + + ))} +
+
+
- -
- ) : ( -
- -
+ ) : ( +
+ +
+ )} + ); }; diff --git a/web/components/core/views/calendar-view/index.ts b/web/components/core/views/calendar-view/index.ts index 625ff1fb4..75d8a3a1e 100644 --- a/web/components/core/views/calendar-view/index.ts +++ b/web/components/core/views/calendar-view/index.ts @@ -2,3 +2,4 @@ export * from "./calendar-header"; export * from "./calendar"; export * from "./single-date"; export * from "./single-issue"; +export * from "./inline-create-issue-form"; diff --git a/web/components/core/views/calendar-view/inline-create-issue-form.tsx b/web/components/core/views/calendar-view/inline-create-issue-form.tsx new file mode 100644 index 000000000..8f070543b --- /dev/null +++ b/web/components/core/views/calendar-view/inline-create-issue-form.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +import { InlineCreateIssueFormWrapper } from "components/core"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// types +import { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; + dependencies: any[]; +}; + +const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject, deps: any[]) => { + const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true); + + const router = useRouter(); + const { moduleId, cycleId, viewId } = router.query; + + const container = document.getElementById(`calendar-view-${cycleId ?? moduleId ?? viewId}`); + + useEffect(() => { + if (!ref.current) return; + + const { right } = ref.current.getBoundingClientRect(); + + const width = right; + + const innerWidth = container?.getBoundingClientRect().width ?? window.innerWidth; + + if (width > innerWidth) setIsThereSpaceOnRight(false); + else setIsThereSpaceOnRight(true); + }, [ref, deps, container]); + + return isThereSpaceOnRight; +}; + +const InlineInput = () => { + const { projectDetails } = useProjectDetails(); + + const { register, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +

+ {projectDetails?.identifier ?? "..."} +

+ + + ); +}; + +export const CalendarInlineCreateIssueForm: React.FC = (props) => { + const { isOpen, dependencies } = props; + + const ref = useRef(null); + + const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies); + + return ( + <> +
+ + + +
+ {/* Added to make any other element as outside click. This will make input also to be outside. */} + {isOpen &&
} + + ); +}; diff --git a/web/components/core/views/calendar-view/single-date.tsx b/web/components/core/views/calendar-view/single-date.tsx index ae1c018aa..a67ca762b 100644 --- a/web/components/core/views/calendar-view/single-date.tsx +++ b/web/components/core/views/calendar-view/single-date.tsx @@ -1,10 +1,14 @@ import React, { useState } from "react"; +// next +import { useRouter } from "next/router"; + // react-beautiful-dnd import { Draggable } from "react-beautiful-dnd"; // component import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { SingleCalendarIssue } from "./single-issue"; +import { CalendarInlineCreateIssueForm } from "./inline-create-issue-form"; // icons import { PlusSmallIcon } from "@heroicons/react/24/outline"; // helper @@ -20,23 +24,21 @@ type Props = { issues: IIssue[]; }; addIssueToDate: (date: string) => void; - isMonthlyView: boolean; showWeekEnds: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; -export const SingleCalendarDate: React.FC = ({ - handleIssueAction, - date, - index, - addIssueToDate, - isMonthlyView, - showWeekEnds, - user, - isNotAllowed, -}) => { +export const SingleCalendarDate: React.FC = (props) => { + const { handleIssueAction, date, index, showWeekEnds, user, isNotAllowed } = props; + + const router = useRouter(); + const { cycleId, moduleId } = router.query; + const [showAllIssues, setShowAllIssues] = useState(false); + const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + + const [formPosition, setFormPosition] = useState({ x: 0, y: 0 }); const totalIssues = date.issues.length; @@ -48,8 +50,6 @@ export const SingleCalendarDate: React.FC = ({ ref={provided.innerRef} {...provided.droppableProps} className={`group relative flex min-h-[150px] flex-col gap-1.5 border-t border-custom-border-200 p-2.5 text-left text-sm font-medium hover:bg-custom-background-90 ${ - isMonthlyView ? "" : "pt-9" - } ${ showWeekEnds ? (index + 1) % 7 === 0 ? "" @@ -59,48 +59,66 @@ export const SingleCalendarDate: React.FC = ({ : "border-r" }`} > - {isMonthlyView && {formatDate(new Date(date.date), "d")}} - {totalIssues > 0 && - date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => ( - - {(provided, snapshot) => ( - handleIssueAction(issue, "edit")} - handleDeleteIssue={() => handleIssueAction(issue, "delete")} - user={user} - isNotAllowed={isNotAllowed} - /> - )} - - ))} - {totalIssues > 4 && ( - - )} + <> + {formatDate(new Date(date.date), "d")} + {totalIssues > 0 && + date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => ( + + {(provided, snapshot) => ( + handleIssueAction(issue, "edit")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} + user={user} + isNotAllowed={isNotAllowed} + /> + )} + + ))} -
- -
+ setIsCreateIssueFormOpen(false)} + prePopulatedData={{ + target_date: date.date, + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + }} + /> - {provided.placeholder} + {totalIssues > 4 && ( + + )} + +
+ +
+ + {provided.placeholder} +
)} diff --git a/web/components/core/views/calendar-view/single-issue.tsx b/web/components/core/views/calendar-view/single-issue.tsx index f6c1cc2f7..e0e9aa2a5 100644 --- a/web/components/core/views/calendar-view/single-issue.tsx +++ b/web/components/core/views/calendar-view/single-issue.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { mutate } from "swr"; @@ -9,28 +8,23 @@ import { mutate } from "swr"; import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd"; // services import issuesService from "services/issues.service"; +import trackEventServices from "services/track-event.service"; // hooks import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useIssuesProperties from "hooks/use-issue-properties"; import useToast from "hooks/use-toast"; // components import { CustomMenu, Tooltip } from "components/ui"; -import { - ViewAssigneeSelect, - ViewDueDateSelect, - ViewEstimateSelect, - ViewLabelSelect, - ViewPrioritySelect, - ViewStartDateSelect, - ViewStateSelect, -} from "components/issues"; +import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; +import { LabelSelect, MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; // icons import { LinkIcon, PaperClipIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; import { LayerDiagonalIcon } from "components/icons"; // helper import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // type -import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types"; +import { ICurrentUserResponse, IIssue, IState, ISubIssueResponse, TIssuePriorities } from "types"; // fetch-keys import { CYCLE_ISSUES_WITH_PARAMS, @@ -47,6 +41,7 @@ type Props = { provided: DraggableProvided; snapshot: DraggableStateSnapshot; issue: IIssue; + projectId: string; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -58,11 +53,12 @@ export const SingleCalendarIssue: React.FC = ({ provided, snapshot, issue, + projectId, user, isNotAllowed, }) => { const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const { workspaceSlug, cycleId, moduleId, viewId } = router.query; const { setToastAlert } = useToast(); @@ -154,10 +150,99 @@ export const SingleCalendarIssue: React.FC = ({ }); }; + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + const displayProperties = properties ? Object.values(properties).some((value) => value === true) : false; + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + return (
= ({
)} - - - {properties.key && ( - - - {issue.project_detail?.identifier}-{issue.sequence_id} - - - )} - - {truncateText(issue.name, 25)} + + + {displayProperties && (
{properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -248,21 +335,25 @@ export const SingleCalendarIssue: React.FC = ({ /> )} {properties.labels && issue.labels.length > 0 && ( - )} {properties.assignee && ( - )} {properties.estimate && issue.estimate_point !== null && ( diff --git a/web/components/core/views/gantt-chart-view/index.tsx b/web/components/core/views/gantt-chart-view/index.tsx index a881cb7aa..2cd10f95f 100644 --- a/web/components/core/views/gantt-chart-view/index.tsx +++ b/web/components/core/views/gantt-chart-view/index.tsx @@ -6,20 +6,24 @@ import { IssueGanttChartView } from "components/issues"; import { ModuleIssuesGanttChartView } from "components/modules"; import { ViewIssuesGanttChartView } from "components/views"; -export const GanttChartView = () => { +type Props = { + disableUserActions: boolean; +}; + +export const GanttChartView: React.FC = ({ disableUserActions }) => { const router = useRouter(); const { cycleId, moduleId, viewId } = router.query; return ( <> {cycleId ? ( - + ) : moduleId ? ( - + ) : viewId ? ( - + ) : ( - + )} ); diff --git a/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx b/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx new file mode 100644 index 000000000..a5bc85f6e --- /dev/null +++ b/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// components +import { InlineCreateIssueFormWrapper } from "components/core"; + +// types +import { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; +}; + +const InlineInput = () => { + const { projectDetails } = useProjectDetails(); + + const { register, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +
+

{projectDetails?.identifier ?? "..."}

+ + + ); +}; + +export const GanttInlineCreateIssueForm: React.FC = (props) => ( + <> + + + + {props.isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + +); diff --git a/web/components/core/views/index.ts b/web/components/core/views/index.ts index 8b2dc87cb..13da90d8e 100644 --- a/web/components/core/views/index.ts +++ b/web/components/core/views/index.ts @@ -5,3 +5,4 @@ export * from "./list-view"; export * from "./spreadsheet-view"; export * from "./all-views"; export * from "./issues-view"; +export * from "./inline-issue-create-wrapper"; diff --git a/web/components/core/views/inline-issue-create-wrapper.tsx b/web/components/core/views/inline-issue-create-wrapper.tsx new file mode 100644 index 000000000..ec5d8e79e --- /dev/null +++ b/web/components/core/views/inline-issue-create-wrapper.tsx @@ -0,0 +1,273 @@ +import { useEffect, useRef } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// react hook form +import { useForm, FormProvider } from "react-hook-form"; + +// headless ui +import { Transition } from "@headlessui/react"; + +// services +import modulesService from "services/modules.service"; +import issuesService from "services/issues.service"; + +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +import useKeypress from "hooks/use-keypress"; +import useIssuesView from "hooks/use-issues-view"; +import useMyIssues from "hooks/my-issues/use-my-issues"; +import useGanttChartIssues from "hooks/gantt-chart/issue-view"; +import useCalendarIssuesView from "hooks/use-calendar-issues-view"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; + +// helpers +import { getFetchKeysForIssueMutation } from "helpers/string.helper"; + +// fetch-keys +import { + USER_ISSUE, + SUB_ISSUES, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + CYCLE_DETAILS, + MODULE_DETAILS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, +} from "constants/fetch-keys"; + +// types +import { IIssue } from "types"; + +const defaultValues: Partial = { + name: "", +}; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; + className?: string; + children?: React.ReactNode; +}; + +export const addIssueToCycle = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + cycleId: string, + user: any, + params: any +) => { + if (!workspaceSlug || !projectId) return; + + await issuesService + .addIssueToCycle( + workspaceSlug as string, + projectId.toString(), + cycleId, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (cycleId) { + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params)); + mutate(CYCLE_DETAILS(cycleId as string)); + } + }); +}; + +export const addIssueToModule = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleId: string, + user: any, + params: any +) => { + await modulesService + .addIssuesToModule( + workspaceSlug as string, + projectId.toString(), + moduleId as string, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (moduleId) { + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); + mutate(MODULE_DETAILS(moduleId as string)); + } + }); +}; + +export const InlineCreateIssueFormWrapper: React.FC = (props) => { + const { isOpen, handleClose, onSuccess, prePopulatedData, children, className } = props; + + const ref = useRef(null); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; + + const { user } = useUser(); + + const { setToastAlert } = useToast(); + + const { displayFilters, params } = useIssuesView(); + const { params: calendarParams } = useCalendarIssuesView(); + const { ...viewGanttParams } = params; + const { params: spreadsheetParams } = useSpreadsheetIssuesView(); + const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { params: ganttParams } = useGanttChartIssues( + workspaceSlug?.toString(), + projectId?.toString() + ); + + const method = useForm({ defaultValues }); + const { + reset, + handleSubmit, + getValues, + formState: { errors, isSubmitting }, + } = method; + + useOutsideClickDetector(ref, handleClose); + useKeypress("Escape", handleClose); + + useEffect(() => { + const values = getValues(); + + if (prePopulatedData) reset({ ...defaultValues, ...values, ...prePopulatedData }); + }, [reset, prePopulatedData, getValues]); + + useEffect(() => { + if (!isOpen) reset({ ...defaultValues }); + }, [isOpen, reset]); + + useEffect(() => { + if (!errors) return; + + Object.keys(errors).forEach((key) => { + const error = errors[key as keyof IIssue]; + + setToastAlert({ + type: "error", + title: "Error!", + message: error?.message?.toString() || "Some error occurred. Please try again.", + }); + }); + }, [errors, setToastAlert]); + + const { calendarFetchKey, ganttFetchKey, spreadsheetFetchKey } = getFetchKeysForIssueMutation({ + cycleId: cycleId, + moduleId: moduleId, + viewId: viewId, + projectId: projectId?.toString() ?? "", + calendarParams, + spreadsheetParams, + viewGanttParams, + ganttParams, + }); + + const onSubmitHandler = async (formData: IIssue) => { + if (!workspaceSlug || !projectId || !user || isSubmitting) return; + + reset({ ...defaultValues }); + + await (!isDraftIssues + ? issuesService.createIssues(workspaceSlug.toString(), projectId.toString(), formData, user) + : issuesService.createDraftIssue( + workspaceSlug.toString(), + projectId.toString(), + formData, + user + ) + ) + .then(async (res) => { + await mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)); + if (formData.cycle && formData.cycle !== "") + await addIssueToCycle( + workspaceSlug.toString(), + projectId.toString(), + res.id, + formData.cycle, + user, + params + ); + if (formData.module && formData.module !== "") + await addIssueToModule( + workspaceSlug.toString(), + projectId.toString(), + res.id, + formData.module, + user, + params + ); + + if (isDraftIssues) + await mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId.toString() ?? "", params)); + if (displayFilters.layout === "calendar") await mutate(calendarFetchKey); + if (displayFilters.layout === "gantt_chart") await mutate(ganttFetchKey); + if (displayFilters.layout === "spreadsheet") await mutate(spreadsheetFetchKey); + if (groupedIssues) await mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + if (onSuccess) await onSuccess(res); + + if (formData.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + + if (formData.parent && formData.parent !== "") mutate(SUB_ISSUES(formData.parent)); + }) + .catch((err) => { + Object.keys(err || {}).forEach((key) => { + const error = err?.[key]; + const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorTitle || "Some error occurred. Please try again.", + }); + }); + }); + }; + + return ( + <> + + +
+ {children} +
+
+
+ + ); +}; diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index 556c4d98e..c09e7c80b 100644 --- a/web/components/core/views/issues-view.tsx +++ b/web/components/core/views/issues-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/router"; @@ -19,7 +19,12 @@ import useIssuesProperties from "hooks/use-issue-properties"; import useProjectMembers from "hooks/use-project-members"; // components import { FiltersList, AllViews } from "components/core"; -import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { + CreateUpdateIssueModal, + DeleteIssueModal, + DeleteDraftIssueModal, + CreateUpdateDraftIssueModal, +} from "components/issues"; import { CreateUpdateViewModal } from "components/views"; // ui import { PrimaryButton, SecondaryButton } from "components/ui"; @@ -70,8 +75,13 @@ export const IssuesView: React.FC = ({ // trash box const [trashBox, setTrashBox] = useState(false); + // selected draft issue + const [selectedDraftIssue, setSelectedDraftIssue] = useState(null); + const [selectedDraftForDelete, setSelectDraftForDelete] = useState(null); + const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const isDraftIssues = router.asPath.includes("draft-issues"); const { user } = useUserAuth(); @@ -80,14 +90,12 @@ export const IssuesView: React.FC = ({ const { groupedByIssues, mutateIssues, - issueView, - groupByProperty: selectedGroup, - orderBy, + displayFilters, filters, isEmpty, setFilters, params, - showEmptyGroups, + setDisplayFilters, } = useIssuesView(); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); @@ -108,6 +116,17 @@ export const IssuesView: React.FC = ({ const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString()); + useEffect(() => { + if (!isDraftIssues) return; + + if ( + displayFilters.layout === "calendar" || + displayFilters.layout === "gantt_chart" || + displayFilters.layout === "spreadsheet" + ) + setDisplayFilters({ layout: "list" }); + }, [isDraftIssues, displayFilters, setDisplayFilters]); + const handleDeleteIssue = useCallback( (issue: IIssue) => { setDeleteIssueModal(true); @@ -116,6 +135,9 @@ export const IssuesView: React.FC = ({ [setDeleteIssueModal, setIssueToDelete] ); + const handleDraftIssueClick = useCallback((issue: any) => setSelectedDraftIssue(issue), []); + const handleDraftIssueDelete = useCallback((issue: any) => setSelectDraftForDelete(issue), []); + const handleOnDragEnd = useCallback( async (result: DropResult) => { setTrashBox(false); @@ -129,7 +151,7 @@ export const IssuesView: React.FC = ({ if (destination.droppableId === "trashBox") { handleDeleteIssue(draggedItem); } else { - if (orderBy === "sort_order") { + if (displayFilters.order_by === "sort_order") { let newSortOrder = draggedItem.sort_order; const destinationGroupArray = groupedByIssues[destination.droppableId]; @@ -177,16 +199,19 @@ export const IssuesView: React.FC = ({ const destinationGroup = destination.droppableId; // destination group id - if (orderBy === "sort_order" || source.droppableId !== destination.droppableId) { + if ( + displayFilters.order_by === "sort_order" || + source.droppableId !== destination.droppableId + ) { // different group/column; // source.droppableId !== destination.droppableId -> even if order by is not sort_order, // if the issue is moved to a different group, then we will change the group of the // dragged item(or issue) - if (selectedGroup === "priority") + if (displayFilters.group_by === "priority") draggedItem.priority = destinationGroup as TIssuePriorities; - else if (selectedGroup === "state") { + else if (displayFilters.group_by === "state") { draggedItem.state = destinationGroup; draggedItem.state_detail = states?.find((s) => s.id === destinationGroup) as IState; } @@ -213,8 +238,14 @@ export const IssuesView: React.FC = ({ return { ...prevData, - [sourceGroup]: orderArrayBy(sourceGroupArray, orderBy), - [destinationGroup]: orderArrayBy(destinationGroupArray, orderBy), + [sourceGroup]: orderArrayBy( + sourceGroupArray, + displayFilters.order_by ?? "-created_at" + ), + [destinationGroup]: orderArrayBy( + destinationGroupArray, + displayFilters.order_by ?? "-created_at" + ), }; }, false @@ -267,13 +298,13 @@ export const IssuesView: React.FC = ({ } }, [ + displayFilters.group_by, + displayFilters.order_by, workspaceSlug, cycleId, moduleId, groupedByIssues, projectId, - selectedGroup, - orderBy, handleDeleteIssue, params, states, @@ -287,19 +318,19 @@ export const IssuesView: React.FC = ({ let preloadedValue: string | string[] = groupTitle; - if (selectedGroup === "labels") { + if (displayFilters.group_by === "labels") { if (groupTitle === "None") preloadedValue = []; else preloadedValue = [groupTitle]; } - if (selectedGroup) + if (displayFilters.group_by) setPreloadedData({ - [selectedGroup]: preloadedValue, + [displayFilters.group_by]: preloadedValue, actionType: "createIssue", }); else setPreloadedData({ actionType: "createIssue" }); }, - [setCreateIssueModal, setPreloadedData, selectedGroup] + [displayFilters.group_by, setCreateIssueModal, setPreloadedData] ); const addIssueToDate = useCallback( @@ -344,6 +375,14 @@ export const IssuesView: React.FC = ({ [makeIssueCopy, handleEditIssue, handleDeleteIssue] ); + const handleDraftIssueAction = useCallback( + (issue: IIssue, action: "edit" | "delete") => { + if (action === "edit") handleDraftIssueClick(issue); + else if (action === "delete") handleDraftIssueDelete(issue); + }, + [handleDraftIssueClick, handleDraftIssueDelete] + ); + const removeIssueFromCycle = useCallback( (bridgeId: string, issueId: string) => { if (!workspaceSlug || !projectId || !cycleId) return; @@ -352,7 +391,7 @@ export const IssuesView: React.FC = ({ CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params), (prevData: any) => { if (!prevData) return prevData; - if (selectedGroup) { + if (displayFilters.group_by) { const filteredData: any = {}; for (const key in prevData) { filteredData[key] = prevData[key].filter((item: any) => item.id !== issueId); @@ -384,7 +423,7 @@ export const IssuesView: React.FC = ({ console.log(e); }); }, - [workspaceSlug, projectId, cycleId, params, selectedGroup, setToastAlert] + [displayFilters.group_by, workspaceSlug, projectId, cycleId, params, setToastAlert] ); const removeIssueFromModule = useCallback( @@ -395,7 +434,7 @@ export const IssuesView: React.FC = ({ MODULE_ISSUES_WITH_PARAMS(moduleId as string, params), (prevData: any) => { if (!prevData) return prevData; - if (selectedGroup) { + if (displayFilters.group_by) { const filteredData: any = {}; for (const key in prevData) { filteredData[key] = prevData[key].filter((item: any) => item.id !== issueId); @@ -427,7 +466,7 @@ export const IssuesView: React.FC = ({ console.log(e); }); }, - [workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert] + [displayFilters.group_by, workspaceSlug, projectId, moduleId, params, setToastAlert] ); const nullFilters = Object.keys(filters).filter( @@ -442,6 +481,7 @@ export const IssuesView: React.FC = ({ setCreateViewModal(null)} + viewType="project" preLoadedData={createViewModal} user={user} /> @@ -452,6 +492,19 @@ export const IssuesView: React.FC = ({ ...preloadedData, }} /> + setSelectedDraftIssue(null)} + data={ + selectedDraftIssue + ? { + ...selectedDraftIssue, + is_draft: true, + } + : null + } + fieldsToShow={["all"]} + /> setEditIssueModal(false)} @@ -463,6 +516,12 @@ export const IssuesView: React.FC = ({ data={issueToDelete} user={user} /> + setSelectDraftForDelete(null)} + /> + {areFiltersApplied && ( <>
@@ -481,7 +540,6 @@ export const IssuesView: React.FC = ({ state: null, start_date: null, target_date: null, - type: null, }) } /> @@ -513,29 +571,34 @@ export const IssuesView: React.FC = ({ addIssueToGroup={addIssueToGroup} disableUserActions={disableUserActions} dragDisabled={ - selectedGroup === "created_by" || - selectedGroup === "labels" || - selectedGroup === "state_detail.group" || - selectedGroup === "assignees" + displayFilters.group_by === "created_by" || + displayFilters.group_by === "labels" || + displayFilters.group_by === "state_detail.group" || + displayFilters.group_by === "assignees" } emptyState={{ - title: cycleId + title: isDraftIssues + ? "Draft issues will appear here" + : cycleId ? "Cycle issues will appear here" : moduleId ? "Module issues will appear here" : "Project issues will appear here", - description: - "Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.", - primaryButton: { - icon: , - text: "New Issue", - onClick: () => { - const e = new KeyboardEvent("keydown", { - key: "c", - }); - document.dispatchEvent(e); - }, - }, + description: isDraftIssues + ? "Draft issues are issues that are not yet created." + : "Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.", + primaryButton: !isDraftIssues + ? { + icon: , + text: "New Issue", + onClick: () => { + const e = new KeyboardEvent("keydown", { + key: "c", + }); + document.dispatchEvent(e); + }, + } + : undefined, secondaryButton: cycleId || moduleId ? ( = ({ }} handleOnDragEnd={handleOnDragEnd} handleIssueAction={handleIssueAction} + handleDraftIssueAction={handleDraftIssueAction} openIssuesListModal={openIssuesListModal ?? null} removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null} trashBox={trashBox} setTrashBox={setTrashBox} viewProps={{ - groupByProperty: selectedGroup, groupedIssues: groupedByIssues, + displayFilters, isEmpty, - issueView, mutateIssues, - orderBy, params, properties, - showEmptyGroups, }} /> diff --git a/web/components/core/views/list-view/all-lists.tsx b/web/components/core/views/list-view/all-lists.tsx index 84ef02962..58025b328 100644 --- a/web/components/core/views/list-view/all-lists.tsx +++ b/web/components/core/views/list-view/all-lists.tsx @@ -1,5 +1,12 @@ +import { useRouter } from "next/router"; + +// hooks +import useMyIssues from "hooks/my-issues/use-my-issues"; +import useIssuesView from "hooks/use-issues-view"; +import useProfileIssues from "hooks/use-profile-issues"; // components import { SingleList } from "components/core/views/list-view/single-list"; +import { IssuePeekOverview } from "components/issues"; // types import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types"; @@ -8,7 +15,10 @@ type Props = { states: IState[] | undefined; addIssueToGroup: (groupTitle: string) => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void; openIssuesListModal?: (() => void) | null; + myIssueProjectId?: string | null; + handleMyIssueOpen?: (issue: IIssue) => void; removeIssue: ((bridgeId: string, issueId: string) => void) | null; disableUserActions: boolean; disableAddIssueOption?: boolean; @@ -23,23 +33,50 @@ export const AllLists: React.FC = ({ disableUserActions, disableAddIssueOption = false, openIssuesListModal, + handleMyIssueOpen, + myIssueProjectId, removeIssue, states, + handleDraftIssueAction, user, userAuth, viewProps, }) => { - const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps; + const router = useRouter(); + const { workspaceSlug, projectId, userId } = router.query; + + const isProfileIssue = + router.pathname.includes("assigned") || + router.pathname.includes("created") || + router.pathname.includes("subscribed"); + + const isMyIssue = router.pathname.includes("my-issues"); + const { mutateIssues } = useIssuesView(); + const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString()); + + const { displayFilters, groupedIssues } = viewProps; return ( <> + + isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues() + } + projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> {groupedIssues && (
{Object.keys(groupedIssues).map((singleGroup) => { const currentState = - selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; + displayFilters?.group_by === "state" + ? states?.find((s) => s.id === singleGroup) + : null; - if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null; + if (!displayFilters?.show_empty_groups && groupedIssues[singleGroup].length === 0) + return null; return ( = ({ groupTitle={singleGroup} currentState={currentState} addIssueToGroup={() => addIssueToGroup(singleGroup)} + handleDraftIssueAction={handleDraftIssueAction} handleIssueAction={handleIssueAction} + handleMyIssueOpen={handleMyIssueOpen} openIssuesListModal={openIssuesListModal} removeIssue={removeIssue} disableUserActions={disableUserActions} diff --git a/web/components/core/views/list-view/index.ts b/web/components/core/views/list-view/index.ts index c515ed1c2..4d59be165 100644 --- a/web/components/core/views/list-view/index.ts +++ b/web/components/core/views/list-view/index.ts @@ -1,3 +1,4 @@ export * from "./all-lists"; export * from "./single-issue"; export * from "./single-list"; +export * from "./inline-create-issue-form"; diff --git a/web/components/core/views/list-view/inline-create-issue-form.tsx b/web/components/core/views/list-view/inline-create-issue-form.tsx new file mode 100644 index 000000000..2f7e1287e --- /dev/null +++ b/web/components/core/views/list-view/inline-create-issue-form.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +// react hook form +import { useFormContext } from "react-hook-form"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// components +import { InlineCreateIssueFormWrapper } from "../inline-issue-create-wrapper"; + +// types +import { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; +}; + +const InlineInput = () => { + const { projectDetails } = useProjectDetails(); + + const { register, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +

+ {projectDetails?.identifier ?? "..."} +

+ + + ); +}; + +export const ListInlineCreateIssueForm: React.FC = (props) => ( + <> + + + + {props.isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + +); diff --git a/web/components/core/views/list-view/single-issue.tsx b/web/components/core/views/list-view/single-issue.tsx index d89ef6927..b0950a684 100644 --- a/web/components/core/views/list-view/single-issue.tsx +++ b/web/components/core/views/list-view/single-issue.tsx @@ -1,24 +1,18 @@ import React, { useCallback, useState } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { mutate } from "swr"; // services import issuesService from "services/issues.service"; +import trackEventServices from "services/track-event.service"; // hooks import useToast from "hooks/use-toast"; // components -import { - ViewAssigneeSelect, - ViewDueDateSelect, - ViewEstimateSelect, - ViewIssueLabel, - ViewPrioritySelect, - ViewStartDateSelect, - ViewStateSelect, -} from "components/issues"; +import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; +import { LabelSelect, MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; // ui import { Tooltip, CustomMenu, ContextMenu } from "components/ui"; // icons @@ -40,8 +34,10 @@ import { ICurrentUserResponse, IIssue, IIssueViewProps, + IState, ISubIssueResponse, IUserProfileProjectSegregation, + TIssuePriorities, UserAuth, } from "types"; // fetch-keys @@ -55,12 +51,16 @@ import { type Props = { type?: string; issue: IIssue; + projectId: string; groupTitle?: string; editIssue: () => void; index: number; makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; + handleDraftIssueSelect?: (issue: IIssue) => void; + handleDraftIssueDelete?: (issue: IIssue) => void; + handleMyIssueOpen?: (issue: IIssue) => void; disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; @@ -70,28 +70,33 @@ type Props = { export const SingleListIssue: React.FC = ({ type, issue, + projectId, editIssue, index, makeIssueCopy, removeIssue, groupTitle, + handleDraftIssueDelete, handleDeleteIssue, + handleMyIssueOpen, disableUserActions, user, userAuth, viewProps, + handleDraftIssueSelect, }) => { // context menu const [contextMenu, setContextMenu] = useState(false); const [contextMenuPosition, setContextMenuPosition] = useState(null); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query; + const { workspaceSlug, cycleId, moduleId, userId } = router.query; const isArchivedIssues = router.pathname.includes("archived-issues"); + const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; const { setToastAlert } = useToast(); - const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps; + const { displayFilters, properties, mutateIssues } = viewProps; const partialUpdateIssue = useCallback( (formData: Partial, issue: IIssue) => { @@ -124,9 +129,9 @@ export const SingleListIssue: React.FC = ({ handleIssuesMutation( formData, groupTitle ?? "", - selectedGroup, + displayFilters?.group_by ?? null, index, - orderBy, + displayFilters?.order_by ?? "-created_at", prevData ), false @@ -148,15 +153,14 @@ export const SingleListIssue: React.FC = ({ }); }, [ + displayFilters, workspaceSlug, cycleId, moduleId, userId, groupTitle, index, - selectedGroup, mutateIssues, - orderBy, user, ] ); @@ -175,10 +179,102 @@ export const SingleListIssue: React.FC = ({ }); }; + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + const issuePath = isArchivedIssues ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}` + : isDraftIssues + ? `#` : `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`; + const openPeekOverview = (issue: IIssue) => { + const { query } = router; + + if (handleMyIssueOpen) handleMyIssueOpen(issue); + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues; @@ -192,26 +288,45 @@ export const SingleListIssue: React.FC = ({ > {!isNotAllowed && ( <> - + { + if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue); + else editIssue(); + }} + > Edit issue - - Make a copy... - - handleDeleteIssue(issue)}> + {!isDraftIssues && ( + + Make a copy... + + )} + { + if (isDraftIssues && handleDraftIssueDelete) handleDraftIssueDelete(issue); + else handleDeleteIssue(issue); + }} + > Delete issue )} - - Copy issue link - - - - Open issue in new tab - - + {!isDraftIssues && ( + <> + + Copy issue link + + + + Open issue in new tab + + + + )} +
{ @@ -221,23 +336,30 @@ export const SingleListIssue: React.FC = ({ }} >
- - - {properties.key && ( - - - {issue.project_detail?.identifier}-{issue.sequence_id} - - - )} - - {issue.name} +
+ {properties.key && ( + + + {issue.project_detail?.identifier}-{issue.sequence_id} + - - + )} + + + +
= ({ }`} > {properties.priority && ( - )} {properties.state && ( - )} {properties.start_date && issue.start_date && ( @@ -279,14 +400,26 @@ export const SingleListIssue: React.FC = ({ isNotAllowed={isNotAllowed} /> )} - {properties.labels && } - {properties.assignee && ( - + )} + {properties.assignee && ( + )} {properties.estimate && issue.estimate_point !== null && ( @@ -330,7 +463,12 @@ export const SingleListIssue: React.FC = ({ )} {type && !isNotAllowed && ( - + { + if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue); + else editIssue(); + }} + >
Edit issue @@ -344,18 +482,25 @@ export const SingleListIssue: React.FC = ({
)} - handleDeleteIssue(issue)}> + { + if (isDraftIssues && handleDraftIssueDelete) handleDraftIssueDelete(issue); + else handleDeleteIssue(issue); + }} + >
Delete issue
- -
- - Copy issue link -
-
+ {!isDraftIssues && ( + +
+ + Copy issue link +
+
+ )}
)}
diff --git a/web/components/core/views/list-view/single-list.tsx b/web/components/core/views/list-view/single-list.tsx index 14829adc4..03401839c 100644 --- a/web/components/core/views/list-view/single-list.tsx +++ b/web/components/core/views/list-view/single-list.tsx @@ -1,3 +1,6 @@ +import { useState } from "react"; + +// next import { useRouter } from "next/router"; import useSWR from "swr"; @@ -10,7 +13,7 @@ import projectService from "services/project.service"; // hooks import useProjects from "hooks/use-projects"; // components -import { SingleListIssue } from "components/core"; +import { SingleListIssue, ListInlineCreateIssueForm } from "components/core"; // ui import { Avatar, CustomMenu } from "components/ui"; // icons @@ -31,7 +34,7 @@ import { UserAuth, } from "types"; // fetch-keys -import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; +import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys"; // constants import { STATE_GROUP_COLORS } from "constants/state"; @@ -40,7 +43,9 @@ type Props = { groupTitle: string; addIssueToGroup: () => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void; openIssuesListModal?: (() => void) | null; + handleMyIssueOpen?: (issue: IIssue) => void; removeIssue: ((bridgeId: string, issueId: string) => void) | null; disableUserActions: boolean; disableAddIssueOption?: boolean; @@ -49,38 +54,65 @@ type Props = { viewProps: IIssueViewProps; }; -export const SingleList: React.FC = ({ - currentState, - groupTitle, - addIssueToGroup, - handleIssueAction, - openIssuesListModal, - removeIssue, - disableUserActions, - disableAddIssueOption = false, - user, - userAuth, - viewProps, -}) => { +export const SingleList: React.FC = (props) => { + const { + currentState, + groupTitle, + handleIssueAction, + openIssuesListModal, + handleDraftIssueAction, + handleMyIssueOpen, + addIssueToGroup, + removeIssue, + disableUserActions, + disableAddIssueOption = false, + user, + userAuth, + viewProps, + } = props; + const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + + const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues"; + const isProfileIssuesPage = router.pathname.split("/")[2] === "profile"; + const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues"; + const isArchivedIssues = router.pathname.includes("archived-issues"); const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; - const { groupByProperty: selectedGroup, groupedIssues } = viewProps; + const { displayFilters, groupedIssues } = viewProps; - const { data: issueLabels } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, - workspaceSlug && projectId - ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) + const { data: issueLabels } = useSWR( + workspaceSlug && projectId && displayFilters?.group_by === "labels" + ? PROJECT_ISSUE_LABELS(projectId.toString()) + : null, + workspaceSlug && projectId && displayFilters?.group_by === "labels" + ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) + : null + ); + + const { data: workspaceLabels } = useSWR( + workspaceSlug && displayFilters?.group_by === "labels" + ? WORKSPACE_LABELS(workspaceSlug.toString()) + : null, + workspaceSlug && displayFilters?.group_by === "labels" + ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null ); const { data: members } = useSWR( - workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, - workspaceSlug && projectId + workspaceSlug && + projectId && + (displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees") + ? PROJECT_MEMBERS(projectId as string) + : null, + workspaceSlug && + projectId && + (displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees") ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) : null ); @@ -90,12 +122,15 @@ export const SingleList: React.FC = ({ const getGroupTitle = () => { let title = addSpaceIfCamelCase(groupTitle); - switch (selectedGroup) { + switch (displayFilters?.group_by) { case "state": title = addSpaceIfCamelCase(currentState?.name ?? ""); break; case "labels": - title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; + title = + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find( + (label) => label.id === groupTitle + )?.name ?? "None"; break; case "project": title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; @@ -113,7 +148,7 @@ export const SingleList: React.FC = ({ const getGroupIcon = () => { let icon; - switch (selectedGroup) { + switch (displayFilters?.group_by) { case "state": icon = currentState && ( = ({ break; case "labels": const labelColor = - issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find( + (label) => label.id === groupTitle + )?.color ?? "#000000"; icon = ( = ({
- {selectedGroup !== null && ( + {displayFilters?.group_by !== null && (
{getGroupIcon()}
)} - {selectedGroup !== null ? ( + {displayFilters?.group_by !== null ? (

{getGroupTitle()} @@ -203,7 +240,11 @@ export const SingleList: React.FC = ({ @@ -220,7 +261,9 @@ export const SingleList: React.FC = ({ position="right" noBorder > - Create new + setIsCreateIssueFormOpen(true)}> + Create new + {openIssuesListModal && ( Add an existing issue @@ -246,11 +289,23 @@ export const SingleList: React.FC = ({ key={issue.id} type={type} issue={issue} + projectId={issue.project_detail.id} groupTitle={groupTitle} index={index} editIssue={() => 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); @@ -269,6 +324,33 @@ export const SingleList: React.FC = ({ ) : (
Loading...
)} + + setIsCreateIssueFormOpen(false)} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by!]: groupTitle, + }} + /> + + {!disableAddIssueOption && !isCreateIssueFormOpen && ( +
+ +
+ )}

diff --git a/web/components/core/views/spreadsheet-view/assignee-column/assignee-column.tsx b/web/components/core/views/spreadsheet-view/assignee-column/assignee-column.tsx new file mode 100644 index 000000000..2f1231924 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/assignee-column/assignee-column.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { MembersSelect } from "components/project"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const AssigneeColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + return ( +
+ + {properties.assignee && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/assignee-column/index.ts b/web/components/core/views/spreadsheet-view/assignee-column/index.ts new file mode 100644 index 000000000..8750550be --- /dev/null +++ b/web/components/core/views/spreadsheet-view/assignee-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-assignee-column"; +export * from "./assignee-column"; diff --git a/web/components/core/views/spreadsheet-view/assignee-column/spreadsheet-assignee-column.tsx b/web/components/core/views/spreadsheet-view/assignee-column/spreadsheet-assignee-column.tsx new file mode 100644 index 000000000..a864126c6 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/assignee-column/spreadsheet-assignee-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { AssigneeColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetAssigneeColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/created-on-column/created-on-column.tsx b/web/components/core/views/spreadsheet-view/created-on-column/created-on-column.tsx new file mode 100644 index 000000000..15c3f7810 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/created-on-column/created-on-column.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; +// helper +import { renderLongDetailDateFormat } from "helpers/date-time.helper"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const CreatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.created_on && ( +
+ {renderLongDetailDateFormat(issue.created_at)} +
+ )} +
+
+); diff --git a/web/components/core/views/spreadsheet-view/created-on-column/index.ts b/web/components/core/views/spreadsheet-view/created-on-column/index.ts new file mode 100644 index 000000000..28781aa17 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/created-on-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-created-on-column"; +export * from "./created-on-column"; diff --git a/web/components/core/views/spreadsheet-view/created-on-column/spreadsheet-created-on-column.tsx b/web/components/core/views/spreadsheet-view/created-on-column/spreadsheet-created-on-column.tsx new file mode 100644 index 000000000..3ce3f2dbe --- /dev/null +++ b/web/components/core/views/spreadsheet-view/created-on-column/spreadsheet-created-on-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { CreatedOnColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetCreatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/due-date-column/due-date-column.tsx b/web/components/core/views/spreadsheet-view/due-date-column/due-date-column.tsx new file mode 100644 index 000000000..7e258f2f6 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/due-date-column/due-date-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +// components +import { ViewDueDateSelect } from "components/issues"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const DueDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.due_date && ( + + )} + +
+); diff --git a/web/components/core/views/spreadsheet-view/due-date-column/index.ts b/web/components/core/views/spreadsheet-view/due-date-column/index.ts new file mode 100644 index 000000000..64b454877 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/due-date-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-due-date-column"; +export * from "./due-date-column"; diff --git a/web/components/core/views/spreadsheet-view/due-date-column/spreadsheet-due-date-column.tsx b/web/components/core/views/spreadsheet-view/due-date-column/spreadsheet-due-date-column.tsx new file mode 100644 index 000000000..1cd2eac26 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/due-date-column/spreadsheet-due-date-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { DueDateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetDueDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/estimate-column/estimate-column.tsx b/web/components/core/views/spreadsheet-view/estimate-column/estimate-column.tsx new file mode 100644 index 000000000..f763e7322 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/estimate-column/estimate-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +// components +import { ViewEstimateSelect } from "components/issues"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const EstimateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.estimate && ( + + )} + +
+); diff --git a/web/components/core/views/spreadsheet-view/estimate-column/index.ts b/web/components/core/views/spreadsheet-view/estimate-column/index.ts new file mode 100644 index 000000000..31f07e6a7 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/estimate-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-estimate-column"; +export * from "./estimate-column"; diff --git a/web/components/core/views/spreadsheet-view/estimate-column/spreadsheet-estimate-column.tsx b/web/components/core/views/spreadsheet-view/estimate-column/spreadsheet-estimate-column.tsx new file mode 100644 index 000000000..a1cc74ad0 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/estimate-column/spreadsheet-estimate-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { EstimateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetEstimateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/index.ts b/web/components/core/views/spreadsheet-view/index.ts index 7729d5e93..e72819dad 100644 --- a/web/components/core/views/spreadsheet-view/index.ts +++ b/web/components/core/views/spreadsheet-view/index.ts @@ -1,4 +1,14 @@ +export * from "./assignee-column"; +export * from "./created-on-column"; +export * from "./due-date-column"; +export * from "./estimate-column"; +export * from "./issue-column"; +export * from "./label-column"; +export * from "./priority-column"; +export * from "./start-date-column"; +export * from "./state-column"; +export * from "./updated-on-column"; export * from "./spreadsheet-view"; -export * from "./single-issue"; +export * from "./issue-column/issue-column"; export * from "./spreadsheet-columns"; -export * from "./spreadsheet-issues"; +export * from "./issue-column/spreadsheet-issue-column"; diff --git a/web/components/core/views/spreadsheet-view/issue-column/index.ts b/web/components/core/views/spreadsheet-view/issue-column/index.ts new file mode 100644 index 000000000..b8d09d1df --- /dev/null +++ b/web/components/core/views/spreadsheet-view/issue-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-issue-column"; +export * from "./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 new file mode 100644 index 000000000..c1df89c92 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx @@ -0,0 +1,179 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; + +// components +import { Popover2 } from "@blueprintjs/popover2"; +// icons +import { Icon } from "components/ui"; +import { + EllipsisHorizontalIcon, + LinkIcon, + PencilIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +// hooks +import useToast from "hooks/use-toast"; +// types +import { IIssue, Properties, UserAuth } from "types"; +// helper +import { copyTextToClipboard } from "helpers/string.helper"; + +type Props = { + issue: IIssue; + projectId: string; + expanded: boolean; + handleToggleExpand: (issueId: string) => void; + properties: Properties; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + setCurrentProjectId: React.Dispatch>; + disableUserActions: boolean; + userAuth: UserAuth; + nestingLevel: number; +}; + +export const IssueColumn: React.FC = ({ + issue, + projectId, + expanded, + handleToggleExpand, + properties, + handleEditIssue, + handleDeleteIssue, + setCurrentProjectId, + disableUserActions, + userAuth, + nestingLevel, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const openPeekOverview = () => { + const { query } = router; + setCurrentProjectId(issue.project_detail.id); + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + + const handleCopyText = () => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard( + `${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); + }; + + const paddingLeft = `${nestingLevel * 54}px`; + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( +
+
+
+ {properties.key && ( + + {issue.project_detail?.identifier}-{issue.sequence_id} + + )} + {!isNotAllowed && !disableUserActions && ( +
+ setIsOpen(nextOpenState)} + content={ +
+ + + + + +
+ } + placement="bottom-start" + > + +
+
+ )} +
+ + {issue.sub_issues_count > 0 && ( +
+ +
+ )} +
+ + + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx b/web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx similarity index 73% rename from web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx rename to web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx index 6677e8849..966852a5b 100644 --- a/web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx +++ b/web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx @@ -1,36 +1,34 @@ -import React, { useState } from "react"; +import React from "react"; // components -import { SingleSpreadsheetIssue } from "components/core"; +import { IssueColumn } from "components/core"; // hooks import useSubIssue from "hooks/use-sub-issue"; // types -import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; +import { IIssue, Properties, UserAuth } from "types"; type Props = { issue: IIssue; - index: number; + projectId: string; expandedIssues: string[]; setExpandedIssues: React.Dispatch>; properties: Properties; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; - gridTemplateColumns: string; + setCurrentProjectId: React.Dispatch>; disableUserActions: boolean; - user: ICurrentUserResponse | undefined; userAuth: UserAuth; nestingLevel?: number; }; -export const SpreadsheetIssues: React.FC = ({ - index, +export const SpreadsheetIssuesColumn: React.FC = ({ issue, + projectId, expandedIssues, setExpandedIssues, - gridTemplateColumns, properties, handleIssueAction, + setCurrentProjectId, disableUserActions, - user, userAuth, nestingLevel = 0, }) => { @@ -49,21 +47,20 @@ export const SpreadsheetIssues: React.FC = ({ const isExpanded = expandedIssues.indexOf(issue.id) > -1; - const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded); + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); return (
- handleIssueAction(issue, "edit")} handleDeleteIssue={() => handleIssueAction(issue, "delete")} + setCurrentProjectId={setCurrentProjectId} disableUserActions={disableUserActions} - user={user} userAuth={userAuth} nestingLevel={nestingLevel} /> @@ -73,17 +70,16 @@ export const SpreadsheetIssues: React.FC = ({ subIssues && subIssues.length > 0 && subIssues.map((subIssue: IIssue) => ( - diff --git a/web/components/core/views/spreadsheet-view/label-column/index.ts b/web/components/core/views/spreadsheet-view/label-column/index.ts new file mode 100644 index 000000000..a1b69c1a9 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-label-column"; +export * from "./label-column"; diff --git a/web/components/core/views/spreadsheet-view/label-column/label-column.tsx b/web/components/core/views/spreadsheet-view/label-column/label-column.tsx new file mode 100644 index 000000000..cad1e7666 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/label-column.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +// components +import { LabelSelect } from "components/project"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const LabelColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + return ( +
+ + {properties.labels && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx b/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx new file mode 100644 index 000000000..5ab77e909 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { LabelColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetLabelColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/priority-column/index.ts b/web/components/core/views/spreadsheet-view/priority-column/index.ts new file mode 100644 index 000000000..fc542331e --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-priority-column"; +export * from "./priority-column"; diff --git a/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx b/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx new file mode 100644 index 000000000..feb8acdf5 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { PrioritySelect } from "components/project"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, Properties, TIssuePriorities } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const PriorityColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + + return ( +
+ + {properties.priority && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx b/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx new file mode 100644 index 000000000..f0b84fb59 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { PriorityColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetPriorityColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/single-issue.tsx b/web/components/core/views/spreadsheet-view/single-issue.tsx index 731d7f921..32cb4ba77 100644 --- a/web/components/core/views/spreadsheet-view/single-issue.tsx +++ b/web/components/core/views/spreadsheet-view/single-issue.tsx @@ -5,15 +5,9 @@ import { useRouter } from "next/router"; import { mutate } from "swr"; // components -import { - ViewAssigneeSelect, - ViewDueDateSelect, - ViewEstimateSelect, - ViewIssueLabel, - ViewPrioritySelect, - ViewStartDateSelect, - ViewStateSelect, -} from "components/issues"; +import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues"; +import { LabelSelect, MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; import { Popover2 } from "@blueprintjs/popover2"; // icons import { Icon } from "components/ui"; @@ -28,6 +22,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useToast from "hooks/use-toast"; // services import issuesService from "services/issues.service"; +import trackEventServices from "services/track-event.service"; // constant import { CYCLE_DETAILS, @@ -39,13 +34,22 @@ import { VIEW_ISSUES, } from "constants/fetch-keys"; // types -import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types"; +import { + ICurrentUserResponse, + IIssue, + IState, + ISubIssueResponse, + Properties, + TIssuePriorities, + UserAuth, +} from "types"; // helper import { copyTextToClipboard } from "helpers/string.helper"; import { renderLongDetailDateFormat } from "helpers/date-time.helper"; type Props = { issue: IIssue; + projectId: string; index: number; expanded: boolean; handleToggleExpand: (issueId: string) => void; @@ -61,6 +65,7 @@ type Props = { export const SingleSpreadsheetIssue: React.FC = ({ issue, + projectId, index, expanded, handleToggleExpand, @@ -77,7 +82,7 @@ export const SingleSpreadsheetIssue: React.FC = ({ const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const { workspaceSlug, cycleId, moduleId, viewId } = router.query; const { params } = useSpreadsheetIssuesView(); @@ -93,7 +98,7 @@ export const SingleSpreadsheetIssue: React.FC = ({ ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) : viewId ? VIEW_ISSUES(viewId.toString(), params) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId, params); if (issue.parent) mutate( @@ -133,13 +138,7 @@ export const SingleSpreadsheetIssue: React.FC = ({ ); issuesService - .patchIssue( - workspaceSlug as string, - projectId as string, - issue.id as string, - formData, - user - ) + .patchIssue(workspaceSlug as string, projectId, issue.id as string, formData, user) .then(() => { if (issue.parent) { mutate(SUB_ISSUES(issue.parent as string)); @@ -180,6 +179,86 @@ export const SingleSpreadsheetIssue: React.FC = ({ }); }; + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + const paddingLeft = `${nestingLevel * 68}px`; const tooltipPosition = index === 0 ? "bottom" : "top"; @@ -283,47 +362,52 @@ export const SingleSpreadsheetIssue: React.FC = ({
{properties.state && (
-
)} {properties.priority && (
-
)} {properties.assignee && (
-
)} {properties.labels && (
- +
)} diff --git a/web/components/core/views/spreadsheet-view/spreadsheet-columns.tsx b/web/components/core/views/spreadsheet-view/spreadsheet-columns.tsx index 181ea93a7..f52f1ab38 100644 --- a/web/components/core/views/spreadsheet-view/spreadsheet-columns.tsx +++ b/web/components/core/views/spreadsheet-view/spreadsheet-columns.tsx @@ -22,10 +22,10 @@ export const SpreadsheetColumns: React.FC = ({ columnData, gridTemplateCo const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage("spreadsheetViewActiveSortingProperty", ""); - const { orderBy, setOrderBy } = useSpreadsheetIssuesView(); + const { displayFilters, setDisplayFilters } = useSpreadsheetIssuesView(); const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { - setOrderBy(order); + setDisplayFilters({ order_by: order }); setSelectedMenuItem(`${order}_${itemKey}`); setActiveSortingProperty(order === "-created_at" ? "" : itemKey); }; @@ -239,7 +239,7 @@ export const SpreadsheetColumns: React.FC = ({ columnData, gridTemplateCo {selectedMenuItem && selectedMenuItem !== "" && - orderBy !== "-created_at" && + displayFilters?.order_by !== "-created_at" && selectedMenuItem.includes(col.propertyName) && ( ; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; openIssuesListModal?: (() => void) | null; disableUserActions: boolean; @@ -26,6 +57,8 @@ type Props = { }; export const SpreadsheetView: React.FC = ({ + spreadsheetIssues, + mutateIssues, handleIssueAction, openIssuesListModal, disableUserActions, @@ -33,83 +66,257 @@ export const SpreadsheetView: React.FC = ({ userAuth, }) => { const [expandedIssues, setExpandedIssues] = useState([]); + const [currentProjectId, setCurrentProjectId] = useState(null); + + const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId, workspaceViewId } = router.query; const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; - const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView(); - const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); - const columnData = SPREADSHEET_COLUMN.map((column) => ({ - ...column, - isActive: properties - ? column.propertyName === "labels" - ? properties[column.propertyName as keyof Properties] - : column.propertyName === "title" - ? true - : properties[column.propertyName as keyof Properties] - : false, - })); + 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 gridTemplateColumns = columnData - .filter((column) => column.isActive) - .map((column) => column.colSize) - .join(" "); + const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) => + router.pathname.includes(path.path) + ); + + const { params: workspaceViewParams } = useWorkspaceIssuesFilters( + workspaceSlug?.toString(), + workspaceViewId?.toString() + ); + + const { params } = useSpreadsheetIssuesView(); + + const partialUpdateIssue = useCallback( + (formData: Partial, issue: IIssue) => { + if (!workspaceSlug || !issue) return; + + const fetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : viewId + ? VIEW_ISSUES(viewId.toString(), params) + : workspaceViewId + ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), workspaceViewParams) + : currentWorkspaceIssuePath + ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params) + : PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project_detail.id, params); + + if (issue.parent) + mutate( + SUB_ISSUES(issue.parent.toString()), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + sub_issues: (prevData.sub_issues ?? []).map((i) => { + if (i.id === issue.id) { + return { + ...i, + ...formData, + }; + } + return i; + }), + }; + }, + false + ); + else + mutate( + fetchKey, + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === issue.id) { + return { + ...p, + ...formData, + }; + } + return p; + }), + false + ); + + projectIssuesServices + .patchIssue( + workspaceSlug as string, + issue.project_detail.id, + issue.id as string, + formData, + user + ) + .then(() => { + if (issue.parent) { + mutate(SUB_ISSUES(issue.parent as string)); + } else { + mutate(fetchKey); + + if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); + if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); + } + }) + .catch((error) => { + console.log(error); + }); + }, + [ + workspaceSlug, + cycleId, + moduleId, + viewId, + workspaceViewId, + currentWorkspaceIssuePath, + workspaceViewParams, + params, + user, + ] + ); + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + const renderColumn = (header: string, Component: React.ComponentType) => ( +
+
+ {header} +
+
+ {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+
+ ); return ( <> mutateIssues()} - projectId={projectId?.toString() ?? ""} + projectId={currentProjectId ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""} readOnly={disableUserActions} /> -
-
- -
- {spreadsheetIssues ? ( -
- {spreadsheetIssues.map((issue: IIssue, index) => ( - +
+
+ {spreadsheetIssues ? ( + <> +
+
+
+ + ID + + + Issue + +
+ + {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+
+ {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)} + + ) : ( +
+ +
+ )} +
+ +
+
+ setIsInlineCreateIssueFormOpen(false)} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + }} /> - ))} -
- {type === "issue" ? ( - - ) : ( - !disableUserActions && ( +
+ + {type === "issue" + ? !disableUserActions && + !isInlineCreateIssueFormOpen && ( + + ) + : !disableUserActions && + !isInlineCreateIssueFormOpen && ( @@ -117,15 +324,11 @@ export const SpreadsheetView: React.FC = ({ } position="left" + verticalPosition="top" optionsClassName="left-5 !w-36" noBorder > - { - const e = new KeyboardEvent("keydown", { key: "c" }); - document.dispatchEvent(e); - }} - > + setIsInlineCreateIssueFormOpen(true)}> Create new {openIssuesListModal && ( @@ -134,13 +337,9 @@ export const SpreadsheetView: React.FC = ({ )} - ) - )} -
+ )}
- ) : ( - - )} +
); diff --git a/web/components/core/views/spreadsheet-view/start-date-column/index.ts b/web/components/core/views/spreadsheet-view/start-date-column/index.ts new file mode 100644 index 000000000..94f229498 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-start-date-column"; +export * from "./start-date-column"; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx b/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx new file mode 100644 index 000000000..064506ca2 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { StartDateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetStartDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx b/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx new file mode 100644 index 000000000..3b4b9a0f7 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +// components +import { ViewStartDateSelect } from "components/issues"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const StartDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.due_date && ( + + )} + +
+); diff --git a/web/components/core/views/spreadsheet-view/state-column/index.ts b/web/components/core/views/spreadsheet-view/state-column/index.ts new file mode 100644 index 000000000..f3cbef871 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-state-column"; +export * from "./state-column"; diff --git a/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx b/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx new file mode 100644 index 000000000..606f3e28a --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { StateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetStateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; 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 new file mode 100644 index 000000000..6b3d3c696 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/state-column.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { StateSelect } from "components/states"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, IState, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const StateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + return ( +
+ + {properties.state && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/index.ts b/web/components/core/views/spreadsheet-view/updated-on-column/index.ts new file mode 100644 index 000000000..af1337a7f --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-updated-on-column"; +export * from "./updated-on-column"; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx b/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx new file mode 100644 index 000000000..bb29e460d --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { UpdatedOnColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetUpdatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx b/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx new file mode 100644 index 000000000..b63519095 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; +// helper +import { renderLongDetailDateFormat } from "helpers/date-time.helper"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const UpdatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.updated_on && ( +
+ {renderLongDetailDateFormat(issue.updated_at)} +
+ )} +
+
+); diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 062dd57e7..7816f0edb 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -127,7 +127,7 @@ export const ActiveCycleDetails: React.FC = () => { cy="34.375" r="22" stroke="rgb(var(--color-text-400))" - stroke-linecap="round" + strokeLinecap="round" /> = ({ cycles, mutateCycles, viewType }) cy="34.375" r="22" stroke="rgb(var(--color-text-400))" - stroke-linecap="round" + strokeLinecap="round" /> { +type Props = { + disableUserActions: boolean; +}; + +export const CycleIssuesGanttChartView: React.FC = ({ disableUserActions }) => { const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; - const { orderBy } = useIssuesView(); + const { displayFilters } = useIssuesView(); const { user } = useUser(); const { projectDetails } = useProjectDetails(); @@ -30,23 +34,31 @@ export const CycleIssuesGanttChartView = () => { const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; return ( -
- - updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) - } - SidebarBlockRender={IssueGanttSidebarBlock} - BlockRender={IssueGanttBlock} - enableBlockLeftResize={isAllowed} - enableBlockRightResize={isAllowed} - enableBlockMove={isAllowed} - enableReorder={orderBy === "sort_order" && isAllowed} - bottomSpacing + <> + mutateGanttIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} /> -
+
+ + updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } + SidebarBlockRender={IssueGanttSidebarBlock} + BlockRender={IssueGanttBlock} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={displayFilters.order_by === "sort_order" && isAllowed} + bottomSpacing + /> +
+ ); }; diff --git a/web/components/cycles/single-cycle-list.tsx b/web/components/cycles/single-cycle-list.tsx index ec01da9e7..a4c21128a 100644 --- a/web/components/cycles/single-cycle-list.tsx +++ b/web/components/cycles/single-cycle-list.tsx @@ -149,6 +149,10 @@ export const SingleCycleList: React.FC = ({ color: group.color, })); + const completedIssues = cycle.completed_issues + cycle.cancelled_issues; + + const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0; + return (
@@ -307,7 +311,7 @@ export const SingleCycleList: React.FC = ({ ) : cycleStatus === "completed" ? ( - {100} % + {Math.round(percentage)} % ) : ( diff --git a/web/components/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx index 7af3bb74f..ab4eb022e 100644 --- a/web/components/emoji-icon-picker/index.tsx +++ b/web/components/emoji-icon-picker/index.tsx @@ -1,8 +1,10 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; // headless ui import { Tab, Transition, Popover } from "@headlessui/react"; // react colors import { TwitterPicker } from "react-color"; +// hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // types import { Props } from "./types"; // emojis @@ -36,6 +38,8 @@ const EmojiIconPicker: React.FC = ({ const [recentEmojis, setRecentEmojis] = useState([]); + const emojiPickerRef = useRef(null); + useEffect(() => { setRecentEmojis(getRecentEmojis()); }, []); @@ -44,6 +48,8 @@ const EmojiIconPicker: React.FC = ({ if (!value || value?.length === 0) onChange(getRandomEmoji()); }, [value, onChange]); + useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false)); + return ( = ({ leaveTo="transform opacity-0 scale-95" > -
+
{tabOptions.map((tab) => ( diff --git a/web/components/estimates/single-estimate.tsx b/web/components/estimates/single-estimate.tsx index 3adf986ae..43edfcb2c 100644 --- a/web/components/estimates/single-estimate.tsx +++ b/web/components/estimates/single-estimate.tsx @@ -66,7 +66,7 @@ export const SingleEstimate: React.FC = ({ return ( <> -
+
diff --git a/web/components/exporter/guide.tsx b/web/components/exporter/guide.tsx index 67b7c6d4d..2643a07cf 100644 --- a/web/components/exporter/guide.tsx +++ b/web/components/exporter/guide.tsx @@ -46,32 +46,38 @@ const IntegrationGuide = () => { return ( <> -
+
<> -
+
{EXPORTERS_LIST.map((service) => (
-
-
- {`${service.title} -
-
-

{service.title}

-

{service.description}

+
+
+
+ {`${service.title} +
+
+

+ {service.title} +

+

+ {service.description} +

+
- {service.type} now + {service.type} @@ -80,10 +86,11 @@ const IntegrationGuide = () => {
))}
-
-

-
-
Previous Exports
+
+
+
+

Previous Exports

+
-

- {exporterServices && exporterServices?.results ? ( - exporterServices?.results?.length > 0 ? ( -
-
- {exporterServices?.results.map((service) => ( - - ))} +
+
+ {exporterServices && exporterServices?.results ? ( + exporterServices?.results?.length > 0 ? ( +
+
+ {exporterServices?.results.map((service) => ( + + ))} +
-
+ ) : ( +

+ No previous export available. +

+ ) ) : ( -

No previous export available.

- ) - ) : ( - - - - - - - )} + + + + + + + )} +
{provider && ( diff --git a/web/components/exporter/single-export.tsx b/web/components/exporter/single-export.tsx index c8ef2dc1a..772119f25 100644 --- a/web/components/exporter/single-export.tsx +++ b/web/components/exporter/single-export.tsx @@ -23,7 +23,7 @@ export const SingleExport: React.FC = ({ service, refreshing }) => { }; return ( -
+

diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx index aa79ae19c..c564f69f2 100644 --- a/web/components/gantt-chart/chart/index.tsx +++ b/web/components/gantt-chart/chart/index.tsx @@ -1,4 +1,6 @@ import { FC, useEffect, useState } from "react"; +// next +import { useRouter } from "next/router"; // icons import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; // components @@ -11,6 +13,8 @@ import { GanttSidebar } from "../sidebar"; import { MonthChartView } from "./month"; // import { QuarterChartView } from "./quarter"; // import { YearChartView } from "./year"; +// icons +import { PlusIcon } from "lucide-react"; // views import { // generateHourChart, @@ -25,6 +29,7 @@ import { getNumberOfDaysBetweenTwoDatesInYear, getMonthChartItemPositionWidthInMonth, } from "../views"; +import { GanttInlineCreateIssueForm } from "components/core/views/gantt-chart-view/inline-create-issue-form"; // types import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; // data @@ -64,12 +69,17 @@ export const ChartViewRoot: FC = ({ const [itemsContainerWidth, setItemsContainerWidth] = useState(0); const [fullScreenMode, setFullScreenMode] = useState(false); + const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + // blocks state management starts const [chartBlocks, setChartBlocks] = useState(null); const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = useChart(); + const router = useRouter(); + const { cycleId, moduleId } = router.query; + const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => blocks && blocks.length > 0 ? blocks.map((block: any) => ({ @@ -294,9 +304,12 @@ export const ChartViewRoot: FC = ({ >
-
+
+
{title}
+
Duration
+
= ({ SidebarBlockRender={SidebarBlockRender} enableReorder={enableReorder} /> + {chartBlocks && ( +
+ setIsCreateIssueFormOpen(false)} + onSuccess={() => { + const ganttSidebar = document.getElementById(`gantt-sidebar-${cycleId}`); + + const timeoutId = setTimeout(() => { + if (ganttSidebar) + ganttSidebar.scrollBy({ + top: ganttSidebar.scrollHeight, + left: 0, + behavior: "smooth", + }); + clearTimeout(timeoutId); + }, 10); + }} + prePopulatedData={{ + start_date: new Date(Date.now()).toISOString().split("T")[0], + target_date: new Date(Date.now() + 86400000).toISOString().split("T")[0], + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + }} + /> + + {!isCreateIssueFormOpen && ( + + )} +
+ )}
= ({ if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - - setIsMoving(true); - const resizableDiv = resizableRef.current; const columnWidth = currentViewData.data.width; @@ -193,6 +188,8 @@ export const ChartDraggable: React.FC = ({ let initialMarginLeft = parseInt(resizableDiv.style.marginLeft); const handleMouseMove = (e: MouseEvent) => { + setIsMoving(true); + let delWidth = 0; delWidth = checkScrollEnd(e); @@ -295,7 +292,9 @@ export const ChartDraggable: React.FC = ({ )}
diff --git a/web/components/gantt-chart/sidebar.tsx b/web/components/gantt-chart/sidebar.tsx index 92e7a603d..2aec274d9 100644 --- a/web/components/gantt-chart/sidebar.tsx +++ b/web/components/gantt-chart/sidebar.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; // react-beautiful-dnd import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; @@ -7,6 +8,8 @@ import { useChart } from "./hooks"; import { Loader } from "components/ui"; // icons import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; +// helpers +import { findTotalDaysInRange } from "helpers/date-time.helper"; // types import { IBlockUpdateData, IGanttBlock } from "./types"; @@ -18,13 +21,12 @@ type Props = { enableReorder: boolean; }; -export const GanttSidebar: React.FC = ({ - title, - blockUpdateHandler, - blocks, - SidebarBlockRender, - enableReorder, -}) => { +export const GanttSidebar: React.FC = (props) => { + const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props; + + const router = useRouter(); + const { cycleId } = router.query; + const { activeBlock, dispatch } = useChart(); // update the active block on hover @@ -85,14 +87,21 @@ export const GanttSidebar: React.FC = ({ {(droppableProvided) => (
<> {blocks ? ( - blocks.length > 0 ? ( - blocks.map((block, index) => ( + blocks.map((block, index) => { + const duration = findTotalDaysInRange( + block.start_date ?? "", + block.target_date ?? "", + true + ); + + return ( = ({ )} -
- +
+
+ +
+
+ {duration} day{duration > 1 ? "s" : ""} +
)} - )) - ) : ( -
- No {title} found -
- ) + ); + }) ) : ( diff --git a/web/components/icons/index.ts b/web/components/icons/index.ts index d3be7f2a8..bf3e94332 100644 --- a/web/components/icons/index.ts +++ b/web/components/icons/index.ts @@ -83,3 +83,5 @@ export * from "./archive-icon"; export * from "./clock-icon"; export * from "./bell-icon"; export * from "./single-comment-icon"; +export * from "./related-icon"; +export * from "./module-icon"; \ No newline at end of file diff --git a/web/components/icons/module-icon.tsx b/web/components/icons/module-icon.tsx new file mode 100644 index 000000000..dbe58eb53 --- /dev/null +++ b/web/components/icons/module-icon.tsx @@ -0,0 +1,59 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const ModuleIcon: React.FC = ({ + width = "24", + height = "24", + className, + color = "#F15B5B", +}) => ( + + + + + + + +); diff --git a/web/components/icons/module/cancelled.tsx b/web/components/icons/module/cancelled.tsx index 9bfc02943..6ea5fd694 100644 --- a/web/components/icons/module/cancelled.tsx +++ b/web/components/icons/module/cancelled.tsx @@ -20,7 +20,7 @@ export const ModuleCancelledIcon: React.FC = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + = ({ width = "20", height = "20", fill="none" xmlns="http://www.w3.org/2000/svg" > - + = ({ + width = "24", + height = "24", + color = "rgb(var(--color-text-200))", + className, +}) => ( + + + + + +); diff --git a/web/components/icons/state/backlog.tsx b/web/components/icons/state/backlog.tsx index eb00f800c..b6378b82d 100644 --- a/web/components/icons/state/backlog.tsx +++ b/web/components/icons/state/backlog.tsx @@ -19,6 +19,6 @@ export const StateGroupBacklogIcon: React.FC = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + ); diff --git a/web/components/icons/state/cancelled.tsx b/web/components/icons/state/cancelled.tsx index 1c3c4e3d2..4b06d80ba 100644 --- a/web/components/icons/state/cancelled.tsx +++ b/web/components/icons/state/cancelled.tsx @@ -19,7 +19,7 @@ export const StateGroupCancelledIcon: React.FC = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + = ({ viewBox="0 0 12 12" fill="none" > - - + + ); diff --git a/web/components/icons/state/unstarted.tsx b/web/components/icons/state/unstarted.tsx index 61a782b1f..aa0d44935 100644 --- a/web/components/icons/state/unstarted.tsx +++ b/web/components/icons/state/unstarted.tsx @@ -19,6 +19,6 @@ export const StateGroupUnstartedIcon: React.FC = ({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + ); diff --git a/web/components/integration/github/select-repository.tsx b/web/components/integration/github/select-repository.tsx index 9857c0088..b46942e6d 100644 --- a/web/components/integration/github/select-repository.tsx +++ b/web/components/integration/github/select-repository.tsx @@ -66,6 +66,8 @@ export const SelectRepository: React.FC = ({ content:

{truncateText(repo.full_name, characterLimit)}

, })) ?? []; + if (userRepositories.length < 1) return null; + return ( { data={importToDelete} user={user} /> -
+
{(!provider || provider === "csv") && ( <> -
+ {/*
Relocation Guide
@@ -78,85 +77,87 @@ const IntegrationGuide = () => {
-
-
- {IMPORTERS_EXPORTERS_LIST.map((service) => ( -
-
-
- {`${service.title} -
-
-

{service.title}

-

{service.description}

-
- +
*/} + {IMPORTERS_EXPORTERS_LIST.map((service) => ( +
+
+
+ {`${service.title} +
+
+

{service.title}

+

+ {service.description} +

- ))} -
-
-

- Previous Imports - -

- {importerServices ? ( - importerServices.length > 0 ? ( -
-
- {importerServices.map((service) => ( - handleDeleteImport(service)} - /> - ))} + +
+ ))} +
+
+

+ Previous Imports + +

+
+
+ {importerServices ? ( + importerServices.length > 0 ? ( +
+
+ {importerServices.map((service) => ( + handleDeleteImport(service)} + /> + ))} +
-
+ ) : ( +

+ No previous imports available. +

+ ) ) : ( -

- No previous imports available. -

- ) - ) : ( - - - - - - - )} + + + + + + + )} +
)} diff --git a/web/components/integration/single-import.tsx b/web/components/integration/single-import.tsx index b74628a83..9ebe1ad22 100644 --- a/web/components/integration/single-import.tsx +++ b/web/components/integration/single-import.tsx @@ -16,7 +16,7 @@ type Props = { }; export const SingleImport: React.FC = ({ service, refreshing, handleDelete }) => ( -
+

diff --git a/web/components/integration/single-integration-card.tsx b/web/components/integration/single-integration-card.tsx index 0a59de8b7..fab37b0c8 100644 --- a/web/components/integration/single-integration-card.tsx +++ b/web/components/integration/single-integration-card.tsx @@ -15,6 +15,7 @@ import { DangerButton, Loader, PrimaryButton } from "components/ui"; // icons import GithubLogo from "public/services/github.png"; import SlackLogo from "public/services/slack.png"; +import { CheckCircle2 } from "lucide-react"; // types import { IAppIntegration, IWorkspaceIntegration } from "types"; // fetch-keys @@ -27,13 +28,12 @@ type Props = { const integrationDetails: { [key: string]: any } = { github: { logo: GithubLogo, - installed: - "Activate GitHub integrations on individual projects to sync with specific repositories.", + installed: "Activate GitHub on individual projects to sync with specific repositories.", notInstalled: "Connect with GitHub with your Plane workspace to sync project issues.", }, slack: { logo: SlackLogo, - installed: "Activate Slack integrations on individual projects to sync with specific channels.", + installed: "Activate Slack on individual projects to sync with specific channels.", notInstalled: "Connect with Slack with your Plane workspace to sync project issues.", }, }; @@ -99,31 +99,22 @@ export const SingleIntegrationCard: React.FC = ({ integration }) => { ); return ( -
+
-
+
{`${integration.title}
-

+

{integration.title} - {workspaceIntegrations ? ( - isInstalled ? ( - - Installed - - ) : ( - - {" "} - Not Installed - - ) - ) : null} + {workspaceIntegrations + ? isInstalled && + : null}

-

+

{workspaceIntegrations ? isInstalled ? integrationDetails[integration.provider].installed @@ -135,12 +126,12 @@ export const SingleIntegrationCard: React.FC = ({ integration }) => { {workspaceIntegrations ? ( isInstalled ? ( - - {deletingIntegration ? "Removing..." : "Remove installation"} + + {deletingIntegration ? "Uninstalling..." : "Uninstall"} ) : ( - {isInstalling ? "Installing..." : "Add installation"} + {isInstalling ? "Installing..." : "Install"} ) ) : ( diff --git a/web/components/integration/slack/select-channel.tsx b/web/components/integration/slack/select-channel.tsx index c116992a8..712c168df 100644 --- a/web/components/integration/slack/select-channel.tsx +++ b/web/components/integration/slack/select-channel.tsx @@ -83,9 +83,7 @@ export const SelectChannel: React.FC = ({ integration }) => { {projectIntegration ? ( diff --git a/web/components/issues/activity.tsx b/web/components/issues/activity.tsx index fe322afe9..e6f54f512 100644 --- a/web/components/issues/activity.tsx +++ b/web/components/issues/activity.tsx @@ -7,9 +7,9 @@ import { useRouter } from "next/router"; import { ActivityIcon, ActivityMessage } from "components/core"; import { CommentCard } from "components/issues/comment"; // ui -import { Icon, Loader } from "components/ui"; +import { Icon, Loader, Tooltip } from "components/ui"; // helpers -import { timeAgo } from "helpers/date-time.helper"; +import { render24HourFormatTime, renderLongDateFormat, timeAgo } from "helpers/date-time.helper"; // types import { IIssueActivity, IIssueComment } from "types"; @@ -120,9 +120,15 @@ export const IssueActivitySection: React.FC = ({ )}{" "} {message}{" "} - - {timeAgo(activityItem.created_at)} - + + + {timeAgo(activityItem.created_at)} + +

diff --git a/web/components/issues/confirm-issue-discard.tsx b/web/components/issues/confirm-issue-discard.tsx new file mode 100644 index 000000000..f8feab73d --- /dev/null +++ b/web/components/issues/confirm-issue-discard.tsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { SecondaryButton, PrimaryButton } from "components/ui"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onDiscard: () => void; + onConfirm: () => Promise; +}; + +export const ConfirmIssueDiscard: React.FC = (props) => { + const { isOpen, handleClose, onDiscard, onConfirm } = props; + + const [isLoading, setIsLoading] = useState(false); + + const onClose = () => { + handleClose(); + setIsLoading(false); + }; + + const handleDeletion = async () => { + setIsLoading(true); + await onConfirm(); + setIsLoading(false); + }; + + return ( + + + +
+ + +
+
+ + +
+
+
+ + Draft Issue + +
+

+ Would you like to save this issue in drafts? +

+
+
+
+
+
+
+ Discard +
+
+ Cancel + + {isLoading ? "Saving..." : "Save Draft"} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/issues/delete-draft-issue-modal.tsx b/web/components/issues/delete-draft-issue-modal.tsx new file mode 100644 index 000000000..8347f555b --- /dev/null +++ b/web/components/issues/delete-draft-issue-modal.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +import useUser from "hooks/use-user"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import issueServices from "services/issues.service"; +// hooks +import useIssuesView from "hooks/use-issues-view"; +import useToast from "hooks/use-toast"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// ui +import { SecondaryButton, DangerButton } from "components/ui"; +// types +import type { IIssue } from "types"; +// fetch-keys +import { PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IIssue | null; + onSubmit?: () => Promise | void; +}; + +export const DeleteDraftIssueModal: React.FC = (props) => { + const { isOpen, handleClose, data, onSubmit } = props; + + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { params } = useIssuesView(); + + const { setToastAlert } = useToast(); + + const { user } = useUser(); + + useEffect(() => { + setIsDeleteLoading(false); + }, [isOpen]); + + const onClose = () => { + setIsDeleteLoading(false); + handleClose(); + }; + + const handleDeletion = async () => { + if (!workspaceSlug || !data || !user) return; + + setIsDeleteLoading(true); + + await issueServices + .deleteDraftIssue(workspaceSlug as string, data.project, data.id, user) + .then(() => { + setIsDeleteLoading(false); + handleClose(); + mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); + setToastAlert({ + title: "Success", + message: "Draft Issue deleted successfully", + type: "success", + }); + }) + .catch((error) => { + console.log(error); + handleClose(); + setToastAlert({ + title: "Error", + message: "Something went wrong", + type: "error", + }); + setIsDeleteLoading(false); + }); + if (onSubmit) await onSubmit(); + }; + + return ( + + + +
+ + +
+
+ + +
+
+ + + +

Delete Draft Issue

+
+
+ +

+ Are you sure you want to delete issue{" "} + + {data?.project_detail.identifier}-{data?.sequence_id} + + {""}? All of the data related to the draft issue will be permanently removed. + This action cannot be undone. +

+
+
+ Cancel + + {isDeleteLoading ? "Deleting..." : "Delete Issue"} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index e9907973f..62fc04723 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -35,6 +35,7 @@ type Props = { data: IIssue | null; user: ICurrentUserResponse | undefined; onSubmit?: () => Promise; + redirection?: boolean; }; export const DeleteIssueModal: React.FC = ({ @@ -43,6 +44,7 @@ export const DeleteIssueModal: React.FC = ({ data, user, onSubmit, + redirection = true, }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); @@ -50,7 +52,7 @@ export const DeleteIssueModal: React.FC = ({ const { workspaceSlug, projectId, cycleId, moduleId, viewId, issueId } = router.query; const isArchivedIssues = router.pathname.includes("archived-issues"); - const { issueView, params } = useIssuesView(); + const { displayFilters, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); const { params: spreadsheetParams } = useSpreadsheetIssuesView(); @@ -73,7 +75,7 @@ export const DeleteIssueModal: React.FC = ({ await issueServices .deleteIssue(workspaceSlug as string, data.project, data.id, user) .then(() => { - if (issueView === "calendar") { + if (displayFilters.layout === "calendar") { const calendarFetchKey = cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) : moduleId @@ -87,7 +89,7 @@ export const DeleteIssueModal: React.FC = ({ (prevData) => (prevData ?? []).filter((p) => p.id !== data.id), false ); - } else if (issueView === "spreadsheet") { + } else if (displayFilters.layout === "spreadsheet") { const spreadsheetFetchKey = cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams) : moduleId @@ -132,7 +134,7 @@ export const DeleteIssueModal: React.FC = ({ message: "Issue deleted successfully", }); - if (issueId) router.back(); + if (issueId && redirection) router.back(); }) .catch((error) => { console.log(error); diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx new file mode 100644 index 000000000..7433da82c --- /dev/null +++ b/web/components/issues/draft-issue-form.tsx @@ -0,0 +1,596 @@ +import React, { FC, useState, useEffect, useRef } from "react"; + +import { useRouter } from "next/router"; + +// react-hook-form +import { Controller, useForm } from "react-hook-form"; +// services +import aiService from "services/ai.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { GptAssistantModal } from "components/core"; +import { ParentIssuesListModal } from "components/issues"; +import { + IssueAssigneeSelect, + IssueDateSelect, + IssueEstimateSelect, + IssueLabelSelect, + IssuePrioritySelect, + IssueProjectSelect, + IssueStateSelect, +} from "components/issues/select"; +import { CreateStateModal } from "components/states"; +import { CreateLabelModal } from "components/labels"; +// ui +import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui"; +import { TipTapEditor } from "components/tiptap"; +// icons +import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; +// types +import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; + +const defaultValues: Partial = { + project: "", + name: "", + description: { + type: "doc", + content: [ + { + type: "paragraph", + }, + ], + }, + description_html: "

", + estimate_point: null, + state: "", + parent: null, + priority: "none", + assignees: [], + assignees_list: [], + labels: [], + labels_list: [], + start_date: null, + target_date: null, +}; + +interface IssueFormProps { + handleFormSubmit: ( + formData: Partial, + action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" + ) => Promise; + data?: Partial | null; + prePopulatedData?: Partial | null; + projectId: string; + setActiveProject: React.Dispatch>; + createMore: boolean; + setCreateMore: React.Dispatch>; + handleClose: () => void; + handleDiscard: () => void; + status: boolean; + user: ICurrentUserResponse | undefined; + fieldsToShow: ( + | "project" + | "name" + | "description" + | "state" + | "priority" + | "assignee" + | "label" + | "startDate" + | "dueDate" + | "estimate" + | "parent" + | "all" + )[]; +} + +export const DraftIssueForm: FC = (props) => { + const { + handleFormSubmit, + data, + prePopulatedData, + projectId, + setActiveProject, + createMore, + setCreateMore, + handleClose, + status, + user, + fieldsToShow, + handleDiscard, + } = props; + + const [stateModal, setStateModal] = useState(false); + const [labelModal, setLabelModal] = useState(false); + const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); + const [selectedParentIssue, setSelectedParentIssue] = useState(null); + + const [gptAssistantModal, setGptAssistantModal] = useState(false); + const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + + const editorRef = useRef(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + control, + getValues, + setValue, + setFocus, + } = useForm({ + defaultValues: prePopulatedData ?? defaultValues, + reValidateMode: "onChange", + }); + + const issueName = watch("name"); + + const onClose = () => { + handleClose(); + }; + + const handleCreateUpdateIssue = async ( + formData: Partial, + action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" + ) => { + await handleFormSubmit( + { + ...(data ?? {}), + ...formData, + is_draft: action === "createDraft" || action === "updateDraft", + }, + action + ); + + setGptAssistantModal(false); + + reset({ + ...defaultValues, + project: projectId, + description: { + type: "doc", + content: [ + { + type: "paragraph", + }, + ], + }, + description_html: "

", + }); + editorRef?.current?.clearEditor(); + }; + + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId) return; + + setValue("description", {}); + setValue("description_html", `${watch("description_html")}

${response}

`); + editorRef.current?.setEditorValue(`${watch("description_html")}`); + }; + + const handleAutoGenerateDescription = async () => { + if (!workspaceSlug || !projectId) return; + + setIAmFeelingLucky(true); + + aiService + .createGptTask( + workspaceSlug as string, + projectId as string, + { + prompt: issueName, + task: "Generate a proper description for this issue.", + }, + user + ) + .then((res) => { + if (res.response === "") + setToastAlert({ + type: "error", + title: "Error!", + message: + "Issue title isn't informative enough to generate the description. Please try with a different title.", + }); + else handleAiAssistance(res.response_html); + }) + .catch((err) => { + const error = err?.data?.error; + + if (err.status === 429) + setToastAlert({ + type: "error", + title: "Error!", + message: + error || + "You have reached the maximum number of requests of 50 requests per month per user.", + }); + else + setToastAlert({ + type: "error", + title: "Error!", + message: error || "Some error occurred. Please try again.", + }); + }) + .finally(() => setIAmFeelingLucky(false)); + }; + + useEffect(() => { + setFocus("name"); + + reset({ + ...defaultValues, + ...(prePopulatedData ?? {}), + ...(data ?? {}), + }); + }, [setFocus, prePopulatedData, reset, data]); + + // update projectId in form when projectId changes + useEffect(() => { + reset({ + ...getValues(), + project: projectId, + }); + }, [getValues, projectId, reset]); + + const startDate = watch("start_date"); + const targetDate = watch("target_date"); + + const minDate = startDate ? new Date(startDate) : null; + minDate?.setDate(minDate.getDate()); + + const maxDate = targetDate ? new Date(targetDate) : null; + maxDate?.setDate(maxDate.getDate()); + + return ( + <> + {projectId && ( + <> + setStateModal(false)} + projectId={projectId} + user={user} + /> + setLabelModal(false)} + projectId={projectId} + user={user} + onSuccess={(response) => { + setValue("labels", [...watch("labels"), response.id]); + setValue("labels_list", [...watch("labels_list"), response.id]); + }} + /> + + )} +
+ handleCreateUpdateIssue(formData, "convertToNewIssue") + )} + > +
+
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( + ( + { + onChange(val); + setActiveProject(val); + }} + /> + )} + /> + )} +

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

+
+ {watch("parent") && + (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && + selectedParentIssue && ( +
+
+ + + {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} + + + {selectedParentIssue.name.substring(0, 50)} + + { + setValue("parent", null); + setSelectedParentIssue(null); + }} + /> +
+
+ )} +
+
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && ( +
+ +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( +
+
+ {issueName && issueName !== "" && ( + + )} + +
+ { + if (!value && !watch("description_html")) return <>; + + return ( + { + onChange(description_html); + setValue("description", description); + }} + /> + ); + }} + /> + { + setGptAssistantModal(false); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + inset="top-2 left-0" + content="" + htmlContent={watch("description_html")} + onResponse={(response) => { + handleAiAssistance(response); + }} + projectId={projectId} + /> +
+ )} +
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( +
+ ( + + )} + /> +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( +
+ ( + + )} + /> +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( +
+ ( + + )} + /> +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( + ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + setSelectedParentIssue(issue); + }} + projectId={projectId} + /> + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( + + {watch("parent") ? ( + <> + setParentIssueListModalOpen(true)} + > + Change parent issue + + setValue("parent", null)} + > + Remove parent issue + + + ) : ( + setParentIssueListModalOpen(true)} + > + Select Parent Issue + + )} + + )} +
+
+
+
+
+
setCreateMore((prevData) => !prevData)} + > + Create more + {}} size="md" /> +
+
+ Discard + + handleCreateUpdateIssue(formData, data?.id ? "updateDraft" : "createDraft") + )} + > + {isSubmitting ? "Saving..." : "Save Draft"} + + + handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createNewIssue") + )} + > + {isSubmitting ? "Saving..." : "Add Issue"} + +
+
+
+ + ); +}; diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx new file mode 100644 index 000000000..b6479d067 --- /dev/null +++ b/web/components/issues/draft-issue-modal.tsx @@ -0,0 +1,409 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import issuesService from "services/issues.service"; +// hooks +import useUser from "hooks/use-user"; +import useIssuesView from "hooks/use-issues-view"; +import useCalendarIssuesView from "hooks/use-calendar-issues-view"; +import useToast from "hooks/use-toast"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +import useLocalStorage from "hooks/use-local-storage"; +import useProjects from "hooks/use-projects"; +import useMyIssues from "hooks/my-issues/use-my-issues"; +// components +import { DraftIssueForm } from "components/issues"; +// types +import type { IIssue } from "types"; +// fetch-keys +import { + PROJECT_ISSUES_DETAILS, + USER_ISSUE, + SUB_ISSUES, + PROJECT_ISSUES_LIST_WITH_PARAMS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + VIEW_ISSUES, + PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, + CYCLE_DETAILS, + MODULE_DETAILS, +} from "constants/fetch-keys"; +import modulesService from "services/modules.service"; + +interface IssuesModalProps { + data?: IIssue | null; + handleClose: () => void; + isOpen: boolean; + isUpdatingSingleIssue?: boolean; + prePopulateData?: Partial; + fieldsToShow?: ( + | "project" + | "name" + | "description" + | "state" + | "priority" + | "assignee" + | "label" + | "startDate" + | "dueDate" + | "estimate" + | "parent" + | "all" + )[]; + onSubmit?: (data: Partial) => Promise | void; +} + +export const CreateUpdateDraftIssueModal: React.FC = (props) => { + const { + data, + handleClose, + isOpen, + isUpdatingSingleIssue = false, + prePopulateData: prePopulateDataProps, + fieldsToShow = ["all"], + onSubmit, + } = props; + + // states + const [createMore, setCreateMore] = useState(false); + const [activeProject, setActiveProject] = useState(null); + const [prePopulateData, setPreloadedData] = useState | undefined>(undefined); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const { displayFilters, params } = useIssuesView(); + const { params: calendarParams } = useCalendarIssuesView(); + const { ...viewGanttParams } = params; + const { params: spreadsheetParams } = useSpreadsheetIssuesView(); + + const { user } = useUser(); + const { projects } = useProjects(); + + const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {}); + + const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + + const { setToastAlert } = useToast(); + + const onClose = () => { + handleClose(); + setActiveProject(null); + }; + + const onDiscard = () => { + clearDraftIssueLocalStorage(); + onClose(); + }; + + useEffect(() => { + setPreloadedData(prePopulateDataProps ?? {}); + + if (cycleId && !prePopulateDataProps?.cycle) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + cycle: cycleId.toString(), + })); + } + if (moduleId && !prePopulateDataProps?.module) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + module: moduleId.toString(), + })); + } + if ( + (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && + !prePopulateDataProps?.assignees + ) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], + })); + } + }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); + + useEffect(() => { + // if modal is closed, reset active project to null + // and return to avoid activeProject being set to some other project + if (!isOpen) { + setActiveProject(null); + return; + } + + // if data is present, set active project to the project of the + // issue. This has more priority than the project in the url. + if (data && data.project) return setActiveProject(data.project); + + if (prePopulateData && prePopulateData.project && !activeProject) + return setActiveProject(prePopulateData.project); + + if (prePopulateData && prePopulateData.project && !activeProject) + return setActiveProject(prePopulateData.project); + + // if data is not present, set active project to the project + // in the url. This has the least priority. + if (projects && projects.length > 0 && !activeProject) + setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null); + }, [activeProject, data, projectId, projects, isOpen, prePopulateData]); + + const calendarFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), calendarParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", calendarParams); + + const spreadsheetFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), spreadsheetParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", spreadsheetParams); + + const ganttFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) + : viewId + ? VIEW_ISSUES(viewId.toString(), viewGanttParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? ""); + + const createDraftIssue = async (payload: Partial) => { + if (!workspaceSlug || !activeProject || !user) return; + + await issuesService + .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user) + .then(async () => { + mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + + if (groupedIssues) mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); + + if (!createMore) onClose(); + }; + + const updateDraftIssue = async (payload: Partial) => { + if (!user) return; + + await issuesService + .updateDraftIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user) + .then((res) => { + if (isUpdatingSingleIssue) { + mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); + } else { + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); + if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString())); + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + } + + if (!payload.is_draft) { + if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); + if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); + } + + if (!createMore) onClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue updated successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be updated. Please try again.", + }); + }); + }; + + const addIssueToCycle = async (issueId: string, cycleId: string) => { + if (!workspaceSlug || !activeProject) return; + + await issuesService + .addIssueToCycle( + workspaceSlug as string, + activeProject ?? "", + cycleId, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (cycleId) { + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params)); + mutate(CYCLE_DETAILS(cycleId as string)); + } + }); + }; + + const addIssueToModule = async (issueId: string, moduleId: string) => { + if (!workspaceSlug || !activeProject) return; + + await modulesService + .addIssuesToModule( + workspaceSlug as string, + activeProject ?? "", + moduleId as string, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (moduleId) { + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); + mutate(MODULE_DETAILS(moduleId as string)); + } + }); + }; + + const createIssue = async (payload: Partial) => { + if (!workspaceSlug || !activeProject) return; + + await issuesService + .createIssues(workspaceSlug as string, activeProject ?? "", payload, user) + .then(async (res) => { + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle); + if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module); + + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + if (displayFilters.layout === "gantt_chart") + mutate(ganttFetchKey, { + start_target_date: true, + order_by: "sort_order", + }); + if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); + if (groupedIssues) mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + if (!createMore) onClose(); + + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + + if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); + }; + + const handleFormSubmit = async ( + formData: Partial, + action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" + ) => { + if (!workspaceSlug || !activeProject) return; + + const payload: Partial = { + ...formData, + assignees_list: formData.assignees ?? [], + labels_list: formData.labels ?? [], + description: formData.description ?? "", + description_html: formData.description_html ?? "

", + }; + + if (action === "createDraft") await createDraftIssue(payload); + else if (action === "updateDraft" || action === "convertToNewIssue") + await updateDraftIssue(payload); + else if (action === "createNewIssue") await createIssue(payload); + + clearDraftIssueLocalStorage(); + + if (onSubmit) await onSubmit(payload); + }; + + if (!projects || projects.length === 0) return null; + + return ( + <> + + + +
+ + +
+
+ + + + + +
+
+
+
+ + ); +}; diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index 0bca224df..c92c3d332 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -23,14 +23,7 @@ import { import { CreateStateModal } from "components/states"; import { CreateLabelModal } from "components/labels"; // ui -import { - CustomMenu, - Input, - Loader, - PrimaryButton, - SecondaryButton, - ToggleSwitch, -} from "components/ui"; +import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui"; import { TipTapEditor } from "components/tiptap"; // icons import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; @@ -52,7 +45,7 @@ const defaultValues: Partial = { estimate_point: null, state: "", parent: null, - priority: null, + priority: "none", assignees: [], assignees_list: [], labels: [], @@ -68,9 +61,10 @@ export interface IssueFormProps { setActiveProject: React.Dispatch>; createMore: boolean; setCreateMore: React.Dispatch>; - handleClose: () => void; + handleDiscardClose: () => void; status: boolean; user: ICurrentUserResponse | undefined; + handleFormDirty: (payload: Partial | null) => void; fieldsToShow: ( | "project" | "name" @@ -87,18 +81,21 @@ export interface IssueFormProps { )[]; } -export const IssueForm: FC = ({ - handleFormSubmit, - initialData, - projectId, - setActiveProject, - createMore, - setCreateMore, - handleClose, - status, - user, - fieldsToShow, -}) => { +export const IssueForm: FC = (props) => { + const { + handleFormSubmit, + initialData, + projectId, + setActiveProject, + createMore, + setCreateMore, + handleDiscardClose, + status, + user, + fieldsToShow, + handleFormDirty, + } = props; + const [stateModal, setStateModal] = useState(false); const [labelModal, setLabelModal] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); @@ -116,7 +113,7 @@ export const IssueForm: FC = ({ const { register, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, isDirty }, handleSubmit, reset, watch, @@ -131,6 +128,27 @@ export const IssueForm: FC = ({ 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"), + }; + + useEffect(() => { + if (isDirty) handleFormDirty(payload); + else handleFormDirty(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(payload), isDirty]); + const handleCreateUpdateIssue = async (formData: Partial) => { await handleFormSubmit(formData); @@ -550,7 +568,13 @@ export const IssueForm: FC = ({ {}} size="md" />
- Discard + { + handleDiscardClose(); + }} + > + Discard + {status ? isSubmitting diff --git a/web/components/issues/gantt-chart/blocks.tsx b/web/components/issues/gantt-chart/blocks.tsx index 0834e3e79..3364565a3 100644 --- a/web/components/issues/gantt-chart/blocks.tsx +++ b/web/components/issues/gantt-chart/blocks.tsx @@ -5,19 +5,27 @@ import { Tooltip } from "components/ui"; // icons import { StateGroupIcon } from "components/icons"; // helpers -import { findTotalDaysInRange, renderShortDate } from "helpers/date-time.helper"; +import { renderShortDate } from "helpers/date-time.helper"; // types import { IIssue } from "types"; export const IssueGanttBlock = ({ data }: { data: IIssue }) => { const router = useRouter(); - const { workspaceSlug } = router.query; + + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: data.id }, + }); + }; return (
router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} + onClick={openPeekOverview} >
{ // rendering issues on gantt sidebar export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => { const router = useRouter(); - const { workspaceSlug } = router.query; - const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true); + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: data.id }, + }); + }; return (
router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} + onClick={openPeekOverview} >
{data?.project_detail?.identifier} {data?.sequence_id}
-
-
{data?.name}
- - {duration} day{duration > 1 ? "s" : ""} - -
+
{data?.name}
); }; diff --git a/web/components/issues/gantt-chart/layout.tsx b/web/components/issues/gantt-chart/layout.tsx index 39e169a60..ed4cd3d70 100644 --- a/web/components/issues/gantt-chart/layout.tsx +++ b/web/components/issues/gantt-chart/layout.tsx @@ -8,15 +8,19 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; -import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; +import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues"; // types import { IIssue } from "types"; -export const IssueGanttChartView = () => { +type Props = { + disableUserActions: boolean; +}; + +export const IssueGanttChartView: React.FC = ({ disableUserActions }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { orderBy } = useIssuesView(); + const { displayFilters } = useIssuesView(); const { user } = useUser(); const { projectDetails } = useProjectDetails(); @@ -29,23 +33,31 @@ export const IssueGanttChartView = () => { const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; return ( -
- - updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) - } - BlockRender={IssueGanttBlock} - SidebarBlockRender={IssueGanttSidebarBlock} - enableBlockLeftResize={isAllowed} - enableBlockRightResize={isAllowed} - enableBlockMove={isAllowed} - enableReorder={orderBy === "sort_order" && isAllowed} - bottomSpacing + <> + mutateGanttIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} /> -
+
+ + updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } + BlockRender={IssueGanttBlock} + SidebarBlockRender={IssueGanttSidebarBlock} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={displayFilters.order_by === "sort_order" && isAllowed} + bottomSpacing + /> +
+ ); }; diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index d0ab71e1c..6b83e7ef4 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -12,7 +12,12 @@ export * from "./main-content"; export * from "./modal"; export * from "./parent-issues-list-modal"; export * from "./sidebar"; -export * from "./sub-issues-list"; export * from "./label"; export * from "./issue-reaction"; export * from "./peek-overview"; +export * from "./confirm-issue-discard"; + +// draft issue +export * from "./draft-issue-form"; +export * from "./draft-issue-modal"; +export * from "./delete-draft-issue-modal"; diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx index b7b154ce2..c4c9a780a 100644 --- a/web/components/issues/main-content.tsx +++ b/web/components/issues/main-content.tsx @@ -18,9 +18,9 @@ import { IssueAttachmentUpload, IssueAttachments, IssueDescriptionForm, - SubIssuesList, IssueReaction, } from "components/issues"; +import { SubIssuesRoot } from "./sub-issues"; // ui import { CustomMenu } from "components/ui"; // icons @@ -43,7 +43,7 @@ export const IssueMainContent: React.FC = ({ uneditable = false, }) => { const router = useRouter(); - const { workspaceSlug, projectId, issueId, archivedIssueId } = router.query; + const { workspaceSlug, projectId, issueId } = router.query; const { setToastAlert } = useToast(); @@ -206,7 +206,7 @@ export const IssueMainContent: React.FC = ({
- +
diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx index 4c4f2ba44..608cf4fd1 100644 --- a/web/components/issues/modal.tsx +++ b/web/components/issues/modal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; @@ -19,8 +19,9 @@ import useInboxView from "hooks/use-inbox-view"; 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"; // components -import { IssueForm } from "components/issues"; +import { IssueForm, ConfirmIssueDiscard } from "components/issues"; // types import type { IIssue } from "types"; // fetch-keys @@ -35,6 +36,7 @@ import { MODULE_DETAILS, VIEW_ISSUES, INBOX_ISSUES, + PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, } from "constants/fetch-keys"; // constants import { INBOX_ISSUE_SOURCE } from "constants/inbox"; @@ -67,20 +69,23 @@ export const CreateUpdateIssueModal: React.FC = ({ handleClose, isOpen, isUpdatingSingleIssue = false, - prePopulateData, + prePopulateData: prePopulateDataProps, fieldsToShow = ["all"], onSubmit, }) => { // states const [createMore, setCreateMore] = useState(false); + const [formDirtyState, setFormDirtyState] = useState(null); + const [showConfirmDiscard, setShowConfirmDiscard] = useState(false); const [activeProject, setActiveProject] = useState(null); + const [prePopulateData, setPreloadedData] = useState>({}); const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query; - const { issueView, params } = useIssuesView(); + const { displayFilters, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); - const { order_by, group_by, ...viewGanttParams } = params; + const { ...viewGanttParams } = params; const { params: inboxParams } = useInboxView(); const { params: spreadsheetParams } = useSpreadsheetIssuesView(); @@ -89,20 +94,81 @@ export const CreateUpdateIssueModal: React.FC = ({ const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } = + useLocalStorage("draftedIssue", {}); + const { setToastAlert } = useToast(); - if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; - if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; - if (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) - prePopulateData = { - ...prePopulateData, - assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""], - }; + useEffect(() => { + setPreloadedData(prePopulateDataProps ?? {}); - const onClose = useCallback(() => { + if (cycleId && !prePopulateDataProps?.cycle) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + cycle: cycleId.toString(), + })); + } + if (moduleId && !prePopulateDataProps?.module) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + module: moduleId.toString(), + })); + } + if ( + (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && + !prePopulateDataProps?.assignees + ) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], + })); + } + }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); + + /** + * + * @description This function is used to close the modals. This function will show a confirm discard modal if the form is dirty. + * @returns void + */ + + const onClose = () => { + if (!showConfirmDiscard) handleClose(); + if (formDirtyState === null) return setActiveProject(null); + const data = JSON.stringify(formDirtyState); + setValueInLocalStorage(data); + }; + + /** + * @description This function is used to close the modals. This function is to be used when the form is submitted, + * meaning we don't need to show the confirm discard modal or store the form data in local storage. + */ + + const onFormSubmitClose = () => { + setFormDirtyState(null); handleClose(); - setActiveProject(null); - }, [handleClose]); + }; + + /** + * @description This function is used to close the modals. This function is to be used when we click outside the modal, + * meaning we don't need to show the confirm discard modal but will store the form data in local storage. + * Use this function when you want to store the form data in local storage. + */ + + const onDiscardClose = () => { + if (formDirtyState !== null) { + setShowConfirmDiscard(true); + } else { + handleClose(); + setActiveProject(null); + } + }; + + const handleFormDirty = (data: any) => { + setFormDirtyState(data); + }; useEffect(() => { // if modal is closed, reset active project to null @@ -247,13 +313,13 @@ export const CreateUpdateIssueModal: React.FC = ({ if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module); - if (issueView === "calendar") mutate(calendarFetchKey); - if (issueView === "gantt_chart") + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + if (displayFilters.layout === "gantt_chart") mutate(ganttFetchKey, { start_target_date: true, order_by: "sort_order", }); - if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); + if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); if (groupedIssues) mutateMyIssues(); setToastAlert({ @@ -275,18 +341,58 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }); - if (!createMore) onClose(); + if (!createMore) onFormSubmitClose(); + }; + + const createDraftIssue = async () => { + if (!workspaceSlug || !activeProject || !user) return; + + const payload: Partial = { + ...formDirtyState, + }; + + await issuesService + .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user) + .then(() => { + mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + if (groupedIssues) mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Draft Issue created successfully.", + }); + + handleClose(); + setActiveProject(null); + setFormDirtyState(null); + setShowConfirmDiscard(false); + + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + + if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); }; const updateIssue = async (payload: Partial) => { + if (!user) return; + await issuesService .patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user) .then((res) => { if (isUpdatingSingleIssue) { mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); } else { - if (issueView === "calendar") mutate(calendarFetchKey); - if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString())); mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); } @@ -294,7 +400,7 @@ export const CreateUpdateIssueModal: React.FC = ({ if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); - if (!createMore) onClose(); + if (!createMore) onFormSubmitClose(); setToastAlert({ type: "success", @@ -331,49 +437,65 @@ export const CreateUpdateIssueModal: React.FC = ({ if (!projects || projects.length === 0) return null; return ( - - - -
- + <> + setShowConfirmDiscard(false)} + onConfirm={createDraftIssue} + onDiscard={() => { + handleClose(); + setActiveProject(null); + setFormDirtyState(null); + setShowConfirmDiscard(false); + clearLocalStorageValue(); + }} + /> -
-
- - - - - + + + +
+ + +
+
+ + + + + +
-
-
-
+
+
+ ); }; 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 ce8e03797..8085b5e78 100644 --- a/web/components/issues/my-issues/my-issues-select-filters.tsx +++ b/web/components/issues/my-issues/my-issues-select-filters.tsx @@ -4,18 +4,21 @@ 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 { MultiLevelDropdown } from "components/ui"; +import { Avatar, MultiLevelDropdown } from "components/ui"; // icons import { PriorityIcon, StateGroupIcon } from "components/icons"; // helpers import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { IIssueFilterOptions, IQuery, TStateGroups } from "types"; +import { IIssueFilterOptions, TStateGroups } from "types"; // fetch-keys import { WORKSPACE_LABELS } from "constants/fetch-keys"; // constants @@ -23,7 +26,7 @@ import { GROUP_CHOICES, PRIORITIES } from "constants/project"; import { DATE_FILTER_OPTIONS } from "constants/filters"; type Props = { - filters: Partial | IQuery; + filters: Partial; onSelect: (option: any) => void; direction?: "left" | "right"; height?: "sm" | "md" | "rg" | "lg"; @@ -55,6 +58,11 @@ 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 && ( @@ -74,25 +82,19 @@ export const MyIssuesSelectFilters: React.FC = ({ height={height} options={[ { - id: "priority", - label: "Priority", - value: PRIORITIES, + id: "project", + label: "Project", + value: joinedProjects, 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), - })), - ], + 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", @@ -142,6 +144,87 @@ 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 d31f884dc..1cbc467a8 100644 --- a/web/components/issues/my-issues/my-issues-view-options.tsx +++ b/web/components/issues/my-issues/my-issues-view-options.tsx @@ -2,25 +2,20 @@ import React from "react"; import { useRouter } from "next/router"; -// headless ui -import { Popover, Transition } from "@headlessui/react"; // hooks import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; -import useEstimateOption from "hooks/use-estimate-option"; // components import { MyIssuesSelectFilters } from "components/issues"; // ui -import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui"; +import { Tooltip } from "components/ui"; // icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; -import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material"; +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { CreditCard } from "lucide-react"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { Properties, TIssueViewOptions } from "types"; -// constants -import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; +import { TIssueViewOptions } from "types"; const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ { @@ -28,31 +23,26 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ Icon: FormatListBulletedOutlined, }, { - type: "kanban", - Icon: GridViewOutlined, + type: "spreadsheet", + Icon: CreditCard, }, ]; export const MyIssuesViewOptions: React.FC = () => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, workspaceViewId } = router.query; - const { - issueView, - setIssueView, - groupBy, - setGroupBy, - orderBy, - setOrderBy, - showEmptyGroups, - setShowEmptyGroups, - properties, - setProperty, - filters, - setFilters, - } = useMyIssuesFilters(workspaceSlug?.toString()); + const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters( + workspaceSlug?.toString() + ); - const { isEstimateActive } = useEstimateOption(); + const workspaceViewPathName = ["workspace-views/all-issues"]; + + const isWorkspaceViewPath = workspaceViewPathName.some((pathname) => + router.pathname.includes(pathname) + ); + + const showFilters = isWorkspaceViewPath || workspaceViewId; return (
@@ -67,228 +57,59 @@ export const MyIssuesViewOptions: React.FC = () => { > ))}
- { - const key = option.key as keyof typeof filters; + {showFilters && ( + { + const key = option.key as keyof typeof filters; - if (key === "start_date" || key === "target_date") { - const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); + if (key === "start_date" || key === "target_date") { + const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); - setFilters({ - [key]: valueExists ? null : option.value, - }); - } else { - const valueExists = filters[key]?.includes(option.value); - - if (valueExists) setFilters({ - [option.key]: ((filters[key] ?? []) as any[])?.filter( - (val) => val !== option.value - ), + [key]: valueExists ? null : option.value, }); - else - setFilters({ - [option.key]: [...((filters[key] ?? []) as any[]), option.value], - }); - } - }} - direction="left" - height="rg" - /> - - {({ open }) => ( - <> - - Display - + } else { + const valueExists = filters[key]?.includes(option.value); - - -
-
- {issueView !== "calendar" && issueView !== "spreadsheet" && ( - <> -
-

Group by

-
- option.key === groupBy) - ?.name ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {GROUP_BY_OPTIONS.map((option) => { - if (issueView === "kanban" && option.key === null) return null; - if ( - option.key === "state" || - option.key === "created_by" || - option.key === "assignees" - ) - return null; - - return ( - setGroupBy(option.key)} - > - {option.name} - - ); - })} - -
-
-
-

Order by

-
- option.key === orderBy)?.name ?? - "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {ORDER_BY_OPTIONS.map((option) => { - if (groupBy === "priority" && option.key === "priority") - return null; - if (option.key === "sort_order") return null; - - return ( - { - setOrderBy(option.key); - }} - > - {option.name} - - ); - })} - -
-
- - )} -
-

Issue type

-
- option.key === filters?.type) - ?.name ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {FILTER_ISSUE_OPTIONS.map((option) => ( - - setFilters({ - type: option.key, - }) - } - > - {option.name} - - ))} - -
-
- - {issueView !== "calendar" && issueView !== "spreadsheet" && ( - <> -
-

Show empty states

-
- -
-
- - )} -
- -
-

Display Properties

-
- {Object.keys(properties).map((key) => { - if (key === "estimate" && !isEstimateActive) return null; - - if ( - issueView === "spreadsheet" && - (key === "attachment_count" || - key === "link" || - key === "sub_issue_count") - ) - return null; - - if ( - issueView !== "spreadsheet" && - (key === "created_on" || key === "updated_on") - ) - return null; - - return ( - - ); - })} -
-
-
-
-
- - )} -
+ if (valueExists) + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter( + (val) => val !== option.value + ), + }); + else + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="left" + height="rg" + /> + )}
); }; diff --git a/web/components/issues/my-issues/my-issues-view.tsx b/web/components/issues/my-issues/my-issues-view.tsx index a66f4618b..ced16b321 100644 --- a/web/components/issues/my-issues/my-issues-view.tsx +++ b/web/components/issues/my-issues/my-issues-view.tsx @@ -57,8 +57,9 @@ export const MyIssuesView: React.FC = ({ const { user } = useUserAuth(); const { groupedIssues, mutateMyIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString()); - const { filters, setFilters, issueView, groupBy, orderBy, properties, showEmptyGroups } = - useMyIssuesFilters(workspaceSlug?.toString()); + const { filters, setFilters, displayFilters, properties } = useMyIssuesFilters( + workspaceSlug?.toString() + ); const { data: labels } = useSWR( workspaceSlug && (filters?.labels ?? []).length > 0 @@ -81,7 +82,13 @@ export const MyIssuesView: React.FC = ({ async (result: DropResult) => { setTrashBox(false); - if (!result.destination || !workspaceSlug || !groupedIssues || groupBy !== "priority") return; + if ( + !result.destination || + !workspaceSlug || + !groupedIssues || + displayFilters?.group_by !== "priority" + ) + return; const { source, destination } = result; @@ -96,7 +103,7 @@ export const MyIssuesView: React.FC = ({ const sourceGroup = source.droppableId; const destinationGroup = destination.droppableId; - draggedItem[groupBy] = destinationGroup as TIssuePriorities; + draggedItem[displayFilters.group_by] = destinationGroup as TIssuePriorities; mutate<{ [key: string]: IIssue[]; @@ -113,8 +120,14 @@ export const MyIssuesView: React.FC = ({ return { ...prevData, - [sourceGroup]: orderArrayBy(sourceGroupArray, orderBy), - [destinationGroup]: orderArrayBy(destinationGroupArray, orderBy), + [sourceGroup]: orderArrayBy( + sourceGroupArray, + displayFilters.order_by ?? "-created_at" + ), + [destinationGroup]: orderArrayBy( + destinationGroupArray, + displayFilters.order_by ?? "-created_at" + ), }; }, false @@ -134,7 +147,7 @@ export const MyIssuesView: React.FC = ({ .catch(() => mutate(USER_ISSUES(workspaceSlug.toString(), params))); } }, - [groupBy, groupedIssues, handleDeleteIssue, orderBy, params, user, workspaceSlug] + [displayFilters, groupedIssues, handleDeleteIssue, params, user, workspaceSlug] ); const addIssueToGroup = useCallback( @@ -143,19 +156,19 @@ export const MyIssuesView: React.FC = ({ let preloadedValue: string | string[] = groupTitle; - if (groupBy === "labels") { + if (displayFilters?.group_by === "labels") { if (groupTitle === "None") preloadedValue = []; else preloadedValue = [groupTitle]; } - if (groupBy) + if (displayFilters?.group_by) setPreloadedData({ - [groupBy]: preloadedValue, + [displayFilters?.group_by]: preloadedValue, actionType: "createIssue", }); else setPreloadedData({ actionType: "createIssue" }); }, - [setCreateIssueModal, setPreloadedData, groupBy] + [setCreateIssueModal, setPreloadedData, displayFilters?.group_by] ); const addIssueToDate = useCallback( @@ -192,7 +205,7 @@ export const MyIssuesView: React.FC = ({ ); const handleIssueAction = useCallback( - (issue: IIssue, action: "copy" | "edit" | "delete") => { + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { if (action === "copy") makeIssueCopy(issue); else if (action === "edit") handleEditIssue(issue); else if (action === "delete") handleDeleteIssue(issue); @@ -263,7 +276,6 @@ export const MyIssuesView: React.FC = ({ state_group: null, start_date: null, target_date: null, - type: null, }) } /> @@ -275,7 +287,7 @@ export const MyIssuesView: React.FC = ({ addIssueToDate={addIssueToDate} addIssueToGroup={addIssueToGroup} disableUserActions={disableUserActions} - dragDisabled={groupBy !== "priority"} + dragDisabled={displayFilters?.group_by !== "priority"} emptyState={{ title: filters.assignees ? "You don't have any issue assigned to you yet" @@ -304,15 +316,12 @@ export const MyIssuesView: React.FC = ({ trashBox={trashBox} setTrashBox={setTrashBox} viewProps={{ - groupByProperty: groupBy, + displayFilters, groupedIssues, isEmpty, - issueView, mutateIssues: mutateMyIssues, - orderBy, params, properties, - showEmptyGroups, }} /> diff --git a/web/components/issues/select/priority.tsx b/web/components/issues/select/priority.tsx index 6a2a07cbd..8624f8cf8 100644 --- a/web/components/issues/select/priority.tsx +++ b/web/components/issues/select/priority.tsx @@ -40,7 +40,7 @@ export const IssuePrioritySelect: React.FC = ({ value, onChange }) => ( - {priority ?? "None"} + {priority}
diff --git a/web/components/issues/sidebar-select/blocked.tsx b/web/components/issues/sidebar-select/blocked.tsx index 02cfd3b16..d7e448377 100644 --- a/web/components/issues/sidebar-select/blocked.tsx +++ b/web/components/issues/sidebar-select/blocked.tsx @@ -1,11 +1,13 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; - // react-hook-form import { UseFormWatch } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// services +import issuesService from "services/issues.service"; // components import { ExistingIssuesListModal } from "components/core"; // icons @@ -29,10 +31,11 @@ export const SidebarBlockedSelect: React.FC = ({ }) => { const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); + const { user } = useUser(); const { setToastAlert } = useToast(); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; const handleClose = () => { setIsBlockedModalOpen(false); @@ -62,21 +65,39 @@ export const SidebarBlockedSelect: React.FC = ({ }, })); - const newBlocked = [...watch("blocked_issues"), ...selectedIssues]; + if (!user) return; + + issuesService + .createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + relation_type: "blocked_by" as const, + issue_detail: issue.blocked_issue_detail, + related_issue: issue.blocked_issue_detail.id, + })), + ], + }) + .then((response) => { + submitChanges({ + related_issues: [ + ...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"), + ...response, + ], + }); + }); - submitChanges({ - blocked_issues: newBlocked, - blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""), - }); handleClose(); }; + const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by"); + return ( <> setIsBlockedModalOpen(false)} - searchParams={{ blocker_blocked_by: true, issue_id: issueId }} + searchParams={{ issue_relation: true, issue_id: issueId }} handleOnSubmit={onSubmit} workspaceLevelToggle /> @@ -87,33 +108,42 @@ export const SidebarBlockedSelect: React.FC = ({
- {watch("blocked_issues") && watch("blocked_issues").length > 0 - ? watch("blocked_issues").map((issue) => ( + {blockedByIssue && blockedByIssue.length > 0 + ? blockedByIssue.map((relation) => (
- {`${issue.blocked_issue_detail?.project_detail.identifier}-${issue.blocked_issue_detail?.sequence_id}`} + {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
- {watch("blocker_issues") && watch("blocker_issues").length > 0 - ? watch("blocker_issues").map((issue) => ( + {blockerIssue && blockerIssue.length > 0 + ? blockerIssue.map((relation) => (
- {`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`} + {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`} +
+ )) + : null} +
+ +
+
+ + ); +}; diff --git a/web/components/issues/sidebar-select/index.ts b/web/components/issues/sidebar-select/index.ts index 5035325fd..8b083841e 100644 --- a/web/components/issues/sidebar-select/index.ts +++ b/web/components/issues/sidebar-select/index.ts @@ -8,3 +8,5 @@ export * from "./module"; export * from "./parent"; export * from "./priority"; export * from "./state"; +export * from "./duplicate"; +export * from "./relates-to"; diff --git a/web/components/issues/sidebar-select/relates-to.tsx b/web/components/issues/sidebar-select/relates-to.tsx new file mode 100644 index 000000000..deadf4d20 --- /dev/null +++ b/web/components/issues/sidebar-select/relates-to.tsx @@ -0,0 +1,168 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; +// react-hook-form +import { UseFormWatch } from "react-hook-form"; +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// icons +import { X } from "lucide-react"; +import { BlockerIcon, RelatedIcon } from "components/icons"; +// components +import { ExistingIssuesListModal } from "components/core"; +// services +import issuesService from "services/issues.service"; +// types +import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types"; + +type Props = { + issueId?: string; + submitChanges: (formData?: Partial) => void; + watch: UseFormWatch; + disabled?: boolean; +}; + +export const SidebarRelatesSelect: React.FC = (props) => { + const { issueId, submitChanges, watch, disabled = false } = props; + + const [isRelatesToModalOpen, setIsRelatesToModalOpen] = useState(false); + + const { user } = useUser(); + const { setToastAlert } = useToast(); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const handleClose = () => { + setIsRelatesToModalOpen(false); + }; + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one issue.", + }); + + return; + } + + const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, + }, + })); + + if (!user) return; + + issuesService + .createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + issue_detail: issue.blocker_issue_detail, + related_issue: issue.blocker_issue_detail.id, + relation_type: "relates_to" as const, + })), + ], + }) + .then((response) => { + submitChanges({ + related_issues: [...watch("related_issues"), ...(response ?? [])], + }); + }); + + handleClose(); + }; + + const relatedToIssueRelation = [ + ...(watch("related_issues")?.filter((i) => i.relation_type === "relates_to") ?? []), + ...(watch("issue_relations") ?? []) + ?.filter((i) => i.relation_type === "relates_to") + .map((i) => ({ + ...i, + issue_detail: i.issue_detail, + related_issue: i.issue_detail?.id, + })), + ]; + + return ( + <> + setIsRelatesToModalOpen(false)} + searchParams={{ issue_relation: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + workspaceLevelToggle + /> +
+
+ +

Relates to

+
+
+
+ {relatedToIssueRelation && relatedToIssueRelation.length > 0 + ? relatedToIssueRelation.map((relation) => ( +
+ + + {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`} + + +
+ )) + : null} +
+ +
+
+ + ); +}; diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index a33d17705..5455192fb 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -30,6 +30,8 @@ import { SidebarStateSelect, SidebarEstimateSelect, SidebarLabelSelect, + SidebarDuplicateSelect, + SidebarRelatesSelect, } from "components/issues"; // ui import { CustomDatePicker, Icon } from "components/ui"; @@ -76,6 +78,8 @@ type Props = { | "delete" | "all" | "subscribe" + | "duplicate" + | "relates_to" )[]; uneditable?: boolean; }; @@ -464,7 +468,19 @@ export const IssueDetailsSidebar: React.FC = ({ {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> @@ -472,7 +488,53 @@ export const IssueDetailsSidebar: React.FC = ({ {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} + watch={watchIssue} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && ( + { + if (!data) return mutate(ISSUE_DETAILS(issueId as string)); + mutate(ISSUE_DETAILS(issueId as string), (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }); + }} + watch={watchIssue} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && ( + { + if (!data) return mutate(ISSUE_DETAILS(issueId as string)); + mutate(ISSUE_DETAILS(issueId as string), (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }); + }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> diff --git a/web/components/issues/sub-issues-list.tsx b/web/components/issues/sub-issues-list.tsx deleted file mode 100644 index 9ba920ff5..000000000 --- a/web/components/issues/sub-issues-list.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { FC, useState } from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -import useSWR, { mutate } from "swr"; - -// headless ui -import { Disclosure, Transition } from "@headlessui/react"; -// services -import issuesService from "services/issues.service"; -// contexts -import { useProjectMyMembership } from "contexts/project-member.context"; -// components -import { ExistingIssuesListModal } from "components/core"; -import { CreateUpdateIssueModal } from "components/issues"; -// ui -import { CustomMenu } from "components/ui"; -// icons -import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; -// types -import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types"; -// fetch-keys -import { SUB_ISSUES } from "constants/fetch-keys"; - -type Props = { - parentIssue: IIssue; - user: ICurrentUserResponse | undefined; - disabled?: boolean; -}; - -export const SubIssuesList: FC = ({ parentIssue, user, disabled = false }) => { - // states - const [createIssueModal, setCreateIssueModal] = useState(false); - const [subIssuesListModal, setSubIssuesListModal] = useState(false); - const [preloadedData, setPreloadedData] = useState | null>(null); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { memberRole } = useProjectMyMembership(); - - const { data: subIssuesResponse } = useSWR( - workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null, - workspaceSlug && parentIssue - ? () => issuesService.subIssues(workspaceSlug as string, parentIssue.project, parentIssue.id) - : null - ); - - const addAsSubIssue = async (data: ISearchIssueResponse[]) => { - if (!workspaceSlug || !parentIssue) return; - - const payload = { - sub_issue_ids: data.map((i) => i.id), - }; - - await issuesService - .addSubIssues(workspaceSlug as string, parentIssue.project, parentIssue.id, payload) - .finally(() => mutate(SUB_ISSUES(parentIssue.id))); - }; - - const handleSubIssueRemove = (issue: IIssue) => { - if (!workspaceSlug || !parentIssue) return; - - mutate( - SUB_ISSUES(parentIssue.id), - (prevData) => { - if (!prevData) return prevData; - - const stateDistribution = { ...prevData.state_distribution }; - - const issueGroup = issue.state_detail.group; - stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1; - - return { - state_distribution: stateDistribution, - sub_issues: prevData.sub_issues.filter((i) => i.id !== issue.id), - }; - }, - false - ); - - issuesService - .patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user) - .finally(() => mutate(SUB_ISSUES(parentIssue.id))); - }; - - const handleCreateIssueModal = () => { - setCreateIssueModal(true); - - setPreloadedData({ - parent: parentIssue.id, - }); - }; - - const completedSubIssue = subIssuesResponse?.state_distribution.completed ?? 0; - const cancelledSubIssue = subIssuesResponse?.state_distribution.cancelled ?? 0; - - const totalCompletedSubIssues = completedSubIssue + cancelledSubIssue; - - const totalSubIssues = subIssuesResponse ? subIssuesResponse.sub_issues.length : 0; - - const completionPercentage = (totalCompletedSubIssues / totalSubIssues) * 100; - - const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled; - - return ( - <> - setCreateIssueModal(false)} - /> - setSubIssuesListModal(false)} - searchParams={{ sub_issue: true, issue_id: parentIssue?.id }} - handleOnSubmit={addAsSubIssue} - workspaceLevelToggle - /> - {subIssuesResponse && subIssuesResponse.sub_issues.length > 0 ? ( - - {({ open }) => ( - <> -
-
- - - Sub-issues{" "} - - {subIssuesResponse.sub_issues.length} - - -
-
-
100 - ? 100 - : completionPercentage.toFixed(0) - }%`, - }} - /> -
- - {isNaN(completionPercentage) - ? 0 - : completionPercentage > 100 - ? 100 - : completionPercentage.toFixed(0)} - % Done - -
-
- - {open && !isNotAllowed ? ( -
- - - - setSubIssuesListModal(true)}> - Add an existing issue - - -
- ) : null} -
- - - {subIssuesResponse.sub_issues.map((issue) => ( - - -
- - - {issue.project_detail.identifier}-{issue.sequence_id} - - {issue.name} -
- - {!isNotAllowed && ( - - )} -
- - ))} -
-
- - )} - - ) : ( - !isNotAllowed && ( - - - Add sub-issue - - } - buttonClassName="whitespace-nowrap" - position="left" - noBorder - noChevron - > - Create new - setSubIssuesListModal(true)}> - Add an existing issue - - - ) - )} - - ); -}; diff --git a/web/components/issues/sub-issues/index.ts b/web/components/issues/sub-issues/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/components/issues/sub-issues/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx new file mode 100644 index 000000000..d9c6fd303 --- /dev/null +++ b/web/components/issues/sub-issues/issue.tsx @@ -0,0 +1,207 @@ +import React from "react"; +// next imports +import Link from "next/link"; +import { useRouter } from "next/router"; +// lucide icons +import { + ChevronDown, + ChevronRight, + X, + Pencil, + Trash, + Link as LinkIcon, + Loader, +} from "lucide-react"; +// components +import { SubIssuesRootList } from "./issues-list"; +import { IssueProperty } from "./properties"; +// ui +import { Tooltip, CustomMenu } from "components/ui"; +// types +import { ICurrentUserResponse, IIssue } from "types"; +import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; + +export interface ISubIssues { + workspaceSlug: string; + projectId: string; + parentIssue: IIssue; + issue: any; + spacingLeft?: number; + user: ICurrentUserResponse | undefined; + editable: boolean; + removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void; + issuesLoader: ISubIssuesRootLoaders; + handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void; + copyText: (text: string) => void; + handleIssueCrudOperation: ( + key: "create" | "existing" | "edit" | "delete", + issueId: string, + issue?: IIssue | null + ) => void; + setPeekParentId: (id: string) => void; +} + +export const SubIssues: React.FC = ({ + workspaceSlug, + projectId, + parentIssue, + issue, + spacingLeft = 0, + user, + editable, + removeIssueFromSubIssues, + issuesLoader, + handleIssuesLoader, + copyText, + handleIssueCrudOperation, + setPeekParentId, +}) => { + const router = useRouter(); + + const openPeekOverview = (issue_id: string) => { + const { query } = router; + + setPeekParentId(parentIssue?.id); + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue_id }, + }); + }; + + return ( +
+ {issue && ( +
+
+ {issue?.sub_issues_count > 0 && ( + <> + {issuesLoader.sub_issues.includes(issue?.id) ? ( +
+ +
+ ) : ( +
handleIssuesLoader({ key: "visibility", issueId: issue?.id })} + > + {issuesLoader && issuesLoader.visibility.includes(issue?.id) ? ( + + ) : ( + + )} +
+ )} + + )} +
+ +
openPeekOverview(issue?.id)} + > +
+
+ {issue.project_detail.identifier}-{issue?.sequence_id} +
+ +
{issue?.name}
+
+
+ +
+ +
+ +
+ + {editable && ( + handleIssueCrudOperation("edit", parentIssue?.id, issue)} + > +
+ + Edit issue +
+
+ )} + + {editable && ( + handleIssueCrudOperation("delete", parentIssue?.id, issue)} + > +
+ + Delete issue +
+
+ )} + + + copyText(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`) + } + > +
+ + Copy issue link +
+
+
+
+ + {editable && ( + <> + {issuesLoader.delete.includes(issue?.id) ? ( +
+ +
+ ) : ( +
{ + handleIssuesLoader({ key: "delete", issueId: issue?.id }); + removeIssueFromSubIssues(parentIssue?.id, issue); + }} + > + +
+ )} + + )} +
+ )} + + {issuesLoader.visibility.includes(issue?.id) && issue?.sub_issues_count > 0 && ( + + )} +
+ ); +}; diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx new file mode 100644 index 000000000..a713d6fb8 --- /dev/null +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -0,0 +1,98 @@ +import React from "react"; +// swr +import useSWR from "swr"; +// components +import { SubIssues } from "./issue"; +// types +import { ICurrentUserResponse, IIssue } from "types"; +import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; +// services +import issuesService from "services/issues.service"; +// fetch keys +import { SUB_ISSUES } from "constants/fetch-keys"; + +export interface ISubIssuesRootList { + workspaceSlug: string; + projectId: string; + parentIssue: IIssue; + spacingLeft?: number; + user: ICurrentUserResponse | undefined; + editable: boolean; + removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void; + issuesLoader: ISubIssuesRootLoaders; + handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void; + copyText: (text: string) => void; + handleIssueCrudOperation: ( + key: "create" | "existing" | "edit" | "delete", + issueId: string, + issue?: IIssue | null + ) => void; + setPeekParentId: (id: string) => void; +} + +export const SubIssuesRootList: React.FC = ({ + workspaceSlug, + projectId, + parentIssue, + spacingLeft = 10, + user, + editable, + removeIssueFromSubIssues, + issuesLoader, + handleIssuesLoader, + copyText, + handleIssueCrudOperation, + setPeekParentId, +}) => { + const { data: issues, isLoading } = useSWR( + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? SUB_ISSUES(parentIssue?.id) + : null, + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id) + : null + ); + + React.useEffect(() => { + if (isLoading) { + handleIssuesLoader({ key: "sub_issues", issueId: parentIssue?.id }); + } else { + if (issuesLoader.sub_issues.includes(parentIssue?.id)) { + handleIssuesLoader({ key: "sub_issues", issueId: parentIssue?.id }); + } + } + }, [isLoading]); + + return ( +
+ {issues && + issues.sub_issues && + issues.sub_issues.length > 0 && + issues.sub_issues.map((issue: IIssue) => ( + + ))} + +
10 ? `border-l border-custom-border-100` : `` + }`} + style={{ left: `${spacingLeft - 12}px` }} + /> +
+ ); +}; diff --git a/web/components/issues/sub-issues/progressbar.tsx b/web/components/issues/sub-issues/progressbar.tsx new file mode 100644 index 000000000..dee91263b --- /dev/null +++ b/web/components/issues/sub-issues/progressbar.tsx @@ -0,0 +1,25 @@ +export interface IProgressBar { + total: number; + done: number; +} + +export const ProgressBar = ({ total = 0, done = 0 }: IProgressBar) => { + const calPercentage = (doneValue: number, totalValue: number): string => { + if (doneValue === 0 || totalValue === 0) return (0).toFixed(0); + return ((100 * doneValue) / totalValue).toFixed(0); + }; + + return ( +
+
+
+
+
+
+
{calPercentage(done, total)}% Done
+
+ ); +}; diff --git a/web/components/issues/sub-issues/properties.tsx b/web/components/issues/sub-issues/properties.tsx new file mode 100644 index 000000000..e2caefffa --- /dev/null +++ b/web/components/issues/sub-issues/properties.tsx @@ -0,0 +1,208 @@ +import React from "react"; +// swr +import { mutate } from "swr"; +// components +import { ViewDueDateSelect, ViewStartDateSelect } from "components/issues"; +import { MembersSelect, PrioritySelect } from "components/project"; +import { StateSelect } from "components/states"; +// hooks +import useIssuesProperties from "hooks/use-issue-properties"; +// types +import { ICurrentUserResponse, IIssue, IState } from "types"; +// fetch-keys +import { SUB_ISSUES } from "constants/fetch-keys"; +// services +import issuesService from "services/issues.service"; +import trackEventServices from "services/track-event.service"; + +export interface IIssueProperty { + workspaceSlug: string; + projectId: string; + parentIssue: IIssue; + issue: IIssue; + user: ICurrentUserResponse | undefined; + editable: boolean; +} + +export const IssueProperty: React.FC = ({ + workspaceSlug, + projectId, + parentIssue, + issue, + user, + editable, +}) => { + const [properties] = useIssuesProperties(workspaceSlug, projectId); + + const handlePriorityChange = (data: any) => { + partialUpdateIssue({ priority: data }); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue({ + state: data, + state_detail: newState, + }); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + const handleAssigneeChange = (data: any) => { + let newData = issue.assignees ?? []; + + if (newData && newData.length > 0) { + if (newData.includes(data)) newData = newData.splice(newData.indexOf(data), 1); + else newData = [...newData, data]; + } else newData = [...newData, data]; + + partialUpdateIssue({ assignees_list: data, assignees: data }); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + const partialUpdateIssue = async (data: Partial) => { + mutate( + workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null, + (elements: any) => { + const _elements = { ...elements }; + const _issues = _elements.sub_issues.map((element: IIssue) => + element.id === issue.id ? { ...element, ...data } : element + ); + _elements["sub_issues"] = [..._issues]; + return _elements; + }, + false + ); + + const issueResponse = await issuesService.patchIssue( + workspaceSlug as string, + issue.project, + issue.id, + data, + user + ); + + mutate( + SUB_ISSUES(parentIssue.id), + (elements: any) => { + const _elements = elements.sub_issues.map((element: IIssue) => + element.id === issue.id ? issueResponse : element + ); + elements["sub_issues"] = _elements; + return elements; + }, + true + ); + }; + + return ( +
+ {properties.priority && ( +
+ +
+ )} + + {properties.state && ( +
+ +
+ )} + + {properties.start_date && issue.start_date && ( +
+ +
+ )} + + {properties.due_date && issue.target_date && ( +
+ +
+ )} + + {properties.assignee && ( +
+ +
+ )} +
+ ); +}; diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx new file mode 100644 index 000000000..352546eab --- /dev/null +++ b/web/components/issues/sub-issues/root.tsx @@ -0,0 +1,375 @@ +import React from "react"; +// next imports +import { useRouter } from "next/router"; +// swr +import useSWR, { mutate } from "swr"; +// lucide icons +import { Plus, ChevronRight, ChevronDown } from "lucide-react"; +// components +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 +import { useProjectMyMembership } from "contexts/project-member.context"; +import useToast from "hooks/use-toast"; +// helpers +import { copyTextToClipboard } from "helpers/string.helper"; +// types +import { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; +// services +import issuesService from "services/issues.service"; +// fetch keys +import { SUB_ISSUES } from "constants/fetch-keys"; + +export interface ISubIssuesRoot { + parentIssue: IIssue; + user: ICurrentUserResponse | undefined; +} + +export interface ISubIssuesRootLoaders { + visibility: string[]; + delete: string[]; + sub_issues: string[]; +} +export interface ISubIssuesRootLoadersHandler { + key: "visibility" | "delete" | "sub_issues"; + issueId: string; +} + +export const SubIssuesRoot: React.FC = ({ parentIssue, user }) => { + const router = useRouter(); + const { workspaceSlug, projectId, peekIssue } = router.query as { + workspaceSlug: string; + projectId: string; + peekIssue: string; + }; + + const { memberRole } = useProjectMyMembership(); + const { setToastAlert } = useToast(); + + const { data: issues, isLoading } = useSWR( + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? SUB_ISSUES(parentIssue?.id) + : null, + workspaceSlug && projectId && parentIssue && parentIssue?.id + ? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id) + : null + ); + + const [peekParentId, setPeekParentId] = React.useState(""); + + const [issuesLoader, setIssuesLoader] = React.useState({ + visibility: [parentIssue?.id], + delete: [], + sub_issues: [], + }); + const handleIssuesLoader = ({ key, issueId }: ISubIssuesRootLoadersHandler) => { + setIssuesLoader((previousData: ISubIssuesRootLoaders) => ({ + ...previousData, + [key]: previousData[key].includes(issueId) + ? previousData[key].filter((i: string) => i !== issueId) + : [...previousData[key], issueId], + })); + }; + + const [issueCrudOperation, setIssueCrudOperation] = React.useState<{ + create: { toggle: boolean; issueId: string | null }; + existing: { toggle: boolean; issueId: string | null }; + edit: { toggle: boolean; issueId: string | null; issue: IIssue | null }; + delete: { toggle: boolean; issueId: string | null; issue: IIssue | null }; + }>({ + create: { + toggle: false, + issueId: null, + }, + existing: { + toggle: false, + issueId: null, + }, + edit: { + toggle: false, + issueId: null, // parent issue id for mutation + issue: null, + }, + delete: { + toggle: false, + issueId: null, // parent issue id for mutation + issue: null, + }, + }); + const handleIssueCrudOperation = ( + key: "create" | "existing" | "edit" | "delete", + issueId: string | null, + issue: IIssue | null = null + ) => { + setIssueCrudOperation({ + ...issueCrudOperation, + [key]: { + toggle: !issueCrudOperation[key].toggle, + issueId: issueId, + issue: issue, + }, + }); + }; + + const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !parentIssue || issueCrudOperation?.existing?.issueId === null) return; + const issueId = issueCrudOperation?.existing?.issueId; + const payload = { + sub_issue_ids: data.map((i) => i.id), + }; + await issuesService.addSubIssues(workspaceSlug, projectId, issueId, payload).finally(() => { + if (issueId) mutate(SUB_ISSUES(issueId)); + }); + }; + + const removeIssueFromSubIssues = async (parentIssueId: string, issue: IIssue) => { + if (!workspaceSlug || !parentIssue || !issue?.id) return; + issuesService + .patchIssue(workspaceSlug, projectId, issue.id, { parent: null }, user) + .then(async () => { + if (parentIssueId) await mutate(SUB_ISSUES(parentIssueId)); + handleIssuesLoader({ key: "delete", issueId: issue?.id }); + setToastAlert({ + type: "success", + title: `Issue removed!`, + message: `Issue removed successfully.`, + }); + }) + .catch(() => { + handleIssuesLoader({ key: "delete", issueId: issue?.id }); + setToastAlert({ + type: "warning", + title: `Error!`, + message: `Error, Please try again later.`, + }); + }); + }; + + const copyText = (text: string) => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard(`${originURL}/${text}`).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); + }; + + const isEditable = memberRole?.isGuest || memberRole?.isViewer ? false : true; + + const mutateSubIssues = (parentIssueId: string | null) => { + if (parentIssueId) mutate(SUB_ISSUES(parentIssueId)); + }; + + return ( +
+ {!issues && isLoading ? ( +
Loading...
+ ) : ( + <> + {issues && issues?.sub_issues && issues?.sub_issues?.length > 0 ? ( + <> + {/* header */} +
+
+ handleIssuesLoader({ key: "visibility", issueId: parentIssue?.id }) + } + > +
+ {issuesLoader.visibility.includes(parentIssue?.id) ? ( + + ) : ( + + )} +
+
Sub-issues
+
({issues?.sub_issues?.length || 0})
+
+ +
+ +
+ + {isEditable && issuesLoader.visibility.includes(parentIssue?.id) && ( +
+
handleIssueCrudOperation("create", parentIssue?.id)} + > + Add sub-issue +
+
handleIssueCrudOperation("existing", parentIssue?.id)} + > + Add an existing issue +
+
+ )} +
+ + {/* issues */} + {issuesLoader.visibility.includes(parentIssue?.id) && ( +
+ +
+ )} + +
+ + + Add sub-issue + + } + buttonClassName="whitespace-nowrap" + position="left" + noBorder + noChevron + > + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("create", parentIssue?.id); + }} + > + Create new + + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("existing", parentIssue?.id); + }} + > + Add an existing issue + + +
+ + ) : ( + isEditable && ( +
+
No Sub-Issues yet
+
+ + + Add sub-issue + + } + buttonClassName="whitespace-nowrap" + position="left" + noBorder + noChevron + > + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("create", parentIssue?.id); + }} + > + Create new + + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("existing", parentIssue?.id); + }} + > + Add an existing issue + + +
+
+ ) + )} + {isEditable && issueCrudOperation?.create?.toggle && ( + { + mutateSubIssues(issueCrudOperation?.create?.issueId); + handleIssueCrudOperation("create", null); + }} + /> + )} + {isEditable && + issueCrudOperation?.existing?.toggle && + issueCrudOperation?.existing?.issueId && ( + handleIssueCrudOperation("existing", null)} + searchParams={{ sub_issue: true, issue_id: issueCrudOperation?.existing?.issueId }} + handleOnSubmit={addAsSubIssueFromExistingIssues} + workspaceLevelToggle + /> + )} + {isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( + <> + { + mutateSubIssues(issueCrudOperation?.edit?.issueId); + handleIssueCrudOperation("edit", null, null); + }} + data={issueCrudOperation?.edit?.issue} + /> + + )} + {isEditable && + issueCrudOperation?.delete?.toggle && + issueCrudOperation?.delete?.issueId && ( + { + mutateSubIssues(issueCrudOperation?.delete?.issueId); + handleIssueCrudOperation("delete", null, null); + }} + data={issueCrudOperation?.delete?.issue} + user={user} + redirection={false} + /> + )} + + )} + + peekParentId && peekIssue && mutateSubIssues(peekParentId)} + projectId={projectId ?? ""} + workspaceSlug={workspaceSlug ?? ""} + readOnly={!isEditable} + /> +
+ ); +}; diff --git a/web/components/issues/view-select/due-date.tsx b/web/components/issues/view-select/due-date.tsx index 5f5e5cbd0..5cbc26452 100644 --- a/web/components/issues/view-select/due-date.tsx +++ b/web/components/issues/view-select/due-date.tsx @@ -34,7 +34,7 @@ export const ViewDueDateSelect: React.FC = ({ const router = useRouter(); const { workspaceSlug } = router.query; - const { issueView } = useIssuesView(); + const { displayFilters } = useIssuesView(); const minDate = issue.start_date ? new Date(issue.start_date) : null; minDate?.setDate(minDate.getDate()); @@ -80,7 +80,9 @@ export const ViewDueDateSelect: React.FC = ({ ); }} className={`${issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} ${ - issueView === "kanban" ? "bg-custom-background-90" : "bg-custom-background-100" + displayFilters.layout === "kanban" + ? "bg-custom-background-90" + : "bg-custom-background-100" }`} minDate={minDate ?? undefined} noBorder={noBorder} diff --git a/web/components/issues/view-select/index.ts b/web/components/issues/view-select/index.ts index d78a82ed3..99191eb3d 100644 --- a/web/components/issues/view-select/index.ts +++ b/web/components/issues/view-select/index.ts @@ -3,5 +3,4 @@ export * from "./due-date"; export * from "./estimate"; export * from "./label"; export * from "./priority"; -export * from "./start-date"; -export * from "./state"; +export * from "./start-date"; \ No newline at end of file diff --git a/web/components/issues/view-select/start-date.tsx b/web/components/issues/view-select/start-date.tsx index 8748567ae..14b78f6ac 100644 --- a/web/components/issues/view-select/start-date.tsx +++ b/web/components/issues/view-select/start-date.tsx @@ -34,7 +34,7 @@ export const ViewStartDateSelect: React.FC = ({ const router = useRouter(); const { workspaceSlug } = router.query; - const { issueView } = useIssuesView(); + const { displayFilters } = useIssuesView(); const maxDate = issue.target_date ? new Date(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); @@ -72,7 +72,9 @@ export const ViewStartDateSelect: React.FC = ({ ); }} className={`${issue?.start_date ? "w-[6.5rem]" : "w-[5rem] text-center"} ${ - issueView === "kanban" ? "bg-custom-background-90" : "bg-custom-background-100" + displayFilters.layout === "kanban" + ? "bg-custom-background-90" + : "bg-custom-background-100" }`} maxDate={maxDate ?? undefined} noBorder={noBorder} diff --git a/web/components/issues/view-select/state.tsx b/web/components/issues/view-select/state.tsx deleted file mode 100644 index 08ca77d80..000000000 --- a/web/components/issues/view-select/state.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState } from "react"; - -import { useRouter } from "next/router"; - -import useSWR from "swr"; - -// services -import stateService from "services/state.service"; -import trackEventServices from "services/track-event.service"; -// ui -import { CustomSearchSelect, Tooltip } from "components/ui"; -// icons -import { StateGroupIcon } from "components/icons"; -// helpers -import { getStatesList } from "helpers/state.helper"; -// types -import { ICurrentUserResponse, IIssue } from "types"; -// fetch-keys -import { STATES_LIST } from "constants/fetch-keys"; - -type Props = { - issue: IIssue; - partialUpdateIssue: (formData: Partial, issue: IIssue) => void; - position?: "left" | "right"; - tooltipPosition?: "top" | "bottom"; - className?: string; - selfPositioned?: boolean; - customButton?: boolean; - user: ICurrentUserResponse | undefined; - isNotAllowed: boolean; -}; - -export const ViewStateSelect: React.FC = ({ - issue, - partialUpdateIssue, - position = "left", - tooltipPosition = "top", - className = "", - selfPositioned = false, - customButton = false, - user, - isNotAllowed, -}) => { - const [fetchStates, setFetchStates] = useState(false); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { data: stateGroups } = useSWR( - workspaceSlug && issue && fetchStates ? STATES_LIST(issue.project) : null, - workspaceSlug && issue && fetchStates - ? () => stateService.getStates(workspaceSlug as string, issue.project) - : null - ); - const states = getStatesList(stateGroups); - - const options = states?.map((state) => ({ - value: state.id, - query: state.name, - content: ( -
- - {state.name} -
- ), - })); - - const selectedOption = issue.state_detail; - - const stateLabel = ( - -
- - {selectedOption && ( - - )} - - {selectedOption?.name ?? "State"} -
-
- ); - - return ( - { - const oldState = states?.find((s) => s.id === issue.state); - const newState = states?.find((s) => s.id === data); - - partialUpdateIssue( - { - state: data, - state_detail: newState, - }, - issue - ); - trackEventServices.trackIssuePartialPropertyUpdateEvent( - { - workspaceSlug, - workspaceId: issue.workspace, - projectId: issue.project_detail.id, - projectIdentifier: issue.project_detail.identifier, - projectName: issue.project_detail.name, - issueId: issue.id, - }, - "ISSUE_PROPERTY_UPDATE_STATE", - user - ); - - if (oldState?.group !== "completed" && newState?.group !== "completed") { - trackEventServices.trackIssueMarkedAsDoneEvent( - { - workspaceSlug: issue.workspace_detail.slug, - workspaceId: issue.workspace_detail.id, - projectId: issue.project_detail.id, - projectIdentifier: issue.project_detail.identifier, - projectName: issue.project_detail.name, - issueId: issue.id, - }, - user - ); - } - }} - options={options} - {...(customButton ? { customButton: stateLabel } : { label: stateLabel })} - position={position} - disabled={isNotAllowed} - onOpen={() => setFetchStates(true)} - noChevron - selfPositioned={selfPositioned} - /> - ); -}; diff --git a/web/components/issues/workspace-views/workspace-issue-view-option.tsx b/web/components/issues/workspace-views/workspace-issue-view-option.tsx new file mode 100644 index 000000000..4e98cce92 --- /dev/null +++ b/web/components/issues/workspace-views/workspace-issue-view-option.tsx @@ -0,0 +1,121 @@ +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"; +// components +import { MyIssuesSelectFilters } from "components/issues"; +// ui +import { Tooltip } from "components/ui"; +// icons +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { CreditCard } from "lucide-react"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; +// types +import { TIssueViewOptions } from "types"; + +const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ + { + type: "list", + Icon: FormatListBulletedOutlined, + }, + { + type: "spreadsheet", + Icon: CreditCard, + }, +]; + +export const WorkspaceIssuesViewOptions: React.FC = () => { + const router = useRouter(); + const { workspaceSlug, workspaceViewId } = router.query; + + const { displayFilters, setDisplayFilters } = useMyIssuesFilters(workspaceSlug?.toString()); + + const { filters, setFilters } = useWorkspaceIssuesFilters( + workspaceSlug?.toString(), + workspaceViewId?.toString() + ); + + const isWorkspaceViewPath = router.pathname.includes("workspace-views/all-issues"); + + const showFilters = isWorkspaceViewPath || workspaceViewId; + + return ( +
+
+ {issueViewOptions.map((option) => ( + {replaceUnderscoreIfSnakeCase(option.type)} View + } + position="bottom" + > + + + ))} +
+ + {showFilters && ( + <> + { + const key = option.key as keyof typeof filters; + + if (key === "start_date" || key === "target_date") { + const valueExists = checkIfArraysHaveSameElements( + filters?.[key] ?? [], + option.value + ); + + setFilters({ + [key]: valueExists ? null : option.value, + }); + } else { + const valueExists = filters[key]?.includes(option.value); + + if (valueExists) + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter( + (val) => val !== option.value + ), + }); + else + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="left" + height="rg" + /> + + )} +
+ ); +}; diff --git a/web/components/labels/create-update-label-inline.tsx b/web/components/labels/create-update-label-inline.tsx index 6306d14ca..264fb7901 100644 --- a/web/components/labels/create-update-label-inline.tsx +++ b/web/components/labels/create-update-label-inline.tsx @@ -16,8 +16,6 @@ import { Popover, Transition } from "@headlessui/react"; import issuesService from "services/issues.service"; // ui import { Input, PrimaryButton, SecondaryButton } from "components/ui"; -// icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; // types import { IIssueLabels } from "types"; // fetch-keys @@ -132,7 +130,7 @@ export const CreateUpdateLabelInline = forwardRef( return (
( }`} > -
) : ( -
Select co-worker
+
+ Select co-worker +
)}