feat: add board rule toggles for approval and review requirements
This commit is contained in:
@@ -86,6 +86,41 @@ def _parse_draft_lead_agent(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_autonomy_token(value: object) -> str | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
text = value.strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text.replace("_", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_fully_autonomous_choice(value: object) -> bool:
|
||||||
|
token = _normalize_autonomy_token(value)
|
||||||
|
if token is None:
|
||||||
|
return False
|
||||||
|
if token in {"autonomous", "fully-autonomous", "full-autonomy"}:
|
||||||
|
return True
|
||||||
|
return "autonom" in token and "fully" in token
|
||||||
|
|
||||||
|
|
||||||
|
def _require_approval_for_done_from_draft(draft_goal: object) -> bool:
|
||||||
|
"""Enable done-approval gate unless onboarding selected fully autonomous mode."""
|
||||||
|
if not isinstance(draft_goal, dict):
|
||||||
|
return True
|
||||||
|
raw_lead = draft_goal.get("lead_agent")
|
||||||
|
if not isinstance(raw_lead, dict):
|
||||||
|
return True
|
||||||
|
if _is_fully_autonomous_choice(raw_lead.get("autonomy_level")):
|
||||||
|
return False
|
||||||
|
raw_identity_profile = raw_lead.get("identity_profile")
|
||||||
|
if isinstance(raw_identity_profile, dict):
|
||||||
|
for key in ("autonomy_level", "autonomy", "mode"):
|
||||||
|
if _is_fully_autonomous_choice(raw_identity_profile.get(key)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _apply_user_profile(
|
def _apply_user_profile(
|
||||||
auth: AuthContext,
|
auth: AuthContext,
|
||||||
profile: BoardOnboardingUserProfile | None,
|
profile: BoardOnboardingUserProfile | None,
|
||||||
@@ -408,6 +443,9 @@ async def confirm_onboarding(
|
|||||||
board.target_date = payload.target_date
|
board.target_date = payload.target_date
|
||||||
board.goal_confirmed = True
|
board.goal_confirmed = True
|
||||||
board.goal_source = "lead_agent_onboarding"
|
board.goal_source = "lead_agent_onboarding"
|
||||||
|
board.require_approval_for_done = _require_approval_for_done_from_draft(
|
||||||
|
onboarding.draft_goal,
|
||||||
|
)
|
||||||
|
|
||||||
onboarding.status = "confirmed"
|
onboarding.status = "confirmed"
|
||||||
onboarding.updated_at = utcnow()
|
onboarding.updated_at = utcnow()
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ from app.schemas.errors import BlockedTaskError
|
|||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||||
from app.services.activity_log import record_activity
|
from app.services.activity_log import record_activity
|
||||||
from app.services.approval_task_links import load_task_ids_by_approval
|
from app.services.approval_task_links import (
|
||||||
|
load_task_ids_by_approval,
|
||||||
|
pending_approval_conflicts_by_task,
|
||||||
|
)
|
||||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
||||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||||
@@ -113,6 +116,151 @@ def _blocked_task_error(blocked_by_task_ids: Sequence[UUID]) -> HTTPException:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_required_for_done_error() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": (
|
||||||
|
"Task can only be marked done when a linked approval has been approved."
|
||||||
|
),
|
||||||
|
"blocked_by_task_ids": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _review_required_for_done_error() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": (
|
||||||
|
"Task can only be marked done from review when the board rule is enabled."
|
||||||
|
),
|
||||||
|
"blocked_by_task_ids": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_approval_blocks_status_change_error() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": (
|
||||||
|
"Task status cannot be changed while a linked approval is pending."
|
||||||
|
),
|
||||||
|
"blocked_by_task_ids": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_has_approved_linked_approval(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
board_id: UUID,
|
||||||
|
task_id: UUID,
|
||||||
|
) -> bool:
|
||||||
|
linked_approval_ids = select(col(ApprovalTaskLink.approval_id)).where(
|
||||||
|
col(ApprovalTaskLink.task_id) == task_id,
|
||||||
|
)
|
||||||
|
statement = (
|
||||||
|
select(col(Approval.id))
|
||||||
|
.where(col(Approval.board_id) == board_id)
|
||||||
|
.where(col(Approval.status) == "approved")
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
col(Approval.task_id) == task_id,
|
||||||
|
col(Approval.id).in_(linked_approval_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return (await session.exec(statement)).first() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_has_pending_linked_approval(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
board_id: UUID,
|
||||||
|
task_id: UUID,
|
||||||
|
) -> bool:
|
||||||
|
conflicts = await pending_approval_conflicts_by_task(
|
||||||
|
session,
|
||||||
|
board_id=board_id,
|
||||||
|
task_ids=[task_id],
|
||||||
|
)
|
||||||
|
return task_id in conflicts
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_approved_linked_approval_for_done(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
board_id: UUID,
|
||||||
|
task_id: UUID,
|
||||||
|
previous_status: str,
|
||||||
|
target_status: str,
|
||||||
|
) -> None:
|
||||||
|
if previous_status == "done" or target_status != "done":
|
||||||
|
return
|
||||||
|
requires_approval = (
|
||||||
|
await session.exec(
|
||||||
|
select(col(Board.require_approval_for_done)).where(col(Board.id) == board_id),
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if requires_approval is False:
|
||||||
|
return
|
||||||
|
if not await _task_has_approved_linked_approval(
|
||||||
|
session,
|
||||||
|
board_id=board_id,
|
||||||
|
task_id=task_id,
|
||||||
|
):
|
||||||
|
raise _approval_required_for_done_error()
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_review_before_done_when_enabled(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
board_id: UUID,
|
||||||
|
previous_status: str,
|
||||||
|
target_status: str,
|
||||||
|
) -> None:
|
||||||
|
if previous_status == "done" or target_status != "done":
|
||||||
|
return
|
||||||
|
requires_review = (
|
||||||
|
await session.exec(
|
||||||
|
select(col(Board.require_review_before_done)).where(col(Board.id) == board_id),
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if requires_review and previous_status != "review":
|
||||||
|
raise _review_required_for_done_error()
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_no_pending_approval_for_status_change_when_enabled(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
board_id: UUID,
|
||||||
|
task_id: UUID,
|
||||||
|
previous_status: str,
|
||||||
|
target_status: str,
|
||||||
|
status_requested: bool,
|
||||||
|
) -> None:
|
||||||
|
if not status_requested or previous_status == target_status:
|
||||||
|
return
|
||||||
|
blocks_status_change = (
|
||||||
|
await session.exec(
|
||||||
|
select(col(Board.block_status_changes_with_pending_approval)).where(
|
||||||
|
col(Board.id) == board_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if not blocks_status_change:
|
||||||
|
return
|
||||||
|
if await _task_has_pending_linked_approval(
|
||||||
|
session,
|
||||||
|
board_id=board_id,
|
||||||
|
task_id=task_id,
|
||||||
|
):
|
||||||
|
raise _pending_approval_blocks_status_change_error()
|
||||||
|
|
||||||
|
|
||||||
def _truncate_snippet(value: str) -> str:
|
def _truncate_snippet(value: str) -> str:
|
||||||
text = value.strip()
|
text = value.strip()
|
||||||
if len(text) <= TASK_SNIPPET_MAX_LEN:
|
if len(text) <= TASK_SNIPPET_MAX_LEN:
|
||||||
@@ -1447,6 +1595,27 @@ async def _apply_lead_task_update(
|
|||||||
else:
|
else:
|
||||||
await _lead_apply_assignment(session, update=update)
|
await _lead_apply_assignment(session, update=update)
|
||||||
_lead_apply_status(update)
|
_lead_apply_status(update)
|
||||||
|
await _require_no_pending_approval_for_status_change_when_enabled(
|
||||||
|
session,
|
||||||
|
board_id=update.board_id,
|
||||||
|
task_id=update.task.id,
|
||||||
|
previous_status=update.previous_status,
|
||||||
|
target_status=update.task.status,
|
||||||
|
status_requested="status" in update.updates,
|
||||||
|
)
|
||||||
|
await _require_review_before_done_when_enabled(
|
||||||
|
session,
|
||||||
|
board_id=update.board_id,
|
||||||
|
previous_status=update.previous_status,
|
||||||
|
target_status=update.task.status,
|
||||||
|
)
|
||||||
|
await _require_approved_linked_approval_for_done(
|
||||||
|
session,
|
||||||
|
board_id=update.board_id,
|
||||||
|
task_id=update.task.id,
|
||||||
|
previous_status=update.previous_status,
|
||||||
|
target_status=update.task.status,
|
||||||
|
)
|
||||||
|
|
||||||
if normalized_tag_ids is not None:
|
if normalized_tag_ids is not None:
|
||||||
await replace_tags(
|
await replace_tags(
|
||||||
@@ -1701,6 +1870,27 @@ async def _finalize_updated_task(
|
|||||||
) -> TaskRead:
|
) -> TaskRead:
|
||||||
for key, value in update.updates.items():
|
for key, value in update.updates.items():
|
||||||
setattr(update.task, key, value)
|
setattr(update.task, key, value)
|
||||||
|
await _require_no_pending_approval_for_status_change_when_enabled(
|
||||||
|
session,
|
||||||
|
board_id=update.board_id,
|
||||||
|
task_id=update.task.id,
|
||||||
|
previous_status=update.previous_status,
|
||||||
|
target_status=update.task.status,
|
||||||
|
status_requested="status" in update.updates,
|
||||||
|
)
|
||||||
|
await _require_review_before_done_when_enabled(
|
||||||
|
session,
|
||||||
|
board_id=update.board_id,
|
||||||
|
previous_status=update.previous_status,
|
||||||
|
target_status=update.task.status,
|
||||||
|
)
|
||||||
|
await _require_approved_linked_approval_for_done(
|
||||||
|
session,
|
||||||
|
board_id=update.board_id,
|
||||||
|
task_id=update.task.id,
|
||||||
|
previous_status=update.previous_status,
|
||||||
|
target_status=update.task.status,
|
||||||
|
)
|
||||||
update.task.updated_at = utcnow()
|
update.task.updated_at = utcnow()
|
||||||
|
|
||||||
status_raw = update.updates.get("status")
|
status_raw = update.updates.get("status")
|
||||||
|
|||||||
@@ -39,5 +39,8 @@ class Board(TenantScoped, table=True):
|
|||||||
target_date: datetime | None = None
|
target_date: datetime | None = None
|
||||||
goal_confirmed: bool = Field(default=False)
|
goal_confirmed: bool = Field(default=False)
|
||||||
goal_source: str | None = None
|
goal_source: str | None = None
|
||||||
|
require_approval_for_done: bool = Field(default=True)
|
||||||
|
require_review_before_done: bool = Field(default=False)
|
||||||
|
block_status_changes_with_pending_approval: bool = Field(default=False)
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
updated_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class BoardBase(SQLModel):
|
|||||||
target_date: datetime | None = None
|
target_date: datetime | None = None
|
||||||
goal_confirmed: bool = False
|
goal_confirmed: bool = False
|
||||||
goal_source: str | None = None
|
goal_source: str | None = None
|
||||||
|
require_approval_for_done: bool = True
|
||||||
|
require_review_before_done: bool = False
|
||||||
|
block_status_changes_with_pending_approval: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BoardCreate(BoardBase):
|
class BoardCreate(BoardBase):
|
||||||
@@ -68,6 +71,9 @@ class BoardUpdate(SQLModel):
|
|||||||
target_date: datetime | None = None
|
target_date: datetime | None = None
|
||||||
goal_confirmed: bool | None = None
|
goal_confirmed: bool | None = None
|
||||||
goal_source: str | None = None
|
goal_source: str | None = None
|
||||||
|
require_approval_for_done: bool | None = None
|
||||||
|
require_review_before_done: bool | None = None
|
||||||
|
block_status_changes_with_pending_approval: bool | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_gateway_id(self) -> Self:
|
def validate_gateway_id(self) -> Self:
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add board rule toggles
|
||||||
|
|
||||||
|
Revision ID: c2e9f1a6d4b8
|
||||||
|
Revises: e2f9c6b4a1d3
|
||||||
|
Create Date: 2026-02-12 23:55:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "c2e9f1a6d4b8"
|
||||||
|
down_revision = "e2f9c6b4a1d3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"require_approval_for_done",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.true(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"require_review_before_done",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"boards",
|
||||||
|
sa.Column(
|
||||||
|
"block_status_changes_with_pending_approval",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("boards", "block_status_changes_with_pending_approval")
|
||||||
|
op.drop_column("boards", "require_review_before_done")
|
||||||
|
op.drop_column("boards", "require_approval_for_done")
|
||||||
45
backend/tests/test_board_onboarding_autonomy_toggle.py
Normal file
45
backend/tests/test_board_onboarding_autonomy_toggle.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.api.board_onboarding import _require_approval_for_done_from_draft
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_approval_for_done_defaults_true_without_lead_agent_draft() -> None:
|
||||||
|
assert _require_approval_for_done_from_draft(None) is True
|
||||||
|
assert _require_approval_for_done_from_draft({}) is True
|
||||||
|
assert _require_approval_for_done_from_draft({"lead_agent": "invalid"}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_approval_for_done_stays_enabled_for_non_fully_autonomous_modes() -> None:
|
||||||
|
assert (
|
||||||
|
_require_approval_for_done_from_draft(
|
||||||
|
{"lead_agent": {"autonomy_level": "ask_first"}},
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_require_approval_for_done_from_draft(
|
||||||
|
{"lead_agent": {"autonomy_level": "balanced"}},
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_approval_for_done_disables_for_fully_autonomous_choices() -> None:
|
||||||
|
assert (
|
||||||
|
_require_approval_for_done_from_draft(
|
||||||
|
{"lead_agent": {"autonomy_level": "autonomous"}},
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_require_approval_for_done_from_draft(
|
||||||
|
{"lead_agent": {"autonomy_level": "fully-autonomous"}},
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_require_approval_for_done_from_draft(
|
||||||
|
{"lead_agent": {"identity_profile": {"autonomy_level": "fully autonomous"}}},
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
@@ -76,6 +76,28 @@ def test_board_update_rejects_empty_description_patch() -> None:
|
|||||||
BoardUpdate(description=" ")
|
BoardUpdate(description=" ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_rule_toggles_have_expected_defaults() -> None:
|
||||||
|
"""Boards should default to approval-gated done and optional review gating."""
|
||||||
|
created = BoardCreate(
|
||||||
|
name="Ops Board",
|
||||||
|
slug="ops-board",
|
||||||
|
description="Operations workflow board.",
|
||||||
|
gateway_id=uuid4(),
|
||||||
|
)
|
||||||
|
assert created.require_approval_for_done is True
|
||||||
|
assert created.require_review_before_done is False
|
||||||
|
assert created.block_status_changes_with_pending_approval is False
|
||||||
|
|
||||||
|
updated = BoardUpdate(
|
||||||
|
require_approval_for_done=False,
|
||||||
|
require_review_before_done=True,
|
||||||
|
block_status_changes_with_pending_approval=True,
|
||||||
|
)
|
||||||
|
assert updated.require_approval_for_done is False
|
||||||
|
assert updated.require_review_before_done is True
|
||||||
|
assert updated.block_status_changes_with_pending_approval is True
|
||||||
|
|
||||||
|
|
||||||
def test_onboarding_confirm_requires_goal_fields() -> None:
|
def test_onboarding_confirm_requires_goal_fields() -> None:
|
||||||
"""Onboarding confirm should enforce goal fields for goal board types."""
|
"""Onboarding confirm should enforce goal fields for goal board types."""
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
|
|||||||
395
backend/tests/test_tasks_done_approval_gate.py
Normal file
395
backend/tests/test_tasks_done_approval_gate.py
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.api import tasks as tasks_api
|
||||||
|
from app.api.deps import ActorContext
|
||||||
|
from app.models.agents import Agent
|
||||||
|
from app.models.approval_task_links import ApprovalTaskLink
|
||||||
|
from app.models.approvals import Approval
|
||||||
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
|
from app.models.organizations import Organization
|
||||||
|
from app.models.tasks import Task
|
||||||
|
from app.schemas.tasks import TaskUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_engine() -> AsyncEngine:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.connect() as conn, conn.begin():
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_session(engine: AsyncEngine) -> AsyncSession:
|
||||||
|
return AsyncSession(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_board_task_and_agent(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
task_status: str = "review",
|
||||||
|
require_approval_for_done: bool = True,
|
||||||
|
require_review_before_done: bool = False,
|
||||||
|
block_status_changes_with_pending_approval: bool = False,
|
||||||
|
) -> tuple[Board, Task, Agent]:
|
||||||
|
organization_id = uuid4()
|
||||||
|
gateway = Gateway(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization_id,
|
||||||
|
name="gateway",
|
||||||
|
url="https://gateway.local",
|
||||||
|
workspace_root="/tmp/workspace",
|
||||||
|
)
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
organization_id=organization_id,
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
name="board",
|
||||||
|
slug=f"board-{uuid4()}",
|
||||||
|
require_approval_for_done=require_approval_for_done,
|
||||||
|
require_review_before_done=require_review_before_done,
|
||||||
|
block_status_changes_with_pending_approval=block_status_changes_with_pending_approval,
|
||||||
|
)
|
||||||
|
task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status)
|
||||||
|
agent = Agent(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
name="agent",
|
||||||
|
status="online",
|
||||||
|
is_board_lead=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
|
||||||
|
session.add(gateway)
|
||||||
|
session.add(board)
|
||||||
|
session.add(task)
|
||||||
|
session.add(agent)
|
||||||
|
await session.commit()
|
||||||
|
return board, task, agent
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_task_to_done(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
task: Task,
|
||||||
|
agent: Agent,
|
||||||
|
) -> None:
|
||||||
|
await _update_task_status(
|
||||||
|
session,
|
||||||
|
task=task,
|
||||||
|
agent=agent,
|
||||||
|
status="done",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_task_status(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
task: Task,
|
||||||
|
agent: Agent,
|
||||||
|
status: Literal["inbox", "in_progress", "review", "done"],
|
||||||
|
) -> None:
|
||||||
|
await tasks_api.update_task(
|
||||||
|
payload=TaskUpdate(status=status),
|
||||||
|
task=task,
|
||||||
|
session=session,
|
||||||
|
actor=ActorContext(actor_type="agent", agent=agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_rejects_done_without_approved_linked_approval() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
board, task, agent = await _seed_board_task_and_agent(session)
|
||||||
|
session.add(
|
||||||
|
Approval(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
task_id=task.id,
|
||||||
|
action_type="task.review",
|
||||||
|
confidence=65,
|
||||||
|
status="pending",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _update_task_to_done(session, task=task, agent=agent)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
detail = exc.value.detail
|
||||||
|
assert isinstance(detail, dict)
|
||||||
|
assert detail["message"] == (
|
||||||
|
"Task can only be marked done when a linked approval has been approved."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_allows_done_with_approved_primary_task_approval() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
board, task, agent = await _seed_board_task_and_agent(session)
|
||||||
|
session.add(
|
||||||
|
Approval(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
task_id=task.id,
|
||||||
|
action_type="task.review",
|
||||||
|
confidence=92,
|
||||||
|
status="approved",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated = await tasks_api.update_task(
|
||||||
|
payload=TaskUpdate(status="done"),
|
||||||
|
task=task,
|
||||||
|
session=session,
|
||||||
|
actor=ActorContext(actor_type="agent", agent=agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.status == "done"
|
||||||
|
assert updated.assigned_agent_id == agent.id
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_allows_done_with_approved_multi_task_link() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
board, task, agent = await _seed_board_task_and_agent(session)
|
||||||
|
primary_task_id = uuid4()
|
||||||
|
session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
|
||||||
|
|
||||||
|
approval_id = uuid4()
|
||||||
|
session.add(
|
||||||
|
Approval(
|
||||||
|
id=approval_id,
|
||||||
|
board_id=board.id,
|
||||||
|
task_id=primary_task_id,
|
||||||
|
action_type="task.batch_review",
|
||||||
|
confidence=88,
|
||||||
|
status="approved",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated = await tasks_api.update_task(
|
||||||
|
payload=TaskUpdate(status="done"),
|
||||||
|
task=task,
|
||||||
|
session=session,
|
||||||
|
actor=ActorContext(actor_type="agent", agent=agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.status == "done"
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_allows_done_without_approval_when_board_toggle_disabled() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
_board, task, agent = await _seed_board_task_and_agent(
|
||||||
|
session,
|
||||||
|
require_approval_for_done=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await tasks_api.update_task(
|
||||||
|
payload=TaskUpdate(status="done"),
|
||||||
|
task=task,
|
||||||
|
session=session,
|
||||||
|
actor=ActorContext(actor_type="agent", agent=agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.status == "done"
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_rejects_done_from_in_progress_when_review_toggle_enabled() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
_board, task, agent = await _seed_board_task_and_agent(
|
||||||
|
session,
|
||||||
|
task_status="in_progress",
|
||||||
|
require_approval_for_done=False,
|
||||||
|
require_review_before_done=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _update_task_to_done(session, task=task, agent=agent)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
detail = exc.value.detail
|
||||||
|
assert isinstance(detail, dict)
|
||||||
|
assert detail["message"] == (
|
||||||
|
"Task can only be marked done from review when the board rule is enabled."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_allows_done_from_review_when_review_toggle_enabled() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
_board, task, agent = await _seed_board_task_and_agent(
|
||||||
|
session,
|
||||||
|
task_status="review",
|
||||||
|
require_approval_for_done=False,
|
||||||
|
require_review_before_done=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await tasks_api.update_task(
|
||||||
|
payload=TaskUpdate(status="done"),
|
||||||
|
task=task,
|
||||||
|
session=session,
|
||||||
|
actor=ActorContext(actor_type="agent", agent=agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.status == "done"
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_rejects_status_change_with_pending_approval_when_toggle_enabled() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
board, task, agent = await _seed_board_task_and_agent(
|
||||||
|
session,
|
||||||
|
task_status="inbox",
|
||||||
|
require_approval_for_done=False,
|
||||||
|
block_status_changes_with_pending_approval=True,
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Approval(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
task_id=task.id,
|
||||||
|
action_type="task.execute",
|
||||||
|
confidence=70,
|
||||||
|
status="pending",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _update_task_status(
|
||||||
|
session,
|
||||||
|
task=task,
|
||||||
|
agent=agent,
|
||||||
|
status="in_progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
detail = exc.value.detail
|
||||||
|
assert isinstance(detail, dict)
|
||||||
|
assert detail["message"] == (
|
||||||
|
"Task status cannot be changed while a linked approval is pending."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_allows_status_change_with_pending_approval_when_toggle_disabled() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
board, task, agent = await _seed_board_task_and_agent(
|
||||||
|
session,
|
||||||
|
task_status="inbox",
|
||||||
|
require_approval_for_done=False,
|
||||||
|
block_status_changes_with_pending_approval=False,
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Approval(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
task_id=task.id,
|
||||||
|
action_type="task.execute",
|
||||||
|
confidence=70,
|
||||||
|
status="pending",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated = await tasks_api.update_task(
|
||||||
|
payload=TaskUpdate(status="in_progress"),
|
||||||
|
task=task,
|
||||||
|
session=session,
|
||||||
|
actor=ActorContext(actor_type="agent", agent=agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.status == "in_progress"
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> None:
|
||||||
|
engine = await _make_engine()
|
||||||
|
try:
|
||||||
|
async with await _make_session(engine) as session:
|
||||||
|
board, task, agent = await _seed_board_task_and_agent(
|
||||||
|
session,
|
||||||
|
task_status="inbox",
|
||||||
|
require_approval_for_done=False,
|
||||||
|
block_status_changes_with_pending_approval=True,
|
||||||
|
)
|
||||||
|
primary_task_id = uuid4()
|
||||||
|
session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
|
||||||
|
|
||||||
|
approval_id = uuid4()
|
||||||
|
session.add(
|
||||||
|
Approval(
|
||||||
|
id=approval_id,
|
||||||
|
board_id=board.id,
|
||||||
|
task_id=primary_task_id,
|
||||||
|
action_type="task.batch_execute",
|
||||||
|
confidence=73,
|
||||||
|
status="pending",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _update_task_status(
|
||||||
|
session,
|
||||||
|
task=task,
|
||||||
|
agent=agent,
|
||||||
|
status="in_progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
@@ -21,4 +21,7 @@ export interface BoardCreate {
|
|||||||
target_date?: string | null;
|
target_date?: string | null;
|
||||||
goal_confirmed?: boolean;
|
goal_confirmed?: boolean;
|
||||||
goal_source?: string | null;
|
goal_source?: string | null;
|
||||||
|
require_approval_for_done?: boolean;
|
||||||
|
require_review_before_done?: boolean;
|
||||||
|
block_status_changes_with_pending_approval?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface BoardRead {
|
|||||||
target_date?: string | null;
|
target_date?: string | null;
|
||||||
goal_confirmed?: boolean;
|
goal_confirmed?: boolean;
|
||||||
goal_source?: string | null;
|
goal_source?: string | null;
|
||||||
|
require_approval_for_done?: boolean;
|
||||||
|
require_review_before_done?: boolean;
|
||||||
|
block_status_changes_with_pending_approval?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -21,4 +21,7 @@ export interface BoardUpdate {
|
|||||||
target_date?: string | null;
|
target_date?: string | null;
|
||||||
goal_confirmed?: boolean | null;
|
goal_confirmed?: boolean | null;
|
||||||
goal_source?: string | null;
|
goal_source?: string | null;
|
||||||
|
require_approval_for_done?: boolean | null;
|
||||||
|
require_review_before_done?: boolean | null;
|
||||||
|
block_status_changes_with_pending_approval?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ export default function EditBoardPage() {
|
|||||||
);
|
);
|
||||||
const [boardType, setBoardType] = useState<string | undefined>(undefined);
|
const [boardType, setBoardType] = useState<string | undefined>(undefined);
|
||||||
const [objective, setObjective] = useState<string | undefined>(undefined);
|
const [objective, setObjective] = useState<string | undefined>(undefined);
|
||||||
|
const [requireApprovalForDone, setRequireApprovalForDone] = useState<
|
||||||
|
boolean | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [requireReviewBeforeDone, setRequireReviewBeforeDone] = useState<
|
||||||
|
boolean | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [
|
||||||
|
blockStatusChangesWithPendingApproval,
|
||||||
|
setBlockStatusChangesWithPendingApproval,
|
||||||
|
] = useState<boolean | undefined>(undefined);
|
||||||
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -189,6 +199,14 @@ export default function EditBoardPage() {
|
|||||||
boardGroupId ?? baseBoard?.board_group_id ?? "none";
|
boardGroupId ?? baseBoard?.board_group_id ?? "none";
|
||||||
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
|
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
|
||||||
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
|
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
|
||||||
|
const resolvedRequireApprovalForDone =
|
||||||
|
requireApprovalForDone ?? baseBoard?.require_approval_for_done ?? true;
|
||||||
|
const resolvedRequireReviewBeforeDone =
|
||||||
|
requireReviewBeforeDone ?? baseBoard?.require_review_before_done ?? false;
|
||||||
|
const resolvedBlockStatusChangesWithPendingApproval =
|
||||||
|
blockStatusChangesWithPendingApproval ??
|
||||||
|
baseBoard?.block_status_changes_with_pending_approval ??
|
||||||
|
false;
|
||||||
const resolvedSuccessMetrics =
|
const resolvedSuccessMetrics =
|
||||||
successMetrics ??
|
successMetrics ??
|
||||||
(baseBoard?.success_metrics
|
(baseBoard?.success_metrics
|
||||||
@@ -238,6 +256,11 @@ export default function EditBoardPage() {
|
|||||||
setDescription(updated.description ?? "");
|
setDescription(updated.description ?? "");
|
||||||
setBoardType(updated.board_type ?? "goal");
|
setBoardType(updated.board_type ?? "goal");
|
||||||
setObjective(updated.objective ?? "");
|
setObjective(updated.objective ?? "");
|
||||||
|
setRequireApprovalForDone(updated.require_approval_for_done ?? true);
|
||||||
|
setRequireReviewBeforeDone(updated.require_review_before_done ?? false);
|
||||||
|
setBlockStatusChangesWithPendingApproval(
|
||||||
|
updated.block_status_changes_with_pending_approval ?? false,
|
||||||
|
);
|
||||||
setSuccessMetrics(
|
setSuccessMetrics(
|
||||||
updated.success_metrics
|
updated.success_metrics
|
||||||
? JSON.stringify(updated.success_metrics, null, 2)
|
? JSON.stringify(updated.success_metrics, null, 2)
|
||||||
@@ -271,7 +294,10 @@ export default function EditBoardPage() {
|
|||||||
setMetricsError(null);
|
setMetricsError(null);
|
||||||
|
|
||||||
let parsedMetrics: Record<string, unknown> | null = null;
|
let parsedMetrics: Record<string, unknown> | null = null;
|
||||||
if (resolvedSuccessMetrics.trim()) {
|
if (
|
||||||
|
resolvedBoardType !== "general" &&
|
||||||
|
resolvedSuccessMetrics.trim()
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
|
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
|
||||||
string,
|
string,
|
||||||
@@ -291,9 +317,19 @@ export default function EditBoardPage() {
|
|||||||
board_group_id:
|
board_group_id:
|
||||||
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
|
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
|
||||||
board_type: resolvedBoardType,
|
board_type: resolvedBoardType,
|
||||||
objective: resolvedObjective.trim() || null,
|
objective:
|
||||||
success_metrics: parsedMetrics,
|
resolvedBoardType === "general"
|
||||||
target_date: localDateInputToUtcIso(resolvedTargetDate),
|
? null
|
||||||
|
: resolvedObjective.trim() || null,
|
||||||
|
require_approval_for_done: resolvedRequireApprovalForDone,
|
||||||
|
require_review_before_done: resolvedRequireReviewBeforeDone,
|
||||||
|
block_status_changes_with_pending_approval:
|
||||||
|
resolvedBlockStatusChangesWithPendingApproval,
|
||||||
|
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
||||||
|
target_date:
|
||||||
|
resolvedBoardType === "general"
|
||||||
|
? null
|
||||||
|
: localDateInputToUtcIso(resolvedTargetDate),
|
||||||
};
|
};
|
||||||
|
|
||||||
updateBoardMutation.mutate({ boardId, data: payload });
|
updateBoardMutation.mutate({ boardId, data: payload });
|
||||||
@@ -408,6 +444,7 @@ export default function EditBoardPage() {
|
|||||||
agents.
|
agents.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{resolvedBoardType !== "general" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Target date
|
Target date
|
||||||
@@ -419,6 +456,7 @@ export default function EditBoardPage() {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -434,6 +472,8 @@ export default function EditBoardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{resolvedBoardType !== "general" ? (
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Objective
|
Objective
|
||||||
@@ -465,6 +505,122 @@ export default function EditBoardPage() {
|
|||||||
<p className="text-xs text-red-500">{metricsError}</p>
|
<p className="text-xs text-red-500">{metricsError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="space-y-3 border-t border-slate-200 pt-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">Rules</h2>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Configure board-level workflow enforcement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={resolvedRequireApprovalForDone}
|
||||||
|
aria-label="Require approval"
|
||||||
|
onClick={() =>
|
||||||
|
setRequireApprovalForDone(!resolvedRequireApprovalForDone)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||||
|
resolvedRequireApprovalForDone
|
||||||
|
? "border-emerald-600 bg-emerald-600"
|
||||||
|
: "border-slate-300 bg-slate-200"
|
||||||
|
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||||
|
resolvedRequireApprovalForDone
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-slate-900">
|
||||||
|
Require approval
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-600">
|
||||||
|
Require at least one linked approval in{" "}
|
||||||
|
<code>approved</code> state before a task can be marked{" "}
|
||||||
|
<code>done</code>.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={resolvedRequireReviewBeforeDone}
|
||||||
|
aria-label="Require review before done"
|
||||||
|
onClick={() =>
|
||||||
|
setRequireReviewBeforeDone(!resolvedRequireReviewBeforeDone)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||||
|
resolvedRequireReviewBeforeDone
|
||||||
|
? "border-emerald-600 bg-emerald-600"
|
||||||
|
: "border-slate-300 bg-slate-200"
|
||||||
|
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||||
|
resolvedRequireReviewBeforeDone
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-slate-900">
|
||||||
|
Require review before done
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-600">
|
||||||
|
Tasks must move to <code>review</code> before they can be
|
||||||
|
marked <code>done</code>.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-slate-200 px-3 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={resolvedBlockStatusChangesWithPendingApproval}
|
||||||
|
aria-label="Block status changes with pending approval"
|
||||||
|
onClick={() =>
|
||||||
|
setBlockStatusChangesWithPendingApproval(
|
||||||
|
!resolvedBlockStatusChangesWithPendingApproval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||||
|
resolvedBlockStatusChangesWithPendingApproval
|
||||||
|
? "border-emerald-600 bg-emerald-600"
|
||||||
|
: "border-slate-300 bg-slate-200"
|
||||||
|
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||||
|
resolvedBlockStatusChangesWithPendingApproval
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-slate-900">
|
||||||
|
Block status changes with pending approval
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-600">
|
||||||
|
Prevent status transitions while any linked approval is in{" "}
|
||||||
|
<code>pending</code> state.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{gateways.length === 0 ? (
|
{gateways.length === 0 ? (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
|
|||||||
Reference in New Issue
Block a user