From 9a7b0f3beb40164bf2325e9af09344657849d281 Mon Sep 17 00:00:00 2001 From: yupix Date: Thu, 13 Aug 2026 20:02:02 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E3=82=BF=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=81=AE=E3=83=A9=E3=83=99=E3=83=AB=E3=83=95?= =?UTF-8?q?=E3=82=A3=E3=83=AB=E3=82=BF=EF=BC=88label=5Fid=20=E3=82=AF?= =?UTF-8?q?=E3=82=A8=E3=83=AA=E3=83=91=E3=83=A9=E3=83=A1=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=81=A8=E4=B8=80=E8=A6=A7=20UI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ListTasksQuery に label_id を追加し、assignee_id と同じ EXISTS サブクエリで 指定ラベルが付与されたタスクのみに絞り込む - 一覧ページのツールバーにラベルフィルタ(ラジオ選択・「すべて」で解除)を追加。 検索中は他のリスト操作と同様に非表示。切替時は先頭ページへ戻す - 仕様書 §4.1 のクエリパラメータ表に label_id を追記、openapi.json / CLI 型を再生成 - 一覧ページが labels API を叩くようになったため、TaskTable ストーリーの fetch モックに /labels 分岐を追加(従来はプロジェクト一覧の分岐に吸われていた)。 タスクフィクスチャにも labels を補完 - 統合テスト: label_id フィルタで対象タスクのみ返ること・非対象ラベルで 0 件に なることを追加 --- .../crates/handler/src/handlers/tasks.rs | 6 ++ apps/backend/crates/payload/src/tasks.rs | 1 + apps/backend/tests/task_labels_integration.rs | 38 ++++++++++ apps/cli/src/api/openapi.d.ts | 1 + apps/frontend/openapi.json | 9 +++ .../projects/@projectKey/tasks/+Page.vue | 71 +++++++++++++++++++ .../stories/pages/TaskTable.stories.ts | 28 +++++++- docs/features/tasks/1.core.md | 1 + 8 files changed, 154 insertions(+), 1 deletion(-) diff --git a/apps/backend/crates/handler/src/handlers/tasks.rs b/apps/backend/crates/handler/src/handlers/tasks.rs index ac4c6b90c..8c8eeddf2 100644 --- a/apps/backend/crates/handler/src/handlers/tasks.rs +++ b/apps/backend/crates/handler/src/handlers/tasks.rs @@ -363,6 +363,12 @@ pub async fn list_tasks( vec![sea_orm::Value::from(uid)], )); } + if let Some(lid) = q.label_id { + query = query.filter(Expr::cust_with_values( + "EXISTS (SELECT 1 FROM task_labels WHERE task_labels.task_id = tasks.id AND task_labels.label_id = $1)", + vec![sea_orm::Value::from(lid)], + )); + } query = match q.sort.as_deref().unwrap_or("created_at_desc") { "priority_asc" => query.order_by_asc(tasks::Column::Priority), diff --git a/apps/backend/crates/payload/src/tasks.rs b/apps/backend/crates/payload/src/tasks.rs index a5e8d63ec..4df5baafd 100644 --- a/apps/backend/crates/payload/src/tasks.rs +++ b/apps/backend/crates/payload/src/tasks.rs @@ -175,6 +175,7 @@ pub struct ListTasksQuery { pub status_id: Option, pub priority: Option, pub assignee_id: Option, + pub label_id: Option, pub milestone_id: Option, pub sprint_id: Option, pub parent_task_id: Option, diff --git a/apps/backend/tests/task_labels_integration.rs b/apps/backend/tests/task_labels_integration.rs index e486d6f33..f02fb7c3c 100644 --- a/apps/backend/tests/task_labels_integration.rs +++ b/apps/backend/tests/task_labels_integration.rs @@ -149,6 +149,44 @@ async fn task_labels_suite() { .expect("task in list"); assert_eq!(listed_task["labels"][0]["name"], "feature"); + // label_id フィルタ: 指定ラベルが付いたタスクだけが返る + let feature_task = app + .post_json_with_session( + &tasks_path, + serde_json::json!({ + "title": "feature ラベルのタスク", + "status_id": status_id, + "label_ids": [label_ids[1]] + }), + ) + .await; + assert_eq!(feature_task.status(), StatusCode::CREATED); + let feature_task_body: Value = feature_task.json().await.expect("feature task json"); + let feature_task_id = feature_task_body["id"].as_str().expect("task id"); + + let filtered = app + .get_with_session(&format!("{tasks_path}?label_id={}", label_ids[1])) + .await; + assert_eq!(filtered.status(), StatusCode::OK); + let filtered_body: Value = filtered.json().await.expect("filtered json"); + let filtered_tasks = filtered_body["tasks"].as_array().expect("tasks array"); + assert_eq!(filtered_tasks.len(), 2); + assert!( + filtered_tasks + .iter() + .all(|t| t["id"] == task_id.as_str() || t["id"] == feature_task_id) + ); + + let filtered_bug = app + .get_with_session(&format!("{tasks_path}?label_id={}", label_ids[0])) + .await; + assert_eq!(filtered_bug.status(), StatusCode::OK); + let filtered_bug_body: Value = filtered_bug.json().await.expect("filtered bug json"); + assert_eq!( + filtered_bug_body["tasks"].as_array().expect("tasks").len(), + 0 + ); + // 別プロジェクトのラベル ID は 400(付け替えも起きない) let other = app.insert_tenant_project(user.id).await; let other_labels_path = format!( diff --git a/apps/cli/src/api/openapi.d.ts b/apps/cli/src/api/openapi.d.ts index adfae77c9..3e3acfb0c 100644 --- a/apps/cli/src/api/openapi.d.ts +++ b/apps/cli/src/api/openapi.d.ts @@ -11475,6 +11475,7 @@ export interface operations { status_id?: string; priority?: string; assignee_id?: string; + label_id?: string; milestone_id?: string; sprint_id?: string; parent_task_id?: string; diff --git a/apps/frontend/openapi.json b/apps/frontend/openapi.json index 90954317a..d2a286cc2 100644 --- a/apps/frontend/openapi.json +++ b/apps/frontend/openapi.json @@ -12555,6 +12555,15 @@ "format": "uuid" } }, + { + "name": "label_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "milestone_id", "in": "query", diff --git a/apps/frontend/src/pages/@tenant/projects/@projectKey/tasks/+Page.vue b/apps/frontend/src/pages/@tenant/projects/@projectKey/tasks/+Page.vue index fbd13ea84..003f36d5a 100644 --- a/apps/frontend/src/pages/@tenant/projects/@projectKey/tasks/+Page.vue +++ b/apps/frontend/src/pages/@tenant/projects/@projectKey/tasks/+Page.vue @@ -33,6 +33,8 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -55,6 +57,7 @@ import type { components } from '@/generated/api'; // ---- 定数 ---- const LIST_TASKS_PATH = '/v1/tenants/{tenant_id}/projects/{project_id}/tasks' as const; const LIST_STATUSES_PATH = '/v1/tenants/{tenant_id}/projects/{project_id}/statuses' as const; +const LIST_LABELS_PATH = '/v1/tenants/{tenant_id}/projects/{project_id}/labels' as const; const TASKS_PAGE_SIZE = 20; const SEARCH_PAGE_SIZE = 50; const SEARCH_DEBOUNCE_MS = 300; @@ -218,6 +221,16 @@ watch(projectKey, () => { pagination.value = { ...pagination.value, pageIndex: 0 }; }); +// ---- ラベルフィルタ ---- +// null は「すべて」。切り替え時は先頭ページへ戻す +const selectedLabelId = ref(null); +watch(selectedLabelId, () => { + pagination.value = { ...pagination.value, pageIndex: 0 }; +}); +watch(projectKey, () => { + selectedLabelId.value = null; +}); + // ---- クエリ②: タスク一覧 ---- const tasksQuery = useQuery({ queryKey: computed(() => [ @@ -229,6 +242,7 @@ const tasksQuery = useQuery({ query: { limit: pagination.value.pageSize, offset: pagination.value.pageIndex * pagination.value.pageSize, + label_id: selectedLabelId.value ?? undefined, }, }, }, @@ -241,6 +255,7 @@ const tasksQuery = useQuery({ query: { limit: pagination.value.pageSize, offset: pagination.value.pageIndex * pagination.value.pageSize, + label_id: selectedLabelId.value ?? undefined, }, }, signal, @@ -280,6 +295,29 @@ const statusesQuery = useQuery({ enabled: computed(() => !!tenantId.value && !!projectId.value), }); +// ---- クエリ④: ラベル一覧(フィルタ用) ---- +const labelsQuery = useQuery({ + queryKey: computed(() => [ + 'get', + LIST_LABELS_PATH, + { params: { path: { tenant_id: tenantId.value!, project_id: projectId.value! } } }, + ]), + queryFn: async ({ signal }) => { + const { data, error } = await fetchClient.GET(LIST_LABELS_PATH, { + params: { path: { tenant_id: tenantId.value!, project_id: projectId.value! } }, + signal, + }); + if (error) throw error; + return data; + }, + enabled: computed(() => !!tenantId.value && !!projectId.value), +}); + +const projectLabels = computed(() => labelsQuery.data.value ?? []); +const selectedLabelName = computed( + () => projectLabels.value.find((label) => label.id === selectedLabelId.value)?.name ?? null, +); + /** status_id → { name, color } 解決用 Map */ const statusMap = computed(() => { const statuses = statusesQuery.data.value ?? []; @@ -632,6 +670,39 @@ const table = useVueTable({ + + + + + + + すべて + + + + + + +
+ ラベルの取得に失敗しました + +