Merge branch 'master' into perf/activity-events-eventtype-createdat
This commit is contained in:
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -62,6 +62,36 @@ jobs:
|
|||||||
nextjs-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-
|
nextjs-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- name: Run migration integrity gate
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||||
|
HEAD_SHA="${{ github.sha }}"
|
||||||
|
git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
||||||
|
else
|
||||||
|
BASE_SHA="${{ github.event.before }}"
|
||||||
|
HEAD_SHA="${{ github.sha }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED_FILES"
|
||||||
|
|
||||||
|
if ! echo "$CHANGED_FILES" | grep -Eq '^backend/(app/models|db|migrations|alembic\.ini)'; then
|
||||||
|
echo "No migration-relevant backend changes detected; skipping migration gate."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$CHANGED_FILES" | grep -Eq '^backend/app/models/' && ! echo "$CHANGED_FILES" | grep -Eq '^backend/migrations/versions/'; then
|
||||||
|
echo "Model changes detected without a migration under backend/migrations/versions/."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
make backend-migration-check
|
||||||
|
|
||||||
- name: Run backend checks
|
- name: Run backend checks
|
||||||
env:
|
env:
|
||||||
# Keep CI builds deterministic.
|
# Keep CI builds deterministic.
|
||||||
|
|||||||
27
Makefile
27
Makefile
@@ -104,6 +104,33 @@ frontend-test: frontend-tooling ## Frontend tests (vitest)
|
|||||||
backend-migrate: ## Apply backend DB migrations (uses backend/migrations)
|
backend-migrate: ## Apply backend DB migrations (uses backend/migrations)
|
||||||
cd $(BACKEND_DIR) && uv run alembic upgrade head
|
cd $(BACKEND_DIR) && uv run alembic upgrade head
|
||||||
|
|
||||||
|
.PHONY: backend-migration-check
|
||||||
|
backend-migration-check: ## Validate migration graph + reversible path on clean Postgres
|
||||||
|
@set -euo pipefail; \
|
||||||
|
(cd $(BACKEND_DIR) && uv run python scripts/check_migration_graph.py); \
|
||||||
|
CONTAINER_NAME="mc-migration-check-$$RANDOM"; \
|
||||||
|
docker run -d --rm --name $$CONTAINER_NAME -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=migration_ci -p 55432:5432 postgres:16 >/dev/null; \
|
||||||
|
cleanup() { docker rm -f $$CONTAINER_NAME >/dev/null 2>&1 || true; }; \
|
||||||
|
trap cleanup EXIT; \
|
||||||
|
for i in $$(seq 1 30); do \
|
||||||
|
if docker exec $$CONTAINER_NAME pg_isready -U postgres -d migration_ci >/dev/null 2>&1; then break; fi; \
|
||||||
|
sleep 1; \
|
||||||
|
if [ $$i -eq 30 ]; then echo "Postgres did not become ready"; exit 1; fi; \
|
||||||
|
done; \
|
||||||
|
cd $(BACKEND_DIR) && \
|
||||||
|
AUTH_MODE=local \
|
||||||
|
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
||||||
|
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
||||||
|
uv run alembic upgrade head && \
|
||||||
|
AUTH_MODE=local \
|
||||||
|
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
||||||
|
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
||||||
|
uv run alembic downgrade base && \
|
||||||
|
AUTH_MODE=local \
|
||||||
|
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
||||||
|
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: frontend-build ## Build artifacts
|
build: frontend-build ## Build artifacts
|
||||||
|
|
||||||
|
|||||||
@@ -1054,12 +1054,16 @@ async def update_task(
|
|||||||
updates.pop("comment", None)
|
updates.pop("comment", None)
|
||||||
updates.pop("depends_on_task_ids", None)
|
updates.pop("depends_on_task_ids", None)
|
||||||
updates.pop("tag_ids", None)
|
updates.pop("tag_ids", None)
|
||||||
|
requested_status = payload.status if "status" in payload.model_fields_set else None
|
||||||
update = _TaskUpdateInput(
|
update = _TaskUpdateInput(
|
||||||
task=task,
|
task=task,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
previous_status=previous_status,
|
previous_status=previous_status,
|
||||||
previous_assigned=previous_assigned,
|
previous_assigned=previous_assigned,
|
||||||
|
status_requested=(
|
||||||
|
requested_status is not None and requested_status != previous_status
|
||||||
|
),
|
||||||
updates=updates,
|
updates=updates,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
depends_on_task_ids=depends_on_task_ids,
|
depends_on_task_ids=depends_on_task_ids,
|
||||||
@@ -1299,6 +1303,7 @@ class _TaskUpdateInput:
|
|||||||
board_id: UUID
|
board_id: UUID
|
||||||
previous_status: str
|
previous_status: str
|
||||||
previous_assigned: UUID | None
|
previous_assigned: UUID | None
|
||||||
|
status_requested: bool
|
||||||
updates: dict[str, object]
|
updates: dict[str, object]
|
||||||
comment: str | None
|
comment: str | None
|
||||||
depends_on_task_ids: list[UUID] | None
|
depends_on_task_ids: list[UUID] | None
|
||||||
@@ -1597,7 +1602,7 @@ async def _apply_lead_task_update(
|
|||||||
task_id=update.task.id,
|
task_id=update.task.id,
|
||||||
previous_status=update.previous_status,
|
previous_status=update.previous_status,
|
||||||
target_status=update.task.status,
|
target_status=update.task.status,
|
||||||
status_requested="status" in update.updates,
|
status_requested=update.status_requested,
|
||||||
)
|
)
|
||||||
await _require_review_before_done_when_enabled(
|
await _require_review_before_done_when_enabled(
|
||||||
session,
|
session,
|
||||||
@@ -1878,7 +1883,7 @@ async def _finalize_updated_task(
|
|||||||
task_id=update.task.id,
|
task_id=update.task.id,
|
||||||
previous_status=update.previous_status,
|
previous_status=update.previous_status,
|
||||||
target_status=update.task.status,
|
target_status=update.task.status,
|
||||||
status_requested="status" in update.updates,
|
status_requested=update.status_requested,
|
||||||
)
|
)
|
||||||
await _require_review_before_done_when_enabled(
|
await _require_review_before_done_when_enabled(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def upgrade() -> None:
|
|||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('agents', sa.Column('gateway_id', sa.Uuid(), nullable=False))
|
op.add_column('agents', sa.Column('gateway_id', sa.Uuid(), nullable=False))
|
||||||
op.create_index(op.f('ix_agents_gateway_id'), 'agents', ['gateway_id'], unique=False)
|
op.create_index(op.f('ix_agents_gateway_id'), 'agents', ['gateway_id'], unique=False)
|
||||||
op.create_foreign_key(None, 'agents', 'gateways', ['gateway_id'], ['id'])
|
op.create_foreign_key('fk_agents_gateway_id_gateways', 'agents', 'gateways', ['gateway_id'], ['id'])
|
||||||
op.drop_column('gateways', 'main_session_key')
|
op.drop_column('gateways', 'main_session_key')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ def upgrade() -> None:
|
|||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.add_column('gateways', sa.Column('main_session_key', sa.VARCHAR(), autoincrement=False, nullable=False))
|
op.add_column('gateways', sa.Column('main_session_key', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||||
op.drop_constraint(None, 'agents', type_='foreignkey')
|
op.drop_constraint('fk_agents_gateway_id_gateways', 'agents', type_='foreignkey')
|
||||||
op.drop_index(op.f('ix_agents_gateway_id'), table_name='agents')
|
op.drop_index(op.f('ix_agents_gateway_id'), table_name='agents')
|
||||||
op.drop_column('agents', 'gateway_id')
|
op.drop_column('agents', 'gateway_id')
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|||||||
85
backend/scripts/check_migration_graph.py
Normal file
85
backend/scripts/check_migration_graph.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Migration graph integrity checks for CI.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- alembic script graph can be loaded (detects broken/missing links)
|
||||||
|
- single head by default (unless ALLOW_MULTIPLE_HEADS=true)
|
||||||
|
- no orphan revisions (all revisions reachable from heads)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(value: str | None) -> bool:
|
||||||
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
alembic_ini = root / "alembic.ini"
|
||||||
|
cfg = Config(str(alembic_ini))
|
||||||
|
cfg.attributes["configure_logger"] = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
script = ScriptDirectory.from_config(cfg)
|
||||||
|
except Exception as exc: # pragma: no cover - CI path
|
||||||
|
print(f"ERROR: unable to load Alembic script directory: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
heads = list(script.get_heads())
|
||||||
|
except Exception as exc: # pragma: no cover - CI path
|
||||||
|
print(f"ERROR: unable to resolve Alembic heads: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
allow_multiple_heads = _truthy(os.getenv("ALLOW_MULTIPLE_HEADS"))
|
||||||
|
if not heads:
|
||||||
|
print("ERROR: no Alembic heads found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if len(heads) > 1 and not allow_multiple_heads:
|
||||||
|
print("ERROR: multiple Alembic heads detected (set ALLOW_MULTIPLE_HEADS=true only for intentional merge windows)")
|
||||||
|
for h in heads:
|
||||||
|
print(f" - {h}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
reachable: set[str] = set()
|
||||||
|
for walk_rev in script.walk_revisions(base="base", head="heads"):
|
||||||
|
if walk_rev is None:
|
||||||
|
continue
|
||||||
|
if walk_rev.revision:
|
||||||
|
reachable.add(walk_rev.revision)
|
||||||
|
except Exception as exc: # pragma: no cover - CI path
|
||||||
|
print(f"ERROR: failed while walking Alembic revision graph: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
all_revisions: set[str] = set()
|
||||||
|
# Alembic's revision_map is dynamically typed; guard None values.
|
||||||
|
for map_rev in script.revision_map._revision_map.values():
|
||||||
|
if map_rev is None:
|
||||||
|
continue
|
||||||
|
revision = getattr(map_rev, "revision", None)
|
||||||
|
if revision:
|
||||||
|
all_revisions.add(revision)
|
||||||
|
|
||||||
|
orphans = sorted(all_revisions - reachable)
|
||||||
|
if orphans:
|
||||||
|
print("ERROR: orphan Alembic revisions detected (not reachable from heads):")
|
||||||
|
for orphan_rev in orphans:
|
||||||
|
print(f" - {orphan_rev}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("OK: migration graph integrity passed")
|
||||||
|
print(f"Heads: {', '.join(heads)}")
|
||||||
|
print(f"Reachable revisions: {len(reachable)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -356,6 +356,53 @@ async def test_update_task_allows_status_change_with_pending_approval_when_toggl
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task_allows_dependency_change_with_pending_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,
|
||||||
|
task_status="review",
|
||||||
|
require_approval_for_done=False,
|
||||||
|
block_status_changes_with_pending_approval=True,
|
||||||
|
)
|
||||||
|
dependency = Task(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
title="Dependency",
|
||||||
|
status="inbox",
|
||||||
|
)
|
||||||
|
session.add(dependency)
|
||||||
|
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="review",
|
||||||
|
depends_on_task_ids=[dependency.id],
|
||||||
|
),
|
||||||
|
task=task,
|
||||||
|
session=session,
|
||||||
|
actor=ActorContext(actor_type="user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.depends_on_task_ids == [dependency.id]
|
||||||
|
assert updated.status == "inbox"
|
||||||
|
assert updated.blocked_by_task_ids == [dependency.id]
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> (
|
async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> (
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
# Development workflow
|
# Development workflow
|
||||||
|
|
||||||
Placeholder: see root `README.md` for current setup steps.
|
## Migration integrity gate (CI)
|
||||||
|
|
||||||
|
CI enforces a migration integrity gate to prevent merge-time schema breakages.
|
||||||
|
|
||||||
|
### What it validates
|
||||||
|
|
||||||
|
- Alembic migrations can apply from a clean Postgres database (`upgrade head`)
|
||||||
|
- Alembic revision graph resolves to a head revision after migration apply
|
||||||
|
- On migration-relevant PRs, CI also checks that model changes are accompanied by migration updates
|
||||||
|
|
||||||
|
If any of these checks fails, CI fails and the PR is blocked.
|
||||||
|
|
||||||
|
### Local reproduction
|
||||||
|
|
||||||
|
From repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make backend-migration-check
|
||||||
|
```
|
||||||
|
|
||||||
|
This command starts a temporary Postgres container, runs migration checks, and cleans up the container.
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { Markdown } from "@/components/atoms/Markdown";
|
|||||||
import { StatusDot } from "@/components/atoms/StatusDot";
|
import { StatusDot } from "@/components/atoms/StatusDot";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
||||||
|
import {
|
||||||
|
DependencyBanner,
|
||||||
|
type DependencyBannerDependency,
|
||||||
|
} from "@/components/molecules/DependencyBanner";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -2211,6 +2215,56 @@ export default function BoardDetailPage() {
|
|||||||
[loadComments],
|
[loadComments],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectedTaskDependencies = useMemo<DependencyBannerDependency[]>(() => {
|
||||||
|
if (!selectedTask) return [];
|
||||||
|
const blockedDependencyIds = new Set(
|
||||||
|
selectedTask.blocked_by_task_ids ?? [],
|
||||||
|
);
|
||||||
|
return (selectedTask.depends_on_task_ids ?? []).map((dependencyId) => {
|
||||||
|
const dependencyTask = taskById.get(dependencyId);
|
||||||
|
const statusLabel = dependencyTask?.status
|
||||||
|
? dependencyTask.status.replace(/_/g, " ")
|
||||||
|
: "unknown";
|
||||||
|
return {
|
||||||
|
id: dependencyId,
|
||||||
|
title: dependencyTask?.title ?? dependencyId,
|
||||||
|
statusLabel,
|
||||||
|
isBlocking: blockedDependencyIds.has(dependencyId),
|
||||||
|
isDone: dependencyTask?.status === "done",
|
||||||
|
disabled: !dependencyTask,
|
||||||
|
onClick: dependencyTask
|
||||||
|
? () => {
|
||||||
|
openComments({ id: dependencyId });
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [openComments, selectedTask, taskById]);
|
||||||
|
|
||||||
|
const selectedTaskResolvedDependencies = useMemo<
|
||||||
|
DependencyBannerDependency[]
|
||||||
|
>(() => {
|
||||||
|
if (!selectedTask) return [];
|
||||||
|
return tasks
|
||||||
|
.filter((task) => task.depends_on_task_ids?.includes(selectedTask.id))
|
||||||
|
.map((task) => {
|
||||||
|
const statusLabel = task.status
|
||||||
|
? task.status.replace(/_/g, " ")
|
||||||
|
: "unknown";
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
statusLabel,
|
||||||
|
isBlocking: false,
|
||||||
|
isDone: task.status === "done",
|
||||||
|
onClick: () => {
|
||||||
|
openComments({ id: task.id });
|
||||||
|
},
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [openComments, selectedTask, tasks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskIdFromUrl) return;
|
if (!taskIdFromUrl) return;
|
||||||
if (openedTaskIdFromUrlRef.current === taskIdFromUrl) return;
|
if (openedTaskIdFromUrlRef.current === taskIdFromUrl) return;
|
||||||
@@ -3382,63 +3436,43 @@ export default function BoardDetailPage() {
|
|||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Dependencies
|
Dependencies
|
||||||
</p>
|
</p>
|
||||||
{selectedTask?.depends_on_task_ids?.length ? (
|
{(() => {
|
||||||
<div className="space-y-2">
|
const hasDependencies =
|
||||||
{selectedTask.depends_on_task_ids.map((depId) => {
|
(selectedTask?.depends_on_task_ids?.length ?? 0) > 0;
|
||||||
const depTask = taskById.get(depId);
|
const hasResolvedDependencies =
|
||||||
const title = depTask?.title ?? depId;
|
selectedTaskResolvedDependencies.length > 0;
|
||||||
const statusLabel = depTask?.status
|
const isDependencyModeBlocked = hasDependencies
|
||||||
? depTask.status.replace(/_/g, " ")
|
? selectedTask?.is_blocked === true
|
||||||
: "unknown";
|
: false;
|
||||||
const isDone = depTask?.status === "done";
|
const bannerVariant =
|
||||||
const isBlocking = (
|
hasDependencies || hasResolvedDependencies
|
||||||
selectedTask.blocked_by_task_ids ?? []
|
? isDependencyModeBlocked
|
||||||
).includes(depId);
|
? "blocked"
|
||||||
return (
|
: "resolved"
|
||||||
<button
|
: "blocked";
|
||||||
key={depId}
|
const displayedDependencies =
|
||||||
type="button"
|
hasDependencies && selectedTask
|
||||||
onClick={() => openComments({ id: depId })}
|
? selectedTaskDependencies
|
||||||
disabled={!depTask}
|
: selectedTaskResolvedDependencies;
|
||||||
className={cn(
|
const childrenMessage =
|
||||||
"w-full rounded-lg border px-3 py-2 text-left transition",
|
hasDependencies && selectedTask?.is_blocked
|
||||||
isBlocking
|
? "Blocked by incomplete dependencies."
|
||||||
? "border-rose-200 bg-rose-50 hover:bg-rose-100/40"
|
: hasDependencies
|
||||||
: isDone
|
? "Dependencies resolved."
|
||||||
? "border-emerald-200 bg-emerald-50 hover:bg-emerald-100/40"
|
: hasResolvedDependencies
|
||||||
: "border-slate-200 bg-white hover:bg-slate-50",
|
? "This task resolves these tasks."
|
||||||
!depTask && "cursor-not-allowed opacity-60",
|
: null;
|
||||||
)}
|
|
||||||
>
|
return (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<DependencyBanner
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
dependencies={displayedDependencies}
|
||||||
{title}
|
variant={bannerVariant}
|
||||||
</p>
|
emptyMessage="No dependencies."
|
||||||
<span
|
>
|
||||||
className={cn(
|
{childrenMessage}
|
||||||
"text-[10px] font-semibold uppercase tracking-wide",
|
</DependencyBanner>
|
||||||
isBlocking
|
);
|
||||||
? "text-rose-700"
|
})()}
|
||||||
: isDone
|
|
||||||
? "text-emerald-700"
|
|
||||||
: "text-slate-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500">No dependencies.</p>
|
|
||||||
)}
|
|
||||||
{selectedTask?.is_blocked ? (
|
|
||||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
|
|
||||||
Blocked by incomplete dependencies.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
94
frontend/src/components/molecules/DependencyBanner.tsx
Normal file
94
frontend/src/components/molecules/DependencyBanner.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface DependencyBannerDependency {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
statusLabel: string;
|
||||||
|
isBlocking?: boolean;
|
||||||
|
isDone?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DependencyBannerProps {
|
||||||
|
variant?: DependencyBannerVariant;
|
||||||
|
dependencies?: DependencyBannerDependency[];
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DependencyBannerVariant = "blocked" | "resolved";
|
||||||
|
|
||||||
|
const toneClassByVariant: Record<DependencyBannerVariant, string> = {
|
||||||
|
blocked: "border-rose-200 bg-rose-50 text-rose-700",
|
||||||
|
resolved: "border-blue-200 bg-blue-50 text-blue-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DependencyBanner({
|
||||||
|
variant = "blocked",
|
||||||
|
dependencies = [],
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
emptyMessage = "No dependencies.",
|
||||||
|
}: DependencyBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)}>
|
||||||
|
{dependencies.length > 0 ? (
|
||||||
|
dependencies.map((dependency) => {
|
||||||
|
const isBlocking = dependency.isBlocking === true;
|
||||||
|
const isDone = dependency.isDone === true;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dependency.id}
|
||||||
|
type="button"
|
||||||
|
onClick={dependency.onClick}
|
||||||
|
disabled={dependency.disabled}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg border px-3 py-2 text-left transition",
|
||||||
|
isBlocking
|
||||||
|
? "border-rose-200 bg-rose-50 hover:bg-rose-100/40"
|
||||||
|
: isDone
|
||||||
|
? "border-emerald-200 bg-emerald-50 hover:bg-emerald-100/40"
|
||||||
|
: "border-slate-200 bg-white hover:bg-slate-50",
|
||||||
|
dependency.disabled && "cursor-not-allowed opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{dependency.title}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-semibold uppercase tracking-wide",
|
||||||
|
isBlocking
|
||||||
|
? "text-rose-700"
|
||||||
|
: isDone
|
||||||
|
? "text-emerald-700"
|
||||||
|
: "text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dependency.statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">{emptyMessage}</p>
|
||||||
|
)}
|
||||||
|
{children ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-3 text-xs",
|
||||||
|
toneClassByVariant[variant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user