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/.cursor/rules/pre-commit-validations.mdc b/.cursor/rules/pre-commit-validations.mdc new file mode 100644 index 00000000000..df7baff47f3 --- /dev/null +++ b/.cursor/rules/pre-commit-validations.mdc @@ -0,0 +1,22 @@ +--- +description: Validacoes obrigatorias antes de commit e PR +alwaysApply: true +--- + +# Pre-Commit e Validacoes + +Quando o usuario pedir para commitar, validar commit, preparar PR ou corrigir erro de hook, siga este fluxo. + +1. Use o hook real do repo: `.husky/pre-commit` roda `pnpm lint-staged`. Nao use `pre-commit run`, pois este repo nao usa `.pre-commit-config.yaml`. +2. Antes de rodar o hook, confira `git status --short --branch` e preserve alteracoes existentes do usuario. Nao reverta arquivos sem pedido explicito. +3. O `lint-staged` valida apenas arquivos staged. Se corrigir arquivos que fazem parte do commit, stageie essas correcoes antes de reexecutar `pnpm lint-staged`. +4. Trate warnings do `pnpm exec oxlint --fix --deny-warnings` como bloqueantes. Corrija a causa raiz em vez de ignorar o hook. +5. Para falhas comuns: + - `consistent-function-scoping`: mova funcoes que nao capturam escopo para fora do componente/funcao. + - `promise(always-return)`: retorne o valor dentro de `.then()` ou lance erro. + - `no-shadow`: renomeie variaveis locais/desestruturadas que colidem com nomes do escopo externo. + - `no-unneeded-ternary`: substitua `cond ? true : false` por `cond`. + - `no-map-spread`: use `Object.assign({}, item, updates)` quando precisar manter copy-on-write. +6. Depois que o hook passar, confira `git status --short --branch` e `git diff --cached --stat` para avisar quais arquivos ficaram staged. + +Se `oxfmt` ou `oxlint --fix` modificar arquivos, inclua essas modificacoes no stage antes de tentar o commit novamente. diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml new file mode 100644 index 00000000000..4201c8109da --- /dev/null +++ b/.github/workflows/docker-images.yml @@ -0,0 +1,175 @@ +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: + - development + - main + - master + - preview + - canary + +permissions: + contents: read + packages: write + +env: + PRODUCTION_APP_BASE_URL: ${{ vars.PRODUCTION_APP_BASE_URL || 'https://plane.agncflamingo.com.br' }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + name: Build ${{ matrix.image }} + if: github.ref_name != 'main' + 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 }} + build-args: | + VITE_API_BASE_URL=${{ env.PRODUCTION_APP_BASE_URL }} + VITE_ADMIN_BASE_URL=${{ env.PRODUCTION_APP_BASE_URL }} + VITE_SPACE_BASE_URL=${{ env.PRODUCTION_APP_BASE_URL }} + VITE_LIVE_BASE_URL=${{ env.PRODUCTION_APP_BASE_URL }} + VITE_WEB_BASE_URL=${{ env.PRODUCTION_APP_BASE_URL }} + + promote-main: + name: Promote ${{ matrix.image }} to main + if: github.ref_name == 'main' + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + image: + - plane-frontend + - plane-space + - plane-admin + - plane-live + - plane-backend + - plane-proxy + + steps: + - name: Prepare image variables + id: vars + shell: bash + run: | + image_namespace="${GITHUB_REPOSITORY,,}" + image="ghcr.io/${image_namespace}/${{ matrix.image }}" + + echo "source_image=${image}:development" >> "$GITHUB_OUTPUT" + echo "main_image=${image}:main" >> "$GITHUB_OUTPUT" + echo "latest_image=${image}:latest" >> "$GITHUB_OUTPUT" + echo "sha_image=${image}:sha-${GITHUB_SHA::7}" >> "$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: Promote development image + shell: bash + run: | + docker buildx imagetools inspect "${{ steps.vars.outputs.source_image }}" + docker buildx imagetools create \ + -t "${{ steps.vars.outputs.main_image }}" \ + -t "${{ steps.vars.outputs.latest_image }}" \ + -t "${{ steps.vars.outputs.sha_image }}" \ + "${{ steps.vars.outputs.source_image }}" + + deploy-dokploy: + name: Deploy Dokploy compose + if: github.ref_name == 'main' + needs: promote-main + runs-on: ubuntu-22.04 + steps: + - name: Trigger Dokploy deployment + env: + DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }} + DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} + DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_COMPOSE_ID }} + shell: bash + run: | + curl --fail-with-body --request POST "${DOKPLOY_URL}/api/compose.deploy" \ + --header "Content-Type: application/json" \ + --header "x-api-key: ${DOKPLOY_API_KEY}" \ + --data "{\"composeId\":\"${DOKPLOY_COMPOSE_ID}\"}" 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/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx index 51401f312ca..88c88fe9bc0 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -7,7 +7,7 @@ import { useState, useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { HelpCircle, MessageSquare, MoveLeft } from "lucide-react"; +import { HelpCircle, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; import { WEB_BASE_URL } from "@plane/constants"; // plane internal packages @@ -24,11 +24,6 @@ const helpOptions = [ href: "https://docs.plane.so/", Icon: PageIcon, }, - { - name: "Join our Forum", - href: "https://forum.plane.so", - Icon: MessageSquare, - }, { name: "Report a bug", href: "https://github.com/makeplane/plane/issues/new/choose", diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 4a202431bc7..ba5e6338d80 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -14,6 +14,7 @@ from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns from .sticky import urlpatterns as sticky_patterns +from .page import urlpatterns as page_patterns urlpatterns = [ *asset_patterns, @@ -28,4 +29,5 @@ *work_item_patterns, *invite_patterns, *sticky_patterns, + *page_patterns, ] diff --git a/apps/api/plane/api/urls/page.py b/apps/api/plane/api/urls/page.py new file mode 100644 index 00000000000..1b1efe5de28 --- /dev/null +++ b/apps/api/plane/api/urls/page.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views.page import PageViewSetV1 + +urlpatterns = [ + path( + "workspaces//projects//pages/", + PageViewSetV1.as_view({"get": "list", "post": "create"}), + name="project-pages-v1", + ), + path( + "workspaces//projects//pages//", + PageViewSetV1.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-pages-detail-v1", + ), +] diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py new file mode 100644 index 00000000000..f2a321ab04a --- /dev/null +++ b/apps/api/plane/api/views/page.py @@ -0,0 +1,12 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from plane.api.middleware.api_authentication import APIKeyAuthentication +from plane.api.rate_limit import ApiKeyRateThrottle +from plane.app.views.page.base import PageViewSet + + +class PageViewSetV1(PageViewSet): + authentication_classes = [APIKeyAuthentication] + throttle_classes = [ApiKeyRateThrottle] diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index e8a4007ea61..3c846f7903c 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -51,6 +51,17 @@ CycleWriteSerializer, CycleUserPropertiesSerializer, ) +from .sprint import ( + WorkspaceSprintSerializer, + WorkspaceSprintAutomationSerializer, + WorkspaceSprintAutomationMemberSerializer, + WorkspaceSprintAutomationWriteSerializer, + WorkspaceSprintSquadSerializer, + WorkspaceSprintSquadMemberSerializer, + WorkspaceSprintSquadWriteSerializer, + 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..b1c05472873 --- /dev/null +++ b/apps/api/plane/app/serializers/sprint.py @@ -0,0 +1,166 @@ +# 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 .user import UserLiteSerializer +from plane.db.models import WorkspaceSprint, WorkspaceSprintAutomation, WorkspaceSprintAutomationMember, 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() + member_ids = serializers.SerializerMethodField() + + class Meta: + model = WorkspaceSprintAutomation + fields = [ + "id", + "workspace_id", + "name", + "description", + "enabled", + "access", + "start_date", + "sprint_duration_days", + "timezone", + "name_template", + "next_sequence", + "auto_create_next", + "logo_props", + "archived_at", + "sort_order", + "member_ids", + "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() + + def get_member_ids(self, obj): + return list( + obj.automation_members.filter(deleted_at__isnull=True).values_list("member_id", flat=True) + ) + + +class WorkspaceSprintAutomationMemberSerializer(BaseSerializer): + member_detail = UserLiteSerializer(source="member", read_only=True) + + class Meta: + model = WorkspaceSprintAutomationMember + fields = ["id", "automation_id", "member", "member_detail", "created_at", "updated_at"] + read_only_fields = fields + + +# Canonical squad names with backwards-compatible automation aliases above. +WorkspaceSprintSquadWriteSerializer = WorkspaceSprintAutomationWriteSerializer +WorkspaceSprintSquadSerializer = WorkspaceSprintAutomationSerializer +WorkspaceSprintSquadMemberSerializer = WorkspaceSprintAutomationMemberSerializer + + +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..99c0535471c 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -28,6 +28,15 @@ ExportWorkspaceUserActivityEndpoint, WorkspaceModulesEndpoint, WorkspaceCyclesEndpoint, + WorkspaceSprintArchiveEndpoint, + WorkspaceSprintAutomationArchiveEndpoint, + WorkspaceSprintAutomationMemberEndpoint, + WorkspaceSprintAutomationViewSet, + WorkspaceSprintSquadArchiveEndpoint, + WorkspaceSprintSquadMemberEndpoint, + WorkspaceSprintSquadViewSet, + WorkspaceSprintIssueViewSet, + WorkspaceSprintViewSet, WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, WorkspaceDraftIssueViewSet, @@ -184,6 +193,97 @@ 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-squads/", + WorkspaceSprintSquadViewSet.as_view({"get": "list", "post": "create"}), + name="workspace-sprint-squads", + ), + path( + "workspaces//sprint-automations//", + WorkspaceSprintAutomationViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace-sprint-automations", + ), + path( + "workspaces//sprint-squads//", + WorkspaceSprintSquadViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace-sprint-squads", + ), + path( + "workspaces//sprint-automations//members/", + WorkspaceSprintAutomationMemberEndpoint.as_view(), + name="workspace-sprint-automation-members", + ), + path( + "workspaces//sprint-squads//members/", + WorkspaceSprintSquadMemberEndpoint.as_view(), + name="workspace-sprint-squad-members", + ), + path( + "workspaces//sprint-automations//archive/", + WorkspaceSprintAutomationArchiveEndpoint.as_view(), + name="workspace-sprint-automation-archive", + ), + path( + "workspaces//sprint-squads//archive/", + WorkspaceSprintSquadArchiveEndpoint.as_view(), + name="workspace-sprint-squad-archive", + ), + 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..0a789bfcd30 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -80,6 +80,17 @@ from .workspace.estimate import WorkspaceEstimatesEndpoint from .workspace.module import WorkspaceModulesEndpoint from .workspace.cycle import WorkspaceCyclesEndpoint +from .workspace.sprint import ( + WorkspaceSprintArchiveEndpoint, + WorkspaceSprintAutomationMemberEndpoint, + WorkspaceSprintAutomationArchiveEndpoint, + WorkspaceSprintAutomationViewSet, + WorkspaceSprintSquadMemberEndpoint, + WorkspaceSprintSquadArchiveEndpoint, + WorkspaceSprintSquadViewSet, + WorkspaceSprintIssueViewSet, + WorkspaceSprintViewSet, +) from .workspace.quick_link import QuickLinkViewSet from .workspace.sticky import WorkspaceStickyViewSet diff --git a/apps/api/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py index 5ba9a439b45..e51de75132b 100644 --- a/apps/api/plane/app/views/analytic/advance.py +++ b/apps/api/plane/app/views/analytic/advance.py @@ -37,6 +37,7 @@ def initialize_workspace(self, slug: str, type: str) -> None: user=self.request.user, date_filter=self.request.GET.get("date_filter", None), project_ids=self.request.GET.get("project_ids", None), + workspace_sprint_id=self.request.GET.get("workspace_sprint_id", None), ) 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/page/base.py b/apps/api/plane/app/views/page/base.py index ec391afc1aa..1e78b67b914 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -30,6 +30,7 @@ from rest_framework.response import Response # Module imports +from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( PageSerializer, @@ -54,6 +55,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets from plane.app.permissions import ProjectPagePermission +from plane.authentication.session import BaseSessionAuthentication def unarchive_archive_page_and_descendants(page_id, archived_at): @@ -76,6 +78,7 @@ class PageViewSet(BaseViewSet): serializer_class = PageSerializer model = Page permission_classes = [ProjectPagePermission] + authentication_classes = [BaseSessionAuthentication, APIKeyAuthentication] search_fields = ["name"] def get_queryset(self): diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index 5ca7aac420f..1a5ca774573 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -39,9 +39,12 @@ IssueAssignee, IssueLabel, ModuleIssue, + WorkspaceSprintIssue, ) from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset +from plane.utils.grouper import issue_group_values, issue_on_results, issue_queryset_grouper +from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.bgtasks.recent_visited_task import recent_visited_task from .. import BaseViewSet from plane.db.models import UserFavorite @@ -168,6 +171,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() @@ -243,7 +260,75 @@ def list(self, request, slug): issue_queryset=issue_queryset, order_by_param=order_by_param ) - # List Paginate + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + {"error": "Group by and sub group by cannot have same parameters"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=total_issue_count_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + queryset=total_issue_count_queryset, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + filters=filters, + queryset=total_issue_count_queryset, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=total_issue_count_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + queryset=total_issue_count_queryset, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_intake__status=1) + | Q(issue_intake__status=-1) + | Q(issue_intake__status=2) + | Q(issue_intake__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + return self.paginate( order_by=order_by_param, request=request, 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..8f705bffca6 --- /dev/null +++ b/apps/api/plane/app/views/workspace/sprint.py @@ -0,0 +1,407 @@ +# 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 ( + WorkspaceSprintAutomationMemberSerializer, + WorkspaceSprintAutomationSerializer, + WorkspaceSprintAutomationWriteSerializer, + WorkspaceSprintIssueSerializer, + WorkspaceSprintSerializer, + WorkspaceSprintWriteSerializer, +) +from plane.bgtasks.workspace_sprint_task import process_workspace_sprint_automation +from plane.db.models import ( + Issue, + ProjectMember, + Workspace, + WorkspaceMember, + WorkspaceSprint, + WorkspaceSprintAutomation, + WorkspaceSprintAutomationMember, + WorkspaceSprintIssue, +) +from plane.app.views.base import BaseAPIView, BaseViewSet + + +def is_workspace_admin(slug, user): + return WorkspaceMember.objects.filter( + workspace__slug=slug, + member=user, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + + +def accessible_automation_filter(user): + return ( + Q(access=WorkspaceSprintAutomation.PUBLIC_ACCESS) + | Q(created_by=user) + | Q(automation_members__member=user, automation_members__deleted_at__isnull=True) + ) + + +SPRINT_AUTOMATION_PROCESS_FIELDS = { + "enabled", + "start_date", + "sprint_duration_days", + "timezone", + "name_template", + "auto_create_next", +} + + +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", "automation") + .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") + ) + if not is_workspace_admin(self.workspace_slug, self.request.user): + queryset = queryset.filter( + Q(automation__isnull=True) + | Q(automation__access=WorkspaceSprintAutomation.PUBLIC_ACCESS) + | Q(automation__created_by=self.request.user) + | Q(automation__automation_members__member=self.request.user, automation__automation_members__deleted_at__isnull=True) + ).distinct() + + 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): + archived = self.request.GET.get("archived", "false") + queryset = ( + WorkspaceSprintAutomation.objects.filter(workspace__slug=self.workspace_slug) + .select_related("workspace", "created_by") + .prefetch_related("automation_members") + .annotate( + active_sprints_count=Count( + "sprints", + filter=Q(sprints__deleted_at__isnull=True, sprints__archived_at__isnull=True), + ) + ) + .order_by("sort_order", "-created_at") + ) + if archived == "true": + queryset = queryset.filter(archived_at__isnull=False) + else: + queryset = queryset.filter(archived_at__isnull=True) + + if is_workspace_admin(self.workspace_slug, self.request.user): + return queryset + return queryset.filter(accessible_automation_filter(self.request.user)).distinct() + + 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()) & SPRINT_AUTOMATION_PROCESS_FIELDS: + 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 WorkspaceSprintAutomationArchiveEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def post(self, request, slug, automation_id): + automation = WorkspaceSprintAutomation.objects.get( + workspace__slug=slug, + pk=automation_id, + archived_at__isnull=True, + ) + automation.archived_at = timezone.now() + automation.save(update_fields=["archived_at", "updated_at"]) + return Response(WorkspaceSprintAutomationSerializer(automation).data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN], level="WORKSPACE") + def delete(self, request, slug, automation_id): + automation = WorkspaceSprintAutomation.objects.get( + workspace__slug=slug, + pk=automation_id, + archived_at__isnull=False, + ) + automation.archived_at = None + automation.save(update_fields=["archived_at", "updated_at"]) + return Response(WorkspaceSprintAutomationSerializer(automation).data, status=status.HTTP_200_OK) + + +class WorkspaceSprintArchiveEndpoint(BaseAPIView): + def get_queryset(self): + queryset = ( + WorkspaceSprint.objects.filter(workspace__slug=self.workspace_slug, archived_at__isnull=False) + .select_related("workspace", "owned_by", "automation") + .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") + ) + if is_workspace_admin(self.workspace_slug, self.request.user): + return queryset + return queryset.filter( + Q(automation__isnull=True) + | Q(automation__access=WorkspaceSprintAutomation.PUBLIC_ACCESS) + | Q(automation__created_by=self.request.user) + | Q(automation__automation_members__member=self.request.user, automation__automation_members__deleted_at__isnull=True) + ).distinct() + + @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): + queryset = WorkspaceSprint.objects.filter(workspace__slug=slug, pk=sprint_id, archived_at__isnull=True) + if not is_workspace_admin(slug, self.request.user): + queryset = queryset.filter( + Q(automation__isnull=True) + | Q(automation__access=WorkspaceSprintAutomation.PUBLIC_ACCESS) + | Q(automation__created_by=self.request.user) + | Q(automation__automation_members__member=self.request.user, automation__automation_members__deleted_at__isnull=True) + ).distinct() + return queryset.get() + + 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) + + +class WorkspaceSprintAutomationMemberEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug, automation_id): + automation = WorkspaceSprintAutomation.objects.get(workspace__slug=slug, pk=automation_id) + if ( + automation.access == WorkspaceSprintAutomation.PRIVATE_ACCESS + and not is_workspace_admin(slug, request.user) + and automation.created_by_id != request.user.id + and not WorkspaceSprintAutomationMember.objects.filter( + automation=automation, + member=request.user, + deleted_at__isnull=True, + ).exists() + ): + return Response({"error": "Sprint group is not accessible"}, status=status.HTTP_403_FORBIDDEN) + + members = WorkspaceSprintAutomationMember.objects.filter( + automation=automation, + deleted_at__isnull=True, + ).select_related("member") + return Response(WorkspaceSprintAutomationMemberSerializer(members, many=True).data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def patch(self, request, slug, automation_id): + automation = WorkspaceSprintAutomation.objects.get(workspace__slug=slug, pk=automation_id) + if not is_workspace_admin(slug, request.user) and automation.created_by_id != request.user.id: + return Response({"error": "You don't have the required permissions."}, status=status.HTTP_403_FORBIDDEN) + + member_ids = request.data.get("member_ids", []) + if not isinstance(member_ids, list): + return Response({"error": "member_ids must be a list"}, status=status.HTTP_400_BAD_REQUEST) + + valid_member_ids = set( + WorkspaceMember.objects.filter( + workspace__slug=slug, + member_id__in=member_ids, + is_active=True, + ).values_list("member_id", flat=True) + ) + + WorkspaceSprintAutomationMember.objects.filter(automation=automation).exclude(member_id__in=valid_member_ids).delete() + existing_member_ids = set( + WorkspaceSprintAutomationMember.objects.filter( + automation=automation, + member_id__in=valid_member_ids, + deleted_at__isnull=True, + ).values_list("member_id", flat=True) + ) + WorkspaceSprintAutomationMember.objects.bulk_create( + [ + WorkspaceSprintAutomationMember( + automation=automation, + workspace=automation.workspace, + member_id=member_id, + ) + for member_id in valid_member_ids - existing_member_ids + ], + ignore_conflicts=True, + ) + + members = WorkspaceSprintAutomationMember.objects.filter( + automation=automation, + deleted_at__isnull=True, + ).select_related("member") + return Response(WorkspaceSprintAutomationMemberSerializer(members, many=True).data, status=status.HTTP_200_OK) + + +# Canonical squad names with backwards-compatible automation classes above. +WorkspaceSprintSquadViewSet = WorkspaceSprintAutomationViewSet +WorkspaceSprintSquadArchiveEndpoint = WorkspaceSprintAutomationArchiveEndpoint +WorkspaceSprintSquadMemberEndpoint = WorkspaceSprintAutomationMemberEndpoint diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index 218ba2a7179..12d9f2f4111 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -140,6 +140,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, "sub_issue": True, "sub_group_by": None, "show_empty_groups": True, + "assigned_to_me": False, }, display_properties={ "key": True, 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..5fa4d817d5c --- /dev/null +++ b/apps/api/plane/bgtasks/workspace_sprint_task.py @@ -0,0 +1,97 @@ +# 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 + + +process_workspace_sprint_squad = process_workspace_sprint_automation + + +@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/migrations/0125_workspaceuserproperties_navigation_sprint_preference.py b/apps/api/plane/db/migrations/0125_workspaceuserproperties_navigation_sprint_preference.py new file mode 100644 index 00000000000..e657cf500a9 --- /dev/null +++ b/apps/api/plane/db/migrations/0125_workspaceuserproperties_navigation_sprint_preference.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0124_alter_workspacesprint_created_by_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="workspaceuserproperties", + name="navigation_sprint_preference", + field=models.CharField( + choices=[("ACCORDION", "Accordion"), ("TABBED", "Tabbed")], + default="ACCORDION", + max_length=25, + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0126_workspace_sprint_automation_access.py b/apps/api/plane/db/migrations/0126_workspace_sprint_automation_access.py new file mode 100644 index 00000000000..376889f317e --- /dev/null +++ b/apps/api/plane/db/migrations/0126_workspace_sprint_automation_access.py @@ -0,0 +1,97 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0125_workspaceuserproperties_navigation_sprint_preference"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="workspacesprintautomation", + name="access", + field=models.PositiveSmallIntegerField(choices=[(0, "Private"), (2, "Public")], default=2), + ), + migrations.CreateModel( + name="WorkspaceSprintAutomationMember", + 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(db_index=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)), + ( + "automation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="automation_members", + to="db.workspacesprintautomation", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprintautomationmember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_sprint_automation_members", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_workspacesprintautomationmember", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacesprintautomationmember_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_workspacesprintautomationmember", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace Sprint Automation Member", + "verbose_name_plural": "Workspace Sprint Automation Members", + "db_table": "workspace_sprint_automation_members", + "ordering": ("-created_at",), + "unique_together": {("automation", "member", "deleted_at")}, + }, + ), + migrations.AddConstraint( + model_name="workspacesprintautomationmember", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("automation", "member"), + name="workspace_sprint_automation_member_unique_when_deleted_at_null", + ), + ), + ] diff --git a/apps/api/plane/db/migrations/0127_workspacesprintautomation_logo_props.py b/apps/api/plane/db/migrations/0127_workspacesprintautomation_logo_props.py new file mode 100644 index 00000000000..93a6b4c845f --- /dev/null +++ b/apps/api/plane/db/migrations/0127_workspacesprintautomation_logo_props.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0126_workspace_sprint_automation_access"), + ] + + operations = [ + migrations.AddField( + model_name="workspacesprintautomation", + name="logo_props", + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0128_workspacesprintautomation_archived_at.py b/apps/api/plane/db/migrations/0128_workspacesprintautomation_archived_at.py new file mode 100644 index 00000000000..2380d77eba3 --- /dev/null +++ b/apps/api/plane/db/migrations/0128_workspacesprintautomation_archived_at.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0127_workspacesprintautomation_logo_props"), + ] + + operations = [ + migrations.AddField( + model_name="workspacesprintautomation", + name="archived_at", + field=models.DateTimeField(null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0129_alter_workspacesprintautomationmember_created_by_and_more.py b/apps/api/plane/db/migrations/0129_alter_workspacesprintautomationmember_created_by_and_more.py new file mode 100644 index 00000000000..8c884eb973a --- /dev/null +++ b/apps/api/plane/db/migrations/0129_alter_workspacesprintautomationmember_created_by_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.30 on 2026-06-19 18:32 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0128_workspacesprintautomation_archived_at'), + ] + + operations = [ + migrations.AlterField( + model_name='workspacesprintautomationmember', + 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='workspacesprintautomationmember', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'), + ), + migrations.AlterField( + model_name='workspacesprintautomationmember', + 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='workspacesprintautomationmember', + 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='workspacesprintautomationmember', + 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/migrations/0130_alter_workspacesprintautomation_options_and_more.py b/apps/api/plane/db/migrations/0130_alter_workspacesprintautomation_options_and_more.py new file mode 100644 index 00000000000..57a957ced8e --- /dev/null +++ b/apps/api/plane/db/migrations/0130_alter_workspacesprintautomation_options_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.30 on 2026-06-19 18:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0129_alter_workspacesprintautomationmember_created_by_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='workspacesprintautomation', + options={'ordering': ('sort_order', '-created_at'), 'verbose_name': 'Workspace Sprint Squad', 'verbose_name_plural': 'Workspace Sprint Squads'}, + ), + migrations.AlterField( + model_name='workspacesprintautomation', + name='description', + field=models.TextField(blank=True, verbose_name='Squad Description'), + ), + migrations.AlterField( + model_name='workspacesprintautomation', + name='name', + field=models.CharField(max_length=255, verbose_name='Squad Name'), + ), + migrations.AlterField( + model_name='workspacesprintautomation', + name='start_date', + field=models.DateTimeField(verbose_name='Squad Start Date'), + ), + ] diff --git a/apps/api/plane/db/migrations/0131_workspaceuserproperties_navigation_squad_limit.py b/apps/api/plane/db/migrations/0131_workspaceuserproperties_navigation_squad_limit.py new file mode 100644 index 00000000000..9b21a72cc98 --- /dev/null +++ b/apps/api/plane/db/migrations/0131_workspaceuserproperties_navigation_squad_limit.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-06-19 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0130_alter_workspacesprintautomation_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='workspaceuserproperties', + name='navigation_squad_limit', + field=models.IntegerField(default=10), + ), + ] diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index 5cf9dec2a3e..7dcab9933d2 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -62,6 +62,14 @@ from .session import Session from .social_connection import SocialLoginConnection from .state import State, StateGroup, DEFAULT_STATES +from .sprint import ( + WorkspaceSprint, + WorkspaceSprintAutomation, + WorkspaceSprintAutomationMember, + WorkspaceSprintSquad, + WorkspaceSprintSquadMember, + 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/cycle.py b/apps/api/plane/db/models/cycle.py index 78ea977d911..d381a9f2a06 100644 --- a/apps/api/plane/db/models/cycle.py +++ b/apps/api/plane/db/models/cycle.py @@ -36,6 +36,7 @@ def get_default_display_filters(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", + "assigned_to_me": False, } diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index fe23ee681dc..0371536cb44 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -67,6 +67,7 @@ def get_default_display_filters(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", + "assigned_to_me": False, } diff --git a/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py index d660116fa83..53ded774e87 100644 --- a/apps/api/plane/db/models/module.py +++ b/apps/api/plane/db/models/module.py @@ -34,6 +34,7 @@ def get_default_display_filters(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", + "assigned_to_me": False, } diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 4039b1d2903..44feadefa9b 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -57,6 +57,7 @@ def get_default_props(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", + "assigned_to_me": False, }, } diff --git a/apps/api/plane/db/models/sprint.py b/apps/api/plane/db/models/sprint.py new file mode 100644 index 00000000000..b22a21b2450 --- /dev/null +++ b/apps/api/plane/db/models/sprint.py @@ -0,0 +1,171 @@ +# 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): + PRIVATE_ACCESS = 0 + PUBLIC_ACCESS = 2 + ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public")) + + name = models.CharField(max_length=255, verbose_name="Squad Name") + description = models.TextField(verbose_name="Squad Description", blank=True) + enabled = models.BooleanField(default=True) + access = models.PositiveSmallIntegerField(choices=ACCESS_CHOICES, default=PUBLIC_ACCESS) + start_date = models.DateTimeField(verbose_name="Squad 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) + logo_props = models.JSONField(default=dict) + archived_at = models.DateTimeField(null=True) + + class Meta: + verbose_name = "Workspace Sprint Squad" + verbose_name_plural = "Workspace Sprint Squads" + 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 WorkspaceSprintAutomationMember(WorkspaceBaseModel): + automation = models.ForeignKey( + WorkspaceSprintAutomation, + on_delete=models.CASCADE, + related_name="automation_members", + ) + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_sprint_automation_members", + ) + + class Meta: + unique_together = ["automation", "member", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["automation", "member"], + condition=models.Q(deleted_at__isnull=True), + name="workspace_sprint_automation_member_unique_when_deleted_at_null", + ) + ] + verbose_name = "Workspace Sprint Automation Member" + verbose_name_plural = "Workspace Sprint Automation Members" + db_table = "workspace_sprint_automation_members" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self.automation_id: + self.workspace = self.automation.workspace + super(WorkspaceSprintAutomationMember, self).save(*args, **kwargs) + + def __str__(self): + return f"{self.member} <{self.automation}>" + + +# Canonical domain names. Keep the automation aliases for backwards compatibility +# while the API and frontend migrate to "Squad" terminology. +WorkspaceSprintSquad = WorkspaceSprintAutomation +WorkspaceSprintSquadMember = WorkspaceSprintAutomationMember + + +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/db/models/view.py b/apps/api/plane/db/models/view.py index a02b768a39a..320f295e821 100644 --- a/apps/api/plane/db/models/view.py +++ b/apps/api/plane/db/models/view.py @@ -34,6 +34,7 @@ def get_default_display_filters(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", + "assigned_to_me": False, } diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 80a3e3e3e42..3f9828db3e6 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -40,6 +40,7 @@ def get_default_props(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", + "assigned_to_me": False, }, "display_properties": { "assignee": True, @@ -83,6 +84,7 @@ def get_default_display_filters(): "show_empty_groups": True, "layout": "list", "calendar_date_range": "", + "assigned_to_me": False, } } @@ -332,6 +334,12 @@ class NavigationControlPreference(models.TextChoices): choices=NavigationControlPreference.choices, default=NavigationControlPreference.ACCORDION, ) + navigation_sprint_preference = models.CharField( + max_length=25, + choices=NavigationControlPreference.choices, + default=NavigationControlPreference.ACCORDION, + ) + navigation_squad_limit = models.IntegerField(default=10) class Meta: unique_together = ["workspace", "user", "deleted_at"] diff --git a/apps/api/plane/seeds/data/views.json b/apps/api/plane/seeds/data/views.json index f9d182324fa..3ba2997bc89 100644 --- a/apps/api/plane/seeds/data/views.json +++ b/apps/api/plane/seeds/data/views.json @@ -1,14 +1,42 @@ [ - { - "id": 1, - "name": "Project Urgent Tasks", - "description": "Project Urgent Tasks", - "access": 1, - "filters": {}, - "project_id": 1, - "display_filters": {"layout": "list", "calendar": {"layout": "month", "show_weekends": false}, "group_by": "state", "order_by": "sort_order", "sub_issue": false, "sub_group_by": null, "show_empty_groups": false}, - "display_properties": {"key": true, "link": true, "cycle": true, "state": true, "labels": true, "modules": true, "assignee": true, "due_date": true, "estimate": true, "priority": true, "created_on": true, "issue_type": true, "start_date": true, "updated_on": true, "customer_count": true, "sub_issue_count": true, "attachment_count": true, "customer_request_count": true}, - "sort_order": 75535, - "rich_filters": {"priority__in": "urgent"} - } -] \ No newline at end of file + { + "id": 1, + "name": "Project Urgent Tasks", + "description": "Project Urgent Tasks", + "access": 1, + "filters": {}, + "project_id": 1, + "display_filters": { + "layout": "list", + "calendar": { "layout": "month", "show_weekends": false }, + "group_by": "state", + "order_by": "sort_order", + "sub_issue": false, + "sub_group_by": null, + "show_empty_groups": false, + "assigned_to_me": false + }, + "display_properties": { + "key": true, + "link": true, + "cycle": true, + "state": true, + "labels": true, + "modules": true, + "assignee": true, + "due_date": true, + "estimate": true, + "priority": true, + "created_on": true, + "issue_type": true, + "start_date": true, + "updated_on": true, + "customer_count": true, + "sub_issue_count": true, + "attachment_count": true, + "customer_request_count": true + }, + "sort_order": 75535, + "rich_filters": { "priority__in": "urgent" } + } +] 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/date_utils.py b/apps/api/plane/utils/date_utils.py index d25d5b1eca4..5686a3b85ed 100644 --- a/apps/api/plane/utils/date_utils.py +++ b/apps/api/plane/utils/date_utils.py @@ -128,6 +128,7 @@ def get_analytics_filters( type: str, date_filter: Optional[str] = None, project_ids: Optional[Union[str, List[str]]] = None, + workspace_sprint_id: Optional[str] = None, ) -> Dict[str, Any]: """ Get combined project and date filters for analytics endpoints @@ -138,6 +139,7 @@ def get_analytics_filters( type: The type of filter ("analytics" or "chart") date_filter: Optional date filter string project_ids: Optional list of project IDs or comma-separated string of project IDs + workspace_sprint_id: Optional workspace sprint ID Returns: dict: A dictionary containing: @@ -173,6 +175,10 @@ def get_analytics_filters( base_filters["project_id__in"] = project_ids project_filters["id__in"] = project_ids + if workspace_sprint_id: + base_filters["issue_workspace_sprint__sprint_id"] = workspace_sprint_id + base_filters["issue_workspace_sprint__deleted_at__isnull"] = True + # Initialize date range variables analytics_date_range = None chart_period_range = None 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/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index ab008796715..40042259c06 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -117,6 +117,8 @@ def issue_on_results( "project_id", "parent_id", "cycle_id", + "global_sprint_id", + "global_sprint_name", "sub_issues_count", "created_at", "updated_at", diff --git a/apps/space/app/error.tsx b/apps/space/app/error.tsx index 87aa8dc1992..26bf9e10f99 100644 --- a/apps/space/app/error.tsx +++ b/apps/space/app/error.tsx @@ -7,11 +7,11 @@ // ui import { Button } from "@plane/propel/button"; -function ErrorPage() { - const handleRetry = () => { - window.location.reload(); - }; +const handleRetry = () => { + window.location.reload(); +}; +function ErrorPage() { return (
@@ -22,10 +22,6 @@ function ErrorPage() { details, please write to{" "} support@plane.so - {" "} - or on our{" "} - - Forum .

diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 05f4cca2d0a..7462b4e4b47 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -38,6 +38,7 @@ import { LayoutSelection, MobileLayoutSelection, } from "@/components/issues/issue-layouts/filters"; +import { WorkItemsMeModeToggle } from "@/components/issues/me-mode-toggle"; import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; @@ -75,7 +76,7 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() { const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); - const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; + const isSidebarCollapsed = storedValue === true; const toggleSidebar = () => { setValue(!isSidebarCollapsed); }; @@ -212,6 +213,10 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() { />
+ - {projectPreferences.navigationMode === "TABBED" && ( + {projectPreferences.navigationMode === "TABBED" && !isProjectIssuesListPath && (
diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx index 3706d803086..e3a65612002 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx @@ -4,10 +4,10 @@ * See the LICENSE file for details. */ -import { ProjectPageRoot } from "@/plane-web/components/projects/page"; +import { WorkspaceArchivesRoot } from "@/components/archives"; function ProjectsPage() { - return ; + return ; } export default ProjectsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index c529c4efe9f..8ead07716d2 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -14,9 +14,11 @@ 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"; +import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences"; // plane web components import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; @@ -24,6 +26,7 @@ export const AppSidebar = observer(function AppSidebar() { // store hooks const { allowPermissions } = useUserPermissions(); const { groupedFavorites } = useFavorite(); + const { isWorkspaceItemPinned } = useWorkspaceNavigationPreferences(); // derived values const canPerformWorkspaceMemberActions = allowPermissions( @@ -42,6 +45,8 @@ export const AppSidebar = observer(function AppSidebar() { {/* Projects List */} + {/* Squads List */} + {isWorkspaceItemPinned("squads") && } ); }); 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..916d7fd953f --- /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 { getSprintSquadById } = useWorkspaceSprint(); + const squadId = sprintId?.toString(); + const squad = getSprintSquadById(squadId); + + if (!squadId) 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..2c89c26b037 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/header.tsx @@ -0,0 +1,286 @@ +/** + * 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, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ChartNoAxesColumn } from "lucide-react"; +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + type TFiltersLayoutOptions, + 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 { Tooltip } from "@plane/propel/tooltip"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types"; +import { Breadcrumbs, Header } from "@plane/ui"; +import { getComputedDisplayProperties } from "@plane/utils"; +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { CountChip } from "@/components/common/count-chip"; +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { + DisplayFiltersSelection, + FiltersDropdown, + LayoutSelection, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts/filters"; +import { WorkItemsMeModeToggle } from "@/components/issues/me-mode-toggle"; +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 { usePlatformOS } from "@/hooks/use-platform-os"; + +const SPRINT_VIEW_ID = "all-issues"; +const SPRINT_LAYOUTS = [ + EIssueLayoutTypes.LIST, + EIssueLayoutTypes.KANBAN, + EIssueLayoutTypes.CALENDAR, + EIssueLayoutTypes.SPREADSHEET, + EIssueLayoutTypes.GANTT, +]; +const SPRINT_DISPLAY_FILTERS_BY_LAYOUT: TFiltersLayoutOptions = { + ...ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions, + list: { + ...ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions.list, + display_filters: { + ...ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions.list.display_filters, + group_by: ["state_detail.group", "priority", "project", "labels", "assignees", "created_by", null], + }, + }, + kanban: { + ...ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions.kanban, + display_filters: { + ...ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions.kanban.display_filters, + group_by: ["state_detail.group", "priority", "project", "labels", "assignees", "created_by"], + sub_group_by: ["state_detail.group", "priority", "project", "labels", "assignees", "created_by", null], + }, + }, +}; + +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 { fetchWorkspaceSprints, getSprintById } = useWorkspaceSprint(); + + const sprint = getSprintById(sprintId); + const sprintWeek = sprint ? formatSprintWeek(sprint.start_date, sprint.end_date) : undefined; + + useEffect(() => { + if (workspaceSlugValue) fetchWorkspaceSprints(workspaceSlugValue); + }, [fetchWorkspaceSprints, workspaceSlugValue]); + + return ( + <> +
+ + router.back()} className="min-w-0 flex-grow-0"> + } + /> + } + /> + } + isLast + /> + } + isLast + /> + + {sprintWeek && {sprintWeek}} + + + + +
+ + ); +}); + +export const WorkspaceSprintsHeaderActions = observer(function WorkspaceSprintsHeaderActions() { + const { workspaceSlug, workspaceSprintId } = useParams(); + const sprintId = workspaceSprintId?.toString(); + const workspaceSlugValue = workspaceSlug?.toString(); + const { t } = useTranslation(); + const [analyticsModal, setAnalyticsModal] = useState(false); + const { isMobile } = usePlatformOS(); + + const { + issuesFilter: { filters, updateFilters }, + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { toggleCreateIssueModal } = useCommandPalette(); + const { getSprintById } = useWorkspaceSprint(); + + const issueFilters = filters[SPRINT_VIEW_ID]; + const sprint = sprintId ? getSprintById(sprintId) : undefined; + const activeLayout = issueFilters?.displayFilters?.layout; + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + const displayProperties = { + ...getComputedDisplayProperties(issueFilters?.displayProperties), + project: issueFilters?.displayProperties?.project ?? true, + sprint: false, + }; + + const currentLayoutFilters = useMemo(() => { + const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET; + const layoutFilters = SPRINT_DISPLAY_FILTERS_BY_LAYOUT[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; + const nextDisplayProperties = { + ...getComputedDisplayProperties(issueFilters?.displayProperties), + project: issueFilters?.displayProperties?.project ?? true, + sprint: false, + ...property, + }; + + updateFilters( + workspaceSlugValue, + undefined, + EIssueFilterType.DISPLAY_PROPERTIES, + nextDisplayProperties, + SPRINT_VIEW_ID + ); + }, + [issueFilters?.displayProperties, updateFilters, workspaceSlugValue] + ); + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlugValue) return; + const nextDisplayFilters: Partial = { layout }; + + if (layout === EIssueLayoutTypes.KANBAN && !issueFilters?.displayFilters?.group_by) { + nextDisplayFilters.group_by = "state_detail.group"; + } + + updateFilters( + workspaceSlugValue, + undefined, + EIssueFilterType.DISPLAY_FILTERS, + nextDisplayFilters, + SPRINT_VIEW_ID + ); + }, + [issueFilters?.displayFilters?.group_by, updateFilters, workspaceSlugValue] + ); + + if (!sprintId || !workspaceSlugValue || !issueFilters) return null; + + return ( + <> + setAnalyticsModal(false)} + workspaceSprintDetails={sprint} + /> + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this sprint`} + position="bottom" + > + + + ) : null} +
+ +
+
+ +
+ + + + + + + + + ); +}); 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..0518cb2be12 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/layout.tsx @@ -0,0 +1,246 @@ +/** + * 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 { Link, Outlet, useLocation, useNavigate, useParams } from "react-router"; +import { TabNavigationItem, TabNavigationList } from "@plane/propel/tab-navigation"; +import { Logo } from "@plane/propel/emoji-icon-picker"; +import { ChevronDownIcon, CycleIcon } from "@plane/propel/icons"; +import { MoreHorizontal, Settings } from "lucide-react"; +import type { ICustomSearchSelectOption, TLogoProps } from "@plane/types"; +import { CustomMenu, CustomSearchSelect, Header, Row } from "@plane/ui"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; +import { SwitcherLabel } from "@/components/common/switcher-label"; +import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useSprintNavigationPreferences } from "@/hooks/use-navigation-preferences"; +import { WorkspaceSprintsHeader, WorkspaceSprintsHeaderActions } from "./header"; + +const WorkspaceSprintsLayout = observer(function WorkspaceSprintsLayout() { + const { sprintId, workspaceSprintId } = useParams(); + const { preferences: sprintPreferences } = useSprintNavigationPreferences(); + const hasSprintGroupContext = !!sprintId || !!workspaceSprintId; + + return ( + <> + {!hasSprintGroupContext && sprintPreferences.navigationMode !== "TABBED" && ( + } /> + )} + + + + + + ); +}); + +export default WorkspaceSprintsLayout; + +const WorkspaceSprintsGroupNavigation = observer(function WorkspaceSprintsGroupNavigation() { + const { workspaceSlug, sprintId, workspaceSprintId } = useParams(); + const { pathname, search } = useLocation(); + const navigate = useNavigate(); + const { sidebarCollapsed } = useAppTheme(); + const { preferences: sprintPreferences } = useSprintNavigationPreferences(); + const { + currentWorkspaceSprintSquadIds, + fetchWorkspaceSprintSquads, + fetchWorkspaceSprints, + getSprintSquadById, + getSprintById, + getSprintsBySquadId, + } = useWorkspaceSprint(); + + const workspaceSlugValue = workspaceSlug?.toString(); + const selectedSprintId = workspaceSprintId?.toString(); + const selectedSprint = selectedSprintId ? getSprintById(selectedSprintId) : undefined; + const squadId = sprintId?.toString() ?? selectedSprint?.automation_id ?? undefined; + const squad = squadId ? getSprintSquadById(squadId) : undefined; + const sprintIds = squadId ? getSprintsBySquadId(squadId) : []; + const manageHref = workspaceSlugValue && squadId ? `/${workspaceSlugValue}/sprints/${squadId}` : undefined; + const firstSprintHref = + workspaceSlugValue && sprintIds[0] ? `/${workspaceSlugValue}/sprints/work-items/${sprintIds[0]}` : undefined; + const squadOptions = useMemo( + () => + (currentWorkspaceSprintSquadIds ?? []) + .map((id): ICustomSearchSelectOption | null => { + const squadOption = getSprintSquadById(id); + if (!squadOption) return null; + + return { + value: id, + query: squadOption.name, + content: ( + + ), + }; + }) + .filter((option): option is ICustomSearchSelectOption => option !== null), + [currentWorkspaceSprintSquadIds, getSprintSquadById] + ); + + const handleSquadChange = useCallback( + (value: string) => { + if (!workspaceSlugValue || value === squadId) return; + navigate(`/${workspaceSlugValue}/sprints/${value}`); + }, + [squadId, navigate, workspaceSlugValue] + ); + + useEffect(() => { + if (!workspaceSlugValue) return; + + fetchWorkspaceSprintSquads(workspaceSlugValue); + fetchWorkspaceSprints(workspaceSlugValue, squadId); + }, [squadId, fetchWorkspaceSprintSquads, fetchWorkspaceSprints, workspaceSlugValue]); + + useEffect(() => { + if ( + sprintPreferences.navigationMode !== "TABBED" || + !manageHref || + !firstSprintHref || + pathname !== manageHref || + new URLSearchParams(search).get("view") === "settings" + ) + return; + + navigate(firstSprintHref, { replace: true }); + }, [firstSprintHref, manageHref, navigate, pathname, search, sprintPreferences.navigationMode]); + + if (!workspaceSlugValue || !squadId) return null; + + const settingsHref = `${manageHref}?view=settings`; + const isSettingsPage = pathname === manageHref && new URLSearchParams(search).get("view") === "settings"; + const showSprintTabs = sprintPreferences.navigationMode === "TABBED"; + + return ( +
+ +
+
+ {sidebarCollapsed && ( +
+ +
+ )} +
+ + + navigate(settingsHref)} /> + {showSprintTabs && ( + <> +
+ + {sprintIds.map((id) => { + const sprint = getSprintById(id); + if (!sprint) return null; + + const sprintHref = `/${workspaceSlugValue}/sprints/work-items/${sprint.id}`; + + return ( + + + {sprint.name} + + + ); + })} + + + )} + + + + +
+
+
+
+
+ ); +}); + +type TSprintAutomationSwitcherProps = { + automationName: string; + automationLogoProps: TLogoProps | undefined; + automationId: string | undefined; + automationOptions: ICustomSearchSelectOption[]; + onChange: (value: string) => void; +}; + +function SprintAutomationSwitcher(props: TSprintAutomationSwitcherProps) { + const { automationName, automationLogoProps, automationId, automationOptions, onChange } = props; + + return ( + + + {automationName} + +
+ } + className="h-full rounded" + customButtonClassName="h-full outline-none" + /> + ); +} + +function SprintAutomationIcon({ automationLogoProps }: { automationLogoProps: TLogoProps | undefined }) { + return automationLogoProps?.in_use ? ( + + ) : ( + + ); +} + +type TSprintAutomationActionsMenuProps = { + onOpenSettings: () => void; +}; + +function SprintAutomationActionsMenu(props: TSprintAutomationActionsMenuProps) { + const { onOpenSettings } = props; + + return ( + + + + } + className="flex-shrink-0" + customButtonClassName="grid place-items-center" + placement="bottom-start" + ariaLabel="Squad actions" + useCaptureForOutsideClick + closeOnSelect + > + + + + Settings + + + + ); +} 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..5e6aea6942f --- /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 squad

+

+ Create or select a squad 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..394b175cd7b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sprints/work-items/[workspaceSprintId]/page.tsx @@ -0,0 +1,42 @@ +/** + * 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 { fetchArchivedWorkspaceSprints, fetchWorkspaceSprints, getSprintById } = useWorkspaceSprint(); + + const sprint = getSprintById(workspaceSprintId); + const defaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === "all-issues"); + + useEffect(() => { + fetchWorkspaceSprints(workspaceSlug); + fetchArchivedWorkspaceSprints(workspaceSlug); + }, [fetchArchivedWorkspaceSprints, fetchWorkspaceSprints, workspaceSlug]); + + return ( + <> + + + + ); +} + +export default observer(WorkspaceSprintWorkItemsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/squads/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/squads/layout.tsx new file mode 100644 index 00000000000..d1e7dd792f8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/squads/layout.tsx @@ -0,0 +1,31 @@ +/** + * 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 { Header } from "@plane/ui"; +import { AppHeader } from "@/components/core/app-header"; +import { ContentWrapper } from "@/components/core/content-wrapper"; + +function WorkspaceSquadsHeader() { + return ( +
+ +
Squads
+
+
+ ); +} + +export default function WorkspaceSquadsLayout() { + return ( + <> + } /> + + + + + ); +} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/squads/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/squads/page.tsx new file mode 100644 index 00000000000..da2a4639814 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/squads/page.tsx @@ -0,0 +1,127 @@ +/** + * 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 { useParams, useRouter } from "next/navigation"; +import { Logo } from "@plane/propel/emoji-icon-picker"; +import { CycleIcon } from "@plane/propel/icons"; +import { Button } from "@plane/propel/button"; +import { PageHead } from "@/components/core/page-title"; +import { SprintAutomationModal } from "@/components/workspace/sprints/automation-modal"; +import { useWorkspaceSprint } from "@/hooks/store/use-workspace-sprint"; + +const WorkspaceSquadsPage = observer(function WorkspaceSquadsPage() { + const { workspaceSlug } = useParams(); + const router = useRouter(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const { + currentWorkspaceSprintSquadIds, + fetchWorkspaceSprintSquads, + fetchWorkspaceSprints, + getSprintSquadById, + getSprintsBySquadId, + } = useWorkspaceSprint(); + + const workspaceSlugValue = workspaceSlug?.toString(); + const squadIds = currentWorkspaceSprintSquadIds ?? []; + + useEffect(() => { + if (!workspaceSlugValue) return; + fetchWorkspaceSprintSquads(workspaceSlugValue); + fetchWorkspaceSprints(workspaceSlugValue); + }, [fetchWorkspaceSprintSquads, fetchWorkspaceSprints, workspaceSlugValue]); + + const openSquad = (squadId: string) => { + if (!workspaceSlugValue) return; + + const sprintIds = getSprintsBySquadId(squadId); + const href = sprintIds[0] + ? `/${workspaceSlugValue}/sprints/work-items/${sprintIds[0]}` + : `/${workspaceSlugValue}/sprints/${squadId}`; + + router.push(href); + }; + + return ( + <> + + setIsCreateModalOpen(false)} + onCreated={(squadId) => openSquad(squadId)} + /> +
+
+
+

Squads

+

Manage sprint squads and their active sprints.

+
+ +
+ {squadIds.length === 0 ? ( +
+ No squads yet. +
+ ) : ( +
+ {squadIds.map((squadId) => { + const squad = getSprintSquadById(squadId); + if (!squad) return null; + + const openSprintCount = getSprintsBySquadId(squad.id).length; + + return ( +
+ +
+ {squad.sprint_duration_days} day sprint - {openSprintCount} open sprint + {openSprintCount === 1 ? "" : "s"} +
+
+ + +
+
+ ); + })} +
+ )} +
+ + ); +}); + +export default WorkspaceSquadsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx deleted file mode 100644 index c4039f7ab54..00000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) 2023-present Plane Software, Inc. and contributors - * SPDX-License-Identifier: AGPL-3.0-only - * See the LICENSE file for details. - */ - -import { useTheme } from "next-themes"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// assets -import githubBlackImage from "@/app/assets/logos/github-black.png?url"; -import githubWhiteImage from "@/app/assets/logos/github-white.png?url"; - -export function StarUsOnGitHubLink() { - // plane hooks - const { t } = useTranslation(); - // hooks - const { resolvedTheme } = useTheme(); - const imageSrc = resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage; - - return ( - - - {t("home.star_us_on_github")} - - ); -} diff --git a/apps/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx index 4ab9b02eb8e..cab53e55437 100644 --- a/apps/web/app/(all)/workspace-invitations/page.tsx +++ b/apps/web/app/(all)/workspace-invitations/page.tsx @@ -7,7 +7,7 @@ import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import useSWR from "swr"; -import { Boxes, Share2, Star, User2 } from "lucide-react"; +import { Boxes, User2 } from "lucide-react"; import { CheckIcon, CloseIcon } from "@plane/propel/icons"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; @@ -54,9 +54,9 @@ function WorkspaceInvitationPage() { }) .then(() => { if (invitationDetail.email === currentUser?.email) { - router.push(`/${invitationDetail.workspace.slug}`); + return router.push(`/${invitationDetail.workspace.slug}`); } else { - router.push("/"); + return router.push("/"); } }) .catch((err: unknown) => console.error(err)); @@ -70,7 +70,7 @@ function WorkspaceInvitationPage() { token: token, }) .then(() => { - router.push("/"); + return router.push("/"); }) .catch((err: unknown) => console.error(err)); }; @@ -111,12 +111,6 @@ function WorkspaceInvitationPage() { ) : ( )} - - ) ) : ( diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index c9c82bd2475..916aff53b9d 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -114,6 +114,21 @@ 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"), + ]), + + // Workspace Squads + layout("./(all)/[workspaceSlug]/(projects)/squads/layout.tsx", [ + route(":workspaceSlug/squads", "./(all)/[workspaceSlug]/(projects)/squads/page.tsx"), + ]), + // Archived Projects layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [ route( diff --git a/apps/web/ce/components/global/product-updates/changelog.tsx b/apps/web/ce/components/global/product-updates/changelog.tsx deleted file mode 100644 index 06de2360829..00000000000 --- a/apps/web/ce/components/global/product-updates/changelog.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) 2023-present Plane Software, Inc. and contributors - * SPDX-License-Identifier: AGPL-3.0-only - * See the LICENSE file for details. - */ - -import { useState, useEffect, useRef } from "react"; -import { observer } from "mobx-react"; -// hooks -import { Loader } from "@plane/ui"; -import { ProductUpdatesFallback } from "@/components/global/product-updates/fallback"; -import { useInstance } from "@/hooks/store/use-instance"; - -export const ProductUpdatesChangelog = observer(function ProductUpdatesChangelog() { - // refs - const isLoadingRef = useRef(true); - // states - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); - // store hooks - const { config } = useInstance(); - // derived values - const changeLogUrl = config?.instance_changelog_url; - const shouldShowFallback = !changeLogUrl || changeLogUrl === "" || hasError; - - // timeout fallback - if iframe doesn't load within 15 seconds, show error - useEffect(() => { - if (!changeLogUrl || changeLogUrl === "") { - setIsLoading(false); - isLoadingRef.current = false; - return; - } - - setIsLoading(true); - setHasError(false); - isLoadingRef.current = true; - - const timeoutId = setTimeout(() => { - if (isLoadingRef.current) { - setHasError(true); - setIsLoading(false); - isLoadingRef.current = false; - } - }, 15000); // 15 second timeout - - return () => { - clearTimeout(timeoutId); - }; - }, [changeLogUrl]); - - const handleIframeLoad = () => { - setTimeout(() => { - isLoadingRef.current = false; - setIsLoading(false); - }, 1000); - }; - - const handleIframeError = () => { - isLoadingRef.current = false; - setHasError(true); - setIsLoading(false); - }; - - // Show fallback if URL is missing, empty, or iframe failed to load - if (shouldShowFallback) { - return ( - - ); - } - - return ( -
- {isLoading && ( - - - - )} -