Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/backend/crates/handler/src/handlers/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions apps/backend/crates/payload/src/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ pub struct ListTasksQuery {
pub status_id: Option<Uuid>,
pub priority: Option<String>,
pub assignee_id: Option<Uuid>,
pub label_id: Option<Uuid>,
pub milestone_id: Option<Uuid>,
pub sprint_id: Option<Uuid>,
pub parent_task_id: Option<Uuid>,
Expand Down
38 changes: 38 additions & 0 deletions apps/backend/tests/task_labels_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/api/openapi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions apps/frontend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -12555,6 +12555,15 @@
"format": "uuid"
}
},
{
"name": "label_id",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "milestone_id",
"in": "query",
Expand Down
133 changes: 104 additions & 29 deletions apps/frontend/src/pages/@tenant/projects/@projectKey/tasks/+Page.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Expand All @@ -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 };
Expand Down Expand Up @@ -218,44 +220,45 @@ 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;
return data;
},
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,
);
},
});

Expand All @@ -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 ?? [];
Expand Down Expand Up @@ -632,6 +659,54 @@ const table = useVueTable({
<Button size="sm" class="ml-auto h-8 text-xs" @click="isCreateDialogOpen = true">
新規タスク
</Button>
<!-- ラベル取得失敗はタスク一覧をブロックせず、ツールバー内で再試行を出す -->
<div
v-if="!isSearchActive && labelsQuery.isError.value && !projectLabels.length"
class="flex items-center gap-1.5 text-xs text-destructive"
>
<span>ラベルの取得に失敗しました</span>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="labelsQuery.refetch()"
>
再試行
</Button>
</div>
<DropdownMenu v-if="!isSearchActive && projectLabels.length">
<DropdownMenuTrigger as-child>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
:class="selectedLabelId ? 'border-primary text-primary' : ''"
>
{{ selectedLabelName ?? 'ラベル' }} <PhCaretDown class="ml-1 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
:model-value="selectedLabelId ?? ''"
@update:model-value="(v) => (selectedLabelId = v ? String(v) : null)"
>
<DropdownMenuRadioItem class="text-sm" value="">すべて</DropdownMenuRadioItem>
<DropdownMenuRadioItem
v-for="label in projectLabels"
:key="label.id"
class="text-sm"
:value="label.id"
>
<span
class="mr-1.5 inline-block size-2.5 shrink-0 rounded-full"
:style="{ backgroundColor: label.color }"
aria-hidden="true"
/>
{{ label.name }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu v-if="!isSearchActive">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-8 text-xs">
Expand Down
Loading
Loading