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..2a32084ca 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 { @@ -51,21 +53,21 @@ import { useResolvedTenantId } from '@/composables/useResolvedTenantId'; import { fetchClient, taskSearchQueryOptions } from '@/lib/api-vue-query'; import { formatDeadline, taskDetailHref, taskSeqKey } from '@/lib/task-display'; import type { components } from '@/generated/api'; +import { + buildTasksListQueryParams, + taskListPlaceholderData, + useTaskLabelFilter, + watchAvailableTaskLabels, +} from './task-list-label-filter'; // ---- 定数 ---- 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; -type TasksListQueryKeyParams = { - params?: { - path?: { tenant_id?: string; project_id?: string | null }; - query?: { limit?: number; offset?: number }; - }; -}; - type TaskSearchQueryKeyParams = { params?: { path?: { tenant_id?: string; project_id?: string }; @@ -218,31 +220,31 @@ watch(projectKey, () => { pagination.value = { ...pagination.value, pageIndex: 0 }; }); +// ---- ラベルフィルタ ---- +// null は「すべて」。切り替え時は先頭ページへ戻す +const { selectedLabelId } = useTaskLabelFilter(pagination, projectKey); + // ---- クエリ②: タスク一覧 ---- const tasksQuery = useQuery({ queryKey: computed(() => [ 'get', LIST_TASKS_PATH, - { - params: { - path: { tenant_id: tenantId.value!, project_id: projectId.value! }, - query: { - limit: pagination.value.pageSize, - offset: pagination.value.pageIndex * pagination.value.pageSize, - }, - }, - }, + buildTasksListQueryParams( + tenantId.value!, + projectId.value!, + pagination.value, + selectedLabelId.value, + ), ]), queryFn: async ({ signal }) => { const { data, error } = await fetchClient.GET(LIST_TASKS_PATH, { // query パラメータは openapi-typescript 7.13.0 が正しく operation レベルに生成する - params: { - path: { tenant_id: tenantId.value!, project_id: projectId.value! }, - query: { - limit: pagination.value.pageSize, - offset: pagination.value.pageIndex * pagination.value.pageSize, - }, - }, + ...buildTasksListQueryParams( + tenantId.value!, + projectId.value!, + pagination.value, + selectedLabelId.value, + ), signal, }); if (error) throw error; @@ -250,12 +252,13 @@ const tasksQuery = useQuery({ }, enabled: computed(() => !!tenantId.value && !!projectId.value), placeholderData: (previousData, previousQuery) => { - const prevParams = previousQuery?.queryKey[2] as TasksListQueryKeyParams | undefined; - const prevProjectId = prevParams?.params?.path?.project_id; - if (prevProjectId && projectId.value && prevProjectId === projectId.value) { - return keepPreviousData(previousData); - } - return undefined; + // ラベルフィルタが変わったときは旧条件のデータを見せない(ページング時のみ維持) + return taskListPlaceholderData( + previousData, + previousQuery, + projectId.value, + selectedLabelId.value, + ); }, }); @@ -280,6 +283,30 @@ 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 ?? []); +watchAvailableTaskLabels(selectedLabelId, projectLabels); +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 +659,54 @@ const table = useVueTable({ + +
+ ラベルの取得に失敗しました + +
+ + + + + + + すべて + + + + +