feat: manage skills from task quick menu
This commit is contained in:
parent
572c433bbd
commit
efc93b03e9
@ -438,6 +438,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
labels = validated_data.pop("labels", [])
|
||||
skills = validated_data.pop("skills", [])
|
||||
participants = list(validated_data.pop("participants", []))
|
||||
if "position" not in validated_data:
|
||||
column = validated_data["column"]
|
||||
@ -457,11 +458,14 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
task.participants.set(participant_ids)
|
||||
if labels:
|
||||
task.labels.set(labels)
|
||||
if skills:
|
||||
task.skills.set(skills)
|
||||
return task
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
previous_assignee_id = instance.assignee_id
|
||||
labels = validated_data.pop("labels", None)
|
||||
skills = validated_data.pop("skills", None)
|
||||
participants = validated_data.pop("participants", None)
|
||||
task = super().update(instance, validated_data)
|
||||
if participants is not None:
|
||||
@ -483,4 +487,6 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
if labels is not None:
|
||||
task.labels.set(labels)
|
||||
if skills is not None:
|
||||
task.skills.set(skills)
|
||||
return task
|
||||
|
||||
@ -81,6 +81,7 @@ class TaskViewSet(
|
||||
"due_date": instance.due_date.isoformat() if instance.due_date else None,
|
||||
"row_id": instance.row_id,
|
||||
"label_ids": sorted(instance.labels.values_list("id", flat=True)),
|
||||
"skill_ids": sorted(instance.skills.values_list("id", flat=True)),
|
||||
"participant_ids": sorted(
|
||||
instance.participants.values_list("id", flat=True)
|
||||
),
|
||||
@ -94,6 +95,7 @@ class TaskViewSet(
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
"row_id": task.row_id,
|
||||
"label_ids": sorted(task.labels.values_list("id", flat=True)),
|
||||
"skill_ids": sorted(task.skills.values_list("id", flat=True)),
|
||||
"participant_ids": sorted(task.participants.values_list("id", flat=True)),
|
||||
}
|
||||
changed_fields = [
|
||||
|
||||
@ -19,7 +19,7 @@ from rest_framework.test import APIClient
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from communications.models import Chat, ChatMembership, Message
|
||||
from documents.models import Document
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment, Skill
|
||||
from feed.models import Post
|
||||
from guests.models import Guest, GuestVisit
|
||||
from procurement.constants import ProcurementStatus, UrgencyLevel
|
||||
@ -1975,6 +1975,45 @@ def test_create_task_and_move_to_done_column(api_client, user):
|
||||
assert task.completed_at is not None
|
||||
|
||||
|
||||
def test_create_and_update_task_skills(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
board_response = api_client.get(reverse("api:v1:tasks:task-board-default"))
|
||||
board_id = board_response.data["id"]
|
||||
column_id = board_response.data["columns"][0]["id"]
|
||||
python = Skill.objects.create(name="Python")
|
||||
analytics = Skill.objects.create(name="Аналитика")
|
||||
|
||||
create_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-list"),
|
||||
{
|
||||
"board": board_id,
|
||||
"column": column_id,
|
||||
"title": "Задача с навыком",
|
||||
"skill_ids": [python.id],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
task_id = create_response.data["id"]
|
||||
assert [skill["id"] for skill in create_response.data["skills"]] == [python.id]
|
||||
assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [
|
||||
python.id
|
||||
]
|
||||
|
||||
update_response = api_client.patch(
|
||||
reverse("api:v1:tasks:task-detail", kwargs={"pk": task_id}),
|
||||
{"skill_ids": [analytics.id]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert update_response.status_code == status.HTTP_200_OK
|
||||
assert [skill["id"] for skill in update_response.data["skills"]] == [analytics.id]
|
||||
assert list(Task.objects.get(id=task_id).skills.values_list("id", flat=True)) == [
|
||||
analytics.id
|
||||
]
|
||||
|
||||
|
||||
def test_task_participants_can_change_assignee_in_same_update(api_client, user):
|
||||
previous_assignee = make_user(
|
||||
"task-previous-assignee@example.com",
|
||||
|
||||
@ -193,7 +193,7 @@ type ColumnRowFormState = {
|
||||
|
||||
type LinkedItemMode = "document" | "calendar_event" | "external_link";
|
||||
type LinkedItemTarget = "view" | "form";
|
||||
type TaskAddPanel = "due_date" | "labels" | "priority" | null;
|
||||
type TaskAddPanel = "due_date" | "labels" | "priority" | "skills" | null;
|
||||
|
||||
type TaskCoverLinkedOption = {
|
||||
id: number;
|
||||
@ -3801,6 +3801,7 @@ function TasksPageContent() {
|
||||
const [taskAddMenuPlacement, setTaskAddMenuPlacement] = useState<"top" | "bottom">("top");
|
||||
const [taskAddPanel, setTaskAddPanel] = useState<TaskAddPanel>(null);
|
||||
const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState("");
|
||||
const [viewTaskSkillSearch, setViewTaskSkillSearch] = useState("");
|
||||
const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false);
|
||||
const [cardMenuTaskId, setCardMenuTaskId] = useState<number | null>(null);
|
||||
const [columnMenuId, setColumnMenuId] = useState<number | null>(null);
|
||||
@ -4750,6 +4751,16 @@ function TasksPageContent() {
|
||||
() => (board?.tasks || []).find((task) => task.id === viewTaskId) || null,
|
||||
[board?.tasks, viewTaskId],
|
||||
);
|
||||
const filteredViewTaskSkills = useMemo(() => {
|
||||
const query = viewTaskSkillSearch.trim().toLowerCase();
|
||||
const selectedIds = new Set((viewTask?.skills || []).map((skill) => skill.id));
|
||||
return availableSkills
|
||||
.filter((skill) => !query || skill.name.toLowerCase().includes(query))
|
||||
.sort((left, right) => {
|
||||
const selectedDifference = Number(selectedIds.has(right.id)) - Number(selectedIds.has(left.id));
|
||||
return selectedDifference || left.name.localeCompare(right.name, "ru");
|
||||
});
|
||||
}, [availableSkills, viewTask?.skills, viewTaskSkillSearch]);
|
||||
|
||||
const viewTaskColumn = useMemo(
|
||||
() => activeColumns.find((column) => column.id === viewTask?.column) || null,
|
||||
@ -5118,6 +5129,7 @@ function TasksPageContent() {
|
||||
setTaskAddMenuOpen(false);
|
||||
setTaskAddPanel(null);
|
||||
setTaskLabelSearch("");
|
||||
setViewTaskSkillSearch("");
|
||||
setViewTaskDueDateDraft(task.due_date || "");
|
||||
setViewColumnMenuOpen(false);
|
||||
setLinkedObjectsOpen(false);
|
||||
@ -5180,6 +5192,7 @@ function TasksPageContent() {
|
||||
setTaskAddMenuOpen(false);
|
||||
setTaskAddPanel(null);
|
||||
setTaskLabelSearch("");
|
||||
setViewTaskSkillSearch("");
|
||||
setViewTaskDueDateDraft("");
|
||||
setViewColumnMenuOpen(false);
|
||||
setLinkedMessages([]);
|
||||
@ -6338,6 +6351,27 @@ function TasksPageContent() {
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
|
||||
|
||||
const toggleViewedTaskSkill = useCallback(async (skillId: number) => {
|
||||
if (!board || !viewTask || saving) return;
|
||||
|
||||
const currentSkillIds = viewTask.skills?.map((skill) => skill.id) || [];
|
||||
const nextSkillIds = currentSkillIds.includes(skillId)
|
||||
? currentSkillIds.filter((id) => id !== skillId)
|
||||
: [...currentSkillIds, skillId];
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.updateTask(viewTask.id, { skill_ids: nextSkillIds });
|
||||
await loadBoard(board.id);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (skillError) {
|
||||
setError(getTaskError(skillError, "Не удалось изменить навыки задачи"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
|
||||
|
||||
const createViewedTaskLabel = useCallback(async () => {
|
||||
if (!board || !viewTask || !labelName.trim() || saving) return;
|
||||
|
||||
@ -7634,6 +7668,18 @@ function TasksPageContent() {
|
||||
<Tag size={15} className="app-text-muted" />
|
||||
Метки
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewTaskSkillSearch("");
|
||||
setTaskAddPanel("skills");
|
||||
setTaskAddMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Sparkles size={15} className="app-text-muted" />
|
||||
Навыки
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openTaskCoverPicker()}
|
||||
@ -9492,6 +9538,72 @@ function TasksPageContent() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{taskAddPanel === "skills" ? (
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<Sparkles size={15} className="app-text-muted" />
|
||||
Навыки задачи
|
||||
{(viewTask.skills?.length || 0) > 0 ? (
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
|
||||
{viewTask.skills?.length}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaskAddPanel(null)}
|
||||
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-lg"
|
||||
title="Закрыть"
|
||||
aria-label="Закрыть выбор навыков"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<label className="relative mb-3 block">
|
||||
<Search size={14} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={viewTaskSkillSearch}
|
||||
onChange={(event) => setViewTaskSkillSearch(event.target.value)}
|
||||
className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-sm"
|
||||
placeholder="Поиск навыка"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{filteredViewTaskSkills.length ? (
|
||||
<div className="tasks-column-scroll flex max-h-52 flex-wrap content-start gap-2 overflow-y-auto pr-1">
|
||||
{filteredViewTaskSkills.map((skill) => {
|
||||
const selected = Boolean(viewTask.skills?.some((item) => item.id === skill.id));
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => void toggleViewedTaskSkill(skill.id)}
|
||||
disabled={saving}
|
||||
className={`app-pill app-badge-interactive inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-xs font-medium disabled:opacity-50 ${
|
||||
selected ? "app-selected" : ""
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{selected ? <Check size={13} /> : null}
|
||||
<span>{skill.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-5 text-center">
|
||||
<p className="app-text-muted text-xs">
|
||||
{availableSkills.length ? "Навыки не найдены" : "Навыки пока не созданы"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="app-text-muted mt-2 text-[11px] leading-relaxed">
|
||||
После завершения задачи исполнители и участники с выбранными навыками получат опыт.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewTask.description ? (
|
||||
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
|
||||
<p className="app-text-wrap whitespace-pre-wrap text-sm text-[var(--foreground)]">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user