diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 6de6ee89870..8b1d45b77be 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -65,6 +65,7 @@ IssueFlatSerializer, IssueStateSerializer, IssueLinkSerializer, + IssueChecklistItemSerializer, IssueIntakeSerializer, IssueLiteSerializer, IssueAttachmentSerializer, diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 2e116cd6613..2f7bf44dbd7 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -21,6 +21,7 @@ User, Issue, IssueActivity, + IssueChecklistItem, IssueComment, ProjectUserProperty, IssueAssignee, @@ -598,6 +599,44 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) +class IssueChecklistItemSerializer(BaseSerializer): + class Meta: + model = IssueChecklistItem + fields = [ + "id", + "name", + "status", + "sort_order", + "completed_at", + "completed_by", + "issue", + "project", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "completed_at", + "completed_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + def validate_name(self, value): + value = (value or "").strip() + if not value: + raise serializers.ValidationError("Name cannot be empty.") + return value + + class IssueLinkLiteSerializer(BaseSerializer): class Meta: model = IssueLink diff --git a/apps/api/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py index 436d2277056..c3cb0b9abe1 100644 --- a/apps/api/plane/app/urls/issue.py +++ b/apps/api/plane/app/urls/issue.py @@ -9,6 +9,7 @@ BulkDeleteIssuesEndpoint, SubIssuesEndpoint, IssueLinkViewSet, + IssueChecklistItemViewSet, IssueAttachmentEndpoint, CommentReactionViewSet, IssueActivityEndpoint, @@ -123,6 +124,22 @@ ), name="project-issue-links", ), + path( + "workspaces//projects//issues//checklist-items/", + IssueChecklistItemViewSet.as_view({"get": "list", "post": "create"}), + name="project-issue-checklist-items", + ), + path( + "workspaces//projects//issues//checklist-items//", + IssueChecklistItemViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-checklist-items", + ), path( "workspaces//projects//issues//issue-attachments/", IssueAttachmentEndpoint.as_view(), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 84f7872ec85..b44ea771f5d 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -143,6 +143,7 @@ from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint from .issue.link import IssueLinkViewSet +from .issue.checklist import IssueChecklistItemViewSet from .issue.relation import IssueRelationViewSet diff --git a/apps/api/plane/app/views/issue/checklist.py b/apps/api/plane/app/views/issue/checklist.py new file mode 100644 index 00000000000..937ee37647a --- /dev/null +++ b/apps/api/plane/app/views/issue/checklist.py @@ -0,0 +1,170 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Python imports +import json + +# Django imports +from django.db.models import Max +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseViewSet +from plane.app.serializers import IssueChecklistItemSerializer +from plane.app.permissions import ROLE, ProjectEntityPermission +from plane.db.models import Issue, IssueChecklistItem, ProjectMember +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.host import base_host + +SORT_ORDER_STEP = 65535 + + +class IssueChecklistItemViewSet(BaseViewSet): + permission_classes = [ProjectEntityPermission] + + model = IssueChecklistItem + serializer_class = IssueChecklistItemSerializer + + def get_queryset(self): + # SECURITY: ProjectEntityPermission only proves the caller is a member of + # the URL project_id — it does NOT prove issue_id lives in that project. + # Every lookup must keep workspace__slug + project_id + issue_id together, + # or a member of one project can read/write another project's checklist + # items in the same workspace. See the same note in + # plane/app/views/issue/sub_issue.py:38-44. + queryset = ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + ) + + # SECURITY: a guest without guest_view_all_features may only see the + # checklist of a work item they created themselves, mirroring + # IssueViewSet.retrieve (views/issue/base.py:599-613). Child-entity + # viewsets otherwise gate only on project membership, which would let + # a guest read a checklist on a work item whose detail view they are + # forbidden from opening. + if ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member=self.request.user, + role=ROLE.GUEST.value, + is_active=True, + project__guest_view_all_features=False, + ).exists(): + queryset = queryset.filter(issue__created_by=self.request.user) + + return queryset.order_by("sort_order", "created_at").distinct() + + def create(self, request, slug, project_id, issue_id): + # SECURITY: bind the parent work item to the URL workspace + project so a + # cross-project issue_id 404s instead of silently attaching the item + # somewhere the caller does not expect. + if not Issue.objects.filter(pk=issue_id, project_id=project_id, workspace__slug=slug).exists(): + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = IssueChecklistItemSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + if "sort_order" not in serializer.validated_data: + last_sort_order = IssueChecklistItem.objects.filter( + issue_id=issue_id, project_id=project_id, workspace__slug=slug + ).aggregate(largest=Max("sort_order"))["largest"] + serializer.validated_data["sort_order"] = ( + SORT_ORDER_STEP if last_sort_order is None else last_sort_order + SORT_ORDER_STEP + ) + + serializer.save(project_id=project_id, issue_id=issue_id) + + issue_activity.delay( + type="checklist_item.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + origin=base_host(request=request, is_app=True), + # NOTE: no `notification=True` here. issue_activity defaults it to + # False; IssueLinkViewSet passes True, which would fan out an + # in-app + email notification to every subscriber on every + # checklist change. That is not wanted for a checkbox tick. + ) + + checklist_item = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueChecklistItemSerializer(checklist_item) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, slug, project_id, issue_id, pk): + # SECURITY: resolve through get_queryset(), never a bare pk lookup, so + # this cannot drift out of sync with the scoping filter above. + checklist_item = self.get_queryset().filter(pk=pk).first() + if not checklist_item: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + current_instance = json.dumps(IssueChecklistItemSerializer(checklist_item).data, cls=DjangoJSONEncoder) + + serializer = IssueChecklistItemSerializer(checklist_item, data=request.data, partial=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + serializer.save() + + issue_activity.delay( + type="checklist_item.activity.updated", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + origin=base_host(request=request, is_app=True), + ) + + checklist_item = self.get_queryset().get(id=serializer.data.get("id")) + serializer = IssueChecklistItemSerializer(checklist_item) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, slug, project_id, issue_id, pk): + checklist_item = self.get_queryset().filter(pk=pk).first() + if not checklist_item: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + current_instance = json.dumps(IssueChecklistItemSerializer(checklist_item).data, cls=DjangoJSONEncoder) + + issue_activity.delay( + type="checklist_item.activity.deleted", + requested_data=json.dumps({"checklist_item_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + origin=base_host(request=request, is_app=True), + ) + + checklist_item.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py index 032feb02a60..d0c47c0d4e8 100644 --- a/apps/api/plane/bgtasks/issue_activities_task.py +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -1014,6 +1014,116 @@ def delete_link_activity( ) +def create_checklist_item_activity( + requested_data, + current_instance, + issue_id, + project_id, + actor_id, + workspace_id, + issue_activities, + epoch, +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = json.loads(current_instance) if current_instance is not None else None + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="added a checklist item", + verb="created", + actor_id=actor_id, + field="checklist_item", + new_value=requested_data.get("name", ""), + new_identifier=requested_data.get("id", None), + epoch=epoch, + ) + ) + + +def update_checklist_item_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = json.loads(current_instance) if current_instance is not None else None + + # A drag-reorder PATCH only ever carries sort_order and must stay silent + # (spec FR-025); each of the two conditions below only fires when the + # field it checks is present AND actually different from before. + if "status" in requested_data and requested_data.get("status") != current_instance.get("status"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="updated a checklist item", + verb="updated", + actor_id=actor_id, + field="checklist_item_status", + old_value=current_instance.get("status", ""), + new_value=requested_data.get("status", ""), + old_identifier=current_instance.get("id"), + new_identifier=current_instance.get("id"), + epoch=epoch, + ) + ) + + if "name" in requested_data and requested_data.get("name") != current_instance.get("name"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="renamed a checklist item", + verb="updated", + actor_id=actor_id, + field="checklist_item", + old_value=current_instance.get("name", ""), + new_value=requested_data.get("name", ""), + old_identifier=current_instance.get("id"), + new_identifier=current_instance.get("id"), + epoch=epoch, + ) + ) + + +def delete_checklist_item_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + current_instance = json.loads(current_instance) if current_instance is not None else None + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="removed a checklist item", + verb="deleted", + actor_id=actor_id, + field="checklist_item", + old_value=current_instance.get("name", ""), + new_value="", + epoch=epoch, + ) + ) + + def create_attachment_activity( requested_data, current_instance, @@ -1551,6 +1661,9 @@ def issue_activity( "link.activity.created": create_link_activity, "link.activity.updated": update_link_activity, "link.activity.deleted": delete_link_activity, + "checklist_item.activity.created": create_checklist_item_activity, + "checklist_item.activity.updated": update_checklist_item_activity, + "checklist_item.activity.deleted": delete_checklist_item_activity, "attachment.activity.created": create_attachment_activity, "attachment.activity.deleted": delete_attachment_activity, "issue_relation.activity.created": create_issue_relation_activity, diff --git a/apps/api/plane/db/migrations/0123_issuechecklistitem.py b/apps/api/plane/db/migrations/0123_issuechecklistitem.py new file mode 100644 index 00000000000..a59c896261d --- /dev/null +++ b/apps/api/plane/db/migrations/0123_issuechecklistitem.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.15 on 2026-09-08 10:50 + +import django.db.models.deletion +import plane.db.mixins +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0122_alter_draftissue_assignees_alter_issue_assignees_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='IssueChecklistItem', + 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)), + ('status', models.CharField(choices=[('to_do', 'To Do'), ('in_progress', 'In Progress'), ('skipped', 'Skipped'), ('done', 'Done')], default='to_do', max_length=20)), + ('sort_order', models.FloatField(default=65535)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_checklist_items', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_checklist_item', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Checklist Item', + 'verbose_name_plural': 'Issue Checklist Items', + 'db_table': 'issue_checklist_items', + 'ordering': ('sort_order', 'created_at'), + 'indexes': [models.Index(fields=['issue', 'sort_order'], name='checklist_issue_order_idx')], + }, + bases=(plane.db.mixins.ChangeTrackerMixin, models.Model), + ), + ] diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index 5cf9dec2a3e..d522d80b542 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -34,6 +34,7 @@ IssueActivity, IssueAssignee, IssueBlocker, + IssueChecklistItem, IssueComment, IssueLabel, IssueLink, diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index fe23ee681dc..7f9d3fda5d1 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -15,6 +15,9 @@ from django.db.models import Q from django import apps +# Third party imports +from crum import get_current_user + # Module imports from plane.utils.html_processor import strip_tags from plane.utils.path_validator import sanitize_filename @@ -384,6 +387,75 @@ def __str__(self): return f"{self.issue.name} {self.url}" +class IssueChecklistItem(ChangeTrackerMixin, ProjectBaseModel): + """A single step on a work item's checklist. + + Deliberately minimal: a label and a status. No assignee, no dates — a + step that needs those belongs on a sub-issue instead. + """ + + TRACKED_FIELDS = ["status"] + + class ChecklistItemStatus(models.TextChoices): + TO_DO = "to_do", "To Do" + IN_PROGRESS = "in_progress", "In Progress" + SKIPPED = "skipped", "Skipped" + DONE = "done", "Done" + + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_checklist_item") + name = models.CharField(max_length=255) + status = models.CharField( + max_length=20, + choices=ChecklistItemStatus.choices, + default=ChecklistItemStatus.TO_DO, + ) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True, blank=True) + completed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="completed_checklist_items", + ) + + class Meta: + verbose_name = "Issue Checklist Item" + verbose_name_plural = "Issue Checklist Items" + db_table = "issue_checklist_items" + ordering = ("sort_order", "created_at") + indexes = [models.Index(fields=["issue", "sort_order"], name="checklist_issue_order_idx")] + + def __str__(self): + return f"{self.issue.name} {self.name}" + + def save(self, *args, **kwargs): + kwargs = self._sync_completed_at(kwargs) + super().save(*args, **kwargs) + + def _sync_completed_at(self, kwargs): + """Mirror of Issue._sync_completed_at for checklist status. + + Only DONE sets completed_at/completed_by — SKIPPED deliberately does + not, since ruling a step out is not the same as completing it. + """ + if not self._state.adding and not self.has_changed("status"): + return kwargs + + if self.status == self.ChecklistItemStatus.DONE: + self.completed_at = timezone.now() + self.completed_by = get_current_user() + else: + self.completed_at = None + self.completed_by = None + + update_fields = kwargs.get("update_fields") + if update_fields is not None: + kwargs["update_fields"] = list(set(update_fields) | {"completed_at", "completed_by"}) + return kwargs + + + def get_upload_path(instance, filename): filename = sanitize_filename(filename) or uuid4().hex return f"{instance.workspace.id}/{uuid4().hex}-{filename}" diff --git a/apps/api/plane/tests/contract/app/test_checklist_activity_app.py b/apps/api/plane/tests/contract/app/test_checklist_activity_app.py new file mode 100644 index 00000000000..1b213211b0f --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_checklist_activity_app.py @@ -0,0 +1,169 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for checklist item activity logging (spec FR-024 - FR-027). + +``IssueChecklistItemViewSet`` fires ``issue_activity.delay(...)`` without a +``notification`` kwarg (defaulting to ``False``), unlike ``IssueLinkViewSet``, +which passes ``notification=True`` — copying that would email every +subscriber on every checklist status change. ``issue_activity.delay`` is +patched here with a synchronous ``side_effect`` so the real activity-handler +logic in ``bgtasks/issue_activities_task.py`` runs in-process (Celery tasks +are directly callable without a broker), letting these tests assert on the +actual ``IssueActivity`` rows it writes rather than just the call arguments. +""" + +import pytest +from rest_framework import status + +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import Issue, IssueActivity, IssueChecklistItem, Project, ProjectMember + +CHECKLIST_LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/" +CHECKLIST_DETAIL_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/{pk}/" + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Test Project", identifier="TP", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20) + return project + + +@pytest.fixture +def issue(db, workspace, project, create_user): + issue = Issue(name="Test issue", project=project, workspace=workspace) + issue.save(created_by_id=create_user.id) + return issue + + +@pytest.fixture +def checklist_item(db, workspace, project, issue, create_user): + item = IssueChecklistItem(name="Ship it", issue=issue, project=project, workspace=workspace) + item.save(created_by_id=create_user.id) + return item + + +@pytest.fixture +def run_activity_synchronously(mocker): + """Make issue_activity.delay(...) run the task body in-process instead of + queuing it, so tests can assert on the IssueActivity rows it produces.""" + return mocker.patch( + "plane.app.views.issue.checklist.issue_activity.delay", + side_effect=lambda **kwargs: issue_activity(**kwargs), + ) + + +@pytest.mark.contract +class TestChecklistActivity: + @pytest.mark.django_db + def test_create_writes_activity( + self, session_client, run_activity_synchronously, workspace, project, issue + ): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = session_client.post(url, {"name": "Write tests"}, format="json") + assert response.status_code == status.HTTP_201_CREATED + + activities = IssueActivity.objects.filter(issue_id=issue.id, field="checklist_item", verb="created") + assert activities.count() == 1, f"Expected one create activity row, found {activities.count()}" + assert activities.first().new_value == "Write tests" + + @pytest.mark.django_db + def test_rename_writes_activity( + self, session_client, run_activity_synchronously, workspace, project, issue, checklist_item + ): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = session_client.patch(url, {"name": "Ship it today"}, format="json") + assert response.status_code == status.HTTP_200_OK + + activities = IssueActivity.objects.filter( + issue_id=issue.id, field="checklist_item", verb="updated" + ) + assert activities.count() == 1 + assert activities.first().old_value == "Ship it" + assert activities.first().new_value == "Ship it today" + + @pytest.mark.django_db + def test_status_change_writes_activity( + self, session_client, run_activity_synchronously, workspace, project, issue, checklist_item + ): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = session_client.patch(url, {"status": "done"}, format="json") + assert response.status_code == status.HTTP_200_OK + + activities = IssueActivity.objects.filter(issue_id=issue.id, field="checklist_item_status") + assert activities.count() == 1, f"Expected one status activity row, found {activities.count()}" + assert activities.first().old_value == "to_do" + assert activities.first().new_value == "done" + + @pytest.mark.django_db + def test_delete_writes_activity( + self, session_client, run_activity_synchronously, workspace, project, issue, checklist_item + ): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = session_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + activities = IssueActivity.objects.filter(issue_id=issue.id, field="checklist_item", verb="deleted") + assert activities.count() == 1 + assert activities.first().old_value == "Ship it" + + @pytest.mark.django_db + def test_reorder_writes_no_activity( + self, session_client, run_activity_synchronously, workspace, project, issue, checklist_item + ): + """A drag-reorder PATCH carries only sort_order; it must not appear in + the activity feed (spec FR-025) — a drag would otherwise flood it.""" + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = session_client.patch(url, {"sort_order": 999.5}, format="json") + assert response.status_code == status.HTTP_200_OK + + activities = IssueActivity.objects.filter(issue_id=issue.id) + assert activities.count() == 0, ( + f"Reorder must write no activity, found: {list(activities.values('field', 'verb'))!r}" + ) + + @pytest.mark.django_db + def test_no_op_status_write_no_activity( + self, session_client, run_activity_synchronously, workspace, project, issue, checklist_item + ): + """PATCHing a status the item already holds must be a silent no-op + (spec FR-026), matching the idempotent-retry rationale for having no + separate toggle endpoint.""" + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = session_client.patch(url, {"status": "to_do"}, format="json") + assert response.status_code == status.HTTP_200_OK + + activities = IssueActivity.objects.filter(issue_id=issue.id) + assert activities.count() == 0 + + @pytest.mark.django_db + def test_no_notification_fan_out(self, mocker, session_client, workspace, project, issue, checklist_item): + """issue_activity defaults notification=False; IssueChecklistItemViewSet + must never override it — a status change must not email subscribers.""" + mock_notifications = mocker.patch("plane.bgtasks.issue_activities_task.notifications.delay") + mocker.patch( + "plane.app.views.issue.checklist.issue_activity.delay", + side_effect=lambda **kwargs: issue_activity(**kwargs), + ) + + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = session_client.patch(url, {"status": "done"}, format="json") + assert response.status_code == status.HTTP_200_OK + + mock_notifications.assert_not_called() diff --git a/apps/api/plane/tests/contract/app/test_checklist_cross_project_scope_app.py b/apps/api/plane/tests/contract/app/test_checklist_cross_project_scope_app.py new file mode 100644 index 00000000000..5bf0977c2ab --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_checklist_cross_project_scope_app.py @@ -0,0 +1,144 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for ``IssueChecklistItemViewSet`` cross-project scoping. + +``IssueChecklistItemViewSet`` is guarded by ``ProjectEntityPermission``, which +proves only that the caller is a member of the URL ``project_id`` — not that +``issue_id`` belongs to it (the same gap documented in +``plane/app/views/issue/sub_issue.py:38-44``). Every lookup in the viewset is +scoped on ``workspace__slug`` + ``project_id`` + ``issue_id`` together, so a +member of project A must get a plain 404 (not data, not 403) when addressing +a checklist item that actually lives on project B's issue. +""" + +import pytest +from rest_framework import status + +from plane.db.models import Issue, IssueChecklistItem, Project, ProjectMember + +CHECKLIST_LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/" +CHECKLIST_DETAIL_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/{pk}/" + + +def _make_issue(name, project, workspace, author): + issue = Issue(name=name, project=project, workspace=workspace) + issue.save(created_by_id=author.id) + return issue + + +@pytest.fixture +def project_a(db, workspace, create_user): + """The project the caller is a member of (the URL project).""" + project = Project.objects.create( + name="Project A", identifier="PA", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20) + return project + + +@pytest.fixture +def project_b(db, workspace, create_user): + """A sibling project in the same workspace the caller is NOT a member of.""" + return Project.objects.create(name="Project B", identifier="PB", workspace=workspace, created_by=create_user) + + +@pytest.fixture +def issue_a(db, workspace, project_a, create_user): + return _make_issue("A issue", project_a, workspace, create_user) + + +@pytest.fixture +def issue_b(db, workspace, project_b, create_user): + """The victim: an issue in project B, which the caller cannot see.""" + return _make_issue("B issue", project_b, workspace, create_user) + + +@pytest.fixture +def checklist_item_b(db, workspace, project_b, issue_b, create_user): + """A checklist item on project B's issue.""" + item = IssueChecklistItem(name="B item", issue=issue_b, project=project_b, workspace=workspace) + item.save(created_by_id=create_user.id) + return item + + +@pytest.mark.contract +class TestChecklistCrossProjectScope: + """A project member must not read or write another project's checklist items.""" + + @pytest.mark.django_db + def test_list_cross_project_issue_hidden(self, session_client, workspace, project_a, issue_b, checklist_item_b): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project_a.id, issue_id=issue_b.id) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert response.data == [], f"Leaked cross-project checklist item: {response.data!r}" + + @pytest.mark.django_db + def test_create_cross_project_issue_404s(self, session_client, workspace, project_a, issue_b): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project_a.id, issue_id=issue_b.id) + response = session_client.post(url, {"name": "Sneaky item"}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Expected 404, got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueChecklistItem.objects.filter(name="Sneaky item").exists(), ( + "A checklist item was created on a cross-project issue" + ) + + @pytest.mark.django_db + def test_retrieve_cross_project_item_404s( + self, session_client, workspace, project_a, issue_b, checklist_item_b + ): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project_a.id, issue_id=issue_b.id, pk=checklist_item_b.id + ) + response = session_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_update_cross_project_item_404s(self, session_client, workspace, project_a, issue_b, checklist_item_b): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project_a.id, issue_id=issue_b.id, pk=checklist_item_b.id + ) + response = session_client.patch(url, {"status": "done"}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Expected 404, got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + checklist_item_b.refresh_from_db() + assert checklist_item_b.status == "to_do", "Cross-project item was modified" + + @pytest.mark.django_db + def test_delete_cross_project_item_404s(self, session_client, workspace, project_a, issue_b, checklist_item_b): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project_a.id, issue_id=issue_b.id, pk=checklist_item_b.id + ) + response = session_client.delete(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND, ( + f"Expected 404, got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + checklist_item_b.refresh_from_db() + assert checklist_item_b.deleted_at is None, "Cross-project item was soft-deleted" + + # --- Positive control: legitimate same-project use still works ------------ + + @pytest.mark.django_db + def test_same_project_crud_allowed(self, session_client, workspace, project_a, issue_a): + create_url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project_a.id, issue_id=issue_a.id) + response = session_client.post(create_url, {"name": "Real item"}, format="json") + assert response.status_code == status.HTTP_201_CREATED, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + item_id = response.data["id"] + + detail_url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project_a.id, issue_id=issue_a.id, pk=item_id + ) + response = session_client.patch(detail_url, {"status": "done"}, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "done" diff --git a/apps/api/plane/tests/contract/app/test_checklist_crud_app.py b/apps/api/plane/tests/contract/app/test_checklist_crud_app.py new file mode 100644 index 00000000000..7a3e065adcb --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_checklist_crud_app.py @@ -0,0 +1,99 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for the ``IssueChecklistItemViewSet`` CRUD round-trip and +name validation (spec FR-001, FR-003, FR-004, FR-005, FR-006).""" + +import pytest +from rest_framework import status + +from plane.db.models import Issue, IssueChecklistItem, Project, ProjectMember + +CHECKLIST_LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/" +CHECKLIST_DETAIL_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/{pk}/" + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Test Project", identifier="TP", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20) + return project + + +@pytest.fixture +def issue(db, workspace, project, create_user): + issue = Issue(name="Test issue", project=project, workspace=workspace) + issue.save(created_by_id=create_user.id) + return issue + + +@pytest.mark.contract +class TestChecklistCrud: + @pytest.mark.django_db + def test_create_rename_delete_round_trip(self, session_client, workspace, project, issue): + list_url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + + create_response = session_client.post(list_url, {"name": "Write tests"}, format="json") + assert create_response.status_code == status.HTTP_201_CREATED, ( + f"Got {create_response.status_code}: {getattr(create_response, 'data', None)!r}" + ) + assert create_response.data["name"] == "Write tests" + assert create_response.data["status"] == "to_do" + item_id = create_response.data["id"] + + list_response = session_client.get(list_url) + assert list_response.status_code == status.HTTP_200_OK + # response.data holds pre-render values (UUID objects here, not JSON + # strings), so compare both sides as str for a representation-agnostic check. + assert str(item_id) in {str(row["id"]) for row in list_response.data} + + detail_url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=item_id + ) + rename_response = session_client.patch(detail_url, {"name": "Write and run tests"}, format="json") + assert rename_response.status_code == status.HTTP_200_OK + assert rename_response.data["name"] == "Write and run tests" + + delete_response = session_client.delete(detail_url) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + # `objects` excludes soft-deleted rows, so use `all_objects` to confirm + # the row still exists with `deleted_at` set rather than being purged. + item = IssueChecklistItem.all_objects.get(id=item_id) + assert item.deleted_at is not None, "Item was not soft-deleted" + # excluded from the default manager, which the endpoint reads through + list_response = session_client.get(list_url) + assert str(item_id) not in {str(row["id"]) for row in list_response.data} + + @pytest.mark.django_db + def test_blank_name_rejected(self, session_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = session_client.post(url, {"name": ""}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not IssueChecklistItem.objects.filter(issue=issue).exists() + + @pytest.mark.django_db + def test_whitespace_only_name_rejected(self, session_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = session_client.post(url, {"name": " "}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not IssueChecklistItem.objects.filter(issue=issue).exists() + + @pytest.mark.django_db + def test_name_over_255_chars_rejected(self, session_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = session_client.post(url, {"name": "x" * 256}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueChecklistItem.objects.filter(issue=issue).exists() + + @pytest.mark.django_db + def test_name_is_stripped(self, session_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = session_client.post(url, {"name": " Ship it "}, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == "Ship it" diff --git a/apps/api/plane/tests/contract/app/test_checklist_guest_write_denied_app.py b/apps/api/plane/tests/contract/app/test_checklist_guest_write_denied_app.py new file mode 100644 index 00000000000..bac24efe1ac --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_checklist_guest_write_denied_app.py @@ -0,0 +1,117 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for guest write restrictions on ``IssueChecklistItemViewSet``. + +``ProjectEntityPermission`` allows SAFE_METHODS for any active project member +but restricts writes to role ADMIN (20) or MEMBER (15) — a project GUEST +(role 5) can see a checklist but cannot change it (spec FR-030). This is a +deliberate v1 decision, not an oversight: kept for consistency with every +other issue child object (links, attachments), revisit with usage data. +""" + +from uuid import uuid4 + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from plane.db.models import Issue, IssueChecklistItem, Project, ProjectMember, User, WorkspaceMember + +CHECKLIST_LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/" +CHECKLIST_DETAIL_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/{pk}/" + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Scoped Project", identifier="SP", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20) + return project + + +@pytest.fixture +def issue(db, workspace, project, create_user): + issue = Issue(name="Test issue", project=project, workspace=workspace) + issue.save(created_by_id=create_user.id) + return issue + + +@pytest.fixture +def checklist_item(db, workspace, project, issue, create_user): + item = IssueChecklistItem(name="Existing item", issue=issue, project=project, workspace=workspace) + item.save(created_by_id=create_user.id) + return item + + +@pytest.fixture +def guest(db, workspace, project): + """An active project GUEST (role=5) with guest_view_all_features=True, + so read-visibility is not the thing under test here — write restriction is. + """ + unique_id = uuid4().hex[:8] + user = User.objects.create( + email=f"guest-{unique_id}@plane.so", + username=f"guest_{unique_id}", + first_name="Guest", + last_name="User", + ) + user.set_password("test-password") + user.save() + WorkspaceMember.objects.create(workspace=workspace, member=user, role=5) + ProjectMember.objects.create(project=project, member=user, workspace=workspace, role=5) + project.guest_view_all_features = True + project.save() + return user + + +@pytest.fixture +def guest_client(guest): + client = APIClient() + client.force_authenticate(user=guest) + return client + + +@pytest.mark.contract +class TestChecklistGuestWriteDenied: + """A project guest can read a checklist but cannot change it.""" + + @pytest.mark.django_db + def test_guest_can_list(self, guest_client, workspace, project, issue, checklist_item): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = guest_client.get(url) + assert response.status_code == status.HTTP_200_OK + # response.data holds pre-render values (UUID objects here, not JSON + # strings), so compare both sides as str for a representation-agnostic check. + assert str(checklist_item.id) in {str(row["id"]) for row in response.data} + + @pytest.mark.django_db + def test_guest_cannot_create(self, guest_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = guest_client.post(url, {"name": "Guest's item"}, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN, ( + f"Got {response.status_code}: {getattr(response, 'data', None)!r}" + ) + assert not IssueChecklistItem.objects.filter(name="Guest's item").exists() + + @pytest.mark.django_db + def test_guest_cannot_update(self, guest_client, workspace, project, issue, checklist_item): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = guest_client.patch(url, {"status": "done"}, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + checklist_item.refresh_from_db() + assert checklist_item.status == "to_do" + + @pytest.mark.django_db + def test_guest_cannot_delete(self, guest_client, workspace, project, issue, checklist_item): + url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=checklist_item.id + ) + response = guest_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + checklist_item.refresh_from_db() + assert checklist_item.deleted_at is None diff --git a/apps/api/plane/tests/contract/app/test_checklist_sort_order_app.py b/apps/api/plane/tests/contract/app/test_checklist_sort_order_app.py new file mode 100644 index 00000000000..68b082af20e --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_checklist_sort_order_app.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. + +"""Contract tests for checklist item ordering (spec FR-007, FR-021, FR-023). + +Server-computed append on create (``Max(sort_order) + 65535``), client-driven +float-midpoint reorder via a plain PATCH of ``sort_order``, and a +deterministic ``created_at`` tiebreak when two items share a ``sort_order``. +""" + +import pytest +from rest_framework import status + +from plane.db.models import Issue, IssueChecklistItem, Project, ProjectMember + +CHECKLIST_LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/" +CHECKLIST_DETAIL_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/{pk}/" + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Test Project", identifier="TP", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20) + return project + + +@pytest.fixture +def issue(db, workspace, project, create_user): + issue = Issue(name="Test issue", project=project, workspace=workspace) + issue.save(created_by_id=create_user.id) + return issue + + +@pytest.mark.contract +class TestChecklistSortOrder: + @pytest.mark.django_db + def test_bare_creates_append_in_order(self, session_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + + first = session_client.post(url, {"name": "First"}, format="json") + second = session_client.post(url, {"name": "Second"}, format="json") + third = session_client.post(url, {"name": "Third"}, format="json") + for response in (first, second, third): + assert response.status_code == status.HTTP_201_CREATED + + assert first.data["sort_order"] == 65535 + assert second.data["sort_order"] == 131070 + assert third.data["sort_order"] == 196605 + + list_response = session_client.get(url) + assert [row["name"] for row in list_response.data] == ["First", "Second", "Third"] + + @pytest.mark.django_db + def test_midpoint_reorder(self, session_client, workspace, project, issue): + list_url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + first = session_client.post(list_url, {"name": "First"}, format="json").data + second = session_client.post(list_url, {"name": "Second"}, format="json").data + third = session_client.post(list_url, {"name": "Third"}, format="json").data + + # move "Third" between "First" and "Second" + midpoint = (first["sort_order"] + second["sort_order"]) / 2 + detail_url = CHECKLIST_DETAIL_URL.format( + slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=third["id"] + ) + response = session_client.patch(detail_url, {"sort_order": midpoint}, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.data["sort_order"] == midpoint + + list_response = session_client.get(list_url) + assert [row["name"] for row in list_response.data] == ["First", "Third", "Second"] + + @pytest.mark.django_db + def test_equal_sort_order_breaks_tie_on_created_at(self, db, workspace, project, issue, create_user): + """Two items sharing a sort_order (a concurrent-append race) still + return in a deterministic order via the (sort_order, created_at) + Meta.ordering tiebreak, rather than an arbitrary one.""" + first = IssueChecklistItem(name="Older", issue=issue, project=project, workspace=workspace, sort_order=100) + first.save(created_by_id=create_user.id) + second = IssueChecklistItem(name="Newer", issue=issue, project=project, workspace=workspace, sort_order=100) + second.save(created_by_id=create_user.id) + + ordered_names = list( + IssueChecklistItem.objects.filter(issue=issue).order_by("sort_order", "created_at").values_list( + "name", flat=True + ) + ) + assert ordered_names == ["Older", "Newer"] + + @pytest.mark.django_db + def test_client_supplied_sort_order_on_create_is_honoured(self, session_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = session_client.post(url, {"name": "Positioned", "sort_order": 42.5}, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["sort_order"] == 42.5 diff --git a/apps/api/plane/tests/contract/app/test_checklist_status_app.py b/apps/api/plane/tests/contract/app/test_checklist_status_app.py new file mode 100644 index 00000000000..c81272b52c2 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_checklist_status_app.py @@ -0,0 +1,138 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Contract tests for ``IssueChecklistItem`` status transitions. + +The single most important assertion in this feature: entering ``done`` sets +``completed_at``/``completed_by``, but entering ``skipped`` sets NEITHER — +skipping is not completing (spec FR-017, FR-018, FR-019). This is the +assumption most likely to be miscoded by anyone carrying over boolean-era +"any terminal state means completed" habits. +""" + +import pytest +from rest_framework import status + +from plane.db.models import Issue, IssueChecklistItem, Project, ProjectMember + +CHECKLIST_LIST_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/" +CHECKLIST_DETAIL_URL = "/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/checklist-items/{pk}/" + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Test Project", identifier="TP", workspace=workspace, created_by=create_user + ) + ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20) + return project + + +@pytest.fixture +def issue(db, workspace, project, create_user): + issue = Issue(name="Test issue", project=project, workspace=workspace) + issue.save(created_by_id=create_user.id) + return issue + + +@pytest.fixture +def checklist_item(db, workspace, project, issue, create_user): + item = IssueChecklistItem(name="Ship it", issue=issue, project=project, workspace=workspace) + item.save(created_by_id=create_user.id) + return item + + +def _detail_url(workspace, project, issue, item): + return CHECKLIST_DETAIL_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id, pk=item.id) + + +@pytest.mark.contract +class TestChecklistStatusTransitions: + @pytest.mark.django_db + def test_new_item_defaults_to_to_do(self, session_client, workspace, project, issue): + url = CHECKLIST_LIST_URL.format(slug=workspace.slug, project_id=project.id, issue_id=issue.id) + response = session_client.post(url, {"name": "Fresh item"}, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["status"] == "to_do" + assert response.data["completed_at"] is None + assert response.data["completed_by"] is None + + @pytest.mark.django_db + def test_done_sets_completed_at_and_completed_by( + self, session_client, workspace, project, issue, checklist_item, create_user + ): + url = _detail_url(workspace, project, issue, checklist_item) + response = session_client.patch(url, {"status": "done"}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "done" + assert response.data["completed_at"] is not None, "completed_at was not set on entering done" + # response.data holds a pre-render value (a UUID object here, not a + # JSON string), so compare both sides as str for a robust check. + assert str(response.data["completed_by"]) == str(create_user.id), ( + "completed_by was not set on entering done" + ) + + @pytest.mark.django_db + def test_skipped_sets_neither_completed_field(self, session_client, workspace, project, issue, checklist_item): + """The critical case: skipped is a terminal state but NOT completion.""" + url = _detail_url(workspace, project, issue, checklist_item) + response = session_client.patch(url, {"status": "skipped"}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "skipped" + assert response.data["completed_at"] is None, ( + f"skipped incorrectly set completed_at: {response.data!r}" + ) + assert response.data["completed_by"] is None, ( + f"skipped incorrectly set completed_by: {response.data!r}" + ) + + @pytest.mark.django_db + def test_leaving_done_clears_completed_fields(self, session_client, workspace, project, issue, checklist_item): + url = _detail_url(workspace, project, issue, checklist_item) + response = session_client.patch(url, {"status": "done"}, format="json") + assert response.data["completed_at"] is not None + + response = session_client.patch(url, {"status": "to_do"}, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "to_do" + assert response.data["completed_at"] is None, "completed_at was not cleared leaving done" + assert response.data["completed_by"] is None, "completed_by was not cleared leaving done" + + @pytest.mark.django_db + def test_in_progress_sets_neither_completed_field( + self, session_client, workspace, project, issue, checklist_item + ): + url = _detail_url(workspace, project, issue, checklist_item) + response = session_client.patch(url, {"status": "in_progress"}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["completed_at"] is None + assert response.data["completed_by"] is None + + @pytest.mark.django_db + def test_invalid_status_value_rejected(self, session_client, workspace, project, issue, checklist_item): + url = _detail_url(workspace, project, issue, checklist_item) + response = session_client.patch(url, {"status": "not_a_real_status"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + checklist_item.refresh_from_db() + assert checklist_item.status == "to_do", "Invalid status value was persisted" + + @pytest.mark.django_db + def test_client_supplied_completed_by_is_ignored( + self, session_client, workspace, project, issue, checklist_item, create_user + ): + """completed_by is read-only — a client cannot claim someone else finished the item.""" + url = _detail_url(workspace, project, issue, checklist_item) + response = session_client.patch( + url, + {"status": "done", "completed_by": "00000000-0000-0000-0000-000000000000"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert str(response.data["completed_by"]) == str(create_user.id), ( + "Client-supplied completed_by was honoured instead of the actual actor" + ) diff --git a/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx b/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx index 38bea85d80b..435776f831a 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx @@ -5,12 +5,20 @@ */ import React from "react"; -import { AttachOutline, LinkOutline, RelationsOutline, ViewsOutline } from "@makeplane/propel/icons"; +import { + AttachOutline, + CheckSquareOutline, + LinkOutline, + RelationsOutline, + ViewsOutline, +} from "@makeplane/propel/icons"; +import { EIssueServiceType } from "@plane/types"; import { useTranslation } from "@plane/i18n"; // plane imports import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; // local imports import { IssueAttachmentActionButton } from "./attachments"; +import { ChecklistActionButton } from "./checklist"; import { IssueLinksActionButton } from "./links"; import { RelationActionButton } from "./relations"; import { SubIssuesActionButton } from "./sub-issues"; @@ -32,6 +40,20 @@ export function IssueDetailWidgetActionButtons(props: Props) { return (
+ {issueServiceType === EIssueServiceType.ISSUES && !hideWidgets?.includes("checklist") && ( + } + disabled={disabled} + /> + } + disabled={disabled} + issueServiceType={issueServiceType} + /> + )} {!hideWidgets?.includes("sub-work-items") && ( + ); +} diff --git a/apps/web/core/components/issues/issue-detail-widgets/checklist/helper.tsx b/apps/web/core/components/issues/issue-detail-widgets/checklist/helper.tsx new file mode 100644 index 00000000000..70f2b322948 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail-widgets/checklist/helper.tsx @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo } from "react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssueChecklistItem, TIssueServiceType } from "@plane/types"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; + +export type TChecklistOperations = { + create: (data: Partial) => Promise; + update: (checklistItemId: string, data: Partial) => Promise; + // Status changes are the highest-frequency operation on a checklist — a + // toast per change is unusable, so this surfaces only errors (unlike + // create/update/remove below, which confirm success too). + setStatus: (checklistItemId: string, status: TIssueChecklistItem["status"]) => Promise; + reorder: (checklistItemId: string, sortOrder: number) => Promise; + remove: (checklistItemId: string) => Promise; +}; + +export const useChecklistOperations = ( + workspaceSlug: string, + projectId: string, + issueId: string, + issueServiceType: TIssueServiceType +): TChecklistOperations => { + const { createChecklistItem, updateChecklistItem, removeChecklistItem } = useIssueDetail(issueServiceType); + // i18n + const { t } = useTranslation(); + + const checklistOperations: TChecklistOperations = useMemo( + () => ({ + create: async (data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await createChecklistItem(workspaceSlug, projectId, issueId, data); + setToast({ + message: t("checklist.toasts.created.message"), + type: TOAST_TYPE.SUCCESS, + title: t("checklist.toasts.created.title"), + }); + } catch (error: any) { + setToast({ + message: error?.data?.error ?? t("checklist.toasts.not_created.message"), + type: TOAST_TYPE.ERROR, + title: t("checklist.toasts.not_created.title"), + }); + throw error; + } + }, + update: async (checklistItemId: string, data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await updateChecklistItem(workspaceSlug, projectId, issueId, checklistItemId, data); + setToast({ + message: t("checklist.toasts.updated.message"), + type: TOAST_TYPE.SUCCESS, + title: t("checklist.toasts.updated.title"), + }); + } catch (error: any) { + setToast({ + message: error?.data?.error ?? t("checklist.toasts.not_updated.message"), + type: TOAST_TYPE.ERROR, + title: t("checklist.toasts.not_updated.title"), + }); + throw error; + } + }, + setStatus: async (checklistItemId: string, status: TIssueChecklistItem["status"]) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await updateChecklistItem(workspaceSlug, projectId, issueId, checklistItemId, { status }); + } catch (error: any) { + setToast({ + message: error?.data?.error ?? t("checklist.toasts.not_updated.message"), + type: TOAST_TYPE.ERROR, + title: t("checklist.toasts.not_updated.title"), + }); + throw error; + } + }, + reorder: async (checklistItemId: string, sortOrder: number) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await updateChecklistItem(workspaceSlug, projectId, issueId, checklistItemId, { sort_order: sortOrder }); + } catch { + setToast({ + message: t("checklist.toasts.not_updated.message"), + type: TOAST_TYPE.ERROR, + title: t("checklist.toasts.not_updated.title"), + }); + } + }, + remove: async (checklistItemId: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await removeChecklistItem(workspaceSlug, projectId, issueId, checklistItemId); + setToast({ + message: t("checklist.toasts.removed.message"), + type: TOAST_TYPE.SUCCESS, + title: t("checklist.toasts.removed.title"), + }); + } catch { + setToast({ + message: t("checklist.toasts.not_removed.message"), + type: TOAST_TYPE.ERROR, + title: t("checklist.toasts.not_removed.title"), + }); + } + }, + }), + [workspaceSlug, projectId, issueId, createChecklistItem, updateChecklistItem, removeChecklistItem, t] + ); + + return checklistOperations; +}; diff --git a/apps/web/core/components/issues/issue-detail-widgets/checklist/index.ts b/apps/web/core/components/issues/issue-detail-widgets/checklist/index.ts new file mode 100644 index 00000000000..3c6016adb32 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail-widgets/checklist/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./content"; +export * from "./title"; +export * from "./root"; +export * from "./quick-action-button"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/checklist/quick-action-button.tsx b/apps/web/core/components/issues/issue-detail-widgets/checklist/quick-action-button.tsx new file mode 100644 index 00000000000..1d94bb60df8 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail-widgets/checklist/quick-action-button.tsx @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { TIssueServiceType } from "@plane/types"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; + +type Props = { + issueId: string; + customButton?: React.ReactNode; + disabled?: boolean; + issueServiceType: TIssueServiceType; +}; + +// Deliberately not a modal — there is no checklist modal. Clicking this +// button expands the widget if it is collapsed (or reveals it, for an issue +// with zero items so far) and focuses the inline add-input rendered at the +// bottom of the list. See checklist-add-item.tsx for the focus side. +export const ChecklistActionButton = observer(function ChecklistActionButton(props: Props) { + const { issueId, customButton, disabled = false, issueServiceType } = props; + // store hooks + const { openWidgets, toggleOpenWidget, startAddingChecklistItem } = useIssueDetail(issueServiceType); + + // handlers + const handleOnClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Only expand if currently collapsed — toggling an already-open widget + // would collapse it out from under the user. + if (!openWidgets.includes("checklist")) toggleOpenWidget("checklist"); + startAddingChecklistItem(issueId); + }; + + return ( + + ); +}); diff --git a/apps/web/core/components/issues/issue-detail-widgets/checklist/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/checklist/root.tsx new file mode 100644 index 00000000000..e90f58a08e4 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail-widgets/checklist/root.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import { Collapsible } from "@makeplane/propel/components/collapsible"; +import type { TIssueServiceType } from "@plane/types"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// local imports +import { ChecklistCollapsibleContent } from "./content"; +import { ChecklistCollapsibleTitle } from "./title"; + +type Props = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled?: boolean; + issueServiceType: TIssueServiceType; +}; + +export const ChecklistCollapsible = observer(function ChecklistCollapsible(props: Props) { + const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props; + // store hooks + const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType); + // derived values + const isCollapsibleOpen = openWidgets.includes("checklist"); + + return ( + toggleOpenWidget("checklist")} + trigger={} + > + + + ); +}); diff --git a/apps/web/core/components/issues/issue-detail-widgets/checklist/title.tsx b/apps/web/core/components/issues/issue-detail-widgets/checklist/title.tsx new file mode 100644 index 00000000000..87f4b3df6b9 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail-widgets/checklist/title.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import { LinearProgress } from "@makeplane/propel/components/linear-progress"; +import { useTranslation } from "@plane/i18n"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; +import { getProgress } from "@plane/utils"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; + +type Props = { + issueId: string; + issueServiceType?: TIssueServiceType; +}; + +export const ChecklistCollapsibleTitle = observer(function ChecklistCollapsibleTitle(props: Props) { + const { issueId, issueServiceType = EIssueServiceType.ISSUES } = props; + // translation + const { t } = useTranslation(); + // store hooks + const { + checklist: { getChecklistProgressByIssueId }, + } = useIssueDetail(issueServiceType); + // derived values + const { done, skipped, total, activeTotal } = getChecklistProgressByIssueId(issueId); + + // Every item skipped: activeTotal is 0, so getProgress would read 0% — + // wrong signal when nothing is actually outstanding (spec FR-014). + const isAllSkipped = total > 0 && activeTotal === 0; + const percentage = isAllSkipped ? 100 : getProgress(done, activeTotal); + + return ( + + {t("common.checklist")} + + + + + + {isAllSkipped ? ( + t("checklist.all_skipped") + ) : ( + <> + {done}/{activeTotal} {t("common.done")} + + )} + + {/* Skipped items leave the denominator (spec FR-012, FR-013) — make + that visible rather than mysterious (spec FR-015). */} + {!isAllSkipped && skipped > 0 && ( + + · {skipped} {t("checklist.skipped_suffix")} + + )} + + + ); +}); diff --git a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx index 47772619f24..4f6d23e8008 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx @@ -8,11 +8,13 @@ import React from "react"; import { observer } from "mobx-react"; // plane imports import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useTimeLineRelationOptions } from "@/components/relations"; // local imports import { AttachmentsCollapsible } from "./attachments"; +import { ChecklistCollapsible } from "./checklist"; import { LinksCollapsible } from "./links"; import { RelationsCollapsible } from "./relations"; import { SubIssuesCollapsible } from "./sub-issues"; @@ -34,6 +36,7 @@ export const IssueDetailWidgetCollapsibles = observer(function IssueDetailWidget subIssues: { subIssuesByIssueId }, attachment: { getAttachmentsCountByIssueId, getAttachmentsUploadStatusByIssueId }, relation: { getRelationCountByIssueId }, + checklist: { getChecklistItemIdsByIssueId, isAddingChecklistItem }, } = useIssueDetail(issueServiceType); // derived values const issue = getIssueById(issueId); @@ -41,6 +44,15 @@ export const IssueDetailWidgetCollapsibles = observer(function IssueDetailWidget const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions(); const issueRelationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS); // render conditions + const checklistItemIds = getChecklistItemIdsByIssueId(issueId); + // Checklists have no modal-based "add" flow (research.md D11), so unlike + // every other widget here, a zero-item checklist must still be able to + // render — otherwise there's nowhere for the first item to appear when + // the top-row action button is clicked. + const shouldRenderChecklist = + issueServiceType === EIssueServiceType.ISSUES && + (!!checklistItemIds?.length || isAddingChecklistItem(issueId)) && + !hideWidgets?.includes("checklist"); const shouldRenderSubIssues = !!subIssues && subIssues.length > 0 && !hideWidgets?.includes("sub-work-items"); const shouldRenderRelations = issueRelationsCount > 0 && !hideWidgets?.includes("relations"); const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0 && !hideWidgets?.includes("links"); @@ -52,6 +64,15 @@ export const IssueDetailWidgetCollapsibles = observer(function IssueDetailWidget return (
+ {shouldRenderChecklist && ( + + )} {shouldRenderSubIssues && ( (null); + + // Focus when the "Add checklist item" action button is clicked, whether + // this component just mounted (zero-item case) or was already on screen. + const shouldFocus = isAddingChecklistItem(issueId); + useEffect(() => { + if (shouldFocus) inputRef.current?.focus(); + }, [shouldFocus]); + + const submit = async () => { + const name = value.trim(); + if (!name || isSubmitting) return; + setIsSubmitting(true); + try { + await checklistOperations.create({ name }); + setValue(""); + // Keep focus so consecutive items can be typed without re-engaging + // the input (spec FR-002, SC-001). + inputRef.current?.focus(); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + // create() already surfaces failures via toast and rethrows only so + // submit()'s try/finally can skip clearing the input on failure — + // catch here so that rejection doesn't go unhandled. + submit().catch(() => {}); + } else if (e.key === "Escape") { + setValue(""); + inputRef.current?.blur(); + } + }; + + const handleBlur = () => { + if (value.trim()) { + submit().catch(() => {}); + } else { + // Backed out without typing anything — let the zero-item render gate + // in issue-detail-widget-collapsibles.tsx close the section again + // (spec US1 acceptance scenario 6). + stopAddingChecklistItem(issueId); + } + }; + + if (disabled) return null; + + return ( + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + placeholder={t("checklist.placeholder")} + className="w-full rounded-sm border border-transparent bg-transparent px-2 py-1.5 text-13 text-primary outline-none placeholder:text-placeholder focus:border-subtle focus:bg-surface-2" + /> + ); +}); diff --git a/apps/web/core/components/issues/issue-detail/checklist/checklist-item.tsx b/apps/web/core/components/issues/issue-detail/checklist/checklist-item.tsx new file mode 100644 index 00000000000..6b186c9f31c --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/checklist/checklist-item.tsx @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { Fragment, useEffect, useRef, useState } from "react"; +import { useOutsideClickDetector } from "@plane/hooks"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { observer } from "mobx-react"; +// plane imports +import { DeleteOutline } from "@makeplane/propel/icons"; +import { useTranslation } from "@plane/i18n"; +import { EChecklistItemStatus } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; +import { DropIndicator } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// local imports +import type { TChecklistOperations } from "../../issue-detail-widgets/checklist/helper"; +import { ChecklistStatusDropdown } from "./checklist-status-dropdown"; + +type TChecklistDragData = { id: string }; + +type Props = { + checklistItemId: string; + isDraggable: boolean; + checklistOperations: TChecklistOperations; + onReorder: (sourceId: string, targetId: string, edge: "top" | "bottom") => void; + disabled?: boolean; + issueServiceType: TIssueServiceType; +}; + +export const ChecklistItem = observer(function ChecklistItem(props: Props) { + const { checklistItemId, isDraggable, checklistOperations, onReorder, disabled = false, issueServiceType } = props; + // translation + const { t } = useTranslation(); + // store hooks + const { + checklist: { getChecklistItemById }, + } = useIssueDetail(issueServiceType); + // derived values + const item = getChecklistItemById(checklistItemId); + // state + const [name, setName] = useState(item?.name ?? ""); + const [isDraggedOver, setIsDraggedOver] = useState(false); + const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null); + // Dual-click delete: the first click only arms a confirmation state (the + // button swaps to a "confirm" affordance); the second click within the + // window actually removes the item. Guards against the row's frequent + // hover-only delete icon being fat-fingered. + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + const inputRef = useRef(null); + const rowRef = useRef(null); + const deleteButtonRef = useRef(null); + + useOutsideClickDetector(deleteButtonRef, () => setIsConfirmingDelete(false)); + + // keep the local draft in sync with the store when the item changes from + // elsewhere (e.g. another tab, or the optimistic-rollback restoring it) + useEffect(() => { + if (item && document.activeElement !== inputRef.current) setName(item.name); + }, [item]); + + // Drag-and-drop: the whole row is the drag surface, following the same + // flat-list pattern as project-states/state-item.tsx (attachClosestEdge / + // extractClosestEdge), simplified since checklist items have no groups. + useEffect(() => { + const element = rowRef.current; + if (!element || !checklistItemId) return; + + const initialData: TChecklistDragData = { id: checklistItemId }; + + return combine( + draggable({ + element, + getInitialData: () => initialData, + canDrag: () => isDraggable && !disabled, + }), + dropTargetForElements({ + element, + getData: ({ input, element: dropElement }) => + attachClosestEdge(initialData, { input, element: dropElement, allowedEdges: ["top", "bottom"] }), + canDrop: ({ source }) => (source.data as TChecklistDragData)?.id !== checklistItemId, + onDragEnter: (args) => { + setIsDraggedOver(true); + setClosestEdge(extractClosestEdge(args.self.data) as "top" | "bottom" | null); + }, + onDrag: (args) => { + setClosestEdge(extractClosestEdge(args.self.data) as "top" | "bottom" | null); + }, + onDragLeave: () => { + setIsDraggedOver(false); + setClosestEdge(null); + }, + onDrop: ({ self, source }) => { + setIsDraggedOver(false); + const sourceId = (source.data as TChecklistDragData)?.id; + const edge = extractClosestEdge(self.data) as "top" | "bottom" | null; + if (sourceId && edge) onReorder(sourceId, checklistItemId, edge); + setClosestEdge(null); + }, + }) + ); + }, [checklistItemId, isDraggable, disabled, onReorder]); + + if (!item) return null; + + const isTerminal = item.status === EChecklistItemStatus.DONE || item.status === EChecklistItemStatus.SKIPPED; + + const commitName = () => { + const trimmed = name.trim(); + if (!trimmed) { + setName(item.name); + return; + } + // update() rethrows after toasting so the store can roll back the + // optimistic edit; nothing here needs the rejection, so swallow it. + if (trimmed !== item.name) checklistOperations.update(checklistItemId, { name: trimmed }).catch(() => {}); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + inputRef.current?.blur(); + } else if (e.key === "Escape") { + setName(item.name); + inputRef.current?.blur(); + } + }; + + const handleStatusChange = (status: EChecklistItemStatus) => { + if (status !== item.status) checklistOperations.setStatus(checklistItemId, status).catch(() => {}); + }; + + const handleDeleteClick = () => { + if (isConfirmingDelete) { + checklistOperations.remove(checklistItemId); + setIsConfirmingDelete(false); + } else { + setIsConfirmingDelete(true); + } + }; + + return ( + + +
+ setName(e.target.value)} + onBlur={commitName} + onKeyDown={handleKeyDown} + disabled={disabled} + className={cn( + "w-0 flex-1 rounded-sm border border-transparent bg-transparent px-2 py-1 text-13 outline-none focus:border-subtle focus:bg-surface-1", + isTerminal ? "text-tertiary line-through" : "text-primary", + item.status === EChecklistItemStatus.SKIPPED && "opacity-60" + )} + /> + + {!disabled && ( + + )} +
+ +
+ ); +}); diff --git a/apps/web/core/components/issues/issue-detail/checklist/checklist-list.tsx b/apps/web/core/components/issues/issue-detail/checklist/checklist-list.tsx new file mode 100644 index 00000000000..3761bbc1522 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/checklist/checklist-list.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { TIssueServiceType } from "@plane/types"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// local imports +import type { TChecklistOperations } from "../../issue-detail-widgets/checklist/helper"; +import { computeChecklistItemSortOrder } from "./checklist-order"; +import { ChecklistAddItem } from "./checklist-add-item"; +import { ChecklistItem } from "./checklist-item"; + +type Props = { + issueId: string; + checklistOperations: TChecklistOperations; + disabled?: boolean; + issueServiceType: TIssueServiceType; +}; + +const EMPTY_CHECKLIST_ITEM_IDS: string[] = []; + +export const ChecklistList = observer(function ChecklistList(props: Props) { + const { issueId, checklistOperations, disabled = false, issueServiceType } = props; + // hooks + const { + checklist: { getChecklistItemIdsByIssueId, getChecklistItemById }, + } = useIssueDetail(issueServiceType); + + const checklistItemIds = getChecklistItemIdsByIssueId(issueId) ?? EMPTY_CHECKLIST_ITEM_IDS; + const isDraggable = checklistItemIds.length > 1; + + const handleReorder = useCallback( + (sourceId: string, targetId: string, edge: "top" | "bottom") => { + if (sourceId === targetId) return; + const orderedItems = checklistItemIds + .map((id) => getChecklistItemById(id)) + .filter((item): item is NonNullable => !!item); + const sortOrder = computeChecklistItemSortOrder(orderedItems, targetId, edge); + checklistOperations.reorder(sourceId, sortOrder); + }, + [checklistItemIds, getChecklistItemById, checklistOperations] + ); + + return ( +
+ {checklistItemIds.map((checklistItemId) => ( + + ))} + +
+ ); +}); diff --git a/apps/web/core/components/issues/issue-detail/checklist/checklist-order.ts b/apps/web/core/components/issues/issue-detail/checklist/checklist-order.ts new file mode 100644 index 00000000000..c848d58bbc0 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/checklist/checklist-order.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TIssueChecklistItem } from "@plane/types"; + +const SORT_ORDER_STEP = 65535; + +/** + * Float-midpoint ordering, same algorithm as handleSortOrder in + * issue-layouts/utils.tsx (which is module-private and not exported): drop + * above the first item subtracts a step, drop below the last item adds a + * step, and a drop between two items takes their midpoint. Checklist items + * have no groups, so this is a flat-list simplification of that function. + */ +export function computeChecklistItemSortOrder( + orderedItems: TIssueChecklistItem[], + targetId: string, + edge: "top" | "bottom" +): number { + const targetIndex = orderedItems.findIndex((item) => item.id === targetId); + if (targetIndex === -1) return SORT_ORDER_STEP; + + const target = orderedItems[targetIndex]; + const prev = orderedItems[targetIndex - 1]; + const next = orderedItems[targetIndex + 1]; + + if (edge === "top") { + if (!prev) return target.sort_order - SORT_ORDER_STEP; + return (prev.sort_order + target.sort_order) / 2; + } + // edge === "bottom" + if (!next) return target.sort_order + SORT_ORDER_STEP; + return (target.sort_order + next.sort_order) / 2; +} diff --git a/apps/web/core/components/issues/issue-detail/checklist/checklist-status-dropdown.tsx b/apps/web/core/components/issues/issue-detail/checklist/checklist-status-dropdown.tsx new file mode 100644 index 00000000000..e274e1eee62 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/checklist/checklist-status-dropdown.tsx @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React from "react"; +// plane imports +import { StateGroupIcon } from "@plane/propel/icons"; +import { ChevronDownOutline } from "@makeplane/propel/icons"; +import { CHECKLIST_ITEM_STATUSES, CHECKLIST_ITEM_STATUS_MAP, STATE_GROUPS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { CustomSelect } from "@plane/ui"; +import { cn } from "@plane/utils"; +import type { EChecklistItemStatus } from "@plane/types"; + +type Props = { + value: EChecklistItemStatus; + onChange: (value: EChecklistItemStatus) => void; + disabled?: boolean; +}; + +// Sits at the end of the row as a colored pill (tinted background + text in +// the status's STATE_GROUPS color, same `${color}20` convention used for +// cycle/label chips elsewhere) so the status reads at a glance without +// relying on icon shape alone. All four values reuse +// StateGroupIcon/STATE_GROUPS purely for rendering; the database stores its +// own to_do/in_progress/skipped/done vocabulary (see +// packages/constants/src/checklist.ts). +export function ChecklistStatusDropdown(props: Props) { + const { value, onChange, disabled = false } = props; + const { t } = useTranslation(); + // Falls back to the first known status (to_do) if the server ever returns + // a status value this client's enum doesn't know about, so an older + // client degrades gracefully instead of crashing on a stale build. + const current = CHECKLIST_ITEM_STATUS_MAP[value] ?? CHECKLIST_ITEM_STATUSES[0]; + const color = STATE_GROUPS[current.stateGroup].color; + + return ( + + + {t(current.i18n_label)} + {!disabled && + ); +} diff --git a/apps/web/core/components/issues/issue-detail/checklist/index.ts b/apps/web/core/components/issues/issue-detail/checklist/index.ts new file mode 100644 index 00000000000..4aa4109d3c1 --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/checklist/index.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export * from "./checklist-list"; +export * from "./checklist-item"; +export * from "./checklist-add-item"; +export * from "./checklist-status-dropdown"; +export * from "./checklist-order"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/checklist-item.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/checklist-item.tsx new file mode 100644 index 00000000000..7679099631e --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/checklist-item.tsx @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { ReactNode } from "react"; +import { observer } from "mobx-react"; +import { CheckSquareOutline } from "@makeplane/propel/icons"; +// plane imports +import { CHECKLIST_ITEM_STATUS_MAP } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { EChecklistItemStatus } from "@plane/types"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +// components +import { IssueActivityBlockComponent } from "./"; + +type TIssueChecklistItemActivity = { activityId: string; ends: "top" | "bottom" | undefined }; + +// Handles both checklist_item (add/rename/delete) and checklist_item_status +// (status transitions) — the two `field` values the backend writes (see +// bgtasks/issue_activities_task.py update_checklist_item_activity). No +// activity is ever written for a reorder, so there is no case for it here. +export const IssueChecklistItemActivity = observer(function IssueChecklistItemActivity( + props: TIssueChecklistItemActivity +) { + const { activityId, ends } = props; + const { t } = useTranslation(); + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + if (!activity) return <>; + + const statusLabel = (status: string) => { + const entry = CHECKLIST_ITEM_STATUS_MAP[status as EChecklistItemStatus]; + return entry ? t(entry.i18n_label) : status; + }; + + let content: ReactNode; + if (activity.field === "checklist_item_status") { + content = ( + <> + moved checklist item + from + {statusLabel(activity.old_value ?? "")} + to + {statusLabel(activity.new_value ?? "")} + + ); + } else if (activity.verb === "created") { + content = ( + <> + added checklist item + {activity.new_value} + + ); + } else if (activity.verb === "deleted") { + content = ( + <> + removed checklist item + {activity.old_value} + + ); + } else { + content = ( + <> + renamed checklist item + {activity.old_value} + to + {activity.new_value} + + ); + } + + return ( + + ); +}); diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/index.ts b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/index.ts index c64e4afeca2..f7ecc8dd7da 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/index.ts +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/index.ts @@ -19,6 +19,7 @@ export * from "./cycle"; export * from "./module"; export * from "./label"; export * from "./link"; +export * from "./checklist-item"; export * from "./attachment"; export * from "./archived-at"; export * from "./inbox"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx index 12087a3c072..c621c288337 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx @@ -27,6 +27,7 @@ import { IssueModuleActivity, IssueLabelActivity, IssueLinkActivity, + IssueChecklistItemActivity, IssueAttachmentActivity, IssueArchivedAtActivity, IssueInboxActivity, @@ -84,6 +85,9 @@ export const IssueActivityItem = observer(function IssueActivityItem(props: TIss return ; case "link": return ; + case "checklist_item": + case "checklist_item_status": + return ; case "attachment": return ; case "archived_at": diff --git a/apps/web/core/services/issue/issue.service.ts b/apps/web/core/services/issue/issue.service.ts index f80ed380540..ae4d7a4a35f 100644 --- a/apps/web/core/services/issue/issue.service.ts +++ b/apps/web/core/services/issue/issue.service.ts @@ -14,6 +14,7 @@ import type { TIssue, TIssueActivity, TIssueLink, + TIssueChecklistItem, TIssueServiceType, TIssuesResponse, TIssueSubIssues, @@ -336,6 +337,68 @@ export class IssueService extends APIService { }); } + // Checklist items use the same `checklist-items` path segment for every + // service type (unlike links) so an EE backend exposing + // `/epics//checklist-items/` later needs no client change. + + async fetchChecklistItems(workspaceSlug: string, projectId: string, issueId: string): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/checklist-items/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async createChecklistItem( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial + ): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/checklist-items/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async updateChecklistItem( + workspaceSlug: string, + projectId: string, + issueId: string, + checklistItemId: string, + data: Partial + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/checklist-items/${checklistItemId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteChecklistItem( + workspaceSlug: string, + projectId: string, + issueId: string, + checklistItemId: string + ): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/checklist-items/${checklistItemId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async bulkOperations(workspaceSlug: string, projectId: string, data: TBulkOperationsPayload): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-operation-issues/`, data) .then(async (response) => response?.data) diff --git a/apps/web/core/store/issue/issue-details/checklist.store.ts b/apps/web/core/store/issue/issue-details/checklist.store.ts new file mode 100644 index 00000000000..ce7deb368e3 --- /dev/null +++ b/apps/web/core/store/issue/issue-details/checklist.store.ts @@ -0,0 +1,278 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { orderBy, set } from "lodash-es"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane imports +import { CHECKLIST_DENOMINATOR_EXCLUDED_STATUSES } from "@plane/constants"; +import { EChecklistItemStatus } from "@plane/types"; +import type { + TIssueChecklistItem, + TIssueChecklistItemMap, + TIssueChecklistItemIdMap, + TIssueServiceType, +} from "@plane/types"; +// services +import { IssueService } from "@/services/issue"; +// types +import type { IIssueDetail } from "./root.store"; + +export type TChecklistProgress = { + done: number; + skipped: number; + total: number; + /** total minus skipped — the progress denominator (spec FR-012, FR-013) */ + activeTotal: number; +}; + +export interface IIssueChecklistStoreActions { + addChecklistItems: (issueId: string, items: TIssueChecklistItem[]) => void; + fetchChecklistItems: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + createChecklistItem: ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial + ) => Promise; + updateChecklistItem: ( + workspaceSlug: string, + projectId: string, + issueId: string, + checklistItemId: string, + data: Partial + ) => Promise; + removeChecklistItem: ( + workspaceSlug: string, + projectId: string, + issueId: string, + checklistItemId: string + ) => Promise; + // Not a modal toggle (there is no checklist modal) — this is what lets the + // "Add checklist item" action button reveal the section for an issue that + // has zero items yet, since the normal render gate is item-count based. + startAddingChecklistItem: (issueId: string) => void; + stopAddingChecklistItem: (issueId: string) => void; +} + +export interface IIssueChecklistStore extends IIssueChecklistStoreActions { + // observables + checklistItems: TIssueChecklistItemIdMap; + checklistItemMap: TIssueChecklistItemMap; + activeAddInputIssueId: string | null; + // helper methods + getChecklistItemIdsByIssueId: (issueId: string) => string[] | undefined; + getChecklistItemById: (checklistItemId: string) => TIssueChecklistItem | undefined; + getChecklistProgressByIssueId: (issueId: string) => TChecklistProgress; + isAddingChecklistItem: (issueId: string) => boolean; +} + +export class IssueChecklistStore implements IIssueChecklistStore { + // observables + checklistItems: TIssueChecklistItemIdMap = {}; + checklistItemMap: TIssueChecklistItemMap = {}; + activeAddInputIssueId: string | null = null; + // Guard against out-of-order async responses clobbering newer state: a + // fetch, or an update's reconciliation/rollback, is only applied if it is + // still the most recent request for that issue/item when it resolves. + // Plain bookkeeping, not rendered, so it stays outside MobX observables. + private issueRevision: Record = {}; + private itemRevision: Record = {}; + // root store + rootIssueDetailStore: IIssueDetail; + // services + issueService; + serviceType; + + constructor(rootStore: IIssueDetail, serviceType: TIssueServiceType) { + makeObservable(this, { + // observables + checklistItems: observable, + checklistItemMap: observable, + activeAddInputIssueId: observable.ref, + // actions + addChecklistItems: action.bound, + fetchChecklistItems: action, + createChecklistItem: action, + updateChecklistItem: action, + removeChecklistItem: action, + startAddingChecklistItem: action, + stopAddingChecklistItem: action, + }); + this.serviceType = serviceType; + // root store + this.rootIssueDetailStore = rootStore; + // services + this.issueService = new IssueService(serviceType); + } + + // helper methods + getChecklistItemIdsByIssueId = computedFn((issueId: string) => { + if (!issueId) return undefined; + return this.checklistItems[issueId] ?? undefined; + }); + + getChecklistItemById = (checklistItemId: string) => { + if (!checklistItemId) return undefined; + return this.checklistItemMap[checklistItemId] ?? undefined; + }; + + getChecklistProgressByIssueId = computedFn((issueId: string): TChecklistProgress => { + const ids = this.checklistItems[issueId] ?? []; + let done = 0; + let skipped = 0; + ids.forEach((id) => { + const item = this.checklistItemMap[id]; + if (!item) return; + if (item.status === EChecklistItemStatus.DONE) done += 1; + if (CHECKLIST_DENOMINATOR_EXCLUDED_STATUSES.includes(item.status)) skipped += 1; + }); + const total = ids.length; + return { done, skipped, total, activeTotal: total - skipped }; + }); + + isAddingChecklistItem = (issueId: string) => this.activeAddInputIssueId === issueId; + + // helper: keep the id array sorted by (sort_order, created_at) after any + // mutation that touches ordering. + resortIds = (issueId: string) => { + const ids = this.checklistItems[issueId]; + if (!ids) return; + const sorted = orderBy( + ids.map((id) => this.checklistItemMap[id]).filter((item): item is TIssueChecklistItem => !!item), + ["sort_order", "created_at"] + ).map((item) => item.id); + set(this.checklistItems, issueId, sorted); + }; + + // actions + addChecklistItems = (issueId: string, items: TIssueChecklistItem[]) => { + runInAction(() => { + items.forEach((item) => set(this.checklistItemMap, item.id, item)); + this.checklistItems[issueId] = orderBy(items, ["sort_order", "created_at"]).map((item) => item.id); + }); + }; + + // Bumping the revision invalidates any fetch for this issue that is still + // in flight, so its (now stale) response is skipped instead of overwriting + // the mutation that just landed. See fetchChecklistItems. + private bumpIssueRevision = (issueId: string) => { + this.issueRevision[issueId] = (this.issueRevision[issueId] ?? 0) + 1; + }; + + fetchChecklistItems = async (workspaceSlug: string, projectId: string, issueId: string) => { + const revision = this.issueRevision[issueId] ?? 0; + const response = await this.issueService.fetchChecklistItems(workspaceSlug, projectId, issueId); + if ((this.issueRevision[issueId] ?? 0) === revision) { + this.addChecklistItems(issueId, response); + } + return response; + }; + + createChecklistItem = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial + ) => { + const response = await this.issueService.createChecklistItem(workspaceSlug, projectId, issueId, data); + runInAction(() => { + set(this.checklistItemMap, response.id, response); + this.checklistItems[issueId] = [...(this.checklistItems[issueId] ?? []), response.id]; + this.resortIds(issueId); + }); + this.bumpIssueRevision(issueId); + // Feed-visible change — refresh activity. Status toggles and reorders do + // NOT do this (see updateChecklistItem) to avoid hammering the endpoint. + this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); + return response; + }; + + updateChecklistItem = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + checklistItemId: string, + data: Partial + ) => { + const initialData = { ...this.checklistItemMap[checklistItemId] }; + // Claim the next revision for this item. Two updates can overlap (e.g. + // two quick status toggles) and resolve out of order; only the call that + // still holds the latest revision when it resolves may reconcile or roll + // back, so a slow, now-superseded response can't stomp a newer one. + const revision = (this.itemRevision[checklistItemId] ?? 0) + 1; + this.itemRevision[checklistItemId] = revision; + try { + runInAction(() => { + Object.keys(data).forEach((key) => { + set(this.checklistItemMap, [checklistItemId, key], data[key as keyof TIssueChecklistItem]); + }); + if (data.sort_order !== undefined) this.resortIds(issueId); + }); + + const response = await this.issueService.updateChecklistItem( + workspaceSlug, + projectId, + issueId, + checklistItemId, + data + ); + + if (this.itemRevision[checklistItemId] === revision) { + // Reconcile with the server response: completed_at/completed_by are + // derived server-side from the status *transition*, so an optimistic + // update cannot predict them — overwrite rather than merge. + runInAction(() => { + set(this.checklistItemMap, checklistItemId, response); + if (data.sort_order !== undefined) this.resortIds(issueId); + }); + this.bumpIssueRevision(issueId); + } + + // Only rename changes are feed-visible activity worth an eager refetch; + // reorders and status changes still write activity server-side (except + // reorders, which write none) but don't need an immediate refetch here. + if (data.name !== undefined) { + this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); + } + return response; + } catch (error) { + if (this.itemRevision[checklistItemId] === revision) { + runInAction(() => { + set(this.checklistItemMap, checklistItemId, initialData); + this.resortIds(issueId); + }); + } + throw error; + } + }; + + removeChecklistItem = async (workspaceSlug: string, projectId: string, issueId: string, checklistItemId: string) => { + await this.issueService.deleteChecklistItem(workspaceSlug, projectId, issueId, checklistItemId); + + // Invalidate any update still in flight for this item so its + // reconciliation/rollback can't resurrect what was just deleted. + this.itemRevision[checklistItemId] = (this.itemRevision[checklistItemId] ?? 0) + 1; + + const itemIndex = this.checklistItems[issueId]?.findIndex((id) => id === checklistItemId) ?? -1; + if (itemIndex >= 0) + runInAction(() => { + this.checklistItems[issueId].splice(itemIndex, 1); + delete this.checklistItemMap[checklistItemId]; + }); + + this.bumpIssueRevision(issueId); + this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); + }; + + startAddingChecklistItem = (issueId: string) => { + this.activeAddInputIssueId = issueId; + }; + + stopAddingChecklistItem = (issueId: string) => { + if (this.activeAddInputIssueId === issueId) this.activeAddInputIssueId = null; + }; +} diff --git a/apps/web/core/store/issue/issue-details/issue.store.ts b/apps/web/core/store/issue/issue-details/issue.store.ts index 4abf62b4f5c..d0be8171f01 100644 --- a/apps/web/core/store/issue/issue-details/issue.store.ts +++ b/apps/web/core/store/issue/issue-details/issue.store.ts @@ -103,6 +103,7 @@ export class IssueStore implements IIssueStore { if (issue && issue?.parent && issue?.parent?.id && issue?.parent?.project_id) { this.issueService.retrieve(workspaceSlug, issue.parent.project_id, issue?.parent?.id).then((res) => { this.rootIssueDetailStore.rootIssueStore.issues.addIssue([res]); + return res; }); } // assignees @@ -129,6 +130,14 @@ export class IssueStore implements IIssueStore { // fetch sub issues this.rootIssueDetailStore.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId); + // fetch checklist items — ISSUES only. Epic child routes live in the EE + // backend and diverge from the OSS issue routes (e.g. `/epics//links/` + // vs `/issues//issue-links/`); without this gate every epic open + // would fire a 404 against `/epics//checklist-items/`. + if (this.serviceType === EIssueServiceType.ISSUES) { + this.rootIssueDetailStore.checklist.fetchChecklistItems(workspaceSlug, projectId, issueId); + } + // fetch issue relations this.rootIssueDetailStore.relation.fetchRelations(workspaceSlug, projectId, issueId); @@ -288,6 +297,7 @@ export class IssueStore implements IIssueStore { if (issue?.parent && issue?.parent?.id && issue?.parent?.project_id) { this.issueService.retrieve(workspaceSlug, issue.parent.project_id, issue.parent.id).then((res) => { this.rootIssueDetailStore.rootIssueStore.issues.addIssue([res]); + return res; }); } @@ -321,6 +331,13 @@ export class IssueStore implements IIssueStore { // fetch sub issues rootWorkItemDetailStore.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId); + // fetch checklist items — ISSUES only, same reasoning as fetchIssue() + // above. This entry point resolves issue vs epic dynamically via + // `issue.is_epic`, so the gate mirrors that rather than `this.serviceType`. + if (!issue.is_epic) { + rootWorkItemDetailStore.checklist.fetchChecklistItems(workspaceSlug, projectId, issueId); + } + // fetch issue relations rootWorkItemDetailStore.relation.fetchRelations(workspaceSlug, projectId, issueId); diff --git a/apps/web/core/store/issue/issue-details/root.store.ts b/apps/web/core/store/issue/issue-details/root.store.ts index 8596e1fec72..11f9d04881f 100644 --- a/apps/web/core/store/issue/issue-details/root.store.ts +++ b/apps/web/core/store/issue/issue-details/root.store.ts @@ -9,6 +9,7 @@ import { action, computed, makeObservable, observable } from "mobx"; import type { TIssue, TIssueAttachment, + TIssueChecklistItem, TIssueComment, TIssueCommentReaction, TIssueLink, @@ -23,6 +24,8 @@ import type { IIssueActivityStore, IIssueActivityStoreActions, TActivityLoader } import type { IIssueRootStore } from "../root.store"; import { IssueAttachmentStore } from "./attachment.store"; import type { IIssueAttachmentStore, IIssueAttachmentStoreActions } from "./attachment.store"; +import { IssueChecklistStore } from "./checklist.store"; +import type { IIssueChecklistStore, IIssueChecklistStoreActions } from "./checklist.store"; import { IssueCommentStore } from "./comment.store"; import type { IIssueCommentStore, IIssueCommentStoreActions, TCommentLoader } from "./comment.store"; import { IssueCommentReactionStore } from "./comment_reaction.store"; @@ -65,6 +68,7 @@ export interface IIssueDetail IIssueStoreActions, IIssueReactionStoreActions, IIssueLinkStoreActions, + IIssueChecklistStoreActions, IIssueSubIssuesStoreActions, IIssueSubscriptionStoreActions, IIssueAttachmentStoreActions, @@ -118,6 +122,7 @@ export interface IIssueDetail commentReaction: IIssueCommentReactionStore; subIssues: IIssueSubIssuesStore; link: IIssueLinkStore; + checklist: IIssueChecklistStore; subscription: IIssueSubscriptionStore; relation: IIssueRelationStore; } @@ -139,7 +144,7 @@ export class IssueDetail implements IIssueDetail { issue: undefined, }, }; - openWidgets: TWorkItemWidgets[] = ["sub-work-items", "links", "attachments"]; + openWidgets: TWorkItemWidgets[] = ["checklist", "sub-work-items", "links", "attachments"]; lastWidgetAction: TWorkItemWidgets | null = null; isCreateIssueModalOpen: boolean = false; isIssueLinkModalOpen: boolean = false; @@ -158,6 +163,7 @@ export class IssueDetail implements IIssueDetail { attachment: IIssueAttachmentStore; subIssues: IIssueSubIssuesStore; link: IIssueLinkStore; + checklist: IIssueChecklistStore; subscription: IIssueSubscriptionStore; relation: IIssueRelationStore; activity: IIssueActivityStore; @@ -213,6 +219,7 @@ export class IssueDetail implements IIssueDetail { this.commentReaction = new IssueCommentReactionStore(this); this.subIssues = new IssueSubIssuesStore(this, serviceType); this.link = new IssueLinkStore(this, serviceType); + this.checklist = new IssueChecklistStore(this, serviceType); this.subscription = new IssueSubscriptionStore(this, serviceType); this.relation = new IssueRelationStore(this); } @@ -255,8 +262,8 @@ export class IssueDetail implements IIssueDetail { this.openWidgets = state; if (this.lastWidgetAction) this.lastWidgetAction = null; }; - setLastWidgetAction = (action: TWorkItemWidgets) => { - this.openWidgets = [action]; + setLastWidgetAction = (widgetAction: TWorkItemWidgets) => { + this.openWidgets = [widgetAction]; }; toggleOpenWidget = (state: TWorkItemWidgets) => { if (this.openWidgets && this.openWidgets.includes(state)) @@ -332,6 +339,29 @@ export class IssueDetail implements IIssueDetail { removeLink = async (workspaceSlug: string, projectId: string, issueId: string, linkId: string) => this.link.removeLink(workspaceSlug, projectId, issueId, linkId); + // checklist + addChecklistItems = (issueId: string, items: TIssueChecklistItem[]) => + this.checklist.addChecklistItems(issueId, items); + fetchChecklistItems = async (workspaceSlug: string, projectId: string, issueId: string) => + this.checklist.fetchChecklistItems(workspaceSlug, projectId, issueId); + createChecklistItem = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial + ) => this.checklist.createChecklistItem(workspaceSlug, projectId, issueId, data); + updateChecklistItem = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + checklistItemId: string, + data: Partial + ) => this.checklist.updateChecklistItem(workspaceSlug, projectId, issueId, checklistItemId, data); + removeChecklistItem = async (workspaceSlug: string, projectId: string, issueId: string, checklistItemId: string) => + this.checklist.removeChecklistItem(workspaceSlug, projectId, issueId, checklistItemId); + startAddingChecklistItem = (issueId: string) => this.checklist.startAddingChecklistItem(issueId); + stopAddingChecklistItem = (issueId: string) => this.checklist.stopAddingChecklistItem(issueId); + // sub issues fetchSubIssues = async (workspaceSlug: string, projectId: string, issueId: string) => this.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId); diff --git a/packages/constants/src/checklist.ts b/packages/constants/src/checklist.ts new file mode 100644 index 00000000000..3380cd5e6b7 --- /dev/null +++ b/packages/constants/src/checklist.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { EChecklistItemStatus } from "@plane/types"; +import type { TStateGroups } from "@plane/types"; + +/** + * Presentation-only mapping from a checklist item's status to a state group, + * used purely to reuse StateGroupIcon and STATE_GROUPS colours. The database + * never stores the state group key — "skipped" is not "cancelled", and + * binding a checklist to StateGroup (a workflow concept with a model behind + * it) would constrain any future fifth status value. + */ +export const CHECKLIST_ITEM_STATUSES: { + key: EChecklistItemStatus; + i18n_label: string; + stateGroup: TStateGroups; +}[] = [ + { + key: EChecklistItemStatus.TO_DO, + i18n_label: "checklist.status.to_do", + stateGroup: "unstarted", + }, + { + key: EChecklistItemStatus.IN_PROGRESS, + i18n_label: "checklist.status.in_progress", + stateGroup: "started", + }, + { + key: EChecklistItemStatus.SKIPPED, + i18n_label: "checklist.status.skipped", + stateGroup: "cancelled", + }, + { + key: EChecklistItemStatus.DONE, + i18n_label: "checklist.status.done", + stateGroup: "completed", + }, +]; + +export const CHECKLIST_ITEM_STATUS_MAP: Record = + Object.fromEntries(CHECKLIST_ITEM_STATUSES.map((status) => [status.key, status])) as Record< + EChecklistItemStatus, + (typeof CHECKLIST_ITEM_STATUSES)[number] + >; + +/** Statuses excluded from the progress denominator. Currently just "skipped". */ +export const CHECKLIST_DENOMINATOR_EXCLUDED_STATUSES: EChecklistItemStatus[] = [EChecklistItemStatus.SKIPPED]; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index fe89380d091..b134ac669cd 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -9,6 +9,7 @@ export * from "./analytics"; export * from "./auth"; export * from "./calendar"; export * from "./chart"; +export * from "./checklist"; export * from "./cycle"; export * from "./dashboard"; export * from "./emoji"; diff --git a/packages/i18n/src/locales/cs/common.json b/packages/i18n/src/locales/cs/common.json index c1e9372c24e..5d0f31f7b66 100644 --- a/packages/i18n/src/locales/cs/common.json +++ b/packages/i18n/src/locales/cs/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Kontrolní seznam" }, "chart": { "x_axis": "Osa X", @@ -867,5 +868,42 @@ "description": "Exportujte položky do JSON.", "short_description": "Exportovat jako JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Položka kontrolního seznamu přidána", + "message": "Položka kontrolního seznamu byla přidána" + }, + "not_created": { + "title": "Položka kontrolního seznamu nebyla přidána", + "message": "Položku kontrolního seznamu se nepodařilo přidat" + }, + "updated": { + "title": "Položka kontrolního seznamu aktualizována", + "message": "Položka kontrolního seznamu byla aktualizována" + }, + "not_updated": { + "title": "Položka kontrolního seznamu nebyla aktualizována", + "message": "Položku kontrolního seznamu se nepodařilo aktualizovat" + }, + "removed": { + "title": "Položka kontrolního seznamu odstraněna", + "message": "Položka kontrolního seznamu byla odstraněna" + }, + "not_removed": { + "title": "Položka kontrolního seznamu nebyla odstraněna", + "message": "Položku kontrolního seznamu se nepodařilo odstranit" + } + }, + "all_skipped": "Vše přeskočeno", + "skipped_suffix": "přeskočeno", + "placeholder": "Přidat položku…", + "status": { + "to_do": "K vyřízení", + "in_progress": "Probíhá", + "skipped": "Přeskočeno", + "done": "Hotovo" + } } } diff --git a/packages/i18n/src/locales/cs/work-item.json b/packages/i18n/src/locales/cs/work-item.json index 78196a6bb69..d7f0594ea03 100644 --- a/packages/i18n/src/locales/cs/work-item.json +++ b/packages/i18n/src/locales/cs/work-item.json @@ -23,7 +23,8 @@ "dependency": "Přidat závislost", "relation": "Přidat vztah", "link": "Přidat odkaz", - "existing": "Přidat existující pracovní položku" + "existing": "Přidat existující pracovní položku", + "checklist_item": "Přidat položku kontrolního seznamu" }, "remove": { "label": "Odebrat pracovní položku", diff --git a/packages/i18n/src/locales/de/common.json b/packages/i18n/src/locales/de/common.json index adbb588e219..73859cf7580 100644 --- a/packages/i18n/src/locales/de/common.json +++ b/packages/i18n/src/locales/de/common.json @@ -717,7 +717,8 @@ "developer": "Entwickler", "work_structure": "Arbeitsstruktur", "execution": "Ausführung", - "administration": "Verwaltung" + "administration": "Verwaltung", + "checklist": "Checkliste" }, "chart": { "x_axis": "X-Achse", @@ -867,5 +868,42 @@ "description": "Arbeitselemente in JSON exportieren.", "short_description": "Als JSON exportieren" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Checklistenelement hinzugefügt", + "message": "Das Checklistenelement wurde hinzugefügt" + }, + "not_created": { + "title": "Checklistenelement nicht hinzugefügt", + "message": "Das Checklistenelement konnte nicht hinzugefügt werden" + }, + "updated": { + "title": "Checklistenelement aktualisiert", + "message": "Das Checklistenelement wurde aktualisiert" + }, + "not_updated": { + "title": "Checklistenelement nicht aktualisiert", + "message": "Das Checklistenelement konnte nicht aktualisiert werden" + }, + "removed": { + "title": "Checklistenelement entfernt", + "message": "Das Checklistenelement wurde entfernt" + }, + "not_removed": { + "title": "Checklistenelement nicht entfernt", + "message": "Das Checklistenelement konnte nicht entfernt werden" + } + }, + "all_skipped": "Alle übersprungen", + "skipped_suffix": "übersprungen", + "placeholder": "Element hinzufügen…", + "status": { + "to_do": "Zu erledigen", + "in_progress": "In Bearbeitung", + "skipped": "Übersprungen", + "done": "Erledigt" + } } } diff --git a/packages/i18n/src/locales/de/work-item.json b/packages/i18n/src/locales/de/work-item.json index ddc105e4d61..74e92051c37 100644 --- a/packages/i18n/src/locales/de/work-item.json +++ b/packages/i18n/src/locales/de/work-item.json @@ -23,7 +23,8 @@ "dependency": "Abhängigkeit hinzufügen", "relation": "Beziehung hinzufügen", "link": "Link hinzufügen", - "existing": "Vorhandenes Arbeitselement hinzufügen" + "existing": "Vorhandenes Arbeitselement hinzufügen", + "checklist_item": "Checklistenelement hinzufügen" }, "remove": { "label": "Arbeitselement entfernen", diff --git a/packages/i18n/src/locales/en/common.json b/packages/i18n/src/locales/en/common.json index a138304371e..387b6fd8032 100644 --- a/packages/i18n/src/locales/en/common.json +++ b/packages/i18n/src/locales/en/common.json @@ -402,6 +402,43 @@ } } }, + "checklist": { + "toasts": { + "created": { + "title": "Checklist item added", + "message": "The checklist item has been added" + }, + "not_created": { + "title": "Checklist item not added", + "message": "The checklist item could not be added" + }, + "updated": { + "title": "Checklist item updated", + "message": "The checklist item has been updated" + }, + "not_updated": { + "title": "Checklist item not updated", + "message": "The checklist item could not be updated" + }, + "removed": { + "title": "Checklist item removed", + "message": "The checklist item has been removed" + }, + "not_removed": { + "title": "Checklist item not removed", + "message": "The checklist item could not be removed" + } + }, + "all_skipped": "All skipped", + "skipped_suffix": "skipped", + "placeholder": "Add an item…", + "status": { + "to_do": "To Do", + "in_progress": "In Progress", + "skipped": "Skipped", + "done": "Done" + } + }, "link": { "modal": { "url": { @@ -508,6 +545,7 @@ }, "done": "Done", "sub_work_items": "Sub-work items", + "checklist": "Checklist", "comment": "Comment", "workspace_level": "Workspace level", "order_by": { diff --git a/packages/i18n/src/locales/en/work-item.json b/packages/i18n/src/locales/en/work-item.json index 6acb2439841..f7ddf50f048 100644 --- a/packages/i18n/src/locales/en/work-item.json +++ b/packages/i18n/src/locales/en/work-item.json @@ -23,6 +23,7 @@ "dependency": "Add dependency", "relation": "Add relation", "link": "Add link", + "checklist_item": "Add checklist item", "existing": "Add existing work item" }, "remove": { diff --git a/packages/i18n/src/locales/es/common.json b/packages/i18n/src/locales/es/common.json index 64440c9c653..5fbdcb47631 100644 --- a/packages/i18n/src/locales/es/common.json +++ b/packages/i18n/src/locales/es/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Checklist" }, "chart": { "x_axis": "Eje X", @@ -867,5 +868,42 @@ "description": "Exporta elementos de trabajo a un archivo JSON.", "short_description": "Exportar como json" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Elemento de la checklist añadido", + "message": "Se ha añadido el elemento de la checklist" + }, + "not_created": { + "title": "Elemento de la checklist no añadido", + "message": "No se pudo añadir el elemento de la checklist" + }, + "updated": { + "title": "Elemento de la checklist actualizado", + "message": "Se ha actualizado el elemento de la checklist" + }, + "not_updated": { + "title": "Elemento de la checklist no actualizado", + "message": "No se pudo actualizar el elemento de la checklist" + }, + "removed": { + "title": "Elemento de la checklist eliminado", + "message": "Se ha eliminado el elemento de la checklist" + }, + "not_removed": { + "title": "Elemento de la checklist no eliminado", + "message": "No se pudo eliminar el elemento de la checklist" + } + }, + "all_skipped": "Todo omitido", + "skipped_suffix": "omitidos", + "placeholder": "Añadir un elemento…", + "status": { + "to_do": "Por hacer", + "in_progress": "En curso", + "skipped": "Omitido", + "done": "Hecho" + } } } diff --git a/packages/i18n/src/locales/es/work-item.json b/packages/i18n/src/locales/es/work-item.json index b721959b15b..738807338fc 100644 --- a/packages/i18n/src/locales/es/work-item.json +++ b/packages/i18n/src/locales/es/work-item.json @@ -23,7 +23,8 @@ "dependency": "Agregar dependencia", "relation": "Agregar relación", "link": "Agregar enlace", - "existing": "Agregar elemento de trabajo existente" + "existing": "Agregar elemento de trabajo existente", + "checklist_item": "Añadir elemento de checklist" }, "remove": { "label": "Eliminar elemento de trabajo", diff --git a/packages/i18n/src/locales/fr/common.json b/packages/i18n/src/locales/fr/common.json index a94ab8aba2d..80eeb46c24b 100644 --- a/packages/i18n/src/locales/fr/common.json +++ b/packages/i18n/src/locales/fr/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Checklist" }, "chart": { "x_axis": "Axe X", @@ -867,5 +868,42 @@ "description": "Exportez les éléments de travail vers un fichier JSON.", "short_description": "Exporter en json" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Élément de checklist ajouté", + "message": "L'élément de la checklist a été ajouté" + }, + "not_created": { + "title": "Élément de checklist non ajouté", + "message": "L'élément de la checklist n'a pas pu être ajouté" + }, + "updated": { + "title": "Élément de checklist mis à jour", + "message": "L'élément de la checklist a été mis à jour" + }, + "not_updated": { + "title": "Élément de checklist non mis à jour", + "message": "L'élément de la checklist n'a pas pu être mis à jour" + }, + "removed": { + "title": "Élément de checklist supprimé", + "message": "L'élément de la checklist a été supprimé" + }, + "not_removed": { + "title": "Élément de checklist non supprimé", + "message": "L'élément de la checklist n'a pas pu être supprimé" + } + }, + "all_skipped": "Tout ignoré", + "skipped_suffix": "ignorés", + "placeholder": "Ajouter un élément…", + "status": { + "to_do": "À faire", + "in_progress": "En cours", + "skipped": "Ignoré", + "done": "Terminé" + } } } diff --git a/packages/i18n/src/locales/fr/work-item.json b/packages/i18n/src/locales/fr/work-item.json index 49d15f32435..4168b92a735 100644 --- a/packages/i18n/src/locales/fr/work-item.json +++ b/packages/i18n/src/locales/fr/work-item.json @@ -23,7 +23,8 @@ "dependency": "Ajouter une dépendance", "relation": "Ajouter une relation", "link": "Ajouter un lien", - "existing": "Ajouter un élément de travail existant" + "existing": "Ajouter un élément de travail existant", + "checklist_item": "Ajouter un élément de checklist" }, "remove": { "label": "Supprimer l’élément de travail", diff --git a/packages/i18n/src/locales/id/common.json b/packages/i18n/src/locales/id/common.json index 7d266850dc4..f275bdf5beb 100644 --- a/packages/i18n/src/locales/id/common.json +++ b/packages/i18n/src/locales/id/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Checklist" }, "chart": { "x_axis": "Sumbu-X", @@ -867,5 +868,42 @@ "description": "Ekspor item kerja ke file JSON.", "short_description": "Ekspor sebagai json" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Item checklist ditambahkan", + "message": "Item checklist telah ditambahkan" + }, + "not_created": { + "title": "Item checklist tidak ditambahkan", + "message": "Item checklist tidak dapat ditambahkan" + }, + "updated": { + "title": "Item checklist diperbarui", + "message": "Item checklist telah diperbarui" + }, + "not_updated": { + "title": "Item checklist tidak diperbarui", + "message": "Item checklist tidak dapat diperbarui" + }, + "removed": { + "title": "Item checklist dihapus", + "message": "Item checklist telah dihapus" + }, + "not_removed": { + "title": "Item checklist tidak dihapus", + "message": "Item checklist tidak dapat dihapus" + } + }, + "all_skipped": "Semua dilewati", + "skipped_suffix": "dilewati", + "placeholder": "Tambahkan item…", + "status": { + "to_do": "Belum dikerjakan", + "in_progress": "Sedang berlangsung", + "skipped": "Dilewati", + "done": "Selesai" + } } } diff --git a/packages/i18n/src/locales/id/work-item.json b/packages/i18n/src/locales/id/work-item.json index 0e2abacf61a..58baf5cdd92 100644 --- a/packages/i18n/src/locales/id/work-item.json +++ b/packages/i18n/src/locales/id/work-item.json @@ -23,7 +23,8 @@ "dependency": "Tambah ketergantungan", "relation": "Tambah hubungan", "link": "Tambah tautan", - "existing": "Tambah item kerja yang ada" + "existing": "Tambah item kerja yang ada", + "checklist_item": "Tambahkan item checklist" }, "remove": { "label": "Hapus item kerja", diff --git a/packages/i18n/src/locales/it/common.json b/packages/i18n/src/locales/it/common.json index 6fda1b04957..c2663094305 100644 --- a/packages/i18n/src/locales/it/common.json +++ b/packages/i18n/src/locales/it/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Checklist" }, "chart": { "x_axis": "Asse X", @@ -867,5 +868,42 @@ "description": "Esporta elementi di lavoro in un file JSON.", "short_description": "Esporta come JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Elemento della checklist aggiunto", + "message": "L'elemento della checklist è stato aggiunto" + }, + "not_created": { + "title": "Elemento della checklist non aggiunto", + "message": "Impossibile aggiungere l'elemento della checklist" + }, + "updated": { + "title": "Elemento della checklist aggiornato", + "message": "L'elemento della checklist è stato aggiornato" + }, + "not_updated": { + "title": "Elemento della checklist non aggiornato", + "message": "Impossibile aggiornare l'elemento della checklist" + }, + "removed": { + "title": "Elemento della checklist rimosso", + "message": "L'elemento della checklist è stato rimosso" + }, + "not_removed": { + "title": "Elemento della checklist non rimosso", + "message": "Impossibile rimuovere l'elemento della checklist" + } + }, + "all_skipped": "Tutto saltato", + "skipped_suffix": "saltati", + "placeholder": "Aggiungi un elemento…", + "status": { + "to_do": "Da fare", + "in_progress": "In corso", + "skipped": "Saltato", + "done": "Fatto" + } } } diff --git a/packages/i18n/src/locales/it/work-item.json b/packages/i18n/src/locales/it/work-item.json index aeb3ea5cb38..3d5e4fb6fe6 100644 --- a/packages/i18n/src/locales/it/work-item.json +++ b/packages/i18n/src/locales/it/work-item.json @@ -23,7 +23,8 @@ "dependency": "Aggiungi dipendenza", "relation": "Aggiungi relazione", "link": "Aggiungi link", - "existing": "Aggiungi elemento di lavoro esistente" + "existing": "Aggiungi elemento di lavoro esistente", + "checklist_item": "Aggiungi elemento della checklist" }, "remove": { "label": "Rimuovi elemento di lavoro", diff --git a/packages/i18n/src/locales/ja/common.json b/packages/i18n/src/locales/ja/common.json index 4899411b615..0364f9e467e 100644 --- a/packages/i18n/src/locales/ja/common.json +++ b/packages/i18n/src/locales/ja/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "チェックリスト" }, "chart": { "x_axis": "エックス アクシス", @@ -867,5 +868,42 @@ "description": "作業項目をJSONファイルにエクスポートします。", "short_description": "JSONとしてエクスポート" } + }, + "checklist": { + "toasts": { + "created": { + "title": "チェックリスト項目が追加されました", + "message": "チェックリストの項目が追加されました" + }, + "not_created": { + "title": "チェックリスト項目を追加できませんでした", + "message": "チェックリストの項目を追加できませんでした" + }, + "updated": { + "title": "チェックリスト項目が更新されました", + "message": "チェックリストの項目が更新されました" + }, + "not_updated": { + "title": "チェックリスト項目を更新できませんでした", + "message": "チェックリストの項目を更新できませんでした" + }, + "removed": { + "title": "チェックリスト項目が削除されました", + "message": "チェックリストの項目が削除されました" + }, + "not_removed": { + "title": "チェックリスト項目を削除できませんでした", + "message": "チェックリストの項目を削除できませんでした" + } + }, + "all_skipped": "すべてスキップ済み", + "skipped_suffix": "件スキップ", + "placeholder": "項目を追加…", + "status": { + "to_do": "未着手", + "in_progress": "進行中", + "skipped": "スキップ", + "done": "完了" + } } } diff --git a/packages/i18n/src/locales/ja/work-item.json b/packages/i18n/src/locales/ja/work-item.json index 41bcdaddf42..6ba3a24f698 100644 --- a/packages/i18n/src/locales/ja/work-item.json +++ b/packages/i18n/src/locales/ja/work-item.json @@ -23,7 +23,8 @@ "dependency": "依存関係を追加", "relation": "関連を追加", "link": "リンクを追加", - "existing": "既存の作業項目を追加" + "existing": "既存の作業項目を追加", + "checklist_item": "チェックリスト項目を追加" }, "remove": { "label": "作業項目を削除", diff --git a/packages/i18n/src/locales/ka-ge/common.json b/packages/i18n/src/locales/ka-ge/common.json index f4f7d7f20cb..0b0df7e600f 100644 --- a/packages/i18n/src/locales/ka-ge/common.json +++ b/packages/i18n/src/locales/ka-ge/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "საკონტროლო სია" }, "chart": { "x_axis": "X-ღერძი", @@ -867,5 +868,42 @@ "description": "ექსპორტირეთ სამუშაო ელემენტები JSON ფაილში.", "short_description": "ექსპორტი json-ად" } + }, + "checklist": { + "toasts": { + "created": { + "title": "საკონტროლო სიის პუნქტი დაემატა", + "message": "საკონტროლო სიის პუნქტი დაემატა" + }, + "not_created": { + "title": "საკონტროლო სიის პუნქტი ვერ დაემატა", + "message": "საკონტროლო სიის პუნქტის დამატება ვერ მოხერხდა" + }, + "updated": { + "title": "საკონტროლო სიის პუნქტი განახლდა", + "message": "საკონტროლო სიის პუნქტი განახლდა" + }, + "not_updated": { + "title": "საკონტროლო სიის პუნქტი ვერ განახლდა", + "message": "საკონტროლო სიის პუნქტის განახლება ვერ მოხერხდა" + }, + "removed": { + "title": "საკონტროლო სიის პუნქტი წაიშალა", + "message": "საკონტროლო სიის პუნქტი წაიშალა" + }, + "not_removed": { + "title": "საკონტროლო სიის პუნქტი ვერ წაიშალა", + "message": "საკონტროლო სიის პუნქტის წაშლა ვერ მოხერხდა" + } + }, + "all_skipped": "ყველა გამოტოვებულია", + "skipped_suffix": "გამოტოვებული", + "placeholder": "დაამატეთ პუნქტი…", + "status": { + "to_do": "შესასრულებელი", + "in_progress": "მიმდინარე", + "skipped": "გამოტოვებული", + "done": "დასრულებული" + } } } diff --git a/packages/i18n/src/locales/ka-ge/work-item.json b/packages/i18n/src/locales/ka-ge/work-item.json index d28ab081541..59091e3e96e 100644 --- a/packages/i18n/src/locales/ka-ge/work-item.json +++ b/packages/i18n/src/locales/ka-ge/work-item.json @@ -23,7 +23,8 @@ "dependency": "Add dependency", "relation": "ურთიერთობის დამატება", "link": "ბმულის დამატება", - "existing": "არსებული სამუშაო ელემენტის დამატება" + "existing": "არსებული სამუშაო ელემენტის დამატება", + "checklist_item": "საკონტროლო სიის პუნქტის დამატება" }, "remove": { "label": "სამუშაო ელემენტის ამოღება", diff --git a/packages/i18n/src/locales/ko/common.json b/packages/i18n/src/locales/ko/common.json index 73eea6b1c25..d18d606869f 100644 --- a/packages/i18n/src/locales/ko/common.json +++ b/packages/i18n/src/locales/ko/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "체크리스트" }, "chart": { "x_axis": "X축", @@ -867,5 +868,42 @@ "description": "작업 항목을 JSON 파일로 내보냅니다.", "short_description": "JSON으로 내보내기" } + }, + "checklist": { + "toasts": { + "created": { + "title": "체크리스트 항목이 추가되었습니다", + "message": "체크리스트 항목이 추가되었습니다" + }, + "not_created": { + "title": "체크리스트 항목을 추가하지 못했습니다", + "message": "체크리스트 항목을 추가할 수 없습니다" + }, + "updated": { + "title": "체크리스트 항목이 업데이트되었습니다", + "message": "체크리스트 항목이 업데이트되었습니다" + }, + "not_updated": { + "title": "체크리스트 항목을 업데이트하지 못했습니다", + "message": "체크리스트 항목을 업데이트할 수 없습니다" + }, + "removed": { + "title": "체크리스트 항목이 삭제되었습니다", + "message": "체크리스트 항목이 삭제되었습니다" + }, + "not_removed": { + "title": "체크리스트 항목을 삭제하지 못했습니다", + "message": "체크리스트 항목을 삭제할 수 없습니다" + } + }, + "all_skipped": "모두 건너뜀", + "skipped_suffix": "건너뜀", + "placeholder": "항목 추가…", + "status": { + "to_do": "할 일", + "in_progress": "진행 중", + "skipped": "건너뜀", + "done": "완료" + } } } diff --git a/packages/i18n/src/locales/ko/work-item.json b/packages/i18n/src/locales/ko/work-item.json index 66007d29c70..527e1a0179a 100644 --- a/packages/i18n/src/locales/ko/work-item.json +++ b/packages/i18n/src/locales/ko/work-item.json @@ -23,7 +23,8 @@ "dependency": "종속성 추가", "relation": "관계 추가", "link": "링크 추가", - "existing": "기존 작업 항목 추가" + "existing": "기존 작업 항목 추가", + "checklist_item": "체크리스트 항목 추가" }, "remove": { "label": "작업 항목 제거", diff --git a/packages/i18n/src/locales/pl/common.json b/packages/i18n/src/locales/pl/common.json index 4c4850cbf66..841d55c6dc3 100644 --- a/packages/i18n/src/locales/pl/common.json +++ b/packages/i18n/src/locales/pl/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Lista kontrolna" }, "chart": { "x_axis": "Oś X", @@ -867,5 +868,42 @@ "description": "Eksportuj elementy do pliku JSON.", "short_description": "Eksportuj jako JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Dodano element listy kontrolnej", + "message": "Element listy kontrolnej został dodany" + }, + "not_created": { + "title": "Nie dodano elementu listy kontrolnej", + "message": "Nie udało się dodać elementu listy kontrolnej" + }, + "updated": { + "title": "Zaktualizowano element listy kontrolnej", + "message": "Element listy kontrolnej został zaktualizowany" + }, + "not_updated": { + "title": "Nie zaktualizowano elementu listy kontrolnej", + "message": "Nie udało się zaktualizować elementu listy kontrolnej" + }, + "removed": { + "title": "Usunięto element listy kontrolnej", + "message": "Element listy kontrolnej został usunięty" + }, + "not_removed": { + "title": "Nie usunięto elementu listy kontrolnej", + "message": "Nie udało się usunąć elementu listy kontrolnej" + } + }, + "all_skipped": "Wszystko pominięte", + "skipped_suffix": "pominiętych", + "placeholder": "Dodaj element…", + "status": { + "to_do": "Do zrobienia", + "in_progress": "W trakcie", + "skipped": "Pominięte", + "done": "Zrobione" + } } } diff --git a/packages/i18n/src/locales/pl/work-item.json b/packages/i18n/src/locales/pl/work-item.json index b3759399ee5..de3a20f382b 100644 --- a/packages/i18n/src/locales/pl/work-item.json +++ b/packages/i18n/src/locales/pl/work-item.json @@ -23,7 +23,8 @@ "dependency": "Dodaj zależność", "relation": "Dodaj relację", "link": "Dodaj link", - "existing": "Dodaj istniejący element pracy" + "existing": "Dodaj istniejący element pracy", + "checklist_item": "Dodaj element listy kontrolnej" }, "remove": { "label": "Usuń element pracy", diff --git a/packages/i18n/src/locales/pt-BR/common.json b/packages/i18n/src/locales/pt-BR/common.json index 565e85d8739..d2975107526 100644 --- a/packages/i18n/src/locales/pt-BR/common.json +++ b/packages/i18n/src/locales/pt-BR/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Checklist" }, "chart": { "x_axis": "Eixo X", @@ -867,5 +868,42 @@ "description": "Exporte itens de trabalho para um arquivo JSON.", "short_description": "Exportar como JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Item da checklist adicionado", + "message": "O item da checklist foi adicionado" + }, + "not_created": { + "title": "Item da checklist não adicionado", + "message": "Não foi possível adicionar o item da checklist" + }, + "updated": { + "title": "Item da checklist atualizado", + "message": "O item da checklist foi atualizado" + }, + "not_updated": { + "title": "Item da checklist não atualizado", + "message": "Não foi possível atualizar o item da checklist" + }, + "removed": { + "title": "Item da checklist removido", + "message": "O item da checklist foi removido" + }, + "not_removed": { + "title": "Item da checklist não removido", + "message": "Não foi possível remover o item da checklist" + } + }, + "all_skipped": "Tudo ignorado", + "skipped_suffix": "ignorados", + "placeholder": "Adicionar um item…", + "status": { + "to_do": "A fazer", + "in_progress": "Em andamento", + "skipped": "Ignorado", + "done": "Concluído" + } } } diff --git a/packages/i18n/src/locales/pt-BR/work-item.json b/packages/i18n/src/locales/pt-BR/work-item.json index 7436c408eb3..0e431150746 100644 --- a/packages/i18n/src/locales/pt-BR/work-item.json +++ b/packages/i18n/src/locales/pt-BR/work-item.json @@ -23,7 +23,8 @@ "dependency": "Adicionar dependência", "relation": "Adicionar relação", "link": "Adicionar link", - "existing": "Adicionar item de trabalho existente" + "existing": "Adicionar item de trabalho existente", + "checklist_item": "Adicionar item da checklist" }, "remove": { "label": "Remover item de trabalho", diff --git a/packages/i18n/src/locales/ro/common.json b/packages/i18n/src/locales/ro/common.json index e2b44ce87ec..ed08b337d92 100644 --- a/packages/i18n/src/locales/ro/common.json +++ b/packages/i18n/src/locales/ro/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Listă de verificare" }, "chart": { "x_axis": "axa-X", @@ -867,5 +868,42 @@ "description": "Exportă activitățile într-un fișier JSON.", "short_description": "Exportă ca JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Element din lista de verificare adăugat", + "message": "Elementul din lista de verificare a fost adăugat" + }, + "not_created": { + "title": "Elementul din lista de verificare nu a fost adăugat", + "message": "Elementul din lista de verificare nu a putut fi adăugat" + }, + "updated": { + "title": "Element din lista de verificare actualizat", + "message": "Elementul din lista de verificare a fost actualizat" + }, + "not_updated": { + "title": "Elementul din lista de verificare nu a fost actualizat", + "message": "Elementul din lista de verificare nu a putut fi actualizat" + }, + "removed": { + "title": "Element din lista de verificare eliminat", + "message": "Elementul din lista de verificare a fost eliminat" + }, + "not_removed": { + "title": "Elementul din lista de verificare nu a fost eliminat", + "message": "Elementul din lista de verificare nu a putut fi eliminat" + } + }, + "all_skipped": "Toate omise", + "skipped_suffix": "omise", + "placeholder": "Adăugați un element…", + "status": { + "to_do": "De făcut", + "in_progress": "În desfășurare", + "skipped": "Omis", + "done": "Finalizat" + } } } diff --git a/packages/i18n/src/locales/ro/work-item.json b/packages/i18n/src/locales/ro/work-item.json index b9d56bd76e3..95f61029146 100644 --- a/packages/i18n/src/locales/ro/work-item.json +++ b/packages/i18n/src/locales/ro/work-item.json @@ -23,7 +23,8 @@ "dependency": "Adaugă dependență", "relation": "Adaugă relație", "link": "Adaugă link", - "existing": "Adaugă activitate existentă" + "existing": "Adaugă activitate existentă", + "checklist_item": "Adăugați element din lista de verificare" }, "remove": { "label": "Elimină activitatea", diff --git a/packages/i18n/src/locales/ru/common.json b/packages/i18n/src/locales/ru/common.json index c2881e55f8a..ed08037696b 100644 --- a/packages/i18n/src/locales/ru/common.json +++ b/packages/i18n/src/locales/ru/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Чек-лист" }, "chart": { "x_axis": "Ось X", @@ -867,5 +868,42 @@ "description": "Экспорт рабочих элементов в JSON-файл.", "short_description": "Экспорт в json" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Пункт чек-листа добавлен", + "message": "Пункт чек-листа был добавлен" + }, + "not_created": { + "title": "Пункт чек-листа не добавлен", + "message": "Не удалось добавить пункт чек-листа" + }, + "updated": { + "title": "Пункт чек-листа обновлён", + "message": "Пункт чек-листа был обновлён" + }, + "not_updated": { + "title": "Пункт чек-листа не обновлён", + "message": "Не удалось обновить пункт чек-листа" + }, + "removed": { + "title": "Пункт чек-листа удалён", + "message": "Пункт чек-листа был удалён" + }, + "not_removed": { + "title": "Пункт чек-листа не удалён", + "message": "Не удалось удалить пункт чек-листа" + } + }, + "all_skipped": "Все пропущены", + "skipped_suffix": "пропущено", + "placeholder": "Добавить пункт…", + "status": { + "to_do": "К выполнению", + "in_progress": "В процессе", + "skipped": "Пропущено", + "done": "Готово" + } } } diff --git a/packages/i18n/src/locales/ru/work-item.json b/packages/i18n/src/locales/ru/work-item.json index 014478fbc47..3b10349202a 100644 --- a/packages/i18n/src/locales/ru/work-item.json +++ b/packages/i18n/src/locales/ru/work-item.json @@ -23,7 +23,8 @@ "dependency": "Добавить зависимость", "relation": "Добавить связь", "link": "Добавить ссылку", - "existing": "Добавить существующий рабочий элемент" + "existing": "Добавить существующий рабочий элемент", + "checklist_item": "Добавить пункт чек-листа" }, "remove": { "label": "Удалить рабочий элемент", diff --git a/packages/i18n/src/locales/sk/common.json b/packages/i18n/src/locales/sk/common.json index 4fc339cece6..e110a5cf7c5 100644 --- a/packages/i18n/src/locales/sk/common.json +++ b/packages/i18n/src/locales/sk/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Kontrolný zoznam" }, "chart": { "x_axis": "Os X", @@ -867,5 +868,42 @@ "description": "Exportujte položky do JSON.", "short_description": "Exportovať ako JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Položka kontrolného zoznamu pridaná", + "message": "Položka kontrolného zoznamu bola pridaná" + }, + "not_created": { + "title": "Položka kontrolného zoznamu nebola pridaná", + "message": "Položku kontrolného zoznamu sa nepodarilo pridať" + }, + "updated": { + "title": "Položka kontrolného zoznamu aktualizovaná", + "message": "Položka kontrolného zoznamu bola aktualizovaná" + }, + "not_updated": { + "title": "Položka kontrolného zoznamu nebola aktualizovaná", + "message": "Položku kontrolného zoznamu sa nepodarilo aktualizovať" + }, + "removed": { + "title": "Položka kontrolného zoznamu odstránená", + "message": "Položka kontrolného zoznamu bola odstránená" + }, + "not_removed": { + "title": "Položka kontrolného zoznamu nebola odstránená", + "message": "Položku kontrolného zoznamu sa nepodarilo odstrániť" + } + }, + "all_skipped": "Všetko preskočené", + "skipped_suffix": "preskočených", + "placeholder": "Pridať položku…", + "status": { + "to_do": "Urobiť", + "in_progress": "Prebieha", + "skipped": "Preskočené", + "done": "Hotovo" + } } } diff --git a/packages/i18n/src/locales/sk/work-item.json b/packages/i18n/src/locales/sk/work-item.json index 803aab56d3a..da5523a23c3 100644 --- a/packages/i18n/src/locales/sk/work-item.json +++ b/packages/i18n/src/locales/sk/work-item.json @@ -23,7 +23,8 @@ "dependency": "Pridať závislosť", "relation": "Pridať vzťah", "link": "Pridať odkaz", - "existing": "Pridať existujúcu pracovnú položku" + "existing": "Pridať existujúcu pracovnú položku", + "checklist_item": "Pridať položku kontrolného zoznamu" }, "remove": { "label": "Odstrániť pracovnú položku", diff --git a/packages/i18n/src/locales/tr-TR/common.json b/packages/i18n/src/locales/tr-TR/common.json index 74bd5cbf9b3..fe93915efd1 100644 --- a/packages/i18n/src/locales/tr-TR/common.json +++ b/packages/i18n/src/locales/tr-TR/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Kontrol listesi" }, "chart": { "x_axis": "X ekseni", @@ -867,5 +868,42 @@ "description": "İş öğelerini JSON dosyasına aktarın.", "short_description": "JSON olarak aktar" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Kontrol listesi öğesi eklendi", + "message": "Kontrol listesi öğesi başarıyla eklendi" + }, + "not_created": { + "title": "Kontrol listesi öğesi eklenemedi", + "message": "Kontrol listesi öğesi eklenirken bir sorun oluştu" + }, + "updated": { + "title": "Kontrol listesi öğesi güncellendi", + "message": "Kontrol listesi öğesi başarıyla güncellendi" + }, + "not_updated": { + "title": "Kontrol listesi öğesi güncellenemedi", + "message": "Kontrol listesi öğesi güncellenirken bir sorun oluştu" + }, + "removed": { + "title": "Kontrol listesi öğesi kaldırıldı", + "message": "Kontrol listesi öğesi başarıyla kaldırıldı" + }, + "not_removed": { + "title": "Kontrol listesi öğesi kaldırılamadı", + "message": "Kontrol listesi öğesi kaldırılırken bir sorun oluştu" + } + }, + "all_skipped": "Tümü atlandı", + "skipped_suffix": "atlandı", + "placeholder": "Bir öğe ekleyin…", + "status": { + "to_do": "Yapılacak", + "in_progress": "Devam ediyor", + "skipped": "Atlandı", + "done": "Tamamlandı" + } } } diff --git a/packages/i18n/src/locales/tr-TR/work-item.json b/packages/i18n/src/locales/tr-TR/work-item.json index 27ef7de45d1..6ea65897274 100644 --- a/packages/i18n/src/locales/tr-TR/work-item.json +++ b/packages/i18n/src/locales/tr-TR/work-item.json @@ -23,7 +23,8 @@ "dependency": "Bağımlılık ekle", "relation": "İlişki ekle", "link": "Bağlantı ekle", - "existing": "Varolan iş öğesi ekle" + "existing": "Varolan iş öğesi ekle", + "checklist_item": "Kontrol listesi öğesi ekle" }, "remove": { "label": "İş öğesini kaldır", diff --git a/packages/i18n/src/locales/ua/common.json b/packages/i18n/src/locales/ua/common.json index 89bd906d8fa..47da4535885 100644 --- a/packages/i18n/src/locales/ua/common.json +++ b/packages/i18n/src/locales/ua/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Чек-лист" }, "chart": { "x_axis": "Вісь X", @@ -867,5 +868,42 @@ "description": "Експортуйте одиниці у формат JSON.", "short_description": "Експортувати як JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Пункт чек-листа додано", + "message": "Пункт чек-листа було додано" + }, + "not_created": { + "title": "Пункт чек-листа не додано", + "message": "Не вдалося додати пункт чек-листа" + }, + "updated": { + "title": "Пункт чек-листа оновлено", + "message": "Пункт чек-листа було оновлено" + }, + "not_updated": { + "title": "Пункт чек-листа не оновлено", + "message": "Не вдалося оновити пункт чек-листа" + }, + "removed": { + "title": "Пункт чек-листа видалено", + "message": "Пункт чек-листа було видалено" + }, + "not_removed": { + "title": "Пункт чек-листа не видалено", + "message": "Не вдалося видалити пункт чек-листа" + } + }, + "all_skipped": "Усі пропущено", + "skipped_suffix": "пропущено", + "placeholder": "Додати пункт…", + "status": { + "to_do": "До виконання", + "in_progress": "У процесі", + "skipped": "Пропущено", + "done": "Готово" + } } } diff --git a/packages/i18n/src/locales/ua/work-item.json b/packages/i18n/src/locales/ua/work-item.json index c799374be43..6119bc071e0 100644 --- a/packages/i18n/src/locales/ua/work-item.json +++ b/packages/i18n/src/locales/ua/work-item.json @@ -23,7 +23,8 @@ "dependency": "Додати залежність", "relation": "Додати зв'язок", "link": "Додати посилання", - "existing": "Додати наявну робочу одиницю" + "existing": "Додати наявну робочу одиницю", + "checklist_item": "Додати пункт чек-листа" }, "remove": { "label": "Видалити робочу одиницю", diff --git a/packages/i18n/src/locales/vi-VN/common.json b/packages/i18n/src/locales/vi-VN/common.json index f9aae43c1b9..b362fb9f117 100644 --- a/packages/i18n/src/locales/vi-VN/common.json +++ b/packages/i18n/src/locales/vi-VN/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "Danh sách kiểm tra" }, "chart": { "x_axis": "Trục X", @@ -867,5 +868,42 @@ "description": "Xuất mục công việc thành tệp JSON.", "short_description": "Xuất sang JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "Đã thêm mục trong danh sách kiểm tra", + "message": "Mục trong danh sách kiểm tra đã được thêm" + }, + "not_created": { + "title": "Không thể thêm mục trong danh sách kiểm tra", + "message": "Không thể thêm mục trong danh sách kiểm tra" + }, + "updated": { + "title": "Đã cập nhật mục trong danh sách kiểm tra", + "message": "Mục trong danh sách kiểm tra đã được cập nhật" + }, + "not_updated": { + "title": "Không thể cập nhật mục trong danh sách kiểm tra", + "message": "Không thể cập nhật mục trong danh sách kiểm tra" + }, + "removed": { + "title": "Đã xóa mục trong danh sách kiểm tra", + "message": "Mục trong danh sách kiểm tra đã được xóa" + }, + "not_removed": { + "title": "Không thể xóa mục trong danh sách kiểm tra", + "message": "Không thể xóa mục trong danh sách kiểm tra" + } + }, + "all_skipped": "Tất cả đã bỏ qua", + "skipped_suffix": "đã bỏ qua", + "placeholder": "Thêm một mục…", + "status": { + "to_do": "Cần làm", + "in_progress": "Đang thực hiện", + "skipped": "Đã bỏ qua", + "done": "Hoàn thành" + } } } diff --git a/packages/i18n/src/locales/vi-VN/work-item.json b/packages/i18n/src/locales/vi-VN/work-item.json index fea748bc199..6621be5bb52 100644 --- a/packages/i18n/src/locales/vi-VN/work-item.json +++ b/packages/i18n/src/locales/vi-VN/work-item.json @@ -23,7 +23,8 @@ "dependency": "Thêm phụ thuộc", "relation": "Thêm mối quan hệ", "link": "Thêm liên kết", - "existing": "Thêm mục công việc hiện có" + "existing": "Thêm mục công việc hiện có", + "checklist_item": "Thêm mục trong danh sách kiểm tra" }, "remove": { "label": "Xóa mục công việc", diff --git a/packages/i18n/src/locales/zh-CN/common.json b/packages/i18n/src/locales/zh-CN/common.json index dd67d925a07..0dd3379bb74 100644 --- a/packages/i18n/src/locales/zh-CN/common.json +++ b/packages/i18n/src/locales/zh-CN/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "清单" }, "chart": { "x_axis": "X轴", @@ -867,5 +868,42 @@ "description": "将工作项导出为 JSON 文件。", "short_description": "导出为 JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "已添加清单项", + "message": "已添加该清单项" + }, + "not_created": { + "title": "清单项未添加", + "message": "无法添加该清单项" + }, + "updated": { + "title": "已更新清单项", + "message": "已更新该清单项" + }, + "not_updated": { + "title": "清单项未更新", + "message": "无法更新该清单项" + }, + "removed": { + "title": "已移除清单项", + "message": "已移除该清单项" + }, + "not_removed": { + "title": "清单项未移除", + "message": "无法移除该清单项" + } + }, + "all_skipped": "已全部跳过", + "skipped_suffix": "已跳过", + "placeholder": "添加一项…", + "status": { + "to_do": "待办", + "in_progress": "进行中", + "skipped": "已跳过", + "done": "已完成" + } } } diff --git a/packages/i18n/src/locales/zh-CN/work-item.json b/packages/i18n/src/locales/zh-CN/work-item.json index bfa3e5e2c7b..3dcce91815a 100644 --- a/packages/i18n/src/locales/zh-CN/work-item.json +++ b/packages/i18n/src/locales/zh-CN/work-item.json @@ -23,7 +23,8 @@ "dependency": "添加依赖", "relation": "添加关系", "link": "添加链接", - "existing": "添加现有工作项" + "existing": "添加现有工作项", + "checklist_item": "添加清单项" }, "remove": { "label": "移除工作项", diff --git a/packages/i18n/src/locales/zh-TW/common.json b/packages/i18n/src/locales/zh-TW/common.json index 834a2d922d8..588e6e203e4 100644 --- a/packages/i18n/src/locales/zh-TW/common.json +++ b/packages/i18n/src/locales/zh-TW/common.json @@ -717,7 +717,8 @@ "developer": "Developer", "work_structure": "Work structure", "execution": "Execution", - "administration": "Administration" + "administration": "Administration", + "checklist": "清單" }, "chart": { "x_axis": "X 軸", @@ -867,5 +868,42 @@ "description": "將工作事項匯出為 JSON 檔案。", "short_description": "匯出為 JSON" } + }, + "checklist": { + "toasts": { + "created": { + "title": "已新增清單項目", + "message": "已新增該清單項目" + }, + "not_created": { + "title": "清單項目未新增", + "message": "無法新增該清單項目" + }, + "updated": { + "title": "已更新清單項目", + "message": "已更新該清單項目" + }, + "not_updated": { + "title": "清單項目未更新", + "message": "無法更新該清單項目" + }, + "removed": { + "title": "已移除清單項目", + "message": "已移除該清單項目" + }, + "not_removed": { + "title": "清單項目未移除", + "message": "無法移除該清單項目" + } + }, + "all_skipped": "已全部略過", + "skipped_suffix": "已略過", + "placeholder": "新增項目…", + "status": { + "to_do": "待辦", + "in_progress": "進行中", + "skipped": "已略過", + "done": "已完成" + } } } diff --git a/packages/i18n/src/locales/zh-TW/work-item.json b/packages/i18n/src/locales/zh-TW/work-item.json index 4d59f7b3044..d5ade020544 100644 --- a/packages/i18n/src/locales/zh-TW/work-item.json +++ b/packages/i18n/src/locales/zh-TW/work-item.json @@ -23,7 +23,8 @@ "dependency": "新增相依性", "relation": "新增關聯", "link": "新增連結", - "existing": "新增現有工作事項" + "existing": "新增現有工作事項", + "checklist_item": "新增清單項目" }, "remove": { "label": "移除工作事項", diff --git a/packages/types/src/issues/base.ts b/packages/types/src/issues/base.ts index af802bb4d00..5926d993d88 100644 --- a/packages/types/src/issues/base.ts +++ b/packages/types/src/issues/base.ts @@ -8,6 +8,7 @@ export * from "./issue"; export * from "./issue_reaction"; export * from "./issue_link"; +export * from "./issue_checklist"; export * from "./issue_attachment"; export * from "./issue_relation"; export * from "./issue_sub_issues"; diff --git a/packages/types/src/issues/issue.ts b/packages/types/src/issues/issue.ts index 8054b4c44c3..a344e015742 100644 --- a/packages/types/src/issues/issue.ts +++ b/packages/types/src/issues/issue.ts @@ -155,7 +155,7 @@ export type TBulkOperationsPayload = { properties: Partial; }; -export type TWorkItemWidgets = "sub-work-items" | "relations" | "links" | "attachments"; +export type TWorkItemWidgets = "checklist" | "sub-work-items" | "relations" | "links" | "attachments"; export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS | EIssueServiceType.WORK_ITEMS; diff --git a/packages/types/src/issues/issue_checklist.ts b/packages/types/src/issues/issue_checklist.ts new file mode 100644 index 00000000000..ef097264338 --- /dev/null +++ b/packages/types/src/issues/issue_checklist.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export enum EChecklistItemStatus { + TO_DO = "to_do", + IN_PROGRESS = "in_progress", + SKIPPED = "skipped", + DONE = "done", +} + +export type TIssueChecklistItemEditableFields = { + name: string; + status: EChecklistItemStatus; + sort_order: number; +}; + +export type TIssueChecklistItem = TIssueChecklistItemEditableFields & { + id: string; + issue: string; + project: string; + workspace: string; + completed_at: string | null; + completed_by: string | null; + created_at: string; + updated_at: string; + created_by: string; + updated_by: string | null; +}; + +export type TIssueChecklistItemMap = { + [item_id: string]: TIssueChecklistItem; +}; + +export type TIssueChecklistItemIdMap = { + [issue_id: string]: string[]; +};