From c35317696c3d5cb939e08620a82cf35df1e72529 Mon Sep 17 00:00:00 2001 From: Alan Santos Date: Fri, 19 Jun 2026 14:51:18 -0300 Subject: [PATCH 01/13] feat: add workspace sprint planning Co-authored-by: Cursor --- .cursor/rules/pr-deploy-publicacao.mdc | 23 ++ .github/workflows/docker-images.yml | 98 +++++ .gitignore | 3 + apps/api/plane/app/serializers/__init__.py | 7 + apps/api/plane/app/serializers/issue.py | 6 + apps/api/plane/app/serializers/sprint.py | 140 +++++++ apps/api/plane/app/serializers/view.py | 2 + apps/api/plane/app/urls/workspace.py | 58 +++ apps/api/plane/app/views/__init__.py | 1 + apps/api/plane/app/views/issue/base.py | 31 ++ apps/api/plane/app/views/view/base.py | 15 + apps/api/plane/app/views/workspace/sprint.py | 239 +++++++++++ .../plane/bgtasks/workspace_sprint_task.py | 94 +++++ apps/api/plane/celery.py | 4 + .../db/migrations/0122_workspace_sprint.py | 196 +++++++++ .../0123_workspace_sprint_automation.py | 109 +++++ ...ter_workspacesprint_created_by_and_more.py | 75 ++++ apps/api/plane/db/models/__init__.py | 1 + apps/api/plane/db/models/sprint.py | 123 ++++++ .../contract/app/test_workspace_sprints.py | 124 ++++++ apps/api/plane/utils/filters/converters.py | 2 + apps/api/plane/utils/filters/filterset.py | 17 + .../[workspaceSlug]/(projects)/sidebar.tsx | 3 + .../(projects)/sprints/[sprintId]/page.tsx | 29 ++ .../(projects)/sprints/header.tsx | 163 ++++++++ .../(projects)/sprints/layout.tsx | 21 + .../(projects)/sprints/page.tsx | 23 ++ .../work-items/[workspaceSprintId]/page.tsx | 41 ++ apps/web/app/routes/core.ts | 10 + .../components/issues/issue-layouts/utils.tsx | 2 + .../dropdowns/workspace-sprint/index.tsx | 72 ++++ .../workspace-sprint/sprint-options.tsx | 132 ++++++ .../properties/all-properties.tsx | 54 ++- .../roots/all-issue-layout-root.tsx | 28 +- .../spreadsheet/columns/index.ts | 1 + .../spreadsheet/columns/sprint-column.tsx | 44 ++ .../spreadsheet/roots/workspace-root.tsx | 7 +- .../workspace/sidebar/sprints-list.tsx | 388 ++++++++++++++++++ .../workspace/sprints/automation-modal.tsx | 155 +++++++ .../workspace/sprints/sprint-modal.tsx | 118 ++++++ .../workspace/sprints/sprints-list.tsx | 154 +++++++ .../core/hooks/store/use-workspace-sprint.ts | 15 + apps/web/core/services/sprint.service.ts | 125 ++++++ .../store/issue/issue-details/issue.store.ts | 6 +- .../core/store/issue/workspace/issue.store.ts | 48 ++- apps/web/core/store/root.store.ts | 5 + apps/web/core/store/workspace-sprint.store.ts | 320 +++++++++++++++ docker-compose.prod.yml | 157 +++++++ docs/production-docker.md | 85 ++++ packages/constants/src/issue/common.ts | 12 + packages/services/src/index.ts | 1 + packages/services/src/sprint/index.ts | 7 + .../services/src/sprint/sprint.service.ts | 83 ++++ packages/types/src/index.ts | 1 + packages/types/src/issues/issue.ts | 2 + packages/types/src/sprint/index.ts | 7 + packages/types/src/sprint/sprint.ts | 80 ++++ packages/types/src/view-props.ts | 3 + packages/utils/src/work-item/base.ts | 3 + 59 files changed, 3747 insertions(+), 26 deletions(-) create mode 100644 .cursor/rules/pr-deploy-publicacao.mdc create mode 100644 .github/workflows/docker-images.yml create mode 100644 apps/api/plane/app/serializers/sprint.py create mode 100644 apps/api/plane/app/views/workspace/sprint.py create mode 100644 apps/api/plane/bgtasks/workspace_sprint_task.py create mode 100644 apps/api/plane/db/migrations/0122_workspace_sprint.py create mode 100644 apps/api/plane/db/migrations/0123_workspace_sprint_automation.py create mode 100644 apps/api/plane/db/migrations/0124_alter_workspacesprint_created_by_and_more.py create mode 100644 apps/api/plane/db/models/sprint.py create mode 100644 apps/api/plane/tests/contract/app/test_workspace_sprints.py create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/[sprintId]/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/layout.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/work-items/[workspaceSprintId]/page.tsx create mode 100644 apps/web/core/components/dropdowns/workspace-sprint/index.tsx create mode 100644 apps/web/core/components/dropdowns/workspace-sprint/sprint-options.tsx create mode 100644 apps/web/core/components/issues/issue-layouts/spreadsheet/columns/sprint-column.tsx create mode 100644 apps/web/core/components/workspace/sidebar/sprints-list.tsx create mode 100644 apps/web/core/components/workspace/sprints/automation-modal.tsx create mode 100644 apps/web/core/components/workspace/sprints/sprint-modal.tsx create mode 100644 apps/web/core/components/workspace/sprints/sprints-list.tsx create mode 100644 apps/web/core/hooks/store/use-workspace-sprint.ts create mode 100644 apps/web/core/services/sprint.service.ts create mode 100644 apps/web/core/store/workspace-sprint.store.ts create mode 100644 docker-compose.prod.yml create mode 100644 docs/production-docker.md create mode 100644 packages/services/src/sprint/index.ts create mode 100644 packages/services/src/sprint/sprint.service.ts create mode 100644 packages/types/src/sprint/index.ts create mode 100644 packages/types/src/sprint/sprint.ts diff --git a/.cursor/rules/pr-deploy-publicacao.mdc b/.cursor/rules/pr-deploy-publicacao.mdc new file mode 100644 index 00000000000..a7b90836755 --- /dev/null +++ b/.cursor/rules/pr-deploy-publicacao.mdc @@ -0,0 +1,23 @@ +--- +description: Fluxo padrao para revisar, testar, mergear PRs e publicar em main +alwaysApply: true +--- + +# PR, Deploy e Publicacao + +Quando o usuario pedir para aceitar PRs, fazer deploy, publicar na `main` ou gerar/promover imagem Docker, siga este fluxo. + +1. Identifique o escopo antes de agir: liste PRs abertos com `gh pr list`, confirme repositorio, branch base (`development`, `main` ou a branch padrao do repo) e se ha worktree suja. Nao inclua `.env`, dumps, credenciais, venvs ou arquivos nao relacionados. +2. Analise o conteudo do PR inteiro, nao apenas o ultimo commit: use `gh pr view`, `gh pr diff`, `git log base..head` e `git diff base...head`. +3. Se houver dois ou mais PRs para o mesmo destino, teste o estado combinado sobre a branch base atualizada antes de mergear. Crie uma branch local temporaria, aplique os heads e resolva conflitos com cuidado. +4. Rode validacoes locais antes do merge conforme o escopo: + - Monorepo/web/packages: `pnpm install --frozen-lockfile`, testes especificos adicionados/afetados, `pnpm build` ou build filtrado, e `pnpm check:lint` como informativo. + - API: testes relevantes via Docker conforme `apps/api/tests/RUNNING_TESTS.md`; use `docker compose -f docker-compose-test.yml run --rm api-tests pytest ...` para subsets e rode checagens/migrations relevantes quando houver alteracoes de modelo. +5. Considere lint nao bloqueante apenas quando a falha vier de debitos antigos fora dos arquivos alterados. Reporte isso no resumo e confira diagnósticos dos arquivos tocados. +6. Quando o PR base for `development`, mergeie primeiro em `development`, aguarde o workflow de build/push da imagem Docker e so depois crie PR `development -> main`. +7. Para publicar na `main`, abra PR com resumo do que sera publicado e plano de testes. Mergeie apenas se `mergeStateStatus` estiver `CLEAN` e os checks relevantes estiverem verdes. +8. Apos merge na `main`, acompanhe o workflow disparado em `main` ate concluir. Se houver promocao de imagem ja buildada em `development`, confirme que o workflow promoveu a imagem correta e disparou os webhooks esperados. +9. Se CI falhar, leia `gh run view --log-failed`, corrija a causa raiz em novo PR, mergeie e acompanhe novamente. Para falhas Docker/pnpm, verifique `pnpm-workspace.yaml`, `pnpm fetch/install --offline`, versao do pnpm, versao do Node da imagem e compatibilidade com lockfile. +10. Ao finalizar, responda com links dos PRs, runs, checks executados e qualquer risco residual. + +Nao use push direto para `main` ou `development` quando um PR for apropriado. Nao force push nem use comandos destrutivos sem pedido explicito. diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml new file mode 100644 index 00000000000..f8c1630f69d --- /dev/null +++ b/.github/workflows/docker-images.yml @@ -0,0 +1,98 @@ +name: Build Production Docker Images + +on: + workflow_dispatch: + inputs: + app_release: + description: "Image tag to publish and use as APP_RELEASE" + required: false + type: string + push: + branches: + - main + - master + - preview + - canary + +permissions: + contents: read + packages: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + name: Build ${{ matrix.image }} + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - image: plane-frontend + context: . + dockerfile: ./apps/web/Dockerfile.web + - image: plane-space + context: . + dockerfile: ./apps/space/Dockerfile.space + - image: plane-admin + context: . + dockerfile: ./apps/admin/Dockerfile.admin + - image: plane-live + context: . + dockerfile: ./apps/live/Dockerfile.live + - image: plane-backend + context: ./apps/api + dockerfile: ./apps/api/Dockerfile.api + - image: plane-proxy + context: ./apps/proxy + dockerfile: ./apps/proxy/Dockerfile.ce + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Prepare image variables + id: vars + shell: bash + run: | + image_namespace="${GITHUB_REPOSITORY,,}" + app_release="${{ github.event.inputs.app_release }}" + + if [ -z "$app_release" ]; then + app_release="$(echo "$GITHUB_REF_NAME" | tr '[:upper:]' '[:lower:]' | sed 's#[^a-z0-9._-]#-#g')" + fi + + echo "image_namespace=$image_namespace" >> "$GITHUB_OUTPUT" + echo "app_release=$app_release" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.vars.outputs.image_namespace }}/${{ matrix.image }} + tags: | + type=raw,value=${{ steps.vars.outputs.app_release }} + type=sha,prefix=sha-,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 42ef657d4b0..4da223aaacb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,8 +49,11 @@ pnpm-debug.log* ## Django ## venv +venv*/ .venv +.venv*/ *.pyc +__pycache__/ staticfiles mediafiles .env diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index e8a4007ea61..9042e428c24 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -51,6 +51,13 @@ CycleWriteSerializer, CycleUserPropertiesSerializer, ) +from .sprint import ( + WorkspaceSprintSerializer, + WorkspaceSprintAutomationSerializer, + WorkspaceSprintAutomationWriteSerializer, + WorkspaceSprintIssueSerializer, + WorkspaceSprintWriteSerializer, +) from .asset import FileAssetSerializer from .issue import ( IssueCreateSerializer, diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 673a5570616..5ea277fe628 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -761,6 +761,8 @@ class Meta: class IssueSerializer(DynamicBaseSerializer): # ids cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) + global_sprint_id = serializers.PrimaryKeyRelatedField(read_only=True) + global_sprint_name = serializers.CharField(read_only=True) module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) # Many to many @@ -788,6 +790,8 @@ class Meta: "project_id", "parent_id", "cycle_id", + "global_sprint_id", + "global_sprint_name", "module_ids", "label_ids", "assignee_ids", @@ -852,6 +856,8 @@ def to_representation(self, instance): "archived_at": instance.archived_at, # Computed fields "cycle_id": instance.cycle_id, + "global_sprint_id": getattr(instance, "global_sprint_id", None), + "global_sprint_name": getattr(instance, "global_sprint_name", None), "module_ids": self.get_module_ids(instance), "label_ids": self.get_label_ids(instance), "assignee_ids": self.get_assignee_ids(instance), diff --git a/apps/api/plane/app/serializers/sprint.py b/apps/api/plane/app/serializers/sprint.py new file mode 100644 index 00000000000..8552d1295b6 --- /dev/null +++ b/apps/api/plane/app/serializers/sprint.py @@ -0,0 +1,140 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework import serializers +from django.utils import timezone + +# Module imports +from .base import BaseSerializer +from .issue import IssueStateSerializer +from plane.db.models import WorkspaceSprint, WorkspaceSprintAutomation, WorkspaceSprintIssue + + +class WorkspaceSprintWriteSerializer(BaseSerializer): + automation_id = serializers.UUIDField(required=False, allow_null=True, write_only=True) + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + and data.get("start_date", None) > data.get("end_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed end date") + return data + + def create(self, validated_data): + automation_id = validated_data.pop("automation_id", None) + if automation_id: + validated_data["automation_id"] = automation_id + return super().create(validated_data) + + def update(self, instance, validated_data): + automation_id = validated_data.pop("automation_id", None) + if automation_id: + validated_data["automation_id"] = automation_id + return super().update(instance, validated_data) + + class Meta: + model = WorkspaceSprint + fields = "__all__" + read_only_fields = ["workspace", "project", "owned_by", "archived_at", "source", "sequence_id"] + + +class WorkspaceSprintSerializer(BaseSerializer): + total_issues = serializers.IntegerField(read_only=True) + status = serializers.SerializerMethodField() + + class Meta: + model = WorkspaceSprint + fields = [ + "id", + "workspace_id", + "automation_id", + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "sort_order", + "archived_at", + "source", + "sequence_id", + "logo_props", + "timezone", + "version", + "total_issues", + "status", + "created_at", + "updated_at", + ] + read_only_fields = fields + + def get_status(self, obj): + if obj.archived_at: + return "archived" + if not obj.start_date or not obj.end_date: + return "draft" + now = timezone.now() + if obj.start_date <= now <= obj.end_date: + return "current" + if obj.start_date > now: + return "upcoming" + return "past" + + +class WorkspaceSprintAutomationWriteSerializer(BaseSerializer): + def validate(self, data): + sprint_duration_days = data.get("sprint_duration_days", getattr(self.instance, "sprint_duration_days", None)) + name_template = data.get("name_template", getattr(self.instance, "name_template", None)) + + if sprint_duration_days is not None and sprint_duration_days <= 0: + raise serializers.ValidationError("Sprint duration must be positive") + if not name_template: + raise serializers.ValidationError("Name template is required") + return data + + class Meta: + model = WorkspaceSprintAutomation + fields = "__all__" + read_only_fields = ["workspace", "project", "next_sequence"] + + +class WorkspaceSprintAutomationSerializer(BaseSerializer): + active_sprints_count = serializers.SerializerMethodField() + + class Meta: + model = WorkspaceSprintAutomation + fields = [ + "id", + "workspace_id", + "name", + "description", + "enabled", + "start_date", + "sprint_duration_days", + "timezone", + "name_template", + "next_sequence", + "auto_create_next", + "sort_order", + "active_sprints_count", + "created_at", + "updated_at", + ] + read_only_fields = fields + + def get_active_sprints_count(self, obj): + if hasattr(obj, "active_sprints_count"): + return obj.active_sprints_count + return obj.sprints.filter(archived_at__isnull=True, deleted_at__isnull=True).count() + + +class WorkspaceSprintIssueSerializer(BaseSerializer): + issue_detail = IssueStateSerializer(read_only=True, source="issue") + + class Meta: + model = WorkspaceSprintIssue + fields = "__all__" + read_only_fields = ["workspace", "project", "sprint"] diff --git a/apps/api/plane/app/serializers/view.py b/apps/api/plane/app/serializers/view.py index 72f72ff71b2..493b27b65d0 100644 --- a/apps/api/plane/app/serializers/view.py +++ b/apps/api/plane/app/serializers/view.py @@ -36,6 +36,8 @@ def to_representation(self, instance): "project_id": instance.project_id, "parent_id": instance.parent_id, "cycle_id": instance.cycle_id, + "global_sprint_id": getattr(instance, "global_sprint_id", None), + "global_sprint_name": getattr(instance, "global_sprint_name", None), "sub_issues_count": instance.sub_issues_count, "created_at": instance.created_at, "updated_at": instance.updated_at, diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index d79d5a74522..c9c384a352a 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -28,6 +28,10 @@ ExportWorkspaceUserActivityEndpoint, WorkspaceModulesEndpoint, WorkspaceCyclesEndpoint, + WorkspaceSprintArchiveEndpoint, + WorkspaceSprintAutomationViewSet, + WorkspaceSprintIssueViewSet, + WorkspaceSprintViewSet, WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, WorkspaceDraftIssueViewSet, @@ -184,6 +188,60 @@ WorkspaceCyclesEndpoint.as_view(), name="workspace-cycles", ), + path( + "workspaces//sprints/", + WorkspaceSprintViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-sprints", + ), + path( + "workspaces//sprint-automations/", + WorkspaceSprintAutomationViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-sprint-automations", + ), + path( + "workspaces//sprint-automations//", + WorkspaceSprintAutomationViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace-sprint-automations", + ), + path( + "workspaces//archived-sprints/", + WorkspaceSprintArchiveEndpoint.as_view(), + name="workspace-archived-sprints", + ), + path( + "workspaces//sprints//", + WorkspaceSprintViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace-sprints", + ), + path( + "workspaces//sprints//archive/", + WorkspaceSprintArchiveEndpoint.as_view(), + name="workspace-sprint-archive", + ), + path( + "workspaces//sprints//issues/", + WorkspaceSprintIssueViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-sprint-issues", + ), + path( + "workspaces//sprints//issues//", + WorkspaceSprintIssueViewSet.as_view({"delete": "destroy"}), + name="workspace-sprint-issues", + ), path( "workspaces//user-favorites/", WorkspaceFavoriteEndpoint.as_view(), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 84f7872ec85..7516fd43d5d 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -80,6 +80,7 @@ from .workspace.estimate import WorkspaceEstimatesEndpoint from .workspace.module import WorkspaceModulesEndpoint from .workspace.cycle import WorkspaceCyclesEndpoint +from .workspace.sprint import WorkspaceSprintArchiveEndpoint, WorkspaceSprintAutomationViewSet, WorkspaceSprintIssueViewSet, WorkspaceSprintViewSet from .workspace.quick_link import QuickLinkViewSet from .workspace.sticky import WorkspaceStickyViewSet diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index d9e2ea5a5a8..d1e3aa08f95 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -60,6 +60,7 @@ Project, ProjectMember, UserRecentVisit, + WorkspaceSprintIssue, ) from plane.utils.filters import ComplexFilterBackend, IssueFilterSet from plane.utils.global_paginator import paginate @@ -114,6 +115,20 @@ def get(self, request, slug, project_id): CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) + .annotate( + global_sprint_id=Subquery( + WorkspaceSprintIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values( + "sprint_id" + )[:1] + ) + ) + .annotate( + global_sprint_name=Subquery( + WorkspaceSprintIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values( + "sprint__name" + )[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -174,6 +189,8 @@ def get(self, request, slug, project_id): "project_id", "parent_id", "cycle_id", + "global_sprint_id", + "global_sprint_name", "module_ids", "label_ids", "assignee_ids", @@ -218,6 +235,20 @@ def apply_annotations(self, issues): CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) + .annotate( + global_sprint_id=Subquery( + WorkspaceSprintIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values( + "sprint_id" + )[:1] + ) + ) + .annotate( + global_sprint_name=Subquery( + WorkspaceSprintIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values( + "sprint__name" + )[:1] + ) + ) .annotate( link_count=Subquery( IssueLink.objects.filter(issue=OuterRef("id")) diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index 5ca7aac420f..2cad92151d5 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -39,6 +39,7 @@ IssueAssignee, IssueLabel, ModuleIssue, + WorkspaceSprintIssue, ) from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset @@ -168,6 +169,20 @@ def apply_annotations(self, issues): CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) + .annotate( + global_sprint_id=Subquery( + WorkspaceSprintIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values( + "sprint_id" + )[:1] + ) + ) + .annotate( + global_sprint_name=Subquery( + WorkspaceSprintIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values( + "sprint__name" + )[:1] + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apps/api/plane/app/views/workspace/sprint.py b/apps/api/plane/app/views/workspace/sprint.py new file mode 100644 index 00000000000..90f10934183 --- /dev/null +++ b/apps/api/plane/app/views/workspace/sprint.py @@ -0,0 +1,239 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Django imports +from django.db import transaction +from django.db.models import Count, Q +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE +from plane.app.serializers import ( + WorkspaceSprintAutomationSerializer, + WorkspaceSprintAutomationWriteSerializer, + WorkspaceSprintIssueSerializer, + WorkspaceSprintSerializer, + WorkspaceSprintWriteSerializer, +) +from plane.bgtasks.workspace_sprint_task import process_workspace_sprint_automation +from plane.db.models import Issue, ProjectMember, Workspace, WorkspaceSprint, WorkspaceSprintAutomation, WorkspaceSprintIssue +from plane.app.views.base import BaseAPIView, BaseViewSet + + +class WorkspaceSprintViewSet(BaseViewSet): + serializer_class = WorkspaceSprintSerializer + model = WorkspaceSprint + permission_classes = [WorkspaceEntityPermission] + + def get_serializer_class(self): + return WorkspaceSprintWriteSerializer if self.action in ["create", "update", "partial_update"] else WorkspaceSprintSerializer + + def get_queryset(self): + queryset = ( + WorkspaceSprint.objects.filter(workspace__slug=self.workspace_slug) + .select_related("workspace", "owned_by") + .annotate( + total_issues=Count( + "sprint_issues", + filter=Q( + sprint_issues__deleted_at__isnull=True, + sprint_issues__issue__deleted_at__isnull=True, + sprint_issues__issue__archived_at__isnull=True, + sprint_issues__issue__is_draft=False, + ), + ) + ) + .order_by("sort_order", "-created_at") + ) + archived = self.request.GET.get("archived", "false") + automation_id = self.request.GET.get("automation_id") + + if archived == "true": + queryset = queryset.filter(archived_at__isnull=False) + else: + queryset = queryset.filter(archived_at__isnull=True) + + if automation_id: + queryset = queryset.filter(automation_id=automation_id) + + return queryset + + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + sprint = serializer.save(workspace=workspace, owned_by=request.user) + return Response(WorkspaceSprintSerializer(sprint).data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, slug, pk): + sprint = self.get_queryset().get(pk=pk) + serializer = self.get_serializer(sprint, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + sprint = serializer.save() + return Response(WorkspaceSprintSerializer(sprint).data, status=status.HTTP_200_OK) + + def update(self, request, slug, pk): + sprint = self.get_queryset().get(pk=pk) + serializer = self.get_serializer(sprint, data=request.data) + serializer.is_valid(raise_exception=True) + sprint = serializer.save() + return Response(WorkspaceSprintSerializer(sprint).data, status=status.HTTP_200_OK) + + +class WorkspaceSprintAutomationViewSet(BaseViewSet): + serializer_class = WorkspaceSprintAutomationSerializer + model = WorkspaceSprintAutomation + permission_classes = [WorkspaceEntityPermission] + + def get_serializer_class(self): + return ( + WorkspaceSprintAutomationWriteSerializer + if self.action in ["create", "update", "partial_update"] + else WorkspaceSprintAutomationSerializer + ) + + def get_queryset(self): + return ( + WorkspaceSprintAutomation.objects.filter(workspace__slug=self.workspace_slug) + .select_related("workspace", "created_by") + .annotate( + active_sprints_count=Count( + "sprints", + filter=Q(sprints__deleted_at__isnull=True, sprints__archived_at__isnull=True), + ) + ) + .order_by("sort_order", "-created_at") + ) + + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + automation = serializer.save(workspace=workspace) + process_workspace_sprint_automation(automation) + return Response(WorkspaceSprintAutomationSerializer(automation).data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, slug, pk): + automation = self.get_queryset().get(pk=pk) + serializer = self.get_serializer(automation, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + automation = serializer.save() + if set(request.data.keys()) != {"sort_order"}: + process_workspace_sprint_automation(automation) + return Response(WorkspaceSprintAutomationSerializer(automation).data, status=status.HTTP_200_OK) + + def update(self, request, slug, pk): + automation = self.get_queryset().get(pk=pk) + serializer = self.get_serializer(automation, data=request.data) + serializer.is_valid(raise_exception=True) + automation = serializer.save() + process_workspace_sprint_automation(automation) + return Response(WorkspaceSprintAutomationSerializer(automation).data, status=status.HTTP_200_OK) + + +class WorkspaceSprintArchiveEndpoint(BaseAPIView): + def get_queryset(self): + return ( + WorkspaceSprint.objects.filter(workspace__slug=self.workspace_slug, archived_at__isnull=False) + .select_related("workspace", "owned_by") + .annotate( + total_issues=Count( + "sprint_issues", + filter=Q( + sprint_issues__deleted_at__isnull=True, + sprint_issues__issue__deleted_at__isnull=True, + sprint_issues__issue__archived_at__isnull=True, + sprint_issues__issue__is_draft=False, + ), + ) + ) + .order_by("-archived_at") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug): + automation_id = request.GET.get("automation_id") + queryset = self.get_queryset() + if automation_id: + queryset = queryset.filter(automation_id=automation_id) + return Response(WorkspaceSprintSerializer(queryset, many=True).data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def post(self, request, slug, sprint_id): + sprint = WorkspaceSprint.objects.get(workspace__slug=slug, pk=sprint_id, archived_at__isnull=True) + sprint.archived_at = timezone.now() + sprint.save(update_fields=["archived_at", "updated_at"]) + return Response({"archived_at": str(sprint.archived_at)}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def delete(self, request, slug, sprint_id): + sprint = WorkspaceSprint.objects.get(workspace__slug=slug, pk=sprint_id, archived_at__isnull=False) + sprint.archived_at = None + sprint.save(update_fields=["archived_at", "updated_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + +class WorkspaceSprintIssueViewSet(BaseViewSet): + serializer_class = WorkspaceSprintIssueSerializer + model = WorkspaceSprintIssue + permission_classes = [WorkspaceEntityPermission] + + def get_queryset(self): + return WorkspaceSprintIssue.objects.filter( + workspace__slug=self.workspace_slug, + sprint_id=self.kwargs.get("sprint_id"), + ).select_related("workspace", "project", "sprint", "issue", "issue__state", "issue__project") + + def _get_sprint(self, slug, sprint_id): + return WorkspaceSprint.objects.get(workspace__slug=slug, pk=sprint_id, archived_at__isnull=True) + + def _get_issue_for_write(self, slug, issue_id, user): + issue = Issue.issue_objects.select_related("workspace", "project").get(workspace__slug=slug, pk=issue_id) + if not ProjectMember.objects.filter( + workspace__slug=slug, + project_id=issue.project_id, + member=user, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], + is_active=True, + ).exists(): + return None + return issue + + def list(self, request, slug, sprint_id): + self._get_sprint(slug, sprint_id) + queryset = self.get_queryset() + return Response(self.serializer_class(queryset, many=True).data, status=status.HTTP_200_OK) + + def create(self, request, slug, sprint_id): + issue_id = request.data.get("issue_id") + if not issue_id: + return Response({"error": "issue_id is required"}, status=status.HTTP_400_BAD_REQUEST) + + sprint = self._get_sprint(slug, sprint_id) + issue = self._get_issue_for_write(slug, issue_id, request.user) + if issue is None: + return Response({"error": "Issue is not accessible"}, status=status.HTTP_403_FORBIDDEN) + + with transaction.atomic(): + WorkspaceSprintIssue.objects.filter(issue=issue, deleted_at__isnull=True).delete() + sprint_issue = WorkspaceSprintIssue.objects.create( + workspace=sprint.workspace, + project=issue.project, + sprint=sprint, + issue=issue, + ) + + return Response(self.serializer_class(sprint_issue).data, status=status.HTTP_201_CREATED) + + def destroy(self, request, slug, sprint_id, issue_id): + sprint_issue = self.get_queryset().get(issue_id=issue_id) + issue = self._get_issue_for_write(slug, issue_id, request.user) + if issue is None: + return Response({"error": "Issue is not accessible"}, status=status.HTTP_403_FORBIDDEN) + + sprint_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/bgtasks/workspace_sprint_task.py b/apps/api/plane/bgtasks/workspace_sprint_task.py new file mode 100644 index 00000000000..6e23a95ae6f --- /dev/null +++ b/apps/api/plane/bgtasks/workspace_sprint_task.py @@ -0,0 +1,94 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from datetime import timedelta + +from celery import shared_task +from django.db import transaction +from django.utils import timezone + +from plane.db.models import WorkspaceSprint, WorkspaceSprintAutomation + + +def _format_sprint_name(template, sequence, start_date, end_date): + return ( + template.replace("{{number}}", str(sequence)) + .replace("{{start}}", start_date.strftime("%b %d")) + .replace("{{end}}", end_date.strftime("%b %d")) + ) + + +def _window_for(automation, now): + start_date = automation.start_date + if now < start_date: + return start_date + + elapsed_days = (now.date() - start_date.date()).days + window_offset = elapsed_days // automation.sprint_duration_days + return start_date + timedelta(days=window_offset * automation.sprint_duration_days) + + +def _ensure_sprint_for_window(automation, start_date, sequence): + end_date = start_date + timedelta(days=automation.sprint_duration_days) - timedelta(seconds=1) + sprint = WorkspaceSprint.objects.filter( + workspace=automation.workspace, + automation=automation, + start_date=start_date, + end_date=end_date, + deleted_at__isnull=True, + ).first() + + if sprint: + return sprint, False + + sprint = WorkspaceSprint.objects.create( + workspace=automation.workspace, + automation=automation, + name=_format_sprint_name(automation.name_template, sequence, start_date, end_date), + description=automation.description, + start_date=start_date, + end_date=end_date, + owned_by=automation.created_by or automation.workspace.owner, + source="automation", + sequence_id=sequence, + timezone=automation.timezone, + ) + return sprint, True + + +def process_workspace_sprint_automation(automation): + if not automation.enabled: + return [] + + created = [] + now = timezone.now() + current_start = _window_for(automation, now) + + with transaction.atomic(): + for index, start_date in enumerate( + [ + current_start, + current_start + timedelta(days=automation.sprint_duration_days), + ] + ): + sequence = automation.next_sequence + index + sprint, was_created = _ensure_sprint_for_window(automation, start_date, sequence) + if was_created: + created.append(sprint.id) + + if created: + automation.next_sequence = automation.next_sequence + len(created) + automation.save(update_fields=["next_sequence", "updated_at"]) + + return created + + +@shared_task +def process_workspace_sprint_automations(): + automation_ids = WorkspaceSprintAutomation.objects.filter(enabled=True).values_list("id", flat=True) + for automation_id in automation_ids: + automation = WorkspaceSprintAutomation.objects.select_related("workspace", "workspace__owner", "created_by").get( + id=automation_id + ) + process_workspace_sprint_automation(automation) diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py index 8ae7c7b7051..02ffbca79ff 100644 --- a/apps/api/plane/celery.py +++ b/apps/api/plane/celery.py @@ -92,6 +92,10 @@ def _get_metrics_push_interval_minutes() -> int: "task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link", "schedule": crontab(hour=3, minute=45), # UTC 03:45 }, + "process-workspace-sprint-automations": { + "task": "plane.bgtasks.workspace_sprint_task.process_workspace_sprint_automations", + "schedule": crontab(hour=4, minute=0), # UTC 04:00 + }, } diff --git a/apps/api/plane/db/migrations/0122_workspace_sprint.py b/apps/api/plane/db/migrations/0122_workspace_sprint.py new file mode 100644 index 00000000000..9b0469c3d0c --- /dev/null +++ b/apps/api/plane/db/migrations/0122_workspace_sprint.py @@ -0,0 +1,196 @@ +# Generated by Cursor on 2026-06-19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("db", "0121_alter_estimate_type"), + ] + + operations = [ + migrations.CreateModel( + name="WorkspaceSprint", + 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")), + ("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted 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="Sprint Name")), + ("description", models.TextField(blank=True, verbose_name="Sprint Description")), + ("start_date", models.DateTimeField(blank=True, null=True, verbose_name="Start Date")), + ("end_date", models.DateTimeField(blank=True, null=True, verbose_name="End Date")), + ("sort_order", models.FloatField(default=65535)), + ("archived_at", models.DateTimeField(null=True)), + ("logo_props", models.JSONField(default=dict)), + ("timezone", models.CharField(default="UTC", max_length=255)), + ("version", models.IntegerField(default=1)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprint_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owned_workspace_sprints", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_workspacesprint", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprint_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_workspacesprint", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Sprint", + "verbose_name_plural": "Workspace Sprints", + "db_table": "workspace_sprints", + "ordering": ("-created_at",), + "abstract": False, + }, + ), + migrations.CreateModel( + name="WorkspaceSprintIssue", + 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")), + ("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted At")), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprintissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_workspace_sprint", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_workspacesprintissue", + to="db.project", + ), + ), + ( + "sprint", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sprint_issues", + to="db.workspacesprint", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprintissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_workspacesprintissue", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Sprint Issue", + "verbose_name_plural": "Workspace Sprint Issues", + "db_table": "workspace_sprint_issues", + "ordering": ("-created_at",), + "abstract": False, + }, + ), + migrations.AlterUniqueTogether( + name="workspacesprintissue", + unique_together={("issue", "sprint", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="workspacesprintissue", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("sprint", "issue"), + name="workspace_sprint_issue_when_deleted_at_null", + ), + ), + migrations.AddConstraint( + model_name="workspacesprintissue", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("issue",), + name="workspace_sprint_issue_unique_active_issue", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0123_workspace_sprint_automation.py b/apps/api/plane/db/migrations/0123_workspace_sprint_automation.py new file mode 100644 index 00000000000..58884a87fe7 --- /dev/null +++ b/apps/api/plane/db/migrations/0123_workspace_sprint_automation.py @@ -0,0 +1,109 @@ +# Generated by Cursor on 2026-06-19 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0122_workspace_sprint"), + ] + + operations = [ + migrations.CreateModel( + name="WorkspaceSprintAutomation", + 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")), + ("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted 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="Sprint Group Name")), + ("description", models.TextField(blank=True, verbose_name="Sprint Group Description")), + ("enabled", models.BooleanField(default=True)), + ("start_date", models.DateTimeField(verbose_name="Automation Start Date")), + ("sprint_duration_days", models.PositiveIntegerField(default=14)), + ("timezone", models.CharField(default="UTC", max_length=255)), + ("name_template", models.CharField(default="Sprint {{number}}", max_length=255)), + ("next_sequence", models.IntegerField(default=1)), + ("auto_create_next", models.BooleanField(default=True)), + ("sort_order", models.FloatField(default=65535)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprintautomation_created_by", + to="db.user", + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprintautomation_updated_by", + to="db.user", + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_workspacesprintautomation", + to="db.workspace", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_workspacesprintautomation", + to="db.project", + ), + ), + ], + options={ + "verbose_name": "Workspace Sprint Automation", + "verbose_name_plural": "Workspace Sprint Automations", + "db_table": "workspace_sprint_automations", + "ordering": ("sort_order", "-created_at"), + "abstract": False, + }, + ), + migrations.AddField( + model_name="workspacesprint", + name="automation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sprints", + to="db.workspacesprintautomation", + ), + ), + migrations.AddField( + model_name="workspacesprint", + name="source", + field=models.CharField(default="manual", max_length=50), + ), + migrations.AddField( + model_name="workspacesprint", + name="sequence_id", + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0124_alter_workspacesprint_created_by_and_more.py b/apps/api/plane/db/migrations/0124_alter_workspacesprint_created_by_and_more.py new file mode 100644 index 00000000000..e8d69482fc0 --- /dev/null +++ b/apps/api/plane/db/migrations/0124_alter_workspacesprint_created_by_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.30 on 2026-06-19 17:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0123_workspace_sprint_automation'), + ] + + operations = [ + migrations.AlterField( + model_name='workspacesprint', + name='created_by', + field=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'), + ), + migrations.AlterField( + model_name='workspacesprint', + name='project', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='workspacesprint', + name='updated_by', + field=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'), + ), + migrations.AlterField( + model_name='workspacesprint', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='workspacesprintautomation', + name='created_by', + field=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'), + ), + migrations.AlterField( + model_name='workspacesprintautomation', + name='project', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='workspacesprintautomation', + name='updated_by', + field=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'), + ), + migrations.AlterField( + model_name='workspacesprintautomation', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + migrations.AlterField( + model_name='workspacesprintissue', + name='created_by', + field=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'), + ), + migrations.AlterField( + model_name='workspacesprintissue', + name='project', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), + ), + migrations.AlterField( + model_name='workspacesprintissue', + name='updated_by', + field=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'), + ), + migrations.AlterField( + model_name='workspacesprintissue', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), + ), + ] diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index 5cf9dec2a3e..137e0d47696 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -62,6 +62,7 @@ from .session import Session from .social_connection import SocialLoginConnection from .state import State, StateGroup, DEFAULT_STATES +from .sprint import WorkspaceSprint, WorkspaceSprintAutomation, WorkspaceSprintIssue from .user import Account, Profile, User, BotTypeEnum from .view import IssueView from .webhook import Webhook, WebhookLog diff --git a/apps/api/plane/db/models/sprint.py b/apps/api/plane/db/models/sprint.py new file mode 100644 index 00000000000..08bff2f89e4 --- /dev/null +++ b/apps/api/plane/db/models/sprint.py @@ -0,0 +1,123 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Django imports +from django.conf import settings +from django.db import models + +# Module imports +from .workspace import WorkspaceBaseModel + + +class WorkspaceSprint(WorkspaceBaseModel): + name = models.CharField(max_length=255, verbose_name="Sprint Name") + description = models.TextField(verbose_name="Sprint Description", blank=True) + start_date = models.DateTimeField(verbose_name="Start Date", blank=True, null=True) + end_date = models.DateTimeField(verbose_name="End Date", blank=True, null=True) + automation = models.ForeignKey( + "db.WorkspaceSprintAutomation", + on_delete=models.SET_NULL, + related_name="sprints", + null=True, + blank=True, + ) + source = models.CharField(max_length=50, default="manual") + sequence_id = models.IntegerField(null=True, blank=True) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="owned_workspace_sprints", + ) + sort_order = models.FloatField(default=65535) + archived_at = models.DateTimeField(null=True) + logo_props = models.JSONField(default=dict) + timezone = models.CharField(max_length=255, default="UTC") + version = models.IntegerField(default=1) + + class Meta: + verbose_name = "Workspace Sprint" + verbose_name_plural = "Workspace Sprints" + db_table = "workspace_sprints" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self._state.adding: + smallest_sort_order = WorkspaceSprint.objects.filter(workspace=self.workspace).aggregate( + smallest=models.Min("sort_order") + )["smallest"] + + if smallest_sort_order is not None: + self.sort_order = smallest_sort_order - 10000 + + super(WorkspaceSprint, self).save(*args, **kwargs) + + def __str__(self): + return f"{self.name} <{self.workspace.name}>" + + +class WorkspaceSprintAutomation(WorkspaceBaseModel): + name = models.CharField(max_length=255, verbose_name="Sprint Group Name") + description = models.TextField(verbose_name="Sprint Group Description", blank=True) + enabled = models.BooleanField(default=True) + start_date = models.DateTimeField(verbose_name="Automation Start Date") + sprint_duration_days = models.PositiveIntegerField(default=14) + timezone = models.CharField(max_length=255, default="UTC") + name_template = models.CharField(max_length=255, default="Sprint {{number}}") + next_sequence = models.IntegerField(default=1) + auto_create_next = models.BooleanField(default=True) + + class Meta: + verbose_name = "Workspace Sprint Automation" + verbose_name_plural = "Workspace Sprint Automations" + db_table = "workspace_sprint_automations" + ordering = ("sort_order", "-created_at") + + sort_order = models.FloatField(default=65535) + + def save(self, *args, **kwargs): + if self._state.adding: + smallest_sort_order = WorkspaceSprintAutomation.objects.filter(workspace=self.workspace).aggregate( + smallest=models.Min("sort_order") + )["smallest"] + + if smallest_sort_order is not None: + self.sort_order = smallest_sort_order - 10000 + + super(WorkspaceSprintAutomation, self).save(*args, **kwargs) + + def __str__(self): + return f"{self.name} <{self.workspace.name}>" + + +class WorkspaceSprintIssue(WorkspaceBaseModel): + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_workspace_sprint") + sprint = models.ForeignKey(WorkspaceSprint, on_delete=models.CASCADE, related_name="sprint_issues") + + class Meta: + unique_together = ["issue", "sprint", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["sprint", "issue"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_sprint_issue_when_deleted_at_null", + ), + models.UniqueConstraint( + fields=["issue"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_sprint_issue_unique_active_issue", + ), + ] + verbose_name = "Workspace Sprint Issue" + verbose_name_plural = "Workspace Sprint Issues" + db_table = "workspace_sprint_issues" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self.issue_id: + self.project = self.issue.project + self.workspace = self.issue.workspace + super(WorkspaceSprintIssue, self).save(*args, **kwargs) + + def __str__(self): + return f"{self.issue} <{self.sprint}>" diff --git a/apps/api/plane/tests/contract/app/test_workspace_sprints.py b/apps/api/plane/tests/contract/app/test_workspace_sprints.py new file mode 100644 index 00000000000..a5c9ee0f27f --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_workspace_sprints.py @@ -0,0 +1,124 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from rest_framework import status + +from plane.db.models import Cycle, CycleIssue, Issue, Project, ProjectMember, WorkspaceSprint, WorkspaceSprintIssue + + +@pytest.fixture +def project_factory(db, workspace, create_user): + def _create_project(name="Test Project", identifier="TP"): + project = Project.objects.create( + name=name, + identifier=identifier, + workspace=workspace, + created_by=create_user, + cycle_view=True, + ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + return project + + return _create_project + + +@pytest.fixture +def sprint(workspace, create_user): + return WorkspaceSprint.objects.create(name="Global Sprint", workspace=workspace, owned_by=create_user) + + +@pytest.mark.contract +class TestWorkspaceSprintAPI: + def get_sprint_url(self, workspace_slug, sprint_id=None): + base_url = f"/api/workspaces/{workspace_slug}/sprints/" + return f"{base_url}{sprint_id}/" if sprint_id else base_url + + def get_sprint_issue_url(self, workspace_slug, sprint_id, issue_id=None): + base_url = f"/api/workspaces/{workspace_slug}/sprints/{sprint_id}/issues/" + return f"{base_url}{issue_id}/" if issue_id else base_url + + @pytest.mark.django_db + def test_create_and_list_workspace_sprints(self, session_client, workspace): + response = session_client.post( + self.get_sprint_url(workspace.slug), + {"name": "Sprint 1", "description": "Workspace-level sprint"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == "Sprint 1" + assert str(response.data["workspace_id"]) == str(workspace.id) + + response = session_client.get(self.get_sprint_url(workspace.slug)) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["total_issues"] == 0 + + @pytest.mark.django_db + def test_add_issues_from_multiple_projects_to_workspace_sprint( + self, session_client, workspace, sprint, project_factory + ): + project_one = project_factory(name="Project One", identifier="P1") + project_two = project_factory(name="Project Two", identifier="P2") + issue_one = Issue.objects.create(name="Issue One", project=project_one, workspace=workspace) + issue_two = Issue.objects.create(name="Issue Two", project=project_two, workspace=workspace) + + response = session_client.post( + self.get_sprint_issue_url(workspace.slug, sprint.id), + {"issue_id": str(issue_one.id)}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + + response = session_client.post( + self.get_sprint_issue_url(workspace.slug, sprint.id), + {"issue_id": str(issue_two.id)}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert WorkspaceSprintIssue.objects.filter(sprint=sprint, deleted_at__isnull=True).count() == 2 + + @pytest.mark.django_db + def test_workspace_sprint_coexists_with_project_cycle(self, session_client, workspace, sprint, project_factory): + project = project_factory() + issue = Issue.objects.create(name="Issue with cycle", project=project, workspace=workspace) + cycle = Cycle.objects.create(name="Project Cycle", project=project, workspace=workspace, owned_by=project.created_by) + CycleIssue.objects.create(issue=issue, cycle=cycle, project=project, workspace=workspace) + + response = session_client.post( + self.get_sprint_issue_url(workspace.slug, sprint.id), + {"issue_id": str(issue.id)}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert CycleIssue.objects.filter(issue=issue, cycle=cycle, deleted_at__isnull=True).exists() + assert WorkspaceSprintIssue.objects.filter(issue=issue, sprint=sprint, deleted_at__isnull=True).exists() + + @pytest.mark.django_db + def test_issue_moves_between_workspace_sprints(self, session_client, workspace, sprint, project_factory, create_user): + project = project_factory() + issue = Issue.objects.create(name="Issue", project=project, workspace=workspace) + next_sprint = WorkspaceSprint.objects.create(name="Next Sprint", workspace=workspace, owned_by=create_user) + + response = session_client.post( + self.get_sprint_issue_url(workspace.slug, sprint.id), + {"issue_id": str(issue.id)}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + + response = session_client.post( + self.get_sprint_issue_url(workspace.slug, next_sprint.id), + {"issue_id": str(issue.id)}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert not WorkspaceSprintIssue.objects.filter(issue=issue, sprint=sprint, deleted_at__isnull=True).exists() + assert WorkspaceSprintIssue.objects.filter(issue=issue, sprint=next_sprint, deleted_at__isnull=True).exists() diff --git a/apps/api/plane/utils/filters/converters.py b/apps/api/plane/utils/filters/converters.py index 4d37c2b0b17..aeb01946672 100644 --- a/apps/api/plane/utils/filters/converters.py +++ b/apps/api/plane/utils/filters/converters.py @@ -16,6 +16,7 @@ class LegacyToRichFiltersConverter: "state": "state_id", "labels": "label_id", "cycle": "cycle_id", + "global_sprint_id": "global_sprint_id", "module": "module_id", "assignees": "assignee_id", "mentions": "mention_id", @@ -32,6 +33,7 @@ class LegacyToRichFiltersConverter: "state_id", "label_id", "cycle_id", + "global_sprint_id", "module_id", "assignee_id", "mention_id", diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py index 721bf4c7afd..35c9d633d5d 100644 --- a/apps/api/plane/utils/filters/filterset.py +++ b/apps/api/plane/utils/filters/filterset.py @@ -130,6 +130,9 @@ class IssueFilterSet(BaseFilterSet): cycle_id = filters.UUIDFilter(method="filter_cycle_id") cycle_id__in = UUIDInFilter(method="filter_cycle_id_in", lookup_expr="in") + global_sprint_id = filters.UUIDFilter(method="filter_global_sprint_id") + global_sprint_id__in = UUIDInFilter(method="filter_global_sprint_id_in", lookup_expr="in") + module_id = filters.UUIDFilter(method="filter_module_id") module_id__in = UUIDInFilter(method="filter_module_id_in", lookup_expr="in") @@ -209,6 +212,20 @@ def filter_cycle_id_in(self, queryset, name, value): issue_cycle__deleted_at__isnull=True, ) + def filter_global_sprint_id(self, queryset, name, value): + """Filter by workspace sprint ID, excluding soft deleted sprint links""" + return Q( + issue_workspace_sprint__sprint_id=value, + issue_workspace_sprint__deleted_at__isnull=True, + ) + + def filter_global_sprint_id_in(self, queryset, name, value): + """Filter by workspace sprint IDs (in), excluding soft deleted sprint links""" + return Q( + issue_workspace_sprint__sprint_id__in=value, + issue_workspace_sprint__deleted_at__isnull=True, + ) + def filter_module_id(self, queryset, name, value): """Filter by module ID, excluding soft deleted modules""" return Q( diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index c529c4efe9f..adaddd511a4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -14,6 +14,7 @@ import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/f import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions"; import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; +import { SidebarSprintsList } from "@/components/workspace/sidebar/sprints-list"; // hooks import { useFavorite } from "@/hooks/store/use-favorite"; import { useUserPermissions } from "@/hooks/store/user"; @@ -42,6 +43,8 @@ export const AppSidebar = observer(function AppSidebar() { {/* Projects List */} + {/* Sprints List */} + ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/[sprintId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/[sprintId]/page.tsx new file mode 100644 index 00000000000..7f550a71caf --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/[sprintId]/page.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { PageHead } from "@/components/core/page-title"; +import { WorkspaceSprintsList } from "@/components/workspace/sprints/sprints-list"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; + +function WorkspaceSprintDetailPage() { + const { sprintId } = useParams(); + const { getSprintAutomationById } = useWorkspaceSprint(); + const automationId = sprintId?.toString(); + const automation = getSprintAutomationById(automationId); + + if (!automationId) return null; + + return ( + <> + + + + ); +} + +export default observer(WorkspaceSprintDetailPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/header.tsx new file mode 100644 index 00000000000..458e4ac8aaa --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/header.tsx @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useEffect, useMemo } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { CycleIcon, WorkItemsIcon } from "@plane/propel/icons"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types"; +import { Breadcrumbs, Header } from "@plane/ui"; +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper"; + +const SPRINT_VIEW_ID = "all-issues"; + +const formatSprintWeek = (startDate: string | null, endDate: string | null) => { + if (!startDate && !endDate) return undefined; + + const formatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }); + const start = startDate ? formatter.format(new Date(startDate)) : "No start"; + const end = endDate ? formatter.format(new Date(endDate)) : "No end"; + + return `${start} - ${end}`; +}; + +export const WorkspaceSprintsHeader = observer(function WorkspaceSprintsHeader() { + const router = useAppRouter(); + const { workspaceSlug, workspaceSprintId } = useParams(); + const sprintId = workspaceSprintId?.toString(); + const workspaceSlugValue = workspaceSlug?.toString(); + const { t } = useTranslation(); + + const { + issuesFilter: { filters, updateFilters }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { toggleCreateIssueModal } = useCommandPalette(); + const { fetchWorkspaceSprints, getSprintById } = useWorkspaceSprint(); + + const sprint = getSprintById(sprintId); + const issueFilters = filters[SPRINT_VIEW_ID]; + const activeLayout = issueFilters?.displayFilters?.layout; + const sprintWeek = sprint ? formatSprintWeek(sprint.start_date, sprint.end_date) : undefined; + + useEffect(() => { + if (workspaceSlugValue) fetchWorkspaceSprints(workspaceSlugValue); + }, [fetchWorkspaceSprints, workspaceSlugValue]); + + const currentLayoutFilters = useMemo(() => { + const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET; + const layoutFilters = ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions[layout]; + + if (!sprintId || !layoutFilters?.display_properties) return layoutFilters; + + return { + ...layoutFilters, + display_properties: layoutFilters.display_properties.filter((property) => property !== "sprint"), + }; + }, [activeLayout, sprintId]); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlugValue) return; + updateFilters( + workspaceSlugValue, + undefined, + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + SPRINT_VIEW_ID + ); + }, + [updateFilters, workspaceSlugValue] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlugValue) return; + updateFilters(workspaceSlugValue, undefined, EIssueFilterType.DISPLAY_PROPERTIES, property, SPRINT_VIEW_ID); + }, + [updateFilters, workspaceSlugValue] + ); + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlugValue) return; + updateFilters(workspaceSlugValue, undefined, EIssueFilterType.DISPLAY_FILTERS, { layout }, SPRINT_VIEW_ID); + }, + [updateFilters, workspaceSlugValue] + ); + + return ( + <> +
+ + router.back()} className="flex-grow-0"> + } + /> + } + /> + } + isLast + /> + } + isLast + /> + + {sprintWeek && {sprintWeek}} + + + {sprintId && workspaceSlugValue && ( + <> + + + + + + + + )} + +
+ + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/layout.tsx new file mode 100644 index 00000000000..a6d7d21eb1a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/layout.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Outlet } from "react-router"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { WorkspaceSprintsHeader } from "./header"; + +export default function WorkspaceSprintsLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/page.tsx new file mode 100644 index 00000000000..0d285a29488 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/page.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { PageHead } from "@/components/core/page-title"; + +export default function WorkspaceSprintsPage() { + return ( + <> + +
+
+

Select a sprint

+

+ Create or select a sprint group from the sidebar to manage open and archived sprints. +

+
+
+ + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/work-items/[workspaceSprintId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/work-items/[workspaceSprintId]/page.tsx new file mode 100644 index 00000000000..02c249561fa --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/work-items/[workspaceSprintId]/page.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants"; +import { PageHead } from "@/components/core/page-title"; +import { AllIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/all-issue-layout-root"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; +import type { Route } from "./+types/page"; + +function WorkspaceSprintWorkItemsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, workspaceSprintId } = params; + const [isLoading, setIsLoading] = useState(false); + const { fetchWorkspaceSprints, getSprintById } = useWorkspaceSprint(); + + const sprint = getSprintById(workspaceSprintId); + const defaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === "all-issues"); + + useEffect(() => { + fetchWorkspaceSprints(workspaceSlug); + }, [fetchWorkspaceSprints, workspaceSlug]); + + return ( + <> + + + + ); +} + +export default observer(WorkspaceSprintWorkItemsPage); diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index c9c82bd2475..928e0645322 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -114,6 +114,16 @@ export const coreRoutes: RouteConfigEntry[] = [ ), ]), + // Workspace Sprints + layout("./(all)/[workspaceSlug]/(projects)/sprints/layout.tsx", [ + route(":workspaceSlug/sprints", "./(all)/[workspaceSlug]/(projects)/sprints/page.tsx"), + route( + ":workspaceSlug/sprints/work-items/:workspaceSprintId", + "./(all)/[workspaceSlug]/(projects)/sprints/work-items/[workspaceSprintId]/page.tsx" + ), + route(":workspaceSlug/sprints/:sprintId", "./(all)/[workspaceSlug]/(projects)/sprints/[sprintId]/page.tsx"), + ]), + // Archived Projects layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ route( diff --git a/apps/web/ce/components/issues/issue-layouts/utils.tsx b/apps/web/ce/components/issues/issue-layouts/utils.tsx index 0a0ddf7f728..972d0ef1b1a 100644 --- a/apps/web/ce/components/issues/issue-layouts/utils.tsx +++ b/apps/web/ce/components/issues/issue-layouts/utils.tsx @@ -40,6 +40,7 @@ import { SpreadsheetCycleColumn, SpreadsheetLinkColumn, SpreadsheetPriorityColumn, + SpreadsheetSprintColumn, SpreadsheetStartDateColumn, SpreadsheetStateColumn, SpreadsheetSubIssueColumn, @@ -102,6 +103,7 @@ export const SPREADSHEET_COLUMNS: { [key in keyof IIssueDisplayProperties]: TSpr labels: SpreadsheetLabelColumn, modules: SpreadsheetModuleColumn, cycle: SpreadsheetCycleColumn, + sprint: SpreadsheetSprintColumn, link: SpreadsheetLinkColumn, priority: SpreadsheetPriorityColumn, start_date: SpreadsheetStartDateColumn, diff --git a/apps/web/core/components/dropdowns/workspace-sprint/index.tsx b/apps/web/core/components/dropdowns/workspace-sprint/index.tsx new file mode 100644 index 00000000000..4709f0e5332 --- /dev/null +++ b/apps/web/core/components/dropdowns/workspace-sprint/index.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { ChevronDownIcon, CycleIcon } from "@plane/propel/icons"; +import { ComboDropDown } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; +import { useDropdown } from "@/hooks/use-dropdown"; +import { WorkspaceSprintOptions } from "./sprint-options"; + +type Props = { + value: string | null; + onChange: (sprintId: string | null) => void; + disabled?: boolean; + className?: string; +}; + +export const WorkspaceSprintDropdown = observer(function WorkspaceSprintDropdown(props: Props) { + const { value, onChange, disabled = false, className } = props; + const [isOpen, setIsOpen] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const dropdownRef = useRef(null); + const { getSprintById } = useWorkspaceSprint(); + const selectedName = value ? getSprintById(value)?.name : null; + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + setIsOpen, + }); + + const dropdownOnChange = (sprintId: string | null) => { + onChange(sprintId); + handleClose(); + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + + + {selectedName ?? "No sprint"} + + ); +}); diff --git a/apps/web/core/components/dropdowns/workspace-sprint/sprint-options.tsx b/apps/web/core/components/dropdowns/workspace-sprint/sprint-options.tsx new file mode 100644 index 00000000000..fe84ff20fe8 --- /dev/null +++ b/apps/web/core/components/dropdowns/workspace-sprint/sprint-options.tsx @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect, useRef, useState } from "react"; +import type { Placement } from "@popperjs/core"; +import { Combobox } from "@headlessui/react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { usePopper } from "react-popper"; +import { CheckIcon, CycleIcon, SearchIcon } from "@plane/propel/icons"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +type Props = { + isOpen: boolean; + placement?: Placement; + referenceElement: HTMLButtonElement | null; +}; + +export const WorkspaceSprintOptions = observer(function WorkspaceSprintOptions(props: Props) { + const { isOpen, placement, referenceElement } = props; + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + const inputRef = useRef(null); + const { workspaceSlug } = useParams(); + const { currentWorkspaceSprintIds, fetchedMap, fetchWorkspaceSprints, getSprintById } = useWorkspaceSprint(); + const { isMobile } = usePlatformOS(); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + useEffect(() => { + if (!isOpen || !workspaceSlug) return; + const slug = workspaceSlug.toString(); + if (!fetchedMap[slug]) fetchWorkspaceSprints(slug); + if (!isMobile) inputRef.current?.focus(); + }, [fetchWorkspaceSprints, fetchedMap, isMobile, isOpen, workspaceSlug]); + + const sprintIds = currentWorkspaceSprintIds ?? []; + const options = [ + { + value: null, + query: "No sprint", + content: ( +
+ + No sprint +
+ ), + }, + ...sprintIds.map((sprintId: string) => { + const sprint = getSprintById(sprintId); + return { + value: sprintId, + query: sprint?.name ?? "Sprint", + content: ( +
+ + {sprint?.name ?? "Sprint"} +
+ ), + }; + }), + ]; + const filteredOptions = + query === "" ? options : options.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(event.target.value)} + placeholder="Search" + onKeyDown={(event) => { + if (query !== "" && event.key === "Escape") { + event.stopPropagation(); + setQuery(""); + } + }} + /> +
+
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none ${ + active ? "bg-layer-transparent-hover" : "" + } ${selected ? "text-primary" : "text-secondary"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matches found

+ )} +
+
+
+ ); +}); diff --git a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx index 942017ca384..28c2a42ee7f 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -4,6 +4,8 @@ * See the LICENSE file for details. */ +/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ + import type { SyntheticEvent } from "react"; import { useCallback, useMemo } from "react"; import { xor } from "lodash-es"; @@ -33,12 +35,14 @@ import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { ModuleDropdown } from "@/components/dropdowns/module/dropdown"; import { PriorityDropdown } from "@/components/dropdowns/priority"; import { StateDropdown } from "@/components/dropdowns/state/dropdown"; +import { WorkspaceSprintDropdown } from "@/components/dropdowns/workspace-sprint"; // hooks import { useProjectEstimates } from "@/hooks/store/estimates"; import { useIssues } from "@/hooks/store/use-issues"; import { useLabel } from "@/hooks/store/use-label"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; import { useAppRouter } from "@/hooks/use-app-router"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -58,6 +62,11 @@ export interface IIssueProperties { isEpic?: boolean; } +const handleEventPropagation = (e: SyntheticEvent) => { + e.stopPropagation(); + e.preventDefault(); +}; + export const IssueProperties = observer(function IssueProperties(props: IIssueProperties) { const { issue, updateIssue, displayProperties, isReadOnly, className, isEpic = false } = props; // i18n @@ -72,6 +81,7 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr const { issues: { addCycleToIssue, removeCycleFromIssue }, } = useIssues(storeType); + const { addIssueToSprint, removeIssueFromSprint } = useWorkspaceSprint(); const { areEstimateEnabledByProjectId } = useProjectEstimates(); const { getStateById } = useProjectState(); const { isMobile } = usePlatformOS(); @@ -103,8 +113,24 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr if (!workspaceSlug || !issue.project_id || !issue.id) return; await removeCycleFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id); }, + addIssueToSprint: async (sprintId: string) => { + if (!workspaceSlug || !issue.id) return; + await addIssueToSprint(workspaceSlug.toString(), sprintId, issue.id); + }, + removeIssueFromSprint: async () => { + if (!workspaceSlug || !issue.id || !issue.global_sprint_id) return; + await removeIssueFromSprint(workspaceSlug.toString(), issue.global_sprint_id, issue.id); + }, }), - [workspaceSlug, issue, changeModulesInIssue, addCycleToIssue, removeCycleFromIssue] + [ + workspaceSlug, + issue, + changeModulesInIssue, + addCycleToIssue, + removeCycleFromIssue, + addIssueToSprint, + removeIssueFromSprint, + ] ); const handleState = async (stateId: string) => { @@ -148,6 +174,15 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr [issue, issueOperations] ); + const handleSprint = useCallback( + (sprintId: string | null) => { + if (!issue || issue.global_sprint_id === sprintId) return; + if (sprintId) issueOperations.addIssueToSprint?.(sprintId); + else issueOperations.removeIssueFromSprint?.(); + }, + [issue, issueOperations] + ); + const handleStartDate = async (date: Date | null) => { if (updateIssue) await updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }); @@ -186,11 +221,6 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr const minDate = getDate(issue.start_date); const maxDate = getDate(issue.target_date); - const handleEventPropagation = (e: SyntheticEvent) => { - e.stopPropagation(); - e.preventDefault(); - }; - return (
{/* basic properties */} @@ -369,6 +399,18 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
)} + + {/* sprint */} + +
+ +
+
)} diff --git a/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index a7bad0857bb..8959ebaa375 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -27,18 +27,20 @@ import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; type Props = { + globalViewIdOverride?: string; isDefaultView: boolean; isLoading?: boolean; + routeFiltersOverride?: { [key: string]: string }; toggleLoading: (value: boolean) => void; }; export const AllIssueLayoutRoot = observer(function AllIssueLayoutRoot(props: Props) { - const { isDefaultView, isLoading = false, toggleLoading } = props; + const { globalViewIdOverride, isDefaultView, isLoading = false, routeFiltersOverride, toggleLoading } = props; // router const router = useAppRouter(); const { workspaceSlug: routerWorkspaceSlug, globalViewId: routerGlobalViewId } = useParams(); const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined; - const globalViewId = routerGlobalViewId ? routerGlobalViewId.toString() : undefined; + const globalViewId = globalViewIdOverride ?? (routerGlobalViewId ? routerGlobalViewId.toString() : undefined); // search params const searchParams = useSearchParams(); // store hooks @@ -76,6 +78,10 @@ export const AllIssueLayoutRoot = observer(function AllIssueLayoutRoot(props: Pr searchParams.forEach((value: string, key: string) => { routeFilters[key] = value; }); + if (routeFiltersOverride) { + Object.assign(routeFilters, routeFiltersOverride); + } + const routeFilterKey = JSON.stringify(routeFilters); // Fetch next pages callback const fetchNextPages = useCallback(() => { @@ -95,16 +101,24 @@ export const AllIssueLayoutRoot = observer(function AllIssueLayoutRoot(props: Pr // Fetch issues const { isLoading: issuesLoading } = useSWR( - workspaceSlug && globalViewId ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}` : null, + workspaceSlug && globalViewId + ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}_${routeFilterKey}` + : null, async () => { if (workspaceSlug && globalViewId) { clear(); toggleLoading(true); await fetchFilters(workspaceSlug, globalViewId); - await fetchIssues(workspaceSlug, globalViewId, groupedIssueIds ? "mutation" : "init-loader", { - canGroup: false, - perPageCount: 100, - }); + await fetchIssues( + workspaceSlug, + globalViewId, + groupedIssueIds ? "mutation" : "init-loader", + { + canGroup: false, + perPageCount: 100, + }, + routeFilters + ); toggleLoading(false); } }, diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts index e02316d5147..9b27724d803 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/index.ts @@ -14,6 +14,7 @@ export * from "./link-column"; export * from "./priority-column"; export * from "./start-date-column"; export * from "./state-column"; +export * from "./sprint-column"; export * from "./sub-issue-column"; export * from "./updated-on-column"; export * from "./module-column"; diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/sprint-column.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/sprint-column.tsx new file mode 100644 index 00000000000..213f0f5fcbe --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/sprint-column.tsx @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import type { TIssue } from "@plane/types"; +import { WorkspaceSprintDropdown } from "@/components/dropdowns/workspace-sprint"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; + +type Props = { + issue: TIssue; + disabled: boolean; +}; + +export const SpreadsheetSprintColumn = observer(function SpreadsheetSprintColumn(props: Props) { + const { issue, disabled } = props; + const { workspaceSlug } = useParams(); + const { addIssueToSprint, removeIssueFromSprint } = useWorkspaceSprint(); + + const handleSprint = useCallback( + async (sprintId: string | null) => { + if (!workspaceSlug || !issue || issue.global_sprint_id === sprintId) return; + if (sprintId) await addIssueToSprint(workspaceSlug.toString(), sprintId, issue.id); + else if (issue.global_sprint_id) + await removeIssueFromSprint(workspaceSlug.toString(), issue.global_sprint_id, issue.id); + }, + [workspaceSlug, issue, addIssueToSprint, removeIssueFromSprint] + ); + + return ( +
+ +
+ ); +}); diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx index b1201baf098..3254a19a65b 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx @@ -38,7 +38,7 @@ type Props = { }; export const WorkspaceSpreadsheetRoot = observer(function WorkspaceSpreadsheetRoot(props: Props) { - const { isLoading = false, workspaceSlug, globalViewId, fetchNextPages, issuesLoading } = props; + const { isLoading = false, workspaceSlug, globalViewId, routeFilters, fetchNextPages, issuesLoading } = props; // Custom hooks useWorkspaceIssueProperties(workspaceSlug); @@ -53,6 +53,9 @@ export const WorkspaceSpreadsheetRoot = observer(function WorkspaceSpreadsheetRo // Derived values const issueFilters = globalViewId ? filters?.[globalViewId.toString()] : undefined; + const displayProperties = routeFilters.global_sprint_id + ? { ...issueFilters?.displayProperties, sprint: false } + : (issueFilters?.displayProperties ?? {}); // Permission checker const canEditProperties = useCallback( @@ -115,7 +118,7 @@ export const WorkspaceSpreadsheetRoot = observer(function WorkspaceSpreadsheetRo return ( { + if (!workspaceSlugValue || automationFetchedMap[workspaceSlugValue]) return; + fetchWorkspaceSprintAutomations(workspaceSlugValue); + }, [automationFetchedMap, fetchWorkspaceSprintAutomations, workspaceSlugValue]); + + useEffect(() => { + if (pathname.includes("/sprints")) setIsOpen(true); + }, [pathname]); + + if (!workspaceSlugValue) return null; + + const automationIds = currentWorkspaceSprintAutomationIds ?? []; + + const handleOnAutomationDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropBelow: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlugValue || sourceId === destinationId) return; + + const sortedIds = automationIds.filter((automationId) => automationId !== sourceId); + const destinationIndex = sortedIds.indexOf(destinationId); + if (destinationIndex < 0) return; + + const insertIndex = shouldDropBelow ? destinationIndex + 1 : destinationIndex; + const previousAutomation = getSprintAutomationById(sortedIds[insertIndex - 1]); + const nextAutomation = getSprintAutomationById(sortedIds[insertIndex]); + + let sortOrder: number; + if (previousAutomation && nextAutomation) + sortOrder = (previousAutomation.sort_order + nextAutomation.sort_order) / 2; + else if (previousAutomation) sortOrder = previousAutomation.sort_order + 1000; + else if (nextAutomation) sortOrder = nextAutomation.sort_order - 1000; + else sortOrder = getSprintAutomationById(sourceId)?.sort_order ?? 1000; + + updateWorkspaceSprintAutomationSortOrder(workspaceSlugValue, sourceId, sortOrder).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Could not reorder sprints", + message: "Please try again.", + }); + }); + }; + + return ( + <> + setIsCreateModalOpen(false)} + onCreated={(automationId) => router.push(`/${workspaceSlugValue}/sprints/${automationId}`)} + /> + +
+ setIsOpen(!isOpen)} + aria-label={isOpen ? "Close sprints menu" : "Open sprints menu"} + > + Sprints + +
+ {canCreateSprint && ( + + setIsCreateModalOpen(true)} + className="hidden text-placeholder group-hover:inline-flex" + aria-label="Create sprint" + /> + + )} + setIsOpen(!isOpen)} + className="text-placeholder" + iconClassName={cn("transition-transform", { + "rotate-90": isOpen, + })} + aria-label={isOpen ? "Close sprints menu" : "Open sprints menu"} + /> +
+
+ + {isOpen && ( + + {automationIds.length > 0 ? ( + automationIds.map((automationId: string, index) => ( + + )) + ) : ( +
No sprints yet
+ )} +
+ )} +
+
+ + ); +}); + +type SprintGroupItemProps = { + automationId: string; + canReorder: boolean; + handleOnAutomationDrop: ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropBelow: boolean + ) => void; + isLastChild: boolean; + workspaceSlug: string; +}; + +const SidebarSprintGroupItem = observer(function SidebarSprintGroupItem(props: SprintGroupItemProps) { + const { automationId, canReorder, handleOnAutomationDrop, isLastChild, workspaceSlug } = props; + const [isOpen, setIsOpen] = useState(true); + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); + const itemRef = useRef(null); + const dragHandleRef = useRef(null); + const pathname = usePathname(); + const router = useRouter(); + const { isMobile } = usePlatformOS(); + const { fetchWorkspaceSprints, getSprintAutomationById, getSprintById, getSprintsByAutomationId } = + useWorkspaceSprint(); + + const automation = getSprintAutomationById(automationId); + const sprintIds = getSprintsByAutomationId(automationId); + const detailsHref = `/${workspaceSlug}/sprints/${automationId}`; + const { workspaceSprintId } = useParams(); + const selectedSprintId = workspaceSprintId?.toString(); + const hasSelectedSprint = selectedSprintId ? sprintIds.includes(selectedSprintId) : false; + + useEffect(() => { + fetchWorkspaceSprints(workspaceSlug, automationId); + }, [automationId, fetchWorkspaceSprints, workspaceSlug]); + + useEffect(() => { + if (pathname.includes(`/sprints/${automationId}`) || hasSelectedSprint) setIsOpen(true); + }, [automationId, hasSelectedSprint, pathname]); + + useEffect(() => { + const itemElement = itemRef.current; + const dragHandleElement = dragHandleRef.current; + if (!itemElement) return; + + return combine( + draggable({ + element: itemElement, + canDrag: () => canReorder, + dragHandle: dragHandleElement ?? undefined, + getInitialData: () => ({ id: automationId, dragInstanceId: "WORKSPACE_SPRINT_AUTOMATIONS" }), + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + dropTargetForElements({ + element: itemElement, + canDrop: ({ source }) => + canReorder && + source?.data?.id !== automationId && + source?.data?.dragInstanceId === "WORKSPACE_SPRINT_AUTOMATIONS", + getData: ({ input, element }) => + attachInstruction( + { id: automationId }, + { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + } + ), + onDrag: ({ self }) => { + const extractedInstruction = extractInstruction(self?.data)?.type; + setInstruction( + extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined + ); + }, + onDragLeave: () => setInstruction(undefined), + onDrop: ({ self, source }) => { + setInstruction(undefined); + const extractedInstruction = extractInstruction(self?.data)?.type; + const currentInstruction = extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined; + if (!currentInstruction) return; + + handleOnAutomationDrop( + source?.data?.id as string | undefined, + self?.data?.id as string | undefined, + currentInstruction === "DRAG_BELOW" + ); + }, + }) + ); + }, [automationId, canReorder, handleOnAutomationDrop, isLastChild]); + + if (!automation) return null; + + return ( + +
+ +
+ {canReorder && ( + + + + )} + +
+ + } + customButtonClassName="grid place-items-center" + placement="bottom-start" + ariaLabel="Sprint actions" + closeOnSelect + > + router.push(detailsHref)}> + + + View details + + + router.push(detailsHref)}> + + + Settings + + + + setIsOpen(!isOpen)} + className="hidden text-placeholder group-hover/sprint-item:inline-flex" + iconClassName={cn("transition-transform", { + "rotate-90": isOpen, + })} + aria-label={isOpen ? "Close sprint menu" : "Open sprint menu"} + /> +
+
+
+ + + {isOpen && ( + + {sprintIds.length > 0 ? ( + sprintIds.map((sprintId: string) => { + const sprint = getSprintById(sprintId); + if (!sprint) return null; + const href = `/${workspaceSlug}/sprints/work-items/${sprint.id}`; + return ( + + +
+ + {sprint.name} +
+
+ + ); + }) + ) : ( +
No open sprints
+ )} +
+ )} +
+
+ ); +}); diff --git a/apps/web/core/components/workspace/sprints/automation-modal.tsx b/apps/web/core/components/workspace/sprints/automation-modal.tsx new file mode 100644 index 00000000000..2eb1ac1a4cc --- /dev/null +++ b/apps/web/core/components/workspace/sprints/automation-modal.tsx @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { FormEvent } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { EModalPosition, EModalWidth, Input, ModalCore, TextArea } from "@plane/ui"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; + +type Props = { + isOpen: boolean; + onClose: () => void; + onCreated?: (automationId: string) => void; +}; + +const toDateTimeLocalValue = (date: Date) => { + const timezoneOffset = date.getTimezoneOffset() * 60000; + return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 16); +}; + +export const SprintAutomationModal = observer(function SprintAutomationModal(props: Props) { + const { isOpen, onClose, onCreated } = props; + const { workspaceSlug } = useParams(); + const { createWorkspaceSprintAutomation } = useWorkspaceSprint(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [startDate, setStartDate] = useState(toDateTimeLocalValue(new Date())); + const [duration, setDuration] = useState(14); + const [nameTemplate, setNameTemplate] = useState("Sprint {{number}}"); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleClose = () => { + if (isSubmitting) return; + onClose(); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!workspaceSlug || !name.trim()) return; + + try { + setIsSubmitting(true); + const automation = await createWorkspaceSprintAutomation(workspaceSlug.toString(), { + name: name.trim(), + description, + enabled: true, + start_date: new Date(startDate).toISOString(), + sprint_duration_days: duration, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + name_template: nameTemplate, + auto_create_next: true, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Sprint created", + message: "The sprint group and its active sprints are ready.", + }); + onCreated?.(automation.id); + onClose(); + setName(""); + setDescription(""); + setNameTemplate("Sprint {{number}}"); + setStartDate(toDateTimeLocalValue(new Date())); + setDuration(14); + } catch (_error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Could not create sprint", + message: "Please check the sprint configuration and try again.", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+
+

Create sprint

+

Configure the sprint group, duration, and automatic naming.

+
+
+ setName(event.target.value)} + placeholder="Sprint name" + className="w-full text-14" + required + /> +