Merge branch 'develop'
This commit is contained in:
commit
9343115345
@ -144,6 +144,12 @@ class TaskAutomationRunSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class TaskAutomationSerializer(serializers.ModelSerializer):
|
||||
"""Validate shared automation rules exposed to authenticated task users.
|
||||
|
||||
``can_manage`` intentionally mirrors the temporary broad API permission. It
|
||||
should use the dedicated automation permission once that role is introduced.
|
||||
"""
|
||||
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
boards = serializers.PrimaryKeyRelatedField(
|
||||
queryset=TaskBoard.objects.filter(is_archived=False),
|
||||
@ -188,7 +194,7 @@ class TaskAutomationSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_can_manage(self, obj):
|
||||
user = getattr(self.context.get("request"), "user", None)
|
||||
return bool(user and (user.is_staff or user.is_superuser))
|
||||
return bool(user and user.is_authenticated)
|
||||
|
||||
def validate_name(self, value):
|
||||
name = value.strip()
|
||||
@ -624,6 +630,12 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
cover = obj.cover
|
||||
except TaskCover.DoesNotExist:
|
||||
cover = None
|
||||
if (
|
||||
cover is not None
|
||||
and cover.kind == TaskCoverType.CHECKLIST
|
||||
and not obj.checklist_items.exists()
|
||||
):
|
||||
cover = None
|
||||
payload = (
|
||||
TaskCoverSerializer(cover, context=self.context).data
|
||||
if cover is not None
|
||||
|
||||
@ -32,7 +32,7 @@ from procurement.models import ProcurementRequest
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from rest_framework import filters, parsers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from schedule.models import Event
|
||||
|
||||
@ -169,34 +169,42 @@ def normalize_column_positions(column, row=None):
|
||||
Task.objects.filter(id=task_id).update(position=(index + 1) * 1000)
|
||||
|
||||
|
||||
def send_task_checklist_update(task, event, item_id):
|
||||
def send_task_checklist_update(task, event, item_id, *, cover_cleared=False):
|
||||
extra = {
|
||||
"task_id": task.id,
|
||||
"checklist_total": task.checklist_items.count(),
|
||||
"checklist_completed": task.checklist_items.filter(
|
||||
is_completed=True
|
||||
).count(),
|
||||
}
|
||||
if cover_cleared:
|
||||
extra["cover"] = None
|
||||
send_task_board_update(
|
||||
task.board,
|
||||
event,
|
||||
"checklist_item",
|
||||
item_id,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"checklist_total": task.checklist_items.count(),
|
||||
"checklist_completed": task.checklist_items.filter(
|
||||
is_completed=True
|
||||
).count(),
|
||||
},
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
class TaskAutomationViewSet(viewsets.ModelViewSet):
|
||||
"""Manage shared task automation rules.
|
||||
|
||||
Automation management is temporarily available to every authenticated user.
|
||||
Rules are system-wide and may target multiple boards, so this broad access must
|
||||
be replaced with a dedicated permission before permission roles are finalized.
|
||||
"""
|
||||
|
||||
# TODO(tasks-automation-permissions): Replace broad authenticated access with
|
||||
# a dedicated permission/role for managing shared automation rules.
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = TaskAutomationSerializer
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ["name", "description"]
|
||||
ordering_fields = ["position", "name", "created_at", "last_run_at"]
|
||||
ordering = ["kind", "position", "name", "id"]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in {"catalog", "buttons", "run"}:
|
||||
return [IsAuthenticated()]
|
||||
return [IsAdminUser()]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = (
|
||||
TaskAutomation.objects.select_related("created_by")
|
||||
@ -1804,7 +1812,18 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
if request.method == "DELETE":
|
||||
item_id = item.id
|
||||
item_title = item.title
|
||||
item.delete()
|
||||
with transaction.atomic():
|
||||
item.delete()
|
||||
task._prefetched_objects_cache.pop("checklist_items", None)
|
||||
cover_cleared = False
|
||||
if not task.checklist_items.exists():
|
||||
cover_cleared, _ = TaskCover.objects.filter(
|
||||
task=task,
|
||||
kind=TaskCoverType.CHECKLIST,
|
||||
).delete()
|
||||
cover_cleared = bool(cover_cleared)
|
||||
if cover_cleared:
|
||||
task.save(update_fields=["updated_at"])
|
||||
create_task_activity(
|
||||
task,
|
||||
request.user,
|
||||
@ -1813,7 +1832,12 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
object_id=item_id,
|
||||
metadata={"item_title": item_title},
|
||||
)
|
||||
send_task_checklist_update(task, "checklist_updated", item_id)
|
||||
send_task_checklist_update(
|
||||
task,
|
||||
"checklist_updated",
|
||||
item_id,
|
||||
cover_cleared=cover_cleared,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
serializer = TaskChecklistItemSerializer(
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def remove_empty_checklist_covers(apps, schema_editor):
|
||||
TaskChecklistItem = apps.get_model("tasks", "TaskChecklistItem")
|
||||
TaskCover = apps.get_model("tasks", "TaskCover")
|
||||
database = schema_editor.connection.alias
|
||||
task_ids_with_checklists = TaskChecklistItem.objects.using(database).values("task_id")
|
||||
TaskCover.objects.using(database).filter(kind="checklist").exclude(
|
||||
task_id__in=task_ids_with_checklists,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tasks", "0033_universal_task_covers"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
remove_empty_checklist_covers,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -213,7 +213,7 @@ def test_scheduled_automation_runs_relative_to_task_due_date(task_setup):
|
||||
assert run.context["scheduled_for"] == local_run_at.isoformat()
|
||||
|
||||
|
||||
def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
|
||||
def test_regular_user_can_manage_rules_and_run_eligible_button(
|
||||
api_client,
|
||||
user_factory,
|
||||
task_setup,
|
||||
@ -232,6 +232,19 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
|
||||
api_client.force_authenticate(employee)
|
||||
|
||||
list_response = api_client.get(reverse("api:v1:tasks:task-automation-list"))
|
||||
create_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-automation-list"),
|
||||
{
|
||||
"name": "Правило сотрудника",
|
||||
"kind": "event",
|
||||
"trigger": "task_updated",
|
||||
"conditions": {"operator": "and", "children": []},
|
||||
"actions": [{"type": "set_priority", "priority": "medium"}],
|
||||
"applies_to_all_boards": False,
|
||||
"boards": [board.id],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
buttons_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-automation-buttons"),
|
||||
{"task": task.id},
|
||||
@ -242,7 +255,10 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert list_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
assert create_response.data["created_by"]["id"] == employee.id
|
||||
assert create_response.data["can_manage"] is True
|
||||
assert buttons_response.status_code == status.HTTP_200_OK
|
||||
assert [item["id"] for item in buttons_response.data] == [button.id]
|
||||
assert run_response.status_code == status.HTTP_200_OK
|
||||
@ -250,6 +266,12 @@ def test_regular_user_can_run_eligible_button_but_cannot_manage_rules(
|
||||
assert task.priority == "high"
|
||||
|
||||
|
||||
def test_automation_management_requires_authentication(api_client):
|
||||
response = api_client.get(reverse("api:v1:tasks:task-automation-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_manual_button_rechecks_conditions_on_run(api_client, user_factory, task_setup):
|
||||
owner, board, _, _, task = task_setup
|
||||
employee = user_factory(email="automation-condition@example.com")
|
||||
|
||||
@ -1738,6 +1738,29 @@ def test_task_cover_supports_checklist_comment_link_and_linked_object(
|
||||
],
|
||||
}
|
||||
|
||||
delete_checklist_response = api_client.delete(
|
||||
reverse(
|
||||
"api:v1:tasks:task-checklist-item-detail",
|
||||
kwargs={"pk": task.id, "item_id": checklist_item.id},
|
||||
)
|
||||
)
|
||||
assert delete_checklist_response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not TaskCover.objects.filter(task=task).exists()
|
||||
|
||||
TaskCover.objects.create(
|
||||
task=task,
|
||||
kind=TaskCoverType.CHECKLIST,
|
||||
created_by=user,
|
||||
)
|
||||
board_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
|
||||
)
|
||||
assert board_response.status_code == status.HTTP_200_OK
|
||||
serialized_task = next(
|
||||
item for item in board_response.data["tasks"] if item["id"] == task.id
|
||||
)
|
||||
assert serialized_task["cover"] is None
|
||||
|
||||
comment_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id}),
|
||||
{"text": "Комментарий на обложке"},
|
||||
|
||||
@ -899,6 +899,19 @@ html[data-bs-theme="dark"] .shadow-xl {
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .tasks-board-column-header,
|
||||
html[data-bs-theme="dark"] .tasks-board-subcolumn-header {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .tasks-board-column-header-sticky {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .tasks-board-group-header-sticky {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
|
||||
/* Скрытые полосы прокрутки везде */
|
||||
* {
|
||||
@ -995,3 +1008,84 @@ html[data-bs-theme="dark"] .shadow-xl {
|
||||
.tasks-column-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
.tasks-board-column-group {
|
||||
--tasks-board-stack-gap: 0.5rem;
|
||||
--tasks-board-column-header-height: 4rem;
|
||||
--tasks-board-subcolumn-header-height: 4rem;
|
||||
--tasks-board-group-header-stack-height: calc(
|
||||
var(--tasks-board-column-header-height, 4rem)
|
||||
+ var(--tasks-board-stack-gap, 0.5rem)
|
||||
+ var(--tasks-board-subcolumn-header-height, 4rem)
|
||||
+ 1rem
|
||||
+ 1px
|
||||
);
|
||||
}
|
||||
|
||||
.tasks-board-column-header-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding-bottom: var(--tasks-board-stack-gap, 0.5rem);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.tasks-board-column-header-sticky-standalone {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tasks-board-column-header {
|
||||
min-height: var(--tasks-board-column-header-height, 4rem);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.tasks-board-group-header-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.tasks-board-subcolumn-header-row {
|
||||
min-height: var(--tasks-board-subcolumn-header-height, 4rem);
|
||||
}
|
||||
|
||||
.tasks-board-subcolumn-header-surface::after {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -0.75rem;
|
||||
left: -1px;
|
||||
height: 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.tasks-board-column-group[data-content-bottom-reached="true"] .tasks-board-subcolumn-header-surface::after {
|
||||
border-color: var(--border-subtle);
|
||||
border-top-color: transparent;
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tasks-board-lane-header {
|
||||
position: sticky;
|
||||
top: var(--tasks-board-group-header-stack-height, calc(9.5rem + 1px));
|
||||
z-index: 15;
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tasks-board-content-section-continuous {
|
||||
margin-bottom: -3.9375rem;
|
||||
}
|
||||
|
||||
.tasks-board-content-section-continuous::after {
|
||||
display: block;
|
||||
height: 3.9375rem;
|
||||
content: "";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -584,7 +584,9 @@ function MessageDialogPageContent() {
|
||||
if (!c?.containerRef?.current) return;
|
||||
const viewport = c.containerRef.current;
|
||||
|
||||
const onScroll = () => {
|
||||
let frameId: number | null = null;
|
||||
const processScrollPosition = () => {
|
||||
frameId = null;
|
||||
if (!cm.loadingOlder && !cm.messagesLoading && (cm.hasMoreOlder || cm.allowOneOlderProbe) && viewport.scrollTop <= 120) {
|
||||
cm.loadOlderMessages();
|
||||
}
|
||||
@ -603,8 +605,17 @@ function MessageDialogPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (frameId === null) {
|
||||
frameId = window.requestAnimationFrame(processScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => viewport.removeEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [cm, markRead, scroll]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -54,6 +54,8 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startPos = useRef({ x: 0, y: 0 });
|
||||
const pendingDrag = useRef({ x: 0, y: 0 });
|
||||
const dragFrame = useRef<number | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [flyOut, setFlyOut] = useState<"left" | "right" | "up" | null>(null);
|
||||
|
||||
@ -64,6 +66,18 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
|
||||
const current = queue[0] || null;
|
||||
|
||||
const applyPendingDrag = useCallback(() => {
|
||||
dragFrame.current = null;
|
||||
setDragX(pendingDrag.current.x);
|
||||
setDragY(pendingDrag.current.y);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (dragFrame.current !== null) {
|
||||
window.cancelAnimationFrame(dragFrame.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── perform action ── */
|
||||
|
||||
const performAction = useCallback(
|
||||
@ -89,6 +103,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
// error is handled by parent (actionError state)
|
||||
} finally {
|
||||
setBusy(false);
|
||||
pendingDrag.current = { x: 0, y: 0 };
|
||||
setDragX(0);
|
||||
setDragY(0);
|
||||
setFlyOut(null);
|
||||
@ -118,6 +133,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
(e: React.PointerEvent) => {
|
||||
if (busy || detailOpen) return;
|
||||
startPos.current = { x: e.clientX, y: e.clientY };
|
||||
pendingDrag.current = { x: 0, y: 0 };
|
||||
setDragging(true);
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
@ -127,30 +143,45 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
setDragX(e.clientX - startPos.current.x);
|
||||
setDragY(e.clientY - startPos.current.y);
|
||||
pendingDrag.current = {
|
||||
x: e.clientX - startPos.current.x,
|
||||
y: e.clientY - startPos.current.y,
|
||||
};
|
||||
if (dragFrame.current === null) {
|
||||
dragFrame.current = window.requestAnimationFrame(applyPendingDrag);
|
||||
}
|
||||
},
|
||||
[dragging]
|
||||
[applyPendingDrag, dragging]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
const finalDrag = {
|
||||
x: e.clientX - startPos.current.x,
|
||||
y: e.clientY - startPos.current.y,
|
||||
};
|
||||
pendingDrag.current = finalDrag;
|
||||
if (dragFrame.current !== null) {
|
||||
window.cancelAnimationFrame(dragFrame.current);
|
||||
dragFrame.current = null;
|
||||
}
|
||||
setDragging(false);
|
||||
|
||||
if (dragX > SWIPE_THRESHOLD) {
|
||||
if (finalDrag.x > SWIPE_THRESHOLD) {
|
||||
setFlyOut("right");
|
||||
setTimeout(() => performAction("approved"), 250);
|
||||
} else if (dragX < -SWIPE_THRESHOLD) {
|
||||
} else if (finalDrag.x < -SWIPE_THRESHOLD) {
|
||||
setFlyOut("left");
|
||||
setTimeout(() => performAction("rejected"), 250);
|
||||
} else if (dragY < -SWIPE_THRESHOLD) {
|
||||
} else if (finalDrag.y < -SWIPE_THRESHOLD) {
|
||||
setFlyOut("up");
|
||||
setTimeout(() => performAction("skipped"), 250);
|
||||
} else {
|
||||
pendingDrag.current = { x: 0, y: 0 };
|
||||
setDragX(0);
|
||||
setDragY(0);
|
||||
}
|
||||
}, [dragging, dragX, dragY, performAction]);
|
||||
}, [dragging, performAction]);
|
||||
|
||||
/* ── keyboard ── */
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type DropdownPosition = {
|
||||
@ -31,6 +31,56 @@ function getDropdownPosition(anchor: HTMLElement): DropdownPosition {
|
||||
};
|
||||
}
|
||||
|
||||
function dropdownPositionsEqual(
|
||||
current: DropdownPosition | null,
|
||||
next: DropdownPosition,
|
||||
) {
|
||||
return current?.left === next.left
|
||||
&& current.maxHeight === next.maxHeight
|
||||
&& current.top === next.top
|
||||
&& current.width === next.width;
|
||||
}
|
||||
|
||||
function useDropdownPosition(
|
||||
open: boolean,
|
||||
anchorRef: RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const [position, setPosition] = useState<DropdownPosition | null>(null);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const nextPosition = getDropdownPosition(anchor);
|
||||
setPosition((currentPosition) => (
|
||||
dropdownPositionsEqual(currentPosition, nextPosition) ? currentPosition : nextPosition
|
||||
));
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
let frameId: number | null = null;
|
||||
const schedulePositionUpdate = () => {
|
||||
if (frameId !== null) return;
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null;
|
||||
updatePosition();
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("resize", schedulePositionUpdate);
|
||||
window.addEventListener("scroll", schedulePositionUpdate, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", schedulePositionUpdate);
|
||||
window.removeEventListener("scroll", schedulePositionUpdate, true);
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
return { position, updatePosition };
|
||||
}
|
||||
|
||||
/* ── Single-select dropdown with search ── */
|
||||
export function SearchableSelectSingle<T extends string | number>({
|
||||
label,
|
||||
@ -49,9 +99,12 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
position: dropdownPosition,
|
||||
updatePosition: updateDropdownPosition,
|
||||
} = useDropdownPosition(open, ref);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@ -63,29 +116,10 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedName = items.find((i) => i.id === selectedId)?.name;
|
||||
const toggleOpen = () => {
|
||||
if (!open && ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
if (!open) updateDropdownPosition();
|
||||
setOpen((value) => !value);
|
||||
};
|
||||
|
||||
@ -180,9 +214,12 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
position: dropdownPosition,
|
||||
updatePosition: updateDropdownPosition,
|
||||
} = useDropdownPosition(open, ref);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@ -194,31 +231,12 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedItems = items.filter((i) => selectedIds.includes(i.id));
|
||||
const selectedNames = selectedItems.map((i) => i.name);
|
||||
const isInline = layout === "inline";
|
||||
const toggleOpen = () => {
|
||||
if (!open && ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
if (!open) updateDropdownPosition();
|
||||
setOpen((value) => !value);
|
||||
};
|
||||
|
||||
|
||||
@ -133,7 +133,8 @@ export default function TaskCoverPreview({
|
||||
|
||||
if (cover.kind === "checklist" && cover.checklist) {
|
||||
const { completed, items, total } = cover.checklist;
|
||||
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
if (total <= 0 || items.length === 0) return null;
|
||||
const progress = Math.round((completed / total) * 100);
|
||||
return (
|
||||
<div className={`app-surface-muted flex flex-col justify-center overflow-hidden p-2 ${className}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -76,13 +76,6 @@ export function useChatScroll({
|
||||
|
||||
const floatingDateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Sync selected date from floating date when navigator is closed
|
||||
useEffect(() => {
|
||||
if (floatingDateValue && !isDateNavigatorOpen) {
|
||||
setSelectedHistoryDate(floatingDateValue);
|
||||
}
|
||||
}, [floatingDateValue, isDateNavigatorOpen]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => () => {
|
||||
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
|
||||
@ -144,6 +137,9 @@ export function useChatScroll({
|
||||
setFloatingDate(topDate);
|
||||
setFloatingDateValue(topDateVal);
|
||||
setShowFloatingDate(true);
|
||||
if (topDateVal && !isDateNavigatorOpen) {
|
||||
setSelectedHistoryDate(topDateVal);
|
||||
}
|
||||
|
||||
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
|
||||
floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100);
|
||||
@ -153,7 +149,7 @@ export function useChatScroll({
|
||||
setFloatingDate(null);
|
||||
setFloatingDateValue(null);
|
||||
setShowFloatingDate(false);
|
||||
}, []);
|
||||
}, [isDateNavigatorOpen]);
|
||||
|
||||
// Expose updateFloatingDate as a stable ref so the scroll handler can call it
|
||||
const updateFloatingDateRef = useRef(updateFloatingDate);
|
||||
@ -166,13 +162,22 @@ export function useChatScroll({
|
||||
if (!c?.containerRef?.current) return;
|
||||
const viewport = c.containerRef.current;
|
||||
|
||||
const onScroll = () => updateFloatingDateRef.current(viewport);
|
||||
const frameId = window.requestAnimationFrame(onScroll);
|
||||
let frameId: number | null = null;
|
||||
const updateFromScrollPosition = () => {
|
||||
frameId = null;
|
||||
updateFloatingDateRef.current(viewport);
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (frameId === null) {
|
||||
frameId = window.requestAnimationFrame(updateFromScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener("scroll", onScroll, { passive: true });
|
||||
scheduleUpdate();
|
||||
viewport.addEventListener("scroll", scheduleUpdate, { passive: true });
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
viewport.removeEventListener("scroll", scheduleUpdate);
|
||||
};
|
||||
}, [chatId, messageCount, viewportRef]);
|
||||
|
||||
|
||||
65
frontend/src/hooks/useTaskBoardRealtime.ts
Normal file
65
frontend/src/hooks/useTaskBoardRealtime.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { parseTaskBoardSocketUpdate } from "@/lib/tasks/taskBoardEvents";
|
||||
import wsManager from "@/lib/websocketManager";
|
||||
|
||||
type TaskBoardRealtimeOptions = {
|
||||
viewTaskId: number | null;
|
||||
checklistOpen: boolean;
|
||||
commentsOpen: boolean;
|
||||
activityOpen: boolean;
|
||||
onChecklistChange: (taskId: number) => void;
|
||||
onCommentsChange: (taskId: number) => void;
|
||||
onActivityChange: (taskId: number) => void;
|
||||
onBoardChange: (boardId: number) => void;
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
const COMMENT_EVENTS = new Set([
|
||||
"commented",
|
||||
"comment_edited",
|
||||
"comment_deleted",
|
||||
]);
|
||||
|
||||
export function useTaskBoardRealtime(options: TaskBoardRealtimeOptions): void {
|
||||
const optionsRef = useRef(options);
|
||||
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
optionsRef.current = options;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = wsManager.subscribe((event) => {
|
||||
const update = parseTaskBoardSocketUpdate(event);
|
||||
if (!update) return;
|
||||
|
||||
const current = optionsRef.current;
|
||||
if (update.taskId && update.taskId === current.viewTaskId) {
|
||||
if (update.model === "checklist_item" && current.checklistOpen) {
|
||||
current.onChecklistChange(update.taskId);
|
||||
}
|
||||
if (current.commentsOpen && COMMENT_EVENTS.has(update.event)) {
|
||||
current.onCommentsChange(update.taskId);
|
||||
}
|
||||
if (current.activityOpen) {
|
||||
current.onActivityChange(update.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = setTimeout(() => {
|
||||
syncTimerRef.current = null;
|
||||
optionsRef.current.onBoardChange(update.boardId);
|
||||
}, current.debounceMs ?? 250);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (syncTimerRef.current) {
|
||||
clearTimeout(syncTimerRef.current);
|
||||
syncTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
184
frontend/src/hooks/useTaskBoardScroll.ts
Normal file
184
frontend/src/hooks/useTaskBoardScroll.ts
Normal file
@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
|
||||
import type { TaskBoard } from "@/types/api";
|
||||
|
||||
type BoardPanState = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
frameId: number | null;
|
||||
};
|
||||
|
||||
type UseTaskBoardScrollOptions = {
|
||||
board: TaskBoard | null;
|
||||
desktopWideMode: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export function useTaskBoardScroll({
|
||||
board,
|
||||
desktopWideMode,
|
||||
loading,
|
||||
}: UseTaskBoardScrollOptions) {
|
||||
const boardScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const boardPanRef = useRef<BoardPanState | null>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
|
||||
const applyBoardPan = useCallback(() => {
|
||||
const pan = boardPanRef.current;
|
||||
const container = boardScrollRef.current;
|
||||
if (!pan || !container) return;
|
||||
|
||||
pan.frameId = null;
|
||||
container.scrollLeft = pan.scrollLeft - (pan.currentX - pan.startX);
|
||||
container.scrollTop = pan.scrollTop - (pan.currentY - pan.startY);
|
||||
}, []);
|
||||
|
||||
const startBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 1) return;
|
||||
const container = boardScrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
boardPanRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
currentX: event.clientX,
|
||||
currentY: event.clientY,
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
frameId: null,
|
||||
};
|
||||
container.setPointerCapture(event.pointerId);
|
||||
container.classList.add("cursor-grabbing", "select-none");
|
||||
}, []);
|
||||
|
||||
const moveBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const pan = boardPanRef.current;
|
||||
if (!pan || pan.pointerId !== event.pointerId) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pan.currentX = event.clientX;
|
||||
pan.currentY = event.clientY;
|
||||
if (pan.frameId === null) {
|
||||
pan.frameId = window.requestAnimationFrame(applyBoardPan);
|
||||
}
|
||||
}, [applyBoardPan]);
|
||||
|
||||
const finishBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const pan = boardPanRef.current;
|
||||
const container = boardScrollRef.current;
|
||||
if (!pan || pan.pointerId !== event.pointerId) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pan.currentX = event.clientX;
|
||||
pan.currentY = event.clientY;
|
||||
if (pan.frameId !== null) {
|
||||
window.cancelAnimationFrame(pan.frameId);
|
||||
pan.frameId = null;
|
||||
}
|
||||
applyBoardPan();
|
||||
boardPanRef.current = null;
|
||||
container?.classList.remove("cursor-grabbing", "select-none");
|
||||
if (container?.hasPointerCapture(event.pointerId)) {
|
||||
container.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}, [applyBoardPan]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = boardScrollRef.current;
|
||||
if (!container || loading) return undefined;
|
||||
|
||||
let scrollFrameId: number | null = null;
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
const nextHeight = container.clientHeight;
|
||||
setViewportHeight((currentHeight) => (
|
||||
currentHeight === nextHeight ? currentHeight : nextHeight
|
||||
));
|
||||
};
|
||||
|
||||
const updateContentBottomState = () => {
|
||||
scrollFrameId = null;
|
||||
const updates = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".tasks-board-column-group"),
|
||||
(group) => {
|
||||
const content = group.querySelector<HTMLElement>(".tasks-board-column-content");
|
||||
const stickyHeader = group.querySelector<HTMLElement>(".tasks-board-group-header-sticky");
|
||||
const reached = content && stickyHeader
|
||||
? content.getBoundingClientRect().bottom <= stickyHeader.getBoundingClientRect().bottom + 1
|
||||
: false;
|
||||
return { group, value: reached ? "true" : "false" };
|
||||
},
|
||||
);
|
||||
|
||||
updates.forEach(({ group, value }) => {
|
||||
if (group.dataset.contentBottomReached !== value) {
|
||||
group.dataset.contentBottomReached = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleContentBottomUpdate = () => {
|
||||
if (scrollFrameId === null) {
|
||||
scrollFrameId = window.requestAnimationFrame(updateContentBottomState);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
updateViewportHeight();
|
||||
scheduleContentBottomUpdate();
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
updateContentBottomState();
|
||||
container.addEventListener("scroll", scheduleContentBottomUpdate, { passive: true });
|
||||
|
||||
const observer = new ResizeObserver(handleResize);
|
||||
observer.observe(container);
|
||||
container.querySelectorAll<HTMLElement>(".tasks-board-column-content").forEach((content) => {
|
||||
observer.observe(content);
|
||||
});
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", scheduleContentBottomUpdate);
|
||||
observer.disconnect();
|
||||
if (scrollFrameId !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameId);
|
||||
}
|
||||
};
|
||||
}, [board, desktopWideMode, loading]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const pan = boardPanRef.current;
|
||||
if (pan?.frameId !== null && pan?.frameId !== undefined) {
|
||||
window.cancelAnimationFrame(pan.frameId);
|
||||
}
|
||||
boardScrollRef.current?.classList.remove("cursor-grabbing", "select-none");
|
||||
boardPanRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
boardScrollRef,
|
||||
finishBoardPan,
|
||||
moveBoardPan,
|
||||
startBoardPan,
|
||||
viewportHeight,
|
||||
};
|
||||
}
|
||||
47
frontend/src/lib/task-board-events.test.js
Normal file
47
frontend/src/lib/task-board-events.test.js
Normal file
@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseTaskBoardSocketUpdate } from "./tasks/taskBoardEvents.ts";
|
||||
|
||||
test("task board event parser normalizes task updates", () => {
|
||||
assert.deepEqual(parseTaskBoardSocketUpdate({
|
||||
type: "task_board_update",
|
||||
data: {
|
||||
board_id: "7",
|
||||
model: "task",
|
||||
object_id: "42",
|
||||
event: "updated",
|
||||
},
|
||||
}), {
|
||||
boardId: 7,
|
||||
taskId: 42,
|
||||
model: "task",
|
||||
event: "updated",
|
||||
});
|
||||
});
|
||||
|
||||
test("task board event parser keeps explicit parent task ids", () => {
|
||||
assert.deepEqual(parseTaskBoardSocketUpdate({
|
||||
type: "task_board_update",
|
||||
data: {
|
||||
board_id: 3,
|
||||
model: "checklist_item",
|
||||
object_id: 91,
|
||||
task_id: 12,
|
||||
event: "updated",
|
||||
},
|
||||
}), {
|
||||
boardId: 3,
|
||||
taskId: 12,
|
||||
model: "checklist_item",
|
||||
event: "updated",
|
||||
});
|
||||
});
|
||||
|
||||
test("task board event parser rejects unrelated and invalid events", () => {
|
||||
assert.equal(parseTaskBoardSocketUpdate({ type: "notification" }), null);
|
||||
assert.equal(parseTaskBoardSocketUpdate({
|
||||
type: "task_board_update",
|
||||
data: { board_id: 0 },
|
||||
}), null);
|
||||
});
|
||||
101
frontend/src/lib/task-board-layout.test.js
Normal file
101
frontend/src/lib/task-board-layout.test.js
Normal file
@ -0,0 +1,101 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildTaskBoardLayout,
|
||||
taskPlacementKey,
|
||||
} from "./tasks/boardLayout.ts";
|
||||
|
||||
const column = (id, position, parent = null, isArchived = false) => ({
|
||||
id,
|
||||
board: 1,
|
||||
parent,
|
||||
name: `column-${id}`,
|
||||
position,
|
||||
is_done: false,
|
||||
is_archived: isArchived,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
const row = (id, columnId, position) => ({
|
||||
id,
|
||||
column: columnId,
|
||||
name: `row-${id}`,
|
||||
position,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
const task = (id, columnId, rowId, position) => ({
|
||||
id,
|
||||
board: 1,
|
||||
column: columnId,
|
||||
row: rowId,
|
||||
title: `task-${id}`,
|
||||
priority: "medium",
|
||||
position,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
test("board layout orders columns, subcolumns and rows without mutating source arrays", () => {
|
||||
const columns = [
|
||||
column(2, 2000),
|
||||
column(1, 1000),
|
||||
column(12, 2000, 1),
|
||||
column(11, 1000, 1),
|
||||
column(99, 0, null, true),
|
||||
];
|
||||
const rows = [row(2, 1, 2000), row(1, 1, 1000)];
|
||||
|
||||
const layout = buildTaskBoardLayout(columns, rows, []);
|
||||
|
||||
assert.deepEqual(layout.activeColumns.map(({ id }) => id), [2, 1, 12, 11]);
|
||||
assert.deepEqual(layout.topLevelColumns.map(({ id }) => id), [1, 2]);
|
||||
assert.deepEqual(layout.subcolumnsByParent.get(1)?.map(({ id }) => id), [11, 12]);
|
||||
assert.deepEqual(layout.rowsByColumn.get(1)?.map(({ id }) => id), [1, 2]);
|
||||
assert.deepEqual(layout.leafColumns.map(({ id }) => id), [11, 12, 2]);
|
||||
assert.deepEqual(columns.map(({ id }) => id), [2, 1, 12, 11, 99]);
|
||||
assert.deepEqual(rows.map(({ id }) => id), [2, 1]);
|
||||
});
|
||||
|
||||
test("board layout groups and orders tasks by subcolumn and lane", () => {
|
||||
const tasks = [
|
||||
task(3, 11, null, 2000),
|
||||
task(2, 11, 7, 1000),
|
||||
task(1, 11, null, 1000),
|
||||
];
|
||||
|
||||
const layout = buildTaskBoardLayout(
|
||||
[column(1, 1000), column(11, 1000, 1)],
|
||||
[row(7, 1, 1000)],
|
||||
tasks,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
layout.tasksByPlacement.get(taskPlacementKey(11, null))?.map(({ id }) => id),
|
||||
[1, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
layout.tasksByPlacement.get(taskPlacementKey(11, 7))?.map(({ id }) => id),
|
||||
[2],
|
||||
);
|
||||
});
|
||||
|
||||
test("top-level column count includes direct tasks and all subcolumn tasks", () => {
|
||||
const layout = buildTaskBoardLayout(
|
||||
[column(1, 1000), column(11, 1000, 1), column(12, 2000, 1)],
|
||||
[],
|
||||
[
|
||||
task(1, 1, null, 1000),
|
||||
task(2, 11, null, 1000),
|
||||
task(3, 12, null, 1000),
|
||||
task(4, 12, null, 2000),
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(layout.columnCounts.get(1), 4);
|
||||
assert.equal(layout.columnCounts.get(11), 1);
|
||||
assert.equal(layout.columnCounts.get(12), 2);
|
||||
});
|
||||
96
frontend/src/lib/tasks/boardLayout.ts
Normal file
96
frontend/src/lib/tasks/boardLayout.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import type { TaskCard, TaskColumn, TaskColumnRow } from "@/types/api";
|
||||
|
||||
export type TaskBoardLayout = {
|
||||
activeColumns: TaskColumn[];
|
||||
topLevelColumns: TaskColumn[];
|
||||
subcolumnsByParent: Map<number, TaskColumn[]>;
|
||||
rowsByColumn: Map<number, TaskColumnRow[]>;
|
||||
leafColumns: TaskColumn[];
|
||||
columnCounts: Map<number, number>;
|
||||
tasksByPlacement: Map<string, TaskCard[]>;
|
||||
};
|
||||
|
||||
function byPositionThenId<T extends { id: number; position: number }>(
|
||||
left: T,
|
||||
right: T,
|
||||
): number {
|
||||
return left.position - right.position || left.id - right.id;
|
||||
}
|
||||
|
||||
export function taskPlacementKey(columnId: number, rowId: number | null): string {
|
||||
return `${columnId}:${rowId ?? "base"}`;
|
||||
}
|
||||
|
||||
export function buildTaskBoardLayout(
|
||||
columns: TaskColumn[] | null | undefined,
|
||||
rows: TaskColumnRow[] | null | undefined,
|
||||
tasks: TaskCard[] | null | undefined,
|
||||
): TaskBoardLayout {
|
||||
const activeColumns = (columns || []).filter((column) => !column.is_archived);
|
||||
const topLevelColumns = activeColumns
|
||||
.filter((column) => !column.parent)
|
||||
.sort(byPositionThenId);
|
||||
|
||||
const subcolumnsByParent = new Map<number, TaskColumn[]>();
|
||||
for (const column of activeColumns) {
|
||||
if (!column.parent) continue;
|
||||
const siblings = subcolumnsByParent.get(column.parent) || [];
|
||||
siblings.push(column);
|
||||
subcolumnsByParent.set(column.parent, siblings);
|
||||
}
|
||||
for (const siblings of subcolumnsByParent.values()) {
|
||||
siblings.sort(byPositionThenId);
|
||||
}
|
||||
|
||||
const rowsByColumn = new Map<number, TaskColumnRow[]>();
|
||||
for (const row of rows || []) {
|
||||
const columnRows = rowsByColumn.get(row.column) || [];
|
||||
columnRows.push(row);
|
||||
rowsByColumn.set(row.column, columnRows);
|
||||
}
|
||||
for (const columnRows of rowsByColumn.values()) {
|
||||
columnRows.sort(byPositionThenId);
|
||||
}
|
||||
|
||||
const leafColumns = topLevelColumns.flatMap((column) => {
|
||||
const subcolumns = subcolumnsByParent.get(column.id) || [];
|
||||
return subcolumns.length > 0 ? subcolumns : [column];
|
||||
});
|
||||
|
||||
const tasksByPlacement = new Map<string, TaskCard[]>();
|
||||
const columnCounts = new Map<number, number>();
|
||||
for (const column of activeColumns) columnCounts.set(column.id, 0);
|
||||
|
||||
for (const task of tasks || []) {
|
||||
const key = taskPlacementKey(task.column, task.row || null);
|
||||
const placementTasks = tasksByPlacement.get(key) || [];
|
||||
placementTasks.push(task);
|
||||
tasksByPlacement.set(key, placementTasks);
|
||||
columnCounts.set(task.column, (columnCounts.get(task.column) || 0) + 1);
|
||||
}
|
||||
for (const placementTasks of tasksByPlacement.values()) {
|
||||
placementTasks.sort(byPositionThenId);
|
||||
}
|
||||
|
||||
for (const column of topLevelColumns) {
|
||||
const subcolumns = subcolumnsByParent.get(column.id) || [];
|
||||
if (subcolumns.length === 0) continue;
|
||||
columnCounts.set(
|
||||
column.id,
|
||||
(columnCounts.get(column.id) || 0) + subcolumns.reduce(
|
||||
(total, subcolumn) => total + (columnCounts.get(subcolumn.id) || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
activeColumns,
|
||||
topLevelColumns,
|
||||
subcolumnsByParent,
|
||||
rowsByColumn,
|
||||
leafColumns,
|
||||
columnCounts,
|
||||
tasksByPlacement,
|
||||
};
|
||||
}
|
||||
44
frontend/src/lib/tasks/taskBoardEvents.ts
Normal file
44
frontend/src/lib/tasks/taskBoardEvents.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export type TaskBoardSocketUpdate = {
|
||||
boardId: number;
|
||||
taskId: number | null;
|
||||
event: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type TaskBoardSocketEvent = {
|
||||
type?: unknown;
|
||||
data?: {
|
||||
board_id?: unknown;
|
||||
event?: unknown;
|
||||
model?: unknown;
|
||||
object_id?: unknown;
|
||||
task_id?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export function parseTaskBoardSocketUpdate(
|
||||
event: unknown,
|
||||
): TaskBoardSocketUpdate | null {
|
||||
const socketEvent = event as TaskBoardSocketEvent;
|
||||
if (socketEvent?.type !== "task_board_update") return null;
|
||||
|
||||
const boardId = Number(socketEvent.data?.board_id);
|
||||
if (!Number.isFinite(boardId) || boardId <= 0) return null;
|
||||
|
||||
const model = typeof socketEvent.data?.model === "string"
|
||||
? socketEvent.data.model
|
||||
: "";
|
||||
const eventName = typeof socketEvent.data?.event === "string"
|
||||
? socketEvent.data.event
|
||||
: "";
|
||||
const rawTaskId = socketEvent.data?.task_id
|
||||
?? (model === "task" ? socketEvent.data?.object_id : undefined);
|
||||
const taskId = Number(rawTaskId);
|
||||
|
||||
return {
|
||||
boardId,
|
||||
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
|
||||
event: eventName,
|
||||
model,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user