Compare commits

...

48 Commits

Author SHA1 Message Date
pablohashescobar 0ab6621be3 fix: issue search when searching with identifiers 2023-09-27 16:17:37 +05:30
Aaryan Khandelwal a243bb6a15 chore: gantt chart empty state (#2279)
* chore: gantt empty state

* chore: Add heading to the gantt sidebar
2023-09-27 14:53:26 +05:30
Aaryan Khandelwal b3be363b00 chore: handle calendar date range in frontend (#2277) 2023-09-27 14:41:32 +05:30
Aaryan Khandelwal 5298f1e53c fix: block click happening while moving (#2275) 2023-09-27 13:08:35 +05:30
Dakshesh Jain 2d8cbccfbc style: gantt layout quick-add padding (#2272)
* fix: 'Last Drafted Issue' making sidebar look weird on collapsed

* feat: scroll to the bottom when issue is created

* fix: 'Add Issue' button overlapping issue card in spreadsheet view

* fix: wrong placement of quick-add in calender layout

* fix: spacing for issue card in spreadsheet view

* style: gantt layout quick-add padding

style: removed 'State group' from draft issue

* style: decrese shadow, quick-add position on calender layout, and 'add issue' sticky

* style: button color
2023-09-27 08:51:29 +05:30
Anmol Singh Bhatia 3a6d72e4b6 feat: workspace global view, style: spreadsheet view revamp (#2273)
* chore: workspace view types, services and hooks added

* style: spreadsheet view revamp and code refactor

* feat: workspace view

* fix: build fix

* chore: sidebar workspace issues redirection updated
2023-09-26 19:56:59 +05:30
Aaryan Khandelwal a187e7765c fix: user dashboard greeting timezone (#2267)
* chore: user greeting timezone

* fix: group by labels not working on workspace level
2023-09-26 18:08:01 +05:30
Thomas 4c333d5767 chore: add instructions to contributing guide (#2270)
* chore: add instructions to contributing guide

* dev: update contributing.md to use the new configuration

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
2023-09-26 18:06:48 +05:30
Dakshesh Jain b317a14983 fix: bugs in quick-add and draft issues (#2269)
* fix: 'Last Drafted Issue' making sidebar look weird on collapsed

* feat: scroll to the bottom when issue is created

* fix: 'Add Issue' button overlapping issue card in spreadsheet view

* fix: wrong placement of quick-add in calender layout

* fix: spacing for issue card in spreadsheet view
2023-09-26 17:35:51 +05:30
Bavisetti Narayan 6e0999c35a dev: re-split migrations into two different files (#2268)
* dev: split issue activity migration separate files

* dev: resplit migrations into two different files

* dev: changed the batch size
2023-09-26 16:25:52 +05:30
Bavisetti Narayan 52b57b1e37 dev: migration for 0.13 (#2266)
* dev: updated migrations

* dev: migration for 0.13
2023-09-26 14:18:06 +05:30
Henit Chobisa 88a35efa06 [fix] nginx continuously rewriting and reloading on index page of spaces app (#2236)
* chore: shifted index page to /home route

* chore: added rewrite logic, to rewrite index to /home

* chore: routed home to login route as login page

* chore: updated nginx config to route to login

* chore: updated path for home
2023-09-26 13:46:38 +05:30
Nikhil d38594376b fix: n+1 queries for cycle list and project member endpoints (#2257) 2023-09-26 13:11:23 +05:30
Nikhil dae8ca6053 fix: issue automation iterable error (#2208) 2023-09-26 13:11:00 +05:30
Aaryan Khandelwal 6d3bd78052 chore: add tooltip to show full time on activity logs (#2235) 2023-09-26 13:10:28 +05:30
guru_sainath 1ad99873a9 feat: Add peek overview in sub issues and updated UI for empty states. (#2263) 2023-09-26 13:09:08 +05:30
Dakshesh Jain 7db78594dc fix: draft issue delete not working (#2249)
* fix: draft issue not deleting, project can't be changed in draft issue modal

* fix: removed mutation for view where draft issues are not shown

* fix: inline create issue for draft issue

* fix: clearing data from localstorage on discard click
2023-09-25 19:11:10 +05:30
Dakshesh Jain 5e8d523ed4 feat: quick-add placement in spreadsheet and gantt (#2259)
* feat: sticking quick-add at the bottom of the screen

fix: opening create issue modal instead of quick-add in draft-issues, my-issue and profile page

* fix: build error due to dynamic import
2023-09-25 19:08:26 +05:30
Anmol Singh Bhatia de7a672b79 fix: bug fixes and ui improvement (#2250) 2023-09-25 16:15:49 +05:30
Rhea Jain 0e96eddb57 rename view to layout (#2255)
Co-authored-by: Your Name <you@example.com>
2023-09-25 13:38:49 +05:30
Anmol Singh Bhatia afa10d7195 fix: sub issue state and member select build error (#2254) 2023-09-25 13:18:03 +05:30
Anmol Singh Bhatia 68c8741f93 fix: bug fix related to fetching dropdown options for the profile issue (#2246) 2023-09-25 12:18:35 +05:30
Bavisetti Narayan e8d303dd10 chore: changed priority props in workspace and project (#2253) 2023-09-22 19:48:07 +05:30
Anmol Singh Bhatia c9a6380636 style: settings page improvement (#2211)
* style: settings page improvement

* style: toggle switch styling

---------

Co-authored-by: Anmol Singh Bhatia <asb@Anmols-MacBook-Pro.local>
2023-09-22 18:47:10 +05:30
guru_sainath 1aadbee7e2 fix: resolved pending issue graph in analytics, user wishes in dashboard, and typo in projects list (#2247) 2023-09-22 17:43:23 +05:30
Bavisetti Narayan 02060f654c chore: added epoch in draft (#2244)
* chore: added epoch in draft

* chore: removed extra spaces
2023-09-22 16:32:53 +05:30
Dakshesh Jain 771ca585db feat: quick add (#2240)
* feat: quick add

* style: made text color muted
2023-09-22 15:31:54 +05:30
Nikhil daa0b16960 fix: cycle and module stats when issues are archived (#2185)
* fix: cycle and module stats when issues are archived

* fix: added draft filter

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-09-22 15:17:31 +05:30
Bavisetti Narayan 0005ff5f99 fix: changed priority from None to none (#2229) 2023-09-22 14:44:53 +05:30
Bavisetti Narayan 0c7b7c4e94 chore: added state and priority order in workspace user profile (#2241) 2023-09-22 14:43:55 +05:30
Nikhil 4d835c5b4a chore: updated docker naming conventions (#2239)
* naming convention changes

* dev: update docker-compose-hub in consistent with docker-compose

* dev: updated docker container name

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-09-22 13:21:55 +05:30
Bavisetti Narayan c7092edb61 fix: aws region name (#2234) 2023-09-22 13:00:13 +05:30
guru_sainath 73afb8f4d8 fix: issues resolved in sub issues (#2238) 2023-09-21 19:12:20 +05:30
Aaryan Khandelwal 978909c021 fix: profile issues layout switch (#2228) 2023-09-21 16:04:57 +05:30
Aaryan Khandelwal de9f34cac3 fix: activity label color (#2227) 2023-09-21 16:04:05 +05:30
Aaryan Khandelwal e3793f4464 fix: handle no issues in custom analytics (#2226) 2023-09-21 16:03:33 +05:30
Aaryan Khandelwal 1621125f6d refactor: product updates modal layout (#2225) 2023-09-21 16:03:06 +05:30
guru_sainath bd077e6500 Implemented nested issues in the sub issues section in issue detail page (#2233)
* feat: subissues infinte level

* feat: updated UI for sub issues

* feat: subissues new ui and nested sub issues in issue detail

* chore: removed repeated code
2023-09-21 15:39:45 +05:30
Bavisetti Narayan 60ae940d40 chore: sub issues count in individual issue (#2221) 2023-09-20 17:00:03 +05:30
Dakshesh Jain cdfff12f4f fix: fields not getting selected in the create issue form (#2212)
* fix: hydration error and draft issue workflow

* fix: build error

* fix: properties getting de-selected after create, module & cycle not getting auto-select on the form

* fix: display layout, props being updated directly
2023-09-20 13:06:51 +05:30
Anmol Singh Bhatia e01a0d20fe chore: dynamic position dropdown (#2138)
* chore: dynamic position state dropdown for issue view

* style: state select dropdown styling

* fix: state icon attribute names

* chore: state select dynamic dropdown

* chore: member select dynamic dropdown

* chore: label select dynamic dropdown

* chore: priority select dynamic dropdown

* chore: label select dropdown improvement

* refactor: state dropdown location

* chore: dropdown improvement and code refactor

* chore: dynamic dropdown hook type added

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-09-20 12:24:52 +05:30
Bavisetti Narayan 63c4792e70 fix: changed time to timestamp (#2217) 2023-09-19 21:36:39 +05:30
Bavisetti Narayan ce562fa3ea fix: migration files (#2215) 2023-09-19 20:15:02 +05:30
Bavisetti Narayan a6a0eb9774 chore: added epoch in issue activity (#2187) 2023-09-19 19:46:57 +05:30
Bavisetti Narayan d603c1e8f0 fix: tracking logs for issue activity (#2213) 2023-09-19 19:46:03 +05:30
Bavisetti Narayan 405ef9314f feat: workspace views (#2005)
* feat: workspace views

* fix: added project member filter

* fix: added pagination in workspace views

* fix: filters and group up by for workspace issues

* fix: changed name workspace view to global view

* fix: reordered the urls
2023-09-19 19:45:37 +05:30
Nikhil 926d2ae0a0 dev: self hosted settings file (#2202)
* dev: self hosted settings file

* dev: add analytics and dockerized variable in settings

* dev: update .env.example and docker compose file also

* dev: self hosted setup minio
2023-09-19 18:30:56 +05:30
M. Palanikannan 11258686ad [fix]: Removing dependency on tiptap pro extension (#2209)
* removing dependency on tiptap pro extension

* updated docs to remove tiptap pro setup instructions

* chore: removed pro extension promt from setup.sh

* chore: Removed Pro Extension Setup from CI

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
2023-09-19 16:44:12 +05:30
199 changed files with 9438 additions and 2453 deletions
@@ -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
@@ -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
+58 -16
View File
@@ -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.
-11
View File
@@ -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
+1
View File
@@ -1,6 +1,7 @@
# 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=""
+1 -1
View File
@@ -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 (
+1 -21
View File
@@ -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
+30 -1
View File
@@ -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)
+38 -7
View File
@@ -102,6 +102,8 @@ from plane.api.views import (
BulkEstimatePointEndpoint,
## End Estimates
# Views
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
ViewIssuesEndpoint,
IssueViewFavoriteViewSet,
@@ -184,7 +186,6 @@ from plane.api.views import (
## Exporter
ExportIssuesEndpoint,
## End Exporter
)
@@ -241,7 +242,11 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
path(
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
# user workspaces
path(
"users/me/workspaces/",
@@ -649,6 +654,37 @@ urlpatterns = [
ViewIssuesEndpoint.as_view(),
name="project-view-issues",
),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
@@ -767,11 +803,6 @@ urlpatterns = [
),
name="project-issue",
),
path(
"workspaces/<str:slug>/issues/",
WorkSpaceIssuesEndpoint.as_view(),
name="workspace-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view(
+1 -1
View File
@@ -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,
+111 -20
View File
@@ -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)
@@ -662,6 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
),
}
),
epoch=int(timezone.now().timestamp())
)
# Return all Cycle Issues
+1 -1
View File
@@ -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,
)
)
+8 -4
View File
@@ -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)
+39 -1
View File
@@ -4,6 +4,7 @@ import random
from itertools import chain
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
@@ -129,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)
@@ -149,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)
@@ -315,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)
@@ -326,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)
@@ -568,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):
@@ -586,6 +596,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -607,6 +618,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -890,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):
@@ -908,6 +921,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -929,6 +943,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -1007,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)
@@ -1029,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)
@@ -1231,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)
@@ -1435,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):
@@ -1458,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)
@@ -1506,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):
@@ -1530,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)
@@ -1626,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,
@@ -1675,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)
@@ -1708,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)
@@ -1782,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)
@@ -1826,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)
@@ -1899,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)
@@ -1950,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)
@@ -2013,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)
@@ -2047,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)
@@ -2080,6 +2111,7 @@ class IssueRelationViewSet(BaseViewSet):
IssueRelationSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -2113,6 +2145,7 @@ class IssueRelationViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp())
)
if relation == "blocking":
@@ -2157,6 +2190,8 @@ class IssueRelationViewSet(BaseViewSet):
.select_related("issue")
.distinct()
)
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
@@ -2382,6 +2417,7 @@ class IssueDraftViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@@ -2403,6 +2439,7 @@ class IssueDraftViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@@ -2566,6 +2603,7 @@ class IssueDraftViewSet(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)
+80 -14
View File
@@ -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)
@@ -444,6 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
),
}
),
epoch=int(timezone.now().timestamp())
)
return Response(
@@ -490,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
class ModuleFavoriteViewSet(BaseViewSet):
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite
+1 -1
View File
@@ -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:
+14 -6
View File
@@ -49,16 +49,24 @@ class GlobalSearchEndpoint(BaseAPIView):
def filter_issues(self, query, slug, project_id, workspace_search):
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
q_and = Q()
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\d+\.\d+|\d+", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
if field in ["sequence_id", "project__identifier"]:
# Condition to check sequence
if field == "sequence_id":
sequences = re.findall(r"\d+\.\d+|\d+", query)
for sequence_id in sequences:
q_and &= Q(**{"sequence_id": int(sequence_id)})
# Condition to check identifier
if field == "project__identifier":
identifiers = query.split("-")
if identifiers:
q_and &= Q(**{"project__identifier": identifiers[0]})
else:
q |= Q(**{f"{field}__icontains": query})
final_q = q_and | q
issues = Issue.issue_objects.filter(
q,
final_q,
project__project_projectmember__member=self.request.user,
workspace__slug=slug,
)
+189 -1
View File
@@ -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):
+19 -2
View File
@@ -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(
@@ -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"),
+107 -57
View File
@@ -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,12 +396,13 @@ def track_assignees(
workspace=project.workspace,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.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(
@@ -404,12 +412,13 @@ def create_issue_activity(
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:
@@ -424,6 +433,7 @@ def track_estimate_points(
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to None",
epoch=epoch,
)
)
else:
@@ -438,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(
@@ -457,6 +468,7 @@ def track_archive_at(
field="archived_at",
old_value="archive",
new_value="restore",
epoch=epoch,
)
)
else:
@@ -471,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(
@@ -496,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,
@@ -518,6 +532,11 @@ def update_issue_activity(
"closed_to": track_closed_to,
}
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
)
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
@@ -528,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(
@@ -542,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 = (
@@ -566,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 = (
@@ -593,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(
@@ -609,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 = (
@@ -646,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,
)
)
@@ -666,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 = (
@@ -695,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 = (
@@ -732,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,
)
)
@@ -751,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 = (
@@ -780,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 = (
@@ -803,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 = (
@@ -829,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 = (
@@ -851,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 = (
@@ -875,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(
@@ -891,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:
@@ -914,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
@@ -938,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:
@@ -963,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
@@ -989,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:
@@ -1011,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
@@ -1035,12 +1075,13 @@ 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
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 = (
@@ -1080,12 +1121,13 @@ def create_issue_relation_activity(
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
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 = (
@@ -1109,6 +1151,7 @@ def delete_issue_relation_activity(
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"))
@@ -1124,12 +1167,13 @@ def delete_issue_relation_activity(
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
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@@ -1140,12 +1184,13 @@ def create_draft_issue_activity(
field="draft",
verb="created",
actor=actor,
epoch=epoch,
)
)
def update_draft_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 = (
@@ -1160,6 +1205,7 @@ def update_draft_issue_activity(
comment=f"created the issue",
verb="updated",
actor=actor,
epoch=epoch,
)
)
else:
@@ -1172,13 +1218,14 @@ def update_draft_issue_activity(
field="draft",
verb="updated",
actor=actor,
epoch=epoch,
)
)
def delete_draft_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(
@@ -1188,6 +1235,7 @@ def delete_draft_issue_activity(
field="draft",
verb="deleted",
actor=actor,
epoch=epoch,
)
)
@@ -1200,6 +1248,7 @@ def issue_activity(
issue_id,
actor_id,
project_id,
epoch,
subscriber=True,
):
try:
@@ -1276,6 +1325,7 @@ def issue_activity(
project,
actor,
issue_activities,
epoch,
)
# Save all the values to database
@@ -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:
@@ -26,19 +26,19 @@ def workspace_member_props(old_props):
"calendar_date_range": old_props.get("calendarDateRange", ""),
},
"display_properties": {
"assignee": old_props.get("properties", {}).get("assignee",None),
"attachment_count": old_props.get("properties", {}).get("attachment_count", None),
"created_on": old_props.get("properties", {}).get("created_on", None),
"due_date": old_props.get("properties", {}).get("due_date", None),
"estimate": old_props.get("properties", {}).get("estimate", None),
"key": old_props.get("properties", {}).get("key", None),
"labels": old_props.get("properties", {}).get("labels", None),
"link": old_props.get("properties", {}).get("link", None),
"priority": old_props.get("properties", {}).get("priority", None),
"start_date": old_props.get("properties", {}).get("start_date", None),
"state": old_props.get("properties", {}).get("state", None),
"sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None),
"updated_on": old_props.get("properties", {}).get("updated_on", None),
"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
@@ -1,23 +1,42 @@
# Generated by Django 4.2.3 on 2023-09-15 06:55
from django.db import migrations
def update_issue_activity(apps, schema_editor):
IssueActivityModel = apps.get_model("db", "IssueActivity")
updated_issue_activity = []
for obj in IssueActivityModel.objects.all():
if obj.field == "blocks":
obj.field = "blocked_by"
updated_issue_activity.append(obj)
IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100)
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'),
("db", "0044_auto_20230913_0709"),
]
operations = [
migrations.RunPython(update_issue_activity),
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),
),
]
@@ -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),
]
@@ -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),
]
+1 -1
View File
@@ -50,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
+1
View File
@@ -309,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"
+24 -1
View File
@@ -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):
+76 -115
View File
@@ -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"
+128
View File
@@ -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")
+2 -2
View File
@@ -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
-3
View File
@@ -40,9 +40,6 @@ def filter_priority(params, filter, method):
priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities:
filter["priority__in"] = priorities
else:
if params.get("priority", None) and len(params.get("priority")):
filter["priority__in"] = params.get("priority")
return filter
+18 -6
View File
@@ -9,15 +9,27 @@ from plane.db.models import Issue
def search_issues(query, queryset):
fields = ["name", "sequence_id"]
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
q_and = Q() # Q object for AND conditions
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\d+\.\d+|\d+", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
if field in ["sequence_id", "project__identifier"]:
# Condition to check sequence
if field == "sequence_id":
sequences = re.findall(r"\d+\.\d+|\d+", query)
for sequence_id in sequences:
q_and &= Q(**{"sequence_id": int(sequence_id)})
# Condition to check identifier
if field == "project__identifier":
identifiers = query.split("-")
if identifiers:
q_and &= Q(**{"project__identifier": identifiers[0]})
else:
q |= Q(**{f"{field}__icontains": query})
final_q = q_and | q
return queryset.filter(
q,
final_q,
).distinct()
+28 -79
View File
@@ -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:
+22 -21
View File
@@ -1,8 +1,8 @@
version: "3.8"
services:
plane-web:
container_name: planefrontend
web:
container_name: web
build:
context: .
dockerfile: ./web/Dockerfile.web
@@ -11,11 +11,11 @@ services:
restart: always
command: /usr/local/bin/start.sh web/server.js web
depends_on:
- plane-api
- plane-worker
- api
- worker
plane-deploy:
container_name: planedeploy
space:
container_name: space
build:
context: .
dockerfile: ./space/Dockerfile.space
@@ -24,12 +24,12 @@ services:
restart: always
command: /usr/local/bin/start.sh space/server.js space
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
@@ -43,8 +43,8 @@ services:
- plane-db
- plane-redis
plane-worker:
container_name: planebgworker
worker:
container_name: bgworker
build:
context: ./apiserver
dockerfile: Dockerfile.api
@@ -55,12 +55,12 @@ services:
env_file:
- ./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
@@ -71,7 +71,7 @@ services:
env_file:
- ./apiserver/.env
depends_on:
- plane-api
- api
- plane-db
- plane-redis
@@ -118,8 +118,8 @@ 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
@@ -130,8 +130,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:
+14 -7
View File
@@ -1,29 +1,36 @@
events { }
events {
}
http {
sendfile on;
server {
listen 80;
root /www/data/;
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT};
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 / {
proxy_pass http://planefrontend:3000/;
proxy_pass http://web:3000/;
}
location /api/ {
proxy_pass http://planebackend:8000/api/;
proxy_pass http://api:8000/api/;
}
location /spaces/ {
proxy_pass http://planedeploy:3000/spaces/;
rewrite ^/spaces/?$ /spaces/login break;
proxy_pass http://space:3000/spaces/;
}
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
}
}
}
}
+1 -12
View File
@@ -10,15 +10,4 @@ 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)\"" >> ./apiserver/.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
-2
View File
@@ -1,4 +1,2 @@
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=0
+2 -2
View File
@@ -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) => {
@@ -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,
+1 -1
View File
@@ -1 +1 @@
export * from "./home";
export * from "./login";
@@ -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 <SignInView />;
-1
View File
@@ -17,7 +17,6 @@
"@heroicons/react": "^2.0.12",
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.1",
"@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",
-8
View File
@@ -1,8 +0,0 @@
import React from "react";
// components
import { HomeView } from "components/views";
const HomePage = () => <HomeView />;
export default HomePage;
+8
View File
@@ -0,0 +1,8 @@
import React from "react";
// components
import { LoginView } from "components/views";
const LoginPage = () => <LoginView />;
export default LoginPage;
+1 -21
View File
@@ -1,24 +1,4 @@
# 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="http://localhost:3000/spaces"
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
@@ -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;
@@ -15,17 +15,19 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="divide-y divide-custom-border-200">
<div>
<h6 className="px-3 text-base font-medium">Pending issues</h6>
{defaultAnalytics.pending_issue_user.length > 0 ? (
{defaultAnalytics.pending_issue_user && defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__display_name"
indexBy="assignees__id"
keys={["count"]}
height="250px"
colors={() => `#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<Props> = ({ 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 (
<g transform={`translate(${datum.x},${datum.y})`}>
<image
@@ -50,7 +51,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
y={10}
width={16}
height={16}
xlinkHref={avatar}
xlinkHref={assignee?.assignees__avatar}
style={{ clipPath: "circle(50%)" }}
/>
</g>
@@ -60,7 +61,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
{datum.value ? `${assignee.assignees__display_name}`.toUpperCase()[0] : "?"}
</text>
</g>
);
@@ -161,6 +161,7 @@ export const CommandPalette: React.FC = observer(() => {
/>
<CreateUpdateViewModal
handleClose={() => setIsCreateViewModalOpen(false)}
viewType="project"
isOpen={isCreateViewModalOpen}
user={user}
/>
+29 -15
View File
@@ -1,5 +1,9 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
// icons
import { Icon, Tooltip } from "components/ui";
import { CopyPlus } from "lucide-react";
@@ -10,6 +14,8 @@ import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// types
import { IIssueActivity } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
@@ -52,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 (
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: labels?.find((l) => l.id === labelId)?.color ?? "#000000",
}}
aria-hidden="true"
/>
);
};
const activityDetails: {
[key: string]: {
message: (
@@ -325,14 +351,8 @@ const activityDetails: {
return (
<>
added a new label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<LabelPill labelId={activity.new_identifier ?? ""} />
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span>
{showIssue && (
@@ -348,13 +368,7 @@ const activityDetails: {
<>
removed the label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<LabelPill labelId={activity.old_identifier ?? ""} />
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span>
{showIssue && (
+59 -2
View File
@@ -10,7 +10,14 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types";
import {
IIssueFilterOptions,
IIssueLabels,
IProject,
IState,
IUserLite,
TStateGroups,
} from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
@@ -20,7 +27,9 @@ type Props = {
clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined;
states: IState[] | undefined;
states?: IState[] | undefined;
stateGroup?: string[] | undefined;
project?: IProject[] | undefined;
};
export const FiltersList: React.FC<Props> = ({
@@ -30,6 +39,7 @@ export const FiltersList: React.FC<Props> = ({
labels,
members,
states,
project,
}) => {
if (!filters) return <></>;
@@ -155,6 +165,29 @@ export const FiltersList: React.FC<Props> = ({
: key === "assignees"
? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</div>
);
})
: key === "subscriber"
? filters.subscriber?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
@@ -298,6 +331,30 @@ export const FiltersList: React.FC<Props> = ({
</div>
);
})
: key === "project"
? filters.project?.map((projectId) => {
const currentProject = project?.find((p) => p.id === projectId);
console.log("currentProject", currentProject);
console.log("currentProject", projectId);
return (
<p
key={currentProject?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
>
<span>{currentProject?.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
project: filters.project?.filter((p) => p !== projectId),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})
: (filters[key] as any)?.join(", ")}
<button
type="button"
@@ -67,7 +67,7 @@ export const IssuesFilterView: React.FC = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname.includes("draft-issues");
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const {
displayFilters,
@@ -93,7 +93,9 @@ export const IssuesFilterView: React.FC = () => {
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
<span className="capitalize">
{replaceUnderscoreIfSnakeCase(option.type)} Layout
</span>
}
position="bottom"
>
@@ -228,6 +230,9 @@ export const IssuesFilterView: React.FC = () => {
return null;
if (option.key === "project") return null;
if (isDraftIssues && option.key === "state_detail.group")
return null;
return (
<CustomMenu.MenuItem
key={option.key}
@@ -318,7 +323,7 @@ export const IssuesFilterView: React.FC = () => {
displayFilters.layout !== "spreadsheet" &&
displayFilters.layout !== "gantt_chart" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<h4 className="text-custom-text-200">Show empty groups</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters.show_empty_groups ?? true}
+5
View File
@@ -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,
@@ -86,6 +87,8 @@ export const AllViews: React.FC<Props> = ({
const { groupedIssues, isEmpty, displayFilters } = viewProps;
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug
@@ -174,6 +177,8 @@ export const AllViews: React.FC<Props> = ({
) : displayFilters?.layout === "spreadsheet" ? (
<SpreadsheetView
handleIssueAction={handleIssueAction}
spreadsheetIssues={spreadsheetIssues}
mutateIssues={mutateIssues}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions}
user={user}
@@ -76,7 +76,7 @@ export const AllBoards: React.FC<Props> = ({
readOnly={disableUserActions}
/>
{groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8 bg-custom-background-90">
{Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState =
displayFilters?.group_by === "state"
@@ -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";
@@ -50,8 +50,6 @@ export const BoardHeader: React.FC<Props> = ({
const { displayFilters, groupedIssues } = viewProps;
console.log("dF", displayFilters);
const { data: issueLabels } = useSWR(
workspaceSlug && projectId && displayFilters?.group_by === "labels"
? PROJECT_ISSUE_LABELS(projectId.toString())
@@ -61,6 +59,15 @@ export const BoardHeader: React.FC<Props> = ({
: 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 &&
@@ -84,7 +91,10 @@ export const BoardHeader: React.FC<Props> = ({
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";
@@ -139,7 +149,9 @@ export const BoardHeader: React.FC<Props> = ({
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.color ?? "#000000";
icon = (
<span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
@@ -2,3 +2,4 @@ export * from "./all-boards";
export * from "./board-header";
export * from "./single-board";
export * from "./single-issue";
export * from "./inline-create-issue-form";
@@ -0,0 +1,62 @@
import { useEffect } from "react";
// react hook form
import { useFormContext } from "react-hook-form";
// components
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> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<div>
<h4 className="text-sm font-medium leading-5 text-custom-text-300">
{projectDetails?.identifier ?? "..."}
</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 pl-0 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
export const BoardInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex flex-col justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 rounded bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);
@@ -6,7 +6,7 @@ import { useRouter } from "next/router";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd";
// components
import { BoardHeader, SingleBoardIssue } from "components/core";
import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core";
// ui
import { CustomMenu } from "components/ui";
// icons
@@ -34,31 +34,39 @@ type Props = {
viewProps: IIssueViewProps;
};
export const SingleBoard: React.FC<Props> = ({
addIssueToGroup,
currentState,
groupTitle,
disableUserActions,
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
handleMyIssueOpen,
removeIssue,
user,
userAuth,
viewProps,
}) => {
export const SingleBoard: React.FC<Props> = (props) => {
const {
addIssueToGroup,
currentState,
groupTitle,
disableUserActions,
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
handleMyIssueOpen,
removeIssue,
user,
userAuth,
viewProps,
} = props;
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false);
const { displayFilters, groupedIssues } = viewProps;
const router = useRouter();
const { cycleId, moduleId } = router.query;
const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues";
const isProfileIssuesPage = router.pathname.split("/")[2] === "profile";
const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues";
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
@@ -67,6 +75,24 @@ export const SingleBoard: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
const onCreateClick = () => {
setIsInlineCreateIssueFormOpen(true);
const boardListElement = document.getElementById(`board-list-${groupTitle}`);
// timeout is needed because the animation
// takes time to complete & we can scroll only after that
const timeoutId = setTimeout(() => {
if (boardListElement)
boardListElement.scrollBy({
top: boardListElement.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
};
return (
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<BoardHeader
@@ -115,6 +141,7 @@ export const SingleBoard: React.FC<Props> = ({
</>
)}
<div
id={`board-list-${groupTitle}`}
className={`pt-3 ${
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
} `}
@@ -134,6 +161,7 @@ export const SingleBoard: React.FC<Props> = ({
type={type}
index={index}
issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle}
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
@@ -169,6 +197,19 @@ export const SingleBoard: React.FC<Props> = ({
>
<>{provided.placeholder}</>
</span>
<BoardInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by! === "labels"
? "labels_list"
: displayFilters?.group_by!]:
displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle,
}}
/>
</div>
{displayFilters?.group_by !== "created_by" && (
<div>
@@ -177,7 +218,11 @@ export const SingleBoard: React.FC<Props> = ({
<button
type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={addIssueToGroup}
onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else onCreateClick();
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
@@ -197,7 +242,7 @@ export const SingleBoard: React.FC<Props> = ({
position="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToGroup}>
<CustomMenu.MenuItem onClick={() => onCreateClick()}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
@@ -13,19 +13,14 @@ import {
} from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import trackEventServices from "services/track-event.service";
// hooks
import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
import { MembersSelect, LabelSelect, PrioritySelect } from "components/project";
import { StateSelect } from "components/states";
// ui
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
// icons
@@ -44,7 +39,15 @@ import { LayerDiagonalIcon } from "components/icons";
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
import {
ICurrentUserResponse,
IIssue,
IIssueViewProps,
IState,
ISubIssueResponse,
TIssuePriorities,
UserAuth,
} from "types";
// fetch-keys
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
@@ -53,6 +56,7 @@ type Props = {
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
projectId: string;
groupTitle?: string;
index: number;
editIssue: () => void;
@@ -74,6 +78,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
provided,
snapshot,
issue,
projectId,
index,
editIssue,
makeIssueCopy,
@@ -101,7 +106,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
const { displayFilters, properties, mutateIssues } = viewProps;
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { workspaceSlug, cycleId, moduleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issues");
@@ -188,6 +193,86 @@ export const SingleBoardIssue: React.FC<Props> = ({
});
};
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
);
};
useEffect(() => {
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]);
@@ -343,13 +428,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
)}
<button
type="button"
className="text-sm text-left break-words line-clamp-2"
onClick={() => {
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
else openPeekOverview();
}}
>
{issue.name}
<span className="text-sm text-left break-words line-clamp-2">{issue.name}</span>
</button>
</div>
@@ -359,21 +443,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
}`}
>
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
<StateSelect
value={issue.state_detail}
onChange={handleStateChange}
projectId={projectId}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.start_date && issue.start_date && (
@@ -397,16 +480,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
/>
)}
{properties.labels && issue.labels.length > 0 && (
<ViewIssueLabel labelDetails={issue.label_details} maxRender={2} />
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
user={user}
disabled={isNotAllowed}
/>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
customButton
user={user}
selfPositioned
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.estimate && issue.estimate_point !== null && (
@@ -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<React.SetStateAction<boolean>>;
currentDate: Date;
setCurrentDate: React.Dispatch<React.SetStateAction<Date>>;
showWeekEnds: boolean;
setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>;
changeDateRange: (startDate: Date, endDate: Date) => void;
};
export const CalendarHeader: React.FC<Props> = ({
setIsMonthlyView,
isMonthlyView,
currentDate,
setCurrentDate,
showWeekEnds,
setShowWeekEnds,
changeDateRange,
}) => {
const updateDate = (date: Date) => {
setCurrentDate(date);
}) => (
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</Popover.Button>
changeDateRange(startOfWeek(date), lastDayOfWeek(date));
};
return (
<div className="mb-4 flex items-center justify-between">
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
<Popover className="flex h-full items-center justify-start rounded-lg">
{({ open }) => (
<>
<Popover.Button>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-custom-text-100">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{YEARS_LIST.map((year) => (
<button
onClick={() => setCurrentDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div>
</Popover.Button>
<div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
setCurrentDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-custom-background-80 shadow-lg">
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
{YEARS_LIST.map((year) => (
<button
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
className={` ${
isSameYear(year.value, currentDate)
? "text-sm font-medium text-custom-text-100"
: "text-xs text-custom-text-200 "
} hover:text-sm hover:font-medium hover:text-custom-text-100`}
>
{year.label}
</button>
))}
</div>
<div className="grid grid-cols-4 border-t border-custom-border-200 px-2">
{MONTHS_LIST.map((month) => (
<button
onClick={() =>
updateDate(updateDateWithMonth(`${month.value}`, currentDate))
}
className={`px-2 py-2 text-xs text-custom-text-200 hover:font-medium hover:text-custom-text-100 ${
isSameMonth(`${month.value}`, currentDate)
? "font-medium text-custom-text-100"
: ""
}`}
>
{month.label}
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="flex items-center gap-2">
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(subtractMonths(currentDate, 1));
} else {
setCurrentDate(subtract7DaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
getCurrentWeekEndDate(subtract7DaysToDate(currentDate))
);
}
}}
>
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(addMonths(currentDate, 1));
} else {
setCurrentDate(addSevenDaysToDate(currentDate));
changeDateRange(
getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
getCurrentWeekEndDate(addSevenDaysToDate(currentDate))
);
}
}}
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex w-full items-center justify-end gap-2">
<div className="flex items-center gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none"
className="cursor-pointer"
onClick={() => {
if (isMonthlyView) {
updateDate(new Date());
} else {
setCurrentDate(new Date());
changeDateRange(
getCurrentWeekStartDate(new Date()),
getCurrentWeekEndDate(new Date())
);
}
const previousMonthYear =
currentDate.getMonth() === 0
? currentDate.getFullYear() - 1
: currentDate.getFullYear();
const previousMonthMonth =
currentDate.getMonth() === 0 ? 11 : currentDate.getMonth() - 1;
const previousMonthFirstDate = new Date(previousMonthYear, previousMonthMonth, 1);
setCurrentDate(previousMonthFirstDate);
}}
>
Today
<ChevronLeftIcon className="h-4 w-4" />
</button>
<button
className="cursor-pointer"
onClick={() => {
const nextMonthYear =
currentDate.getMonth() === 11
? currentDate.getFullYear() + 1
: currentDate.getFullYear();
const nextMonthMonth = (currentDate.getMonth() + 1) % 12;
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none ">
{isMonthlyView ? "Monthly" : "Weekly"}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
const nextMonthFirstDate = new Date(nextMonthYear, nextMonthMonth, 1);
setCurrentDate(nextMonthFirstDate);
}}
>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(true);
changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate));
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
<span className="flex items-center gap-2">Monthly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-100" : "opacity-0"}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setIsMonthlyView(false);
changeDateRange(
getCurrentWeekStartDate(currentDate),
getCurrentWeekEndDate(currentDate)
);
}}
className="w-52 text-sm text-custom-text-200"
>
<div className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">Weekly View</span>
<CheckIcon
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-0" : "opacity-100"}`}
/>
</div>
</CustomMenu.MenuItem>
<div className="mt-1 flex w-52 items-center justify-between border-t border-custom-border-200 py-2 px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
<div className="flex w-full items-center justify-end gap-2">
<button
className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none"
onClick={() => setCurrentDate(new Date())}
>
Today
</button>
<CustomMenu
customButton={
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1 text-sm hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none">
Options
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
>
<div className="flex w-52 items-center justify-between px-1 text-sm text-custom-text-200">
<h4>Show weekends</h4>
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
</div>
</CustomMenu>
</div>
</div>
);
export default CalendarHeader;
@@ -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";
@@ -50,31 +46,27 @@ export const CalendarView: React.FC<Props> = ({
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<ICalendarRange>({
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, mutateIssues, params, displayFilters, setDisplayFilters } =
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 =
@@ -148,27 +140,12 @@ export const CalendarView: React.FC<Props> = ({
.then(() => mutate(fetchKey));
};
const changeDateRange = (startDate: Date, endDate: Date) => {
setCalendarDates({
startDate,
endDate,
});
setDisplayFilters({
calendar_date_range: `${renderDateFormat(startDate)};after,${renderDateFormat(
endDate
)};before`,
});
};
useEffect(() => {
if (!displayFilters || displayFilters.calendar_date_range === "")
setDisplayFilters({
calendar_date_range: `${renderDateFormat(
startOfWeek(currentDate)
)};after,${renderDateFormat(lastDayOfWeek(currentDate))};before`,
});
}, [currentDate, displayFilters, setDisplayFilters]);
setCalendarDates({
startDate: startOfWeek(activeMonthDate),
endDate: lastDayOfWeek(activeMonthDate),
});
}, [activeMonthDate]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
@@ -183,15 +160,15 @@ export const CalendarView: React.FC<Props> = ({
{calendarIssues ? (
<div className="h-full overflow-y-auto">
<DragDropContext onDragEnd={onDragEnd}>
<div className="h-full rounded-lg p-8 text-custom-text-200">
<div
id={`calendar-view-${cycleId ?? moduleId ?? viewId ?? ""}`}
className="h-full rounded-lg p-8 text-custom-text-200"
>
<CalendarHeader
isMonthlyView={isMonthlyView}
setIsMonthlyView={setIsMonthlyView}
showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds}
currentDate={currentDate}
setCurrentDate={setCurrentDate}
changeDateRange={changeDateRange}
currentDate={activeMonthDate}
setCurrentDate={setActiveMonthDate}
/>
<div
@@ -202,30 +179,15 @@ export const CalendarView: React.FC<Props> = ({
{weeks.map((date, index) => (
<div
key={index}
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200`}
>
<span>
{isMonthlyView
? formatDate(date, "eee").substring(0, 3)
: formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
<span>{formatDate(date, "eee").substring(0, 3)}</span>
</div>
))}
</div>
<div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
className={`grid h-full auto-rows-min ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
@@ -236,7 +198,6 @@ export const CalendarView: React.FC<Props> = ({
date={date}
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds}
user={user}
isNotAllowed={isNotAllowed}
@@ -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";
@@ -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> | void;
prePopulatedData?: Partial<IIssue>;
dependencies: any[];
};
const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject<HTMLDivElement>, 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 (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">
{projectDetails?.identifier ?? "..."}
</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const CalendarInlineCreateIssueForm: React.FC<Props> = (props) => {
const { isOpen, dependencies } = props;
const ref = useRef<HTMLDivElement>(null);
const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies);
return (
<>
<div
ref={ref}
className={`absolute w-60 top-5 transition-all z-20 ${
isOpen ? "opacity-100 scale-100" : "opacity-0 pointer-events-none scale-95"
} right-0`}
>
<InlineCreateIssueFormWrapper
{...props}
className="flex w-full p-1 px-1.5 rounded z-50 items-center gap-x-3 bg-custom-background-100 shadow-custom-shadow-sm transition-opacity"
>
<InlineInput />
</InlineCreateIssueFormWrapper>
</div>
{/* Added to make any other element as outside click. This will make input also to be outside. */}
{isOpen && <div className="w-screen h-screen fixed inset-0 z-10" />}
</>
);
};
@@ -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<Props> = ({
handleIssueAction,
date,
index,
addIssueToDate,
isMonthlyView,
showWeekEnds,
user,
isNotAllowed,
}) => {
export const SingleCalendarDate: React.FC<Props> = (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<Props> = ({
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,72 @@ export const SingleCalendarDate: React.FC<Props> = ({
: "border-r"
}`}
>
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
{totalIssues > 0 &&
date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => (
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<SingleCalendarIssue
key={index}
index={index}
provided={provided}
snapshot={snapshot}
issue={issue}
handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</Draggable>
))}
{totalIssues > 4 && (
<button
type="button"
className="w-min whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
<>
<span>{formatDate(new Date(date.date), "d")}</span>
{totalIssues > 0 &&
date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => (
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<SingleCalendarIssue
key={index}
index={index}
provided={provided}
snapshot={snapshot}
issue={issue}
projectId={issue.project_detail.id}
handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</Draggable>
))}
<div
className="fixed top-0 left-0 z-50"
style={{
transform: `translate(${formPosition.x}px, ${formPosition.y}px)`,
}}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<CalendarInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
dependencies={[showWeekEnds]}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
target_date: date.date,
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
</div>
<div
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-custom-background-80 p-1 text-xs text-custom-text-200 opacity-0 group-hover:opacity-100`}
>
<button
className="flex items-center justify-center gap-1 text-center"
onClick={() => addIssueToDate(date.date)}
{totalIssues > 4 && (
<button
type="button"
className="w-min whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 px-1.5 py-1 text-xs"
onClick={() => setShowAllIssues((prevData) => !prevData)}
>
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
</button>
)}
<div
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-custom-background-80 p-1 text-xs text-custom-text-200 opacity-0 group-hover:opacity-100`}
>
<PlusSmallIcon className="h-4 w-4 text-custom-text-200" />
Add issue
</button>
</div>
<button
onClick={(e) => {
setIsCreateIssueFormOpen(true);
setFormPosition({ x: e.clientX, y: e.clientY });
}}
className="flex items-center justify-center gap-1 text-center"
>
<PlusSmallIcon className="h-4 w-4 text-custom-text-200" />
Add issue
</button>
</div>
{provided.placeholder}
{provided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
@@ -8,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,
@@ -46,6 +41,7 @@ type Props = {
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
issue: IIssue;
projectId: string;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
@@ -57,11 +53,12 @@ export const SingleCalendarIssue: React.FC<Props> = ({
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();
@@ -153,6 +150,86 @@ export const SingleCalendarIssue: React.FC<Props> = ({
});
};
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;
@@ -225,22 +302,20 @@ export const SingleCalendarIssue: React.FC<Props> = ({
{displayProperties && (
<div className="relative mt-1.5 w-full flex flex-wrap items-center gap-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
className="max-w-full"
isNotAllowed={isNotAllowed}
user={user}
<StateSelect
value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.start_date && issue.start_date && (
@@ -260,21 +335,25 @@ export const SingleCalendarIssue: React.FC<Props> = ({
/>
)}
{properties.labels && issue.labels.length > 0 && (
<ViewLabelSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
maxRender={1}
user={user}
isNotAllowed={isNotAllowed}
disabled={isNotAllowed}
/>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.estimate && issue.estimate_point !== null && (
@@ -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> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<div className="w-[14px] h-[14px] rounded-full border border-custom-border-1000 flex-shrink-0" />
<h4 className="text-sm text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const GanttInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex py-3 px-4 mr-2.5 items-center rounded gap-x-2 border bg-custom-background-100 shadow-custom-shadow-sm"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);
+1
View File
@@ -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";
@@ -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<IIssue> = {
name: "",
};
type Props = {
isOpen: boolean;
handleClose: () => void;
onSuccess?: (data: IIssue) => Promise<void> | void;
prePopulatedData?: Partial<IIssue>;
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> = (props) => {
const { isOpen, handleClose, onSuccess, prePopulatedData, children, className } = props;
const ref = useRef<HTMLFormElement>(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<IIssue>({ 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) => {
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 (
<>
<Transition
show={isOpen}
enter="transition ease-in-out duration-200 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-200 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<FormProvider {...method}>
<form ref={ref} className={className} onSubmit={handleSubmit(onSubmitHandler)}>
{children}
</form>
</FormProvider>
</Transition>
</>
);
};
+23 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router";
@@ -87,8 +87,16 @@ export const IssuesView: React.FC<Props> = ({
const { setToastAlert } = useToast();
const { groupedByIssues, mutateIssues, displayFilters, filters, isEmpty, setFilters, params } =
useIssuesView();
const {
groupedByIssues,
mutateIssues,
displayFilters,
filters,
isEmpty,
setFilters,
params,
setDisplayFilters,
} = useIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { data: stateGroups } = useSWR(
@@ -108,6 +116,17 @@ export const IssuesView: React.FC<Props> = ({
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);
@@ -462,6 +481,7 @@ export const IssuesView: React.FC<Props> = ({
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
viewType="project"
preLoadedData={createViewModal}
user={user}
/>
@@ -1,3 +1,4 @@
export * from "./all-lists";
export * from "./single-issue";
export * from "./single-list";
export * from "./inline-create-issue-form";
@@ -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> | void;
prePopulatedData?: Partial<IIssue>;
};
const InlineInput = () => {
const { projectDetails } = useProjectDetails();
const { register, setFocus } = useFormContext();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">
{projectDetails?.identifier ?? "..."}
</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const ListInlineCreateIssueForm: React.FC<Props> = (props) => (
<>
<InlineCreateIssueFormWrapper
className="flex py-3 px-4 items-center gap-x-5 bg-custom-background-100 shadow-custom-shadow-sm z-10"
{...props}
>
<InlineInput />
</InlineCreateIssueFormWrapper>
{props.isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
</>
);
@@ -6,19 +6,13 @@ 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,
CreateUpdateDraftIssueModal,
} 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,6 +51,7 @@ import {
type Props = {
type?: string;
issue: IIssue;
projectId: string;
groupTitle?: string;
editIssue: () => void;
index: number;
@@ -73,6 +70,7 @@ type Props = {
export const SingleListIssue: React.FC<Props> = ({
type,
issue,
projectId,
editIssue,
index,
makeIssueCopy,
@@ -92,7 +90,7 @@ export const SingleListIssue: React.FC<Props> = ({
const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(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";
@@ -181,6 +179,86 @@ export const SingleListIssue: React.FC<Props> = ({
});
};
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
@@ -290,21 +368,20 @@ export const SingleListIssue: React.FC<Props> = ({
}`}
>
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
user={user}
isNotAllowed={isNotAllowed}
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
user={user}
isNotAllowed={isNotAllowed}
<StateSelect
value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.start_date && issue.start_date && (
@@ -323,14 +400,26 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed}
/>
)}
{properties.labels && <ViewIssueLabel labelDetails={issue.label_details} maxRender={3} />}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
{properties.labels && (
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
maxRender={3}
user={user}
isNotAllowed={isNotAllowed}
disabled={isNotAllowed}
/>
)}
{properties.assignee && (
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
{properties.estimate && issue.estimate_point !== null && (
@@ -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";
@@ -51,40 +54,65 @@ type Props = {
viewProps: IIssueViewProps;
};
export const SingleList: React.FC<Props> = ({
currentState,
groupTitle,
addIssueToGroup,
handleIssueAction,
openIssuesListModal,
handleDraftIssueAction,
handleMyIssueOpen,
removeIssue,
disableUserActions,
disableAddIssueOption = false,
user,
userAuth,
viewProps,
}) => {
export const SingleList: React.FC<Props> = (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 { displayFilters, groupedIssues } = viewProps;
const { data: issueLabels } = useSWR<IIssueLabels[]>(
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
);
@@ -99,7 +127,10 @@ export const SingleList: React.FC<Props> = ({
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";
@@ -153,7 +184,9 @@ export const SingleList: React.FC<Props> = ({
break;
case "labels":
const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.color ?? "#000000";
icon = (
<span
className="h-3 w-3 flex-shrink-0 rounded-full"
@@ -207,7 +240,7 @@ export const SingleList: React.FC<Props> = ({
<button
type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={addIssueToGroup}
onClick={() => setIsCreateIssueFormOpen(true)}
>
<PlusIcon className="h-4 w-4" />
</button>
@@ -224,7 +257,9 @@ export const SingleList: React.FC<Props> = ({
position="right"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToGroup}>Create new</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setIsCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
@@ -250,6 +285,7 @@ export const SingleList: React.FC<Props> = ({
key={issue.id}
type={type}
issue={issue}
projectId={issue.project_detail.id}
groupTitle={groupTitle}
index={index}
editIssue={() => handleIssueAction(issue, "edit")}
@@ -284,6 +320,33 @@ export const SingleList: React.FC<Props> = ({
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
<ListInlineCreateIssueForm
isOpen={isCreateIssueFormOpen && !disableAddIssueOption}
handleClose={() => setIsCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
[displayFilters?.group_by!]: groupTitle,
}}
/>
{!disableAddIssueOption && !isCreateIssueFormOpen && (
<div className="w-full bg-custom-background-100 px-6 py-3">
<button
type="button"
onClick={() => {
if (isDraftIssuesPage || isMyIssuesPage || isProfileIssuesPage) {
addIssueToGroup();
} else setIsCreateIssueFormOpen(true);
}}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
)}
</Disclosure.Panel>
</Transition>
</div>
@@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const AssigneeColumn: React.FC<Props> = ({
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 (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.assignee && (
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};
@@ -0,0 +1,2 @@
export * from "./spreadsheet-assignee-column";
export * from "./assignee-column";
@@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetAssigneeColumn: React.FC<Props> = ({
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 (
<div>
<AssigneeColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetAssigneeColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};
@@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const CreatedOnColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.created_on && (
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
{renderLongDetailDateFormat(issue.created_at)}
</div>
)}
</span>
</div>
);
@@ -0,0 +1,2 @@
export * from "./spreadsheet-created-on-column";
export * from "./created-on-column";
@@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetCreatedOnColumn: React.FC<Props> = ({
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 (
<div>
<CreatedOnColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetCreatedOnColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};
@@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const DueDateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);
@@ -0,0 +1,2 @@
export * from "./spreadsheet-due-date-column";
export * from "./due-date-column";
@@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetDueDateColumn: React.FC<Props> = ({
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 (
<div>
<DueDateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetDueDateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};
@@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const EstimateColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.estimate && (
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
</span>
</div>
);
@@ -0,0 +1,2 @@
export * from "./spreadsheet-estimate-column";
export * from "./estimate-column";
@@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetEstimateColumn: React.FC<Props> = ({
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 (
<div>
<EstimateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetEstimateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};
@@ -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";
@@ -0,0 +1,2 @@
export * from "./spreadsheet-issue-column";
export * from "./issue-column";
@@ -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<React.SetStateAction<string | null>>;
disableUserActions: boolean;
userAuth: UserAuth;
nestingLevel: number;
};
export const IssueColumn: React.FC<Props> = ({
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 (
<div className="group flex items-center w-[28rem] text-sm h-11 sticky top-0 bg-custom-background-100 truncate border-b border-r border-custom-border-200 ">
<div
className="flex gap-1.5 px-4 pr-0 py-2.5 items-center"
style={issue.parent ? { paddingLeft } : {}}
>
<div className="relative flex items-center cursor-pointer text-xs text-center hover:text-custom-text-100">
{properties.key && (
<span className="flex items-center justify-center font-medium opacity-100 group-hover:opacity-0">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
{!isNotAllowed && !disableUserActions && (
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
<Popover2
isOpen={isOpen}
canEscapeKeyClose
onInteraction={(nextOpenState) => setIsOpen(nextOpenState)}
content={
<div
className={`flex flex-col gap-1.5 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none max-h-44 min-w-full border-custom-border-200 bg-custom-background-90`}
>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleEditIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</button>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleDeleteIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</button>
<button
type="button"
className="hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
onClick={() => {
handleCopyText();
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue link</span>
</div>
</button>
</div>
}
placement="bottom-start"
>
<EllipsisHorizontalIcon className="h-5 w-5 text-custom-text-200" />
</Popover2>
</div>
)}
</div>
{issue.sub_issues_count > 0 && (
<div className="h-6 w-6 flex justify-center items-center">
<button
className="h-5 w-5 hover:bg-custom-background-90 hover:text-custom-text-100 rounded-sm cursor-pointer"
onClick={() => handleToggleExpand(issue.id)}
>
<Icon iconName="chevron_right" className={`${expanded ? "rotate-90" : ""}`} />
</button>
</div>
)}
</div>
<span className="flex items-center px-4 py-2.5 h-full truncate flex-grow">
<button
type="button"
className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]"
onClick={openPeekOverview}
>
{issue.name}
</button>
</span>
</div>
);
};
@@ -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<React.SetStateAction<string[]>>;
properties: Properties;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
gridTemplateColumns: string;
setCurrentProjectId: React.Dispatch<React.SetStateAction<string | null>>;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
nestingLevel?: number;
};
export const SpreadsheetIssues: React.FC<Props> = ({
index,
export const SpreadsheetIssuesColumn: React.FC<Props> = ({
issue,
projectId,
expandedIssues,
setExpandedIssues,
gridTemplateColumns,
properties,
handleIssueAction,
setCurrentProjectId,
disableUserActions,
user,
userAuth,
nestingLevel = 0,
}) => {
@@ -49,21 +47,20 @@ export const SpreadsheetIssues: React.FC<Props> = ({
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 (
<div>
<SingleSpreadsheetIssue
<IssueColumn
issue={issue}
index={index}
projectId={projectId}
expanded={isExpanded}
handleToggleExpand={handleToggleExpand}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleEditIssue={() => 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<Props> = ({
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetIssues
<SpreadsheetIssuesColumn
key={subIssue.id}
issue={subIssue}
index={index}
projectId={subIssue.project_detail.id}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleIssueAction={handleIssueAction}
setCurrentProjectId={setCurrentProjectId}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
nestingLevel={nestingLevel + 1}
/>
@@ -0,0 +1,2 @@
export * from "./spreadsheet-label-column";
export * from "./label-column";
@@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const LabelColumn: React.FC<Props> = ({
issue,
projectId,
partialUpdateIssue,
properties,
user,
isNotAllowed,
}) => {
const handleLabelChange = (data: any) => {
partialUpdateIssue({ labels_list: data }, issue);
};
return (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.labels && (
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
maxRender={1}
user={user}
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};
@@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetLabelColumn: React.FC<Props> = ({
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 (
<div>
<LabelColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetLabelColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};
@@ -0,0 +1,2 @@
export * from "./spreadsheet-priority-column";
export * from "./priority-column";
@@ -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<IIssue>, issue: IIssue) => void;
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const PriorityColumn: React.FC<Props> = ({
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 (
<div className="flex items-center text-sm h-11 w-full bg-custom-background-100">
<span className="flex items-center px-4 py-2.5 h-full w-full flex-shrink-0 border-r border-b border-custom-border-200">
{properties.priority && (
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
)}
</span>
</div>
);
};
@@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetPriorityColumn: React.FC<Props> = ({
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 (
<div>
<PriorityColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetPriorityColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};
@@ -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<Props> = ({
issue,
projectId,
index,
expanded,
handleToggleExpand,
@@ -77,7 +82,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
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<Props> = ({
? 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<ISubIssueResponse>(
@@ -133,13 +138,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
);
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<Props> = ({
});
};
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<Props> = ({
</div>
{properties.state && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
className="max-w-full"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
<StateSelect
value={issue.state_detail}
projectId={projectId}
onChange={handleStateChange}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
</div>
)}
{properties.priority && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
<PrioritySelect
value={issue.priority}
onChange={handlePriorityChange}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
</div>
)}
{properties.assignee && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
<MembersSelect
value={issue.assignees}
projectId={projectId}
onChange={handleAssigneeChange}
membersDetails={issue.assignee_details}
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
hideDropdownArrow
disabled={isNotAllowed}
/>
</div>
)}
{properties.labels && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewIssueLabel labelDetails={issue.label_details} maxRender={1} />
<LabelSelect
value={issue.labels}
projectId={projectId}
onChange={handleLabelChange}
labelsDetails={issue.label_details}
hideDropdownArrow
maxRender={1}
user={user}
disabled={isNotAllowed}
/>
</div>
)}
@@ -1,23 +1,54 @@
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
// next
import { useRouter } from "next/router";
import { KeyedMutator, mutate } from "swr";
// components
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
import {
ListInlineCreateIssueForm,
SpreadsheetAssigneeColumn,
SpreadsheetCreatedOnColumn,
SpreadsheetDueDateColumn,
SpreadsheetEstimateColumn,
SpreadsheetIssuesColumn,
SpreadsheetLabelColumn,
SpreadsheetPriorityColumn,
SpreadsheetStartDateColumn,
SpreadsheetStateColumn,
SpreadsheetUpdatedOnColumn,
} from "components/core";
import { CustomMenu, Spinner } from "components/ui";
import { IssuePeekOverview } from "components/issues";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
// constants
import { SPREADSHEET_COLUMN } from "constants/spreadsheet";
import { ICurrentUserResponse, IIssue, ISubIssueResponse, UserAuth } from "types";
import useWorkspaceIssuesFilters from "hooks/use-worskpace-issue-filter";
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
WORKSPACE_VIEW_ISSUES,
} from "constants/fetch-keys";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import projectIssuesServices from "services/issues.service";
// icon
import { PlusIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "lucide-react";
type Props = {
spreadsheetIssues: IIssue[];
mutateIssues: KeyedMutator<
| IIssue[]
| {
[key: string]: IIssue[];
}
>;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null;
disableUserActions: boolean;
@@ -26,6 +57,8 @@ type Props = {
};
export const SpreadsheetView: React.FC<Props> = ({
spreadsheetIssues,
mutateIssues,
handleIssueAction,
openIssuesListModal,
disableUserActions,
@@ -33,83 +66,255 @@ export const SpreadsheetView: React.FC<Props> = ({
userAuth,
}) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(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<IIssue>, 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<ISubIssueResponse>(
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<IIssue[]>(
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<any>) => (
<div className="relative flex flex-col h-max w-full bg-custom-background-100 rounded-sm">
<div className="flex items-center min-w-[9rem] px-4 py-2.5 text-sm font-medium z-[1] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-200">
{header}
</div>
<div className="h-full min-w-[9rem] w-full">
{spreadsheetIssues.map((issue: IIssue, index) => (
<Component
key={`${issue.id}_${index}`}
issue={issue}
projectId={issue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
</div>
);
return (
<>
<IssuePeekOverview
handleMutation={() => mutateIssues()}
projectId={projectId?.toString() ?? ""}
projectId={currentProjectId ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
<div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
<div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max">
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
</div>
{spreadsheetIssues ? (
<div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm ">
{spreadsheetIssues.map((issue: IIssue, index) => (
<SpreadsheetIssues
key={`${issue.id}_${index}`}
index={index}
issue={issue}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
/>
))}
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }}
>
{type === "issue" ? (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!disableUserActions && (
<div className="relative flex h-full w-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
<div className="h-full w-full flex flex-col">
<div className="flex max-h-full overflow-y-auto">
{spreadsheetIssues ? (
<>
<div className="sticky left-0 w-[28rem] z-[2]">
<div className="relative flex flex-col h-max w-full bg-custom-background-100 rounded-sm z-[2]">
<div className="flex items-center text-sm font-medium z-[2] h-11 w-full sticky top-0 bg-custom-background-90 border border-l-0 border-custom-border-200">
<span className="flex items-center px-4 py-2.5 h-full w-20 flex-shrink-0">
ID
</span>
<span className="flex items-center px-4 py-2.5 h-full w-full flex-grow">
Issue
</span>
</div>
{spreadsheetIssues.map((issue: IIssue, index) => (
<SpreadsheetIssuesColumn
key={`${issue.id}_${index}`}
issue={issue}
projectId={issue.project_detail.id}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
setCurrentProjectId={setCurrentProjectId}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
userAuth={userAuth}
/>
))}
</div>
</div>
{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)}
</>
) : (
<div className="flex flex-col justify-center items-center h-full w-full">
<Spinner />
</div>
)}
</div>
<div>
<ListInlineCreateIssueForm
isOpen={isInlineCreateIssueFormOpen}
handleClose={() => setIsInlineCreateIssueFormOpen(false)}
prePopulatedData={{
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/>
{type === "issue"
? !disableUserActions &&
!isInlineCreateIssueFormOpen && (
<button
className="flex gap-1.5 items-center text-custom-primary-100 pl-7 py-2.5 text-sm sticky left-0 z-[1] border-custom-border-200 w-full"
onClick={() => setIsInlineCreateIssueFormOpen(true)}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
)
: !disableUserActions &&
!isInlineCreateIssueFormOpen && (
<CustomMenu
className="sticky left-0 z-[1]"
className="sticky left-0 z-10"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
className="flex gap-1.5 items-center text-custom-primary-100 pl-7 py-2.5 text-sm sticky left-0 z-[1] border-custom-border-200 w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
@@ -117,15 +322,11 @@ export const SpreadsheetView: React.FC<Props> = ({
</button>
}
position="left"
verticalPosition="top"
optionsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<CustomMenu.MenuItem onClick={() => setIsInlineCreateIssueFormOpen(true)}>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
@@ -134,13 +335,9 @@ export const SpreadsheetView: React.FC<Props> = ({
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
)}
</div>
) : (
<Spinner />
)}
</div>
</div>
</>
);
@@ -0,0 +1,2 @@
export * from "./spreadsheet-start-date-column";
export * from "./start-date-column";
@@ -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<IIssue>, issue: IIssue) => void;
expandedIssues: string[];
properties: Properties;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const SpreadsheetStartDateColumn: React.FC<Props> = ({
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 (
<div>
<StartDateColumn
issue={issue}
projectId={projectId}
properties={properties}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue) => (
<SpreadsheetStartDateColumn
key={subIssue.id}
issue={subIssue}
projectId={subIssue.project_detail.id}
partialUpdateIssue={partialUpdateIssue}
expandedIssues={expandedIssues}
properties={properties}
user={user}
isNotAllowed={isNotAllowed}
/>
))}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More