From 755872c9306435c7380f18ec0f116ab10d1379f2 Mon Sep 17 00:00:00 2001 From: pikann Date: Mon, 27 Jul 2026 12:09:06 +0000 Subject: [PATCH 1/7] feat: implement pagination for epic picker across various components --- .../projects/interactions/board-view.tsx | 6 ++++ .../interactions/interaction-layout.tsx | 33 ++++++++++++++++--- .../projects/interactions/list-group.tsx | 6 ++++ .../projects/interactions/list-view.tsx | 5 +++ .../projects/interactions/task-card.tsx | 25 +++++++++++++- .../interactions/task-context-menu.tsx | 29 +++++++++++++--- .../interactions/task-detail/index.tsx | 30 ++++++++++++++--- .../task-detail/properties-panel.tsx | 19 ++++++++++- .../projects/interactions/task-row.tsx | 22 ++++++++++++- .../projects/interactions/view-utils.ts | 11 +++++++ apps/web/src/i18n/locales/en/projects.json | 2 ++ apps/web/src/i18n/locales/es/projects.json | 2 ++ apps/web/src/i18n/locales/fr/projects.json | 2 ++ apps/web/src/i18n/locales/ja/projects.json | 2 ++ apps/web/src/i18n/locales/ko/projects.json | 2 ++ apps/web/src/i18n/locales/pt-BR/projects.json | 2 ++ apps/web/src/i18n/locales/ru/projects.json | 2 ++ apps/web/src/i18n/locales/vi/projects.json | 2 ++ apps/web/src/i18n/locales/zh-CN/projects.json | 2 ++ apps/web/src/lib/interaction-api.ts | 33 ++++++++++++++----- 20 files changed, 213 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/projects/interactions/board-view.tsx b/apps/web/src/components/projects/interactions/board-view.tsx index f829fe5d..99a23ef6 100644 --- a/apps/web/src/components/projects/interactions/board-view.tsx +++ b/apps/web/src/components/projects/interactions/board-view.tsx @@ -25,6 +25,7 @@ import { buildColumnDropUpdate, type ColumnGroupDef, DEFAULT_VISIBLE_FIELDS, + type EpicsPagination, getColumnGroupDefs, getSwimlaneDefs, getTaskColumnKeys, @@ -55,6 +56,8 @@ interface BoardViewProps { ) => Promise; onTaskClick: (task: Task) => void; epics?: Task[]; + /** Load-more state for the epic picker, shared by every card in this view. */ + epicsPagination?: EpicsPagination; onUpdateTask?: (taskId: string, payload: TaskFieldUpdate) => void; onMoveToColumn?: (taskId: string, update: TaskFieldUpdate) => void; /** Opens the delete-confirmation dialog for a task — wired to the @@ -91,6 +94,7 @@ export function BoardView({ canEdit, tasksQueryKey, epics = [], + epicsPagination, onCreateTask, onTaskClick, onUpdateTask, @@ -560,6 +564,7 @@ export function BoardView({ taskTypes={taskTypes} members={members} epics={epics} + epicsPagination={epicsPagination} canEdit={!!canEdit} onOpen={() => onTaskClick(task)} onUpdate={handleInlineUpdate} @@ -577,6 +582,7 @@ export function BoardView({ members={members} customFields={customFields} epics={epics} + epicsPagination={epicsPagination} visibleFields={visibleFields} canEdit={canEdit} isDragging={draggingId === task.id} diff --git a/apps/web/src/components/projects/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index 26ba5552..cbdd04e7 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -1,4 +1,5 @@ import { + useInfiniteQuery, useMutation, useQueries, useQuery, @@ -53,7 +54,7 @@ import { createViewByContext, deleteTask, deleteViewById, - epicTasksQueryOptions, + epicTasksInfiniteQueryOptions, type FilterConfig, type InteractionView, type ListTasksOptions, @@ -97,6 +98,7 @@ import { RoadmapView } from "./roadmap-view"; import { TaskDetailModal } from "./task-detail-modal"; import { UNASSIGNED_FILTER_ID, ViewSettingsPanel } from "./view-settings-panel"; import { + type EpicsPagination, getColumnGroupDefs, getDefaultInitialPageSize, getDefaultPageSize, @@ -550,12 +552,33 @@ export function InteractionLayout({ const { data: sprints = [] } = useQuery(sprintsQueryOptions(projectId)); - // Fetch Epic tasks for display in the epic field on task cards/rows + // Fetch Epic tasks for display in the epic field on task cards/rows. + // Paginated 20-per-page (see epicTasksInfiniteQueryOptions) — every epic + // picker app-wide (task detail, board/list-view cards & rows, the + // right-click context menu) shares this single query and its + // fetchNextPage/hasNextPage via the epicsPagination object below. const epicType = findEpicType(taskTypes); - const { data: epicTasks = [] } = useQuery({ - ...epicTasksQueryOptions(projectId, epicType?.id ?? ""), + const { + data: epicPages, + fetchNextPage: fetchNextEpicsPage, + hasNextPage: hasMoreEpics, + isFetchingNextPage: isFetchingMoreEpics, + } = useInfiniteQuery({ + ...epicTasksInfiniteQueryOptions(projectId, epicType?.id ?? ""), enabled: !!epicType?.id, }); + const epicTasks = useMemo( + () => epicPages?.pages.flatMap((page) => page.items) ?? [], + [epicPages], + ); + const epicsPagination: EpicsPagination = useMemo( + () => ({ + hasMore: !!hasMoreEpics, + isLoadingMore: isFetchingMoreEpics, + onLoadMore: () => void fetchNextEpicsPage(), + }), + [hasMoreEpics, isFetchingMoreEpics, fetchNextEpicsPage], + ); const isRealView = !!activeViewId && !activeViewId.startsWith("__default-"); const effectiveViewId = isManualSort && isRealView ? activeViewId : undefined; @@ -1702,6 +1725,7 @@ export function InteractionLayout({ customFields={customFields} sprints={sprints} epics={epicTasks} + epicsPagination={epicsPagination} viewConfig={activeViewConfig} canCreate={canCreate} canEdit={canEdit} @@ -1752,6 +1776,7 @@ export function InteractionLayout({ members={members} customFields={customFields} epics={epicTasks} + epicsPagination={epicsPagination} viewConfig={activeViewConfig} canCreate={canCreate} columnPagination={columnPagination} diff --git a/apps/web/src/components/projects/interactions/list-group.tsx b/apps/web/src/components/projects/interactions/list-group.tsx index fa980c27..3f7e695c 100644 --- a/apps/web/src/components/projects/interactions/list-group.tsx +++ b/apps/web/src/components/projects/interactions/list-group.tsx @@ -18,6 +18,7 @@ import { getRowColConfig, TaskRow } from "./task-row"; import { buildColumnDropUpdate, type ColumnGroupDef, + type EpicsPagination, getTaskSwimlaneKey, type TaskFieldUpdate, } from "./view-utils"; @@ -32,6 +33,8 @@ export interface ListGroupProps { members: ProjectMember[]; customFields: CustomFieldDefinition[]; epics?: Task[]; + /** Load-more state for the epic picker, shared by every row in this group. */ + epicsPagination?: EpicsPagination; canCreate: boolean; defaultCollapsed?: boolean; fieldSum?: string; @@ -99,6 +102,7 @@ export function ListGroup({ members, customFields, epics = [], + epicsPagination, canCreate, defaultCollapsed, fieldSum, @@ -426,6 +430,7 @@ export function ListGroup({ taskTypes={taskTypes} members={members} epics={epics} + epicsPagination={epicsPagination} canEdit={!!canEdit} onOpen={() => onTaskClick(task)} onUpdate={onUpdateTaskField} @@ -442,6 +447,7 @@ export function ListGroup({ taskTypes={taskTypes} members={members} epics={epics} + epicsPagination={epicsPagination} customFields={customFields} visibleFields={visibleFields} onClick={() => onTaskClick(task)} diff --git a/apps/web/src/components/projects/interactions/list-view.tsx b/apps/web/src/components/projects/interactions/list-view.tsx index cd9a7d73..cbf78065 100644 --- a/apps/web/src/components/projects/interactions/list-view.tsx +++ b/apps/web/src/components/projects/interactions/list-view.tsx @@ -14,6 +14,7 @@ import { applyStatusFilterToColumnDefs, type ColumnGroupDef, DEFAULT_VISIBLE_FIELDS, + type EpicsPagination, getColumnGroupDefs, getSwimlaneDefs, getTaskColumnKeys, @@ -31,6 +32,8 @@ export interface ListViewProps { members?: ProjectMember[]; customFields?: CustomFieldDefinition[]; epics?: Task[]; + /** Load-more state for the epic picker, shared by every group/row/card. */ + epicsPagination?: EpicsPagination; viewConfig?: ViewConfig; canCreate: boolean; onCreateTask: ( @@ -84,6 +87,7 @@ export function ListView({ members = [], customFields = [], epics = [], + epicsPagination, viewConfig, canCreate, onCreateTask, @@ -237,6 +241,7 @@ export function ListView({ members={members} customFields={customFields} epics={epics} + epicsPagination={epicsPagination} canCreate={canCreate} defaultCollapsed={defaultCollapsed} fieldSum={fieldSum} diff --git a/apps/web/src/components/projects/interactions/task-card.tsx b/apps/web/src/components/projects/interactions/task-card.tsx index f6376b1d..6b275ef0 100644 --- a/apps/web/src/components/projects/interactions/task-card.tsx +++ b/apps/web/src/components/projects/interactions/task-card.tsx @@ -30,7 +30,11 @@ import { IMPORTANCE_BUCKET_VALUES, PRIORITY_LEVELS, } from "./priority"; -import { DEFAULT_VISIBLE_FIELDS, type TaskFieldUpdate } from "./view-utils"; +import { + DEFAULT_VISIBLE_FIELDS, + type EpicsPagination, + type TaskFieldUpdate, +} from "./view-utils"; type UpdatePayload = TaskFieldUpdate; @@ -41,6 +45,9 @@ interface TaskCardProps { taskTypes: TaskType[]; members?: ProjectMember[]; epics?: Task[]; + /** Load-more state for the epic picker Popover — omitted when the caller + * doesn't paginate epics (e.g. fewer than one page's worth). */ + epicsPagination?: EpicsPagination; visibleFields?: string[]; customFields?: CustomFieldDefinition[]; onClick?: () => void; @@ -68,6 +75,7 @@ export function TaskCard({ taskTypes, members = [], epics = [], + epicsPagination, visibleFields = DEFAULT_VISIBLE_FIELDS, customFields = [], onClick, @@ -545,6 +553,21 @@ export function TaskCard({ )} ))} + {epicsPagination?.hasMore && ( + + )} ) : epic ? ( diff --git a/apps/web/src/components/projects/interactions/task-context-menu.tsx b/apps/web/src/components/projects/interactions/task-context-menu.tsx index 6bf655ab..85d3fd81 100644 --- a/apps/web/src/components/projects/interactions/task-context-menu.tsx +++ b/apps/web/src/components/projects/interactions/task-context-menu.tsx @@ -24,7 +24,11 @@ import { IMPORTANCE_BUCKET_VALUES, PRIORITY_LEVELS, } from "./priority"; -import type { ColumnGroupDef, TaskFieldUpdate } from "./view-utils"; +import type { + ColumnGroupDef, + EpicsPagination, + TaskFieldUpdate, +} from "./view-utils"; const shortcutChord = (id: string) => SHORTCUT_DISPLAY.find((e) => e.id === id)?.chords[0]; @@ -45,6 +49,9 @@ interface TaskContextMenuProps { taskTypes: TaskType[]; members: ProjectMember[]; epics: Task[]; + /** Load-more state for the epic submenu — omitted when the caller + * doesn't paginate epics (e.g. fewer than one page's worth). */ + epicsPagination?: EpicsPagination; canEdit: boolean; onOpen: () => void; onUpdate?: (taskId: string, update: TaskFieldUpdate) => void; @@ -66,6 +73,7 @@ export function TaskContextMenu({ taskTypes, members, epics, + epicsPagination, canEdit, onOpen, onUpdate, @@ -289,9 +297,22 @@ export function TaskContextMenu({ )} ))} - - - )} + {epicsPagination?.hasMore && ( + epicsPagination.onLoadMore()} + disabled={epicsPagination.isLoadingMore} + > + + {epicsPagination.isLoadingMore + ? t("board.taskContextMenu.loadingEpics") + : t("board.taskContextMenu.loadMoreEpics")} + + + )} + + + )} diff --git a/apps/web/src/components/projects/interactions/task-detail/index.tsx b/apps/web/src/components/projects/interactions/task-detail/index.tsx index 609b7de2..853c09c5 100644 --- a/apps/web/src/components/projects/interactions/task-detail/index.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/index.tsx @@ -1,11 +1,11 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { AlertCircle } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createTask, - epicTasksQueryOptions, + epicTasksInfiniteQueryOptions, sprintsQueryOptions, subtasksQueryOptions, taskQueryOptions, @@ -22,6 +22,7 @@ import { import { cleanBlocks, cn } from "@/lib/utils"; import { getTaskTypeIconComponent } from "../../task-types/task-type-icons"; import { getPriority } from "../priority"; +import type { EpicsPagination } from "../view-utils"; import { TaskActivityPane as ActivityPane } from "./activity-pane"; import { AttachmentsSection } from "./attachments-section"; import { DescriptionSection } from "./description-section"; @@ -113,15 +114,33 @@ export function TaskDetailModal({ const epicType = findEpicType(taskTypes); const normalTaskTypes = getNormalTaskTypes(taskTypes); - // For normal tasks: fetch all epics to populate the Epic picker - const { data: epicTasks = [] } = useQuery({ - ...epicTasksQueryOptions(projectId ?? "", epicType?.id ?? ""), + // For normal tasks: fetch epics (paginated 20-per-page) to populate the + // Epic picker, with a "Load more" affordance for projects with >20 epics. + const { + data: epicPages, + fetchNextPage: fetchNextEpicsPage, + hasNextPage: hasMoreEpics, + isFetchingNextPage: isFetchingMoreEpics, + } = useInfiniteQuery({ + ...epicTasksInfiniteQueryOptions(projectId ?? "", epicType?.id ?? ""), enabled: !!projectId && !!epicType?.id && taskRole === "normal" && (open || mode === "page"), }); + const epicTasks = useMemo( + () => epicPages?.pages.flatMap((page) => page.items) ?? [], + [epicPages], + ); + const epicsPagination: EpicsPagination = useMemo( + () => ({ + hasMore: !!hasMoreEpics, + isLoadingMore: isFetchingMoreEpics, + onLoadMore: () => void fetchNextEpicsPage(), + }), + [hasMoreEpics, isFetchingMoreEpics, fetchNextEpicsPage], + ); // Fetch parent task to display its title/icon (for non-epic parents) const { data: parentTask } = useQuery({ @@ -378,6 +397,7 @@ export function TaskDetailModal({ canEdit={canEdit} taskRole={taskRole} epicTasks={epicTasks} + epicsPagination={epicsPagination} parentTask={parentTask} onUpdate={handleUpdate} onNavigateToTask={navigateToTask} diff --git a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx index 3850980a..a7bfce84 100644 --- a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx @@ -28,6 +28,7 @@ import { PRIORITY_LEVELS, } from "../priority"; import { TaskTypeSelector } from "../task-type-selector"; +import type { EpicsPagination } from "../view-utils"; import { AddFieldDialog } from "./add-field-dialog"; import { FieldRow } from "./primitives"; import type { SelectOption, UserOption } from "./property-field"; @@ -69,6 +70,8 @@ interface PropertiesPanelProps { taskRole?: "epic" | "normal"; /** All epic tasks in the project, for the epic picker on normal tasks */ epicTasks?: Task[]; + /** Load-more state for the epic picker's paginated (20-per-page) query */ + epicsPagination?: EpicsPagination; /** The resolved parent task object (when parent_task_id is set) */ parentTask?: Task; onUpdate?: (payload: UpdatePayload) => void; @@ -99,6 +102,7 @@ export function PropertiesPanel({ canEdit = true, taskRole = "normal", epicTasks = [], + epicsPagination, parentTask, onUpdate, onNavigateToTask, @@ -367,7 +371,20 @@ export function PropertiesPanel({ {e.title} ))} - + {epicsPagination?.hasMore && ( + epicsPagination.onLoadMore()} + disabled={epicsPagination.isLoadingMore} + > + + {epicsPagination.isLoadingMore + ? t("taskDetail.properties.loadingEpics") + : t("taskDetail.properties.loadMoreEpics")} + + + )} + ); diff --git a/apps/web/src/components/projects/interactions/task-row.tsx b/apps/web/src/components/projects/interactions/task-row.tsx index dbfc2322..61fa17ec 100644 --- a/apps/web/src/components/projects/interactions/task-row.tsx +++ b/apps/web/src/components/projects/interactions/task-row.tsx @@ -31,7 +31,11 @@ import { PRIORITY_LEVELS, } from "./priority"; import { TaskTypeSelector } from "./task-type-selector"; -import { DEFAULT_VISIBLE_FIELDS, type TaskFieldUpdate } from "./view-utils"; +import { + DEFAULT_VISIBLE_FIELDS, + type EpicsPagination, + type TaskFieldUpdate, +} from "./view-utils"; // ── Column config ────────────────────────────────────────────────────────────── @@ -132,6 +136,9 @@ interface TaskRowProps { taskTypes: TaskType[]; members?: ProjectMember[]; epics?: Task[]; + /** Load-more state for the epic picker Popover — omitted when the caller + * doesn't paginate epics (e.g. fewer than one page's worth). */ + epicsPagination?: EpicsPagination; customFields?: CustomFieldDefinition[]; visibleFields?: string[]; onClick?: () => void; @@ -154,6 +161,7 @@ export function TaskRow({ taskTypes, members = [], epics = [], + epicsPagination, customFields = [], visibleFields = DEFAULT_VISIBLE_FIELDS, onClick, @@ -636,6 +644,18 @@ export function TaskRow({ )} ))} + {epicsPagination?.hasMore && ( + + )} diff --git a/apps/web/src/components/projects/interactions/view-utils.ts b/apps/web/src/components/projects/interactions/view-utils.ts index f8badc9a..cb90b00b 100644 --- a/apps/web/src/components/projects/interactions/view-utils.ts +++ b/apps/web/src/components/projects/interactions/view-utils.ts @@ -440,6 +440,17 @@ export function buildAllFieldOptions( // ── Task update payload builder for column drag ─────────────────────────────── +/** Load-more state/handler for an epic picker fed by a paginated (20-per-page) + * epic query. Threaded from the view/layout root, which owns the single + * `useInfiniteQuery`, down to whichever picker UI (Popover/DropdownMenu/ + * ContextMenu) is currently rendering the epic list, so every epic picker + * app-wide shares one "Load more" affordance instead of duplicating queries. */ +export interface EpicsPagination { + hasMore: boolean; + isLoadingMore: boolean; + onLoadMore: () => void; +} + export type TaskFieldUpdate = Partial<{ status_id: string | null; assignee_ids: string[]; diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 70ba71ef..816183d0 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -739,6 +739,8 @@ "epic": "Epic", "viewEpic": "View epic", "removeEpic": "Remove epic", + "loadMoreEpics": "Load more epics", + "loadingEpics": "Loading…", "parent": "Parent", "viewParent": "View parent", "removeParent": "Remove parent", diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index acec2538..2bf18b96 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -739,6 +739,8 @@ "epic": "Épica", "viewEpic": "Ver épica", "removeEpic": "Eliminar épica", + "loadMoreEpics": "Cargar más épicas", + "loadingEpics": "Cargando…", "parent": "Tarea principal", "viewParent": "Ver tarea principal", "removeParent": "Eliminar tarea principal", diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 95187b0a..828d4a38 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -739,6 +739,8 @@ "epic": "Epic", "viewEpic": "Voir l'epic", "removeEpic": "Retirer l'epic", + "loadMoreEpics": "Charger plus d'épopées", + "loadingEpics": "Chargement…", "parent": "Parent", "viewParent": "Voir le parent", "removeParent": "Retirer le parent", diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index 0ca2904b..a0543cf5 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -739,6 +739,8 @@ "epic": "エピック", "viewEpic": "エピックを表示", "removeEpic": "エピックを削除", + "loadMoreEpics": "エピックをさらに読み込む", + "loadingEpics": "読み込み中…", "parent": "親タスク", "viewParent": "親タスクを表示", "removeParent": "親タスクを削除", diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index c13548f2..5f8ccfda 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -739,6 +739,8 @@ "epic": "에픽", "viewEpic": "에픽 보기", "removeEpic": "에픽 제거", + "loadMoreEpics": "에픽 더 불러오기", + "loadingEpics": "로딩 중…", "parent": "상위 항목", "viewParent": "상위 항목 보기", "removeParent": "상위 항목 제거", diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index bb6d76a6..7b51ed88 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -739,6 +739,8 @@ "epic": "Épico", "viewEpic": "Ver épico", "removeEpic": "Remover épico", + "loadMoreEpics": "Carregar mais épicos", + "loadingEpics": "Carregando…", "parent": "Pai", "viewParent": "Ver pai", "removeParent": "Remover pai", diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index a6c8ea96..1fac48a2 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -753,6 +753,8 @@ "epic": "Эпик", "viewEpic": "Открыть эпик", "removeEpic": "Убрать эпик", + "loadMoreEpics": "Загрузить больше эпиков", + "loadingEpics": "Загрузка…", "parent": "Родительская", "viewParent": "Открыть родительскую", "removeParent": "Убрать родительскую", diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 4870c449..7ac0ad92 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -739,6 +739,8 @@ "epic": "Epic", "viewEpic": "Xem epic", "removeEpic": "Gỡ epic", + "loadMoreEpics": "Tải thêm epic", + "loadingEpics": "Đang tải…", "parent": "Nhiệm vụ cha", "viewParent": "Xem nhiệm vụ cha", "removeParent": "Gỡ nhiệm vụ cha", diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index f5ef8364..7e0e6907 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -739,6 +739,8 @@ "epic": "史诗", "viewEpic": "查看史诗", "removeEpic": "移除史诗", + "loadMoreEpics": "加载更多史诗任务", + "loadingEpics": "加载中…", "parent": "父任务", "viewParent": "查看父任务", "removeParent": "移除父任务", diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts index 79dc15e8..067963ac 100644 --- a/apps/web/src/lib/interaction-api.ts +++ b/apps/web/src/lib/interaction-api.ts @@ -755,22 +755,39 @@ export const sprintTasksQueryOptions = (projectId: string, sprintId: string) => staleTime: 15_000, }); -/** Fetches all tasks whose task_type is "Epic" for a project. */ +export const EPIC_TASKS_PAGE_SIZE = 20; + +/** Fetches one page of tasks whose task_type is "Epic" for a project. + * Cursor-paginated at EPIC_TASKS_PAGE_SIZE (well under the backend's 200 + * max page size) — callers that need every epic should page through with + * epicTasksInfiniteQueryOptions rather than requesting one giant page. */ export async function listEpicTasks( projectId: string, epicTypeId: string, -): Promise { - const result = await listAllTasks(projectId, { + opts: { cursor?: string } = {}, +): Promise { + return listAllTasks(projectId, { taskTypeIds: [epicTypeId], - pageSize: 500, + pageSize: EPIC_TASKS_PAGE_SIZE, + cursor: opts.cursor, }); - return result.items; } -export const epicTasksQueryOptions = (projectId: string, epicTypeId: string) => - queryOptions({ +/** Infinite-query version of the epic-task fetch — backs every epic picker + * (task detail Epic field, board/list-view quick-pick popovers, the + * right-click "Epic" submenu). Pages load 20 at a time via next_cursor; + * consumers flatten `data.pages` into a flat epic list and expose + * fetchNextPage/hasNextPage as "Load more" affordances in the picker UI. */ +export const epicTasksInfiniteQueryOptions = ( + projectId: string, + epicTypeId: string, +) => + infiniteQueryOptions({ queryKey: ["projects", projectId, "tasks", "epics", epicTypeId], - queryFn: () => listEpicTasks(projectId, epicTypeId), + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + listEpicTasks(projectId, epicTypeId, { cursor: pageParam }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, staleTime: 30_000, enabled: !!projectId && !!epicTypeId, }); From 751e99072715f70dc5c5b3f288ca96084a281359 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 27 Jul 2026 15:19:33 +0000 Subject: [PATCH 2/7] feat: implement epic search functionality in task management - Added a new `useEpicSearch` hook to handle live searching of epics. - Integrated epic search into task context menus, properties panel, and task rows. - Updated UI components to support searching and loading states for epics. - Removed unnecessary task data fetching in mention data hooks. - Enhanced internationalization for epic-related strings, including search placeholders and loading messages. - Implemented infinite scrolling for epic search results to improve user experience. --- .../components/projects/docs/doc-editor.tsx | 4 +- .../projects/interactions/task-card.tsx | 140 ++++++++++++------ .../interactions/task-context-menu.tsx | 73 +++++---- .../task-detail/description-section.tsx | 4 +- .../task-detail/properties-panel.tsx | 116 +++++++++++---- .../interactions/task-detail/task-header.tsx | 2 +- .../projects/interactions/task-row.tsx | 132 ++++++++++++----- .../projects/interactions/use-epic-search.ts | 102 +++++++++++++ .../projects/interactions/view-utils.ts | 21 +++ .../components/shared/comment-blocknote.tsx | 4 +- .../shared/mention-suggestion-menus.tsx | 19 ++- apps/web/src/i18n/locales/en/projects.json | 8 +- apps/web/src/i18n/locales/es/projects.json | 8 +- apps/web/src/i18n/locales/fr/projects.json | 8 +- apps/web/src/i18n/locales/ja/projects.json | 8 +- apps/web/src/i18n/locales/ko/projects.json | 8 +- apps/web/src/i18n/locales/pt-BR/projects.json | 8 +- apps/web/src/i18n/locales/ru/projects.json | 8 +- apps/web/src/i18n/locales/vi/projects.json | 8 +- apps/web/src/i18n/locales/zh-CN/projects.json | 8 +- apps/web/src/lib/interaction-api.ts | 21 +++ apps/web/src/lib/mention-api.ts | 42 +++--- 22 files changed, 556 insertions(+), 196 deletions(-) create mode 100644 apps/web/src/components/projects/interactions/use-epic-search.ts diff --git a/apps/web/src/components/projects/docs/doc-editor.tsx b/apps/web/src/components/projects/docs/doc-editor.tsx index 3fbcf1b2..1297b173 100644 --- a/apps/web/src/components/projects/docs/doc-editor.tsx +++ b/apps/web/src/components/projects/docs/doc-editor.tsx @@ -48,7 +48,7 @@ export const DocEditor = forwardRef( ref, ) { const { resolvedMode } = useThemeMode(); - const { teamMembers, tasks, documents } = useMentionData(projectId); + const { teamMembers, documents } = useMentionData(projectId); const lastSavedRef = useRef(null); const initializedRef = useRef(false); @@ -188,7 +188,7 @@ export const DocEditor = forwardRef( )} diff --git a/apps/web/src/components/projects/interactions/task-card.tsx b/apps/web/src/components/projects/interactions/task-card.tsx index 6b275ef0..ee8d4fb5 100644 --- a/apps/web/src/components/projects/interactions/task-card.tsx +++ b/apps/web/src/components/projects/interactions/task-card.tsx @@ -1,4 +1,12 @@ -import { Check, GripVertical, Layers, Link, User } from "lucide-react"; +import { + Check, + GripVertical, + Layers, + Link, + Loader2, + Search, + User, +} from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -16,11 +24,12 @@ import { } from "@/components/ui/popover"; import { formatDate } from "@/lib/format-date"; import type { Task } from "@/lib/interaction-api"; -import type { - CustomFieldDefinition, - ProjectMember, - TaskStatus, - TaskType, +import { + type CustomFieldDefinition, + findEpicType, + type ProjectMember, + type TaskStatus, + type TaskType, } from "@/lib/project-api"; import { useHoveredTaskStore } from "@/lib/shortcuts/hovered-task-store"; import { cn } from "@/lib/utils"; @@ -30,7 +39,9 @@ import { IMPORTANCE_BUCKET_VALUES, PRIORITY_LEVELS, } from "./priority"; +import { useEpicSearch } from "./use-epic-search"; import { + createEpicScrollHandler, DEFAULT_VISIBLE_FIELDS, type EpicsPagination, type TaskFieldUpdate, @@ -93,6 +104,15 @@ export function TaskCard({ const [isHovered, setIsHovered] = useState(false); const [typePopoverOpen, setTypePopoverOpen] = useState(false); const [epicOpen, setEpicOpen] = useState(false); + const epicTypeId = findEpicType(taskTypes)?.id; + const { + search: epicSearch, + setSearch: setEpicSearch, + isSearching: isEpicSearching, + results: epicSearchResults, + isLoading: epicSearchLoading, + pagination: epicSearchPagination, + } = useEpicSearch(task.project_id, epicTypeId, epicOpen); const taskType = taskTypes.find((t) => t.id === task.task_type_id); const status = statuses.find((s) => s.id === task.status_id); @@ -501,6 +521,10 @@ export function TaskCard({ const epic = task.parent_task_id ? epics.find((e) => e.id === task.parent_task_id) : undefined; + const displayedEpics = isEpicSearching ? epicSearchResults : epics; + const activeEpicsPagination = isEpicSearching + ? epicSearchPagination + : epicsPagination; return canEdit ? ( - - {epics.map((e) => ( - ))} - {epicsPagination?.hasMore && ( - - )} + {isEpicSearching && epicSearchLoading ? ( +
+ +
+ ) : ( + <> + {displayedEpics.map((e) => ( + + ))} + {displayedEpics.length === 0 && ( +

+ {isEpicSearching + ? t("epicPicker.noEpicsFound") + : t("epicPicker.noEpicsYet")} +

+ )} + + )} + {activeEpicsPagination?.isLoadingMore && ( +
+ + {t("epicPicker.loadingMore")} +
+ )} +
) : epic ? ( diff --git a/apps/web/src/components/projects/interactions/task-context-menu.tsx b/apps/web/src/components/projects/interactions/task-context-menu.tsx index 85d3fd81..173d4346 100644 --- a/apps/web/src/components/projects/interactions/task-context-menu.tsx +++ b/apps/web/src/components/projects/interactions/task-context-menu.tsx @@ -1,4 +1,12 @@ -import { Check, Copy, Layers, Link2, Trash2, User } from "lucide-react"; +import { + Check, + Copy, + Layers, + Link2, + Loader2, + Trash2, + User, +} from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -24,10 +32,11 @@ import { IMPORTANCE_BUCKET_VALUES, PRIORITY_LEVELS, } from "./priority"; -import type { - ColumnGroupDef, - EpicsPagination, - TaskFieldUpdate, +import { + type ColumnGroupDef, + createEpicScrollHandler, + type EpicsPagination, + type TaskFieldUpdate, } from "./view-utils"; const shortcutChord = (id: string) => @@ -285,34 +294,34 @@ export function TaskContextMenu({ )} - {epics.map((e) => ( - onUpdate?.(task.id, { parent_task_id: e.id })} - > - - {e.title} - {e.id === task.parent_task_id && ( - - )} - - ))} - {epicsPagination?.hasMore && ( - epicsPagination.onLoadMore()} - disabled={epicsPagination.isLoadingMore} +
- - {epicsPagination.isLoadingMore - ? t("board.taskContextMenu.loadingEpics") - : t("board.taskContextMenu.loadMoreEpics")} - - - )} - - - )} + {epics.map((e) => ( + + onUpdate?.(task.id, { parent_task_id: e.id }) + } + > + + {e.title} + {e.id === task.parent_task_id && ( + + )} + + ))} + {epicsPagination?.isLoadingMore && ( +
+ + {t("epicPicker.loadingMore")} +
+ )} +
+ + + )} diff --git a/apps/web/src/components/projects/interactions/task-detail/description-section.tsx b/apps/web/src/components/projects/interactions/task-detail/description-section.tsx index 54c346ea..e9f6509f 100644 --- a/apps/web/src/components/projects/interactions/task-detail/description-section.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/description-section.tsx @@ -54,7 +54,7 @@ export function DescriptionSection({ }: DescriptionSectionProps) { const { t } = useTranslation("projects"); const { resolvedMode } = useThemeMode(); - const { teamMembers, tasks, documents } = useMentionData(projectId); + const { teamMembers, documents } = useMentionData(projectId); const qc = useQueryClient(); const [writeWithAIOpen, setWriteWithAIOpen] = useState(false); const [selectedAgentId, setSelectedAgentId] = useState(null); @@ -253,7 +253,7 @@ export function DescriptionSection({ )} diff --git a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx index a7bfce84..3bc39500 100644 --- a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx @@ -5,7 +5,9 @@ import { ExternalLink, KanbanSquare, Layers, + Loader2, Plus, + Search, X, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -18,7 +20,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { Sprint, Task } from "@/lib/interaction-api"; -import type { ProjectMember, TaskStatus, TaskType } from "@/lib/project-api"; +import { + findEpicType, + type ProjectMember, + type TaskStatus, + type TaskType, +} from "@/lib/project-api"; import { getTaskTypeIconComponent } from "../../task-types/task-type-icons"; import type { PriorityMeta } from "../priority"; import { @@ -28,7 +35,8 @@ import { PRIORITY_LEVELS, } from "../priority"; import { TaskTypeSelector } from "../task-type-selector"; -import type { EpicsPagination } from "../view-utils"; +import { useEpicSearch } from "../use-epic-search"; +import { createEpicScrollHandler, type EpicsPagination } from "../view-utils"; import { AddFieldDialog } from "./add-field-dialog"; import { FieldRow } from "./primitives"; import type { SelectOption, UserOption } from "./property-field"; @@ -111,6 +119,16 @@ export function PropertiesPanel({ const [localCustomFields, setLocalCustomFields] = useState(initialCustomFields); const [addFieldOpen, setAddFieldOpen] = useState(false); + const [epicMenuOpen, setEpicMenuOpen] = useState(false); + const epicTypeId = findEpicType(taskTypes)?.id; + const { + search: epicSearch, + setSearch: setEpicSearch, + isSearching: isEpicSearching, + results: epicSearchResults, + isLoading: epicSearchLoading, + pagination: epicSearchPagination, + } = useEpicSearch(task.project_id, epicTypeId, epicMenuOpen); useEffect(() => { setLocalCustomFields(initialCustomFields); @@ -322,11 +340,20 @@ export function PropertiesPanel({ const otherEpics = epicTasks.filter( (e) => e.id !== task.parent_task_id, ); + const displayedEpics = isEpicSearching + ? epicSearchResults.filter((e) => e.id !== task.parent_task_id) + : otherEpics; + const activeEpicsPagination = isEpicSearching + ? epicSearchPagination + : epicsPagination; const hasActions = (epic && onNavigateToTask) || (!!task.parent_task_id && canEdit); return ( - + {epic ? ( <> @@ -359,32 +386,63 @@ export function PropertiesPanel({ {t("taskDetail.properties.removeEpic")} )} - {hasActions && otherEpics.length > 0 && ( - - )} - {otherEpics.map((e) => ( - onUpdate?.({ parent_task_id: e.id })} - > - - {e.title} - - ))} - {epicsPagination?.hasMore && ( - epicsPagination.onLoadMore()} - disabled={epicsPagination.isLoadingMore} - > - - {epicsPagination.isLoadingMore - ? t("taskDetail.properties.loadingEpics") - : t("taskDetail.properties.loadMoreEpics")} - - - )} - + {hasActions && } +
+
+ + setEpicSearch(e.target.value)} + onKeyDown={(e) => { + // Let Escape still close the menu; swallow everything + // else so typing doesn't trigger the menu's own arrow-key + // navigation / type-ahead item selection. + if (e.key !== "Escape") e.stopPropagation(); + }} + placeholder={t("epicPicker.searchPlaceholder")} + className="w-full rounded-lg border border-border/30 bg-muted/25 py-1.5 pr-2 pl-8 text-sm placeholder:text-muted-foreground/50 transition-all duration-150 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
+
+
+ {isEpicSearching && epicSearchLoading ? ( +
+ +
+ ) : ( + <> + {displayedEpics.map((e) => ( + + onUpdate?.({ parent_task_id: e.id }) + } + > + + {e.title} + + ))} + {displayedEpics.length === 0 && ( +

+ {isEpicSearching + ? t("epicPicker.noEpicsFound") + : t("epicPicker.noEpicsYet")} +

+ )} + + )} + {activeEpicsPagination?.isLoadingMore && ( +
+ + {t("epicPicker.loadingMore")} +
+ )} +
+
); diff --git a/apps/web/src/components/projects/interactions/task-detail/task-header.tsx b/apps/web/src/components/projects/interactions/task-detail/task-header.tsx index 1f9893b3..6ce78b78 100644 --- a/apps/web/src/components/projects/interactions/task-detail/task-header.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/task-header.tsx @@ -91,7 +91,7 @@ export function TaskHeader({ }; return ( -
+
{/* Breadcrumb (page mode only) */} {mode === "page" && projectName && (
diff --git a/apps/web/src/components/projects/interactions/use-epic-search.ts b/apps/web/src/components/projects/interactions/use-epic-search.ts new file mode 100644 index 00000000..df979003 --- /dev/null +++ b/apps/web/src/components/projects/interactions/use-epic-search.ts @@ -0,0 +1,102 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { searchEpicTasks, type Task } from "@/lib/interaction-api"; +import type { EpicsPagination } from "./view-utils"; + +const SEARCH_DEBOUNCE_MS = 300; + +interface UseEpicSearchResult { + search: string; + setSearch: (value: string) => void; + /** True once the user has typed a non-empty query — callers should swap + * in `results` for their normal paginated epic list while this holds. */ + isSearching: boolean; + /** Server-searched matches; only meaningful while isSearching is true. */ + results: Task[]; + /** True while a debounce or in-flight initial fetch means `results` may + * be stale/incomplete — callers should show a full-list loading state. */ + isLoading: boolean; + /** Load-more state for the search results, in the same shape as the + * picker's normal (non-search) epicsPagination prop, so callers can + * swap between the two without branching on structure. */ + pagination: EpicsPagination; +} + +/** Backs an epic picker's search input with a debounced, cursor-paginated + * server-side search instead of filtering whatever page of epics the + * picker happens to have preloaded — a project can (and in practice does) + * have far more epics than are paginated into memory at once, so + * client-side filtering would silently miss most of them. `enabled` should + * track the picker's open state so the query only fires while it's visible + * and search state resets the moment it closes. */ +export function useEpicSearch( + projectId: string | undefined, + epicTypeId: string | undefined, + enabled: boolean, +): UseEpicSearchResult { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const applyDebounced = useDebouncedCallback( + setDebouncedSearch, + SEARCH_DEBOUNCE_MS, + ); + + const updateSearch = (value: string) => { + setSearch(value); + applyDebounced(value.trim()); + }; + + useEffect(() => { + if (!enabled) { + setSearch(""); + setDebouncedSearch(""); + } + }, [enabled]); + + const isSearching = search.trim().length > 0; + + const { data, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } = + useInfiniteQuery({ + queryKey: [ + "projects", + projectId, + "tasks", + "epics", + epicTypeId, + "search", + debouncedSearch, + ], + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + searchEpicTasks(projectId ?? "", epicTypeId ?? "", debouncedSearch, { + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + enabled: + enabled && !!projectId && !!epicTypeId && debouncedSearch.length > 0, + staleTime: 15_000, + }); + + // Debounce is still pending (input ahead of what's been queried) or the + // initial page for this query text hasn't landed yet — as opposed to + // isFetchingNextPage, which covers subsequent pages and shouldn't block + // the whole list. + const isInitialFetch = isFetching && !isFetchingNextPage; + const debouncePending = search.trim() !== debouncedSearch; + + return { + search, + setSearch: updateSearch, + isSearching, + results: isSearching + ? (data?.pages.flatMap((page) => page.items) ?? []) + : [], + isLoading: isSearching && (debouncePending || isInitialFetch), + pagination: { + hasMore: isSearching && !!hasNextPage, + isLoadingMore: isFetchingNextPage, + onLoadMore: () => void fetchNextPage(), + }, + }; +} diff --git a/apps/web/src/components/projects/interactions/view-utils.ts b/apps/web/src/components/projects/interactions/view-utils.ts index cb90b00b..d74e8363 100644 --- a/apps/web/src/components/projects/interactions/view-utils.ts +++ b/apps/web/src/components/projects/interactions/view-utils.ts @@ -1,4 +1,5 @@ import type { TFunction } from "i18next"; +import type { UIEvent } from "react"; import { type FilterConfig, resolveFilterConfig, @@ -451,6 +452,26 @@ export interface EpicsPagination { onLoadMore: () => void; } +const EPIC_SCROLL_LOAD_THRESHOLD_PX = 48; + +/** Builds an onScroll handler that fires `pagination.onLoadMore()` once the + * epic picker's list is scrolled within EPIC_SCROLL_LOAD_THRESHOLD_PX of its + * bottom, instead of requiring an explicit "Load more" click. */ +export function createEpicScrollHandler( + pagination: EpicsPagination | undefined, +): (e: UIEvent) => void { + return (e) => { + if (!pagination?.hasMore || pagination.isLoadingMore) return; + const el = e.currentTarget; + if ( + el.scrollHeight - el.scrollTop - el.clientHeight < + EPIC_SCROLL_LOAD_THRESHOLD_PX + ) { + pagination.onLoadMore(); + } + }; +} + export type TaskFieldUpdate = Partial<{ status_id: string | null; assignee_ids: string[]; diff --git a/apps/web/src/components/shared/comment-blocknote.tsx b/apps/web/src/components/shared/comment-blocknote.tsx index 04f6bddf..638327a2 100644 --- a/apps/web/src/components/shared/comment-blocknote.tsx +++ b/apps/web/src/components/shared/comment-blocknote.tsx @@ -29,7 +29,7 @@ export const CommentEditor = forwardRef< >(function CommentEditor({ initialBlocks, onSubmit, projectId }, ref) { const { resolvedMode } = useThemeMode(); const initializedRef = useRef(false); - const { teamMembers, tasks, documents } = useMentionData(projectId); + const { teamMembers, documents } = useMentionData(projectId); const editor = useCreateBlockNote({ schema: customSchema, @@ -82,7 +82,7 @@ export const CommentEditor = forwardRef< diff --git a/apps/web/src/components/shared/mention-suggestion-menus.tsx b/apps/web/src/components/shared/mention-suggestion-menus.tsx index de04dc4f..2d424adb 100644 --- a/apps/web/src/components/shared/mention-suggestion-menus.tsx +++ b/apps/web/src/components/shared/mention-suggestion-menus.tsx @@ -4,6 +4,7 @@ import { type DefaultReactSuggestionItem, SuggestionMenuController, } from "@blocknote/react"; +import { searchMentionableTasks } from "@/lib/mention-api"; import type { customSchema } from "./blocknote-schema"; interface MentionSuggestionMenuProps { @@ -18,14 +19,17 @@ interface MentionSuggestionMenuProps { username: string; avatar?: string | null | undefined; }>; - tasks: Array<{ id: string; title: string; task_number: number }>; + /** Needed to search tasks live as the user types after "#" — the project's + * task list has no fixed upper bound, so it's queried on demand instead + * of being preloaded in full (see mention-api.ts). */ + projectId?: string | null; documents: Array<{ id: string; title: string }>; } export function MentionSuggestionMenus({ editor, teamMembers, - tasks, + projectId, documents, }: MentionSuggestionMenuProps) { const getTeamMentionItems = (): DefaultReactSuggestionItem[] => { @@ -48,7 +52,11 @@ export function MentionSuggestionMenus({ })); }; - const getTaskReferenceItems = (): DefaultReactSuggestionItem[] => { + const getTaskReferenceItems = async ( + query: string, + ): Promise => { + if (!projectId) return []; + const tasks = await searchMentionableTasks(projectId, query); return tasks.map((task) => ({ title: task.title, subtext: `#${task.task_number}`, @@ -98,7 +106,10 @@ export function MentionSuggestionMenus({ [ - ...filterSuggestionItems(getTaskReferenceItems(), query), + // Tasks are already filtered server-side by `query`; re-running + // filterSuggestionItems here would incorrectly drop matches on + // the "#" subtext for non-title search terms. + ...(await getTaskReferenceItems(query)), ...filterSuggestionItems(getDocReferenceItems(), query), ]} /> diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 816183d0..968797bc 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -739,8 +739,6 @@ "epic": "Epic", "viewEpic": "View epic", "removeEpic": "Remove epic", - "loadMoreEpics": "Load more epics", - "loadingEpics": "Loading…", "parent": "Parent", "viewParent": "View parent", "removeParent": "Remove parent", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "Description change diff" } }, + "epicPicker": { + "searchPlaceholder": "Search epics…", + "noEpicsFound": "No matching epics", + "noEpicsYet": "No epics in this project yet", + "loadingMore": "Loading…" + }, "board": { "common": { "none": "None" diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index 2bf18b96..7b730c08 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -739,8 +739,6 @@ "epic": "Épica", "viewEpic": "Ver épica", "removeEpic": "Eliminar épica", - "loadMoreEpics": "Cargar más épicas", - "loadingEpics": "Cargando…", "parent": "Tarea principal", "viewParent": "Ver tarea principal", "removeParent": "Eliminar tarea principal", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "Diferencias del cambio de descripción" } }, + "epicPicker": { + "searchPlaceholder": "Buscar épicas…", + "noEpicsFound": "No se encontraron épicas", + "noEpicsYet": "Aún no hay épicas en este proyecto", + "loadingMore": "Cargando…" + }, "board": { "common": { "none": "Ninguno" diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 828d4a38..6c78dcbd 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -739,8 +739,6 @@ "epic": "Epic", "viewEpic": "Voir l'epic", "removeEpic": "Retirer l'epic", - "loadMoreEpics": "Charger plus d'épopées", - "loadingEpics": "Chargement…", "parent": "Parent", "viewParent": "Voir le parent", "removeParent": "Retirer le parent", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "Différences des modifications de la description" } }, + "epicPicker": { + "searchPlaceholder": "Rechercher des epics…", + "noEpicsFound": "Aucun epic correspondant", + "noEpicsYet": "Aucun epic dans ce projet pour l'instant", + "loadingMore": "Chargement…" + }, "board": { "common": { "none": "Aucun" diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index a0543cf5..097187b6 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -739,8 +739,6 @@ "epic": "エピック", "viewEpic": "エピックを表示", "removeEpic": "エピックを削除", - "loadMoreEpics": "エピックをさらに読み込む", - "loadingEpics": "読み込み中…", "parent": "親タスク", "viewParent": "親タスクを表示", "removeParent": "親タスクを削除", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "説明の変更差分" } }, + "epicPicker": { + "searchPlaceholder": "エピックを検索…", + "noEpicsFound": "一致するエピックがありません", + "noEpicsYet": "このプロジェクトにはまだエピックがありません", + "loadingMore": "読み込み中…" + }, "board": { "common": { "none": "なし" diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index 5f8ccfda..f78f2524 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -739,8 +739,6 @@ "epic": "에픽", "viewEpic": "에픽 보기", "removeEpic": "에픽 제거", - "loadMoreEpics": "에픽 더 불러오기", - "loadingEpics": "로딩 중…", "parent": "상위 항목", "viewParent": "상위 항목 보기", "removeParent": "상위 항목 제거", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "설명 변경 차이" } }, + "epicPicker": { + "searchPlaceholder": "에픽 검색…", + "noEpicsFound": "일치하는 에픽이 없습니다", + "noEpicsYet": "이 프로젝트에는 아직 에픽이 없습니다", + "loadingMore": "불러오는 중…" + }, "board": { "common": { "none": "없음" diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index 7b51ed88..3ce6d83a 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -739,8 +739,6 @@ "epic": "Épico", "viewEpic": "Ver épico", "removeEpic": "Remover épico", - "loadMoreEpics": "Carregar mais épicos", - "loadingEpics": "Carregando…", "parent": "Pai", "viewParent": "Ver pai", "removeParent": "Remover pai", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "Diferença de alteração da descrição" } }, + "epicPicker": { + "searchPlaceholder": "Buscar épicos…", + "noEpicsFound": "Nenhum épico encontrado", + "noEpicsYet": "Ainda não há épicos neste projeto", + "loadingMore": "Carregando…" + }, "board": { "common": { "none": "Nenhum" diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index 1fac48a2..39fa9164 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -753,8 +753,6 @@ "epic": "Эпик", "viewEpic": "Открыть эпик", "removeEpic": "Убрать эпик", - "loadMoreEpics": "Загрузить больше эпиков", - "loadingEpics": "Загрузка…", "parent": "Родительская", "viewParent": "Открыть родительскую", "removeParent": "Убрать родительскую", @@ -930,6 +928,12 @@ "descriptionChangeDiff": "Diff изменения описания" } }, + "epicPicker": { + "searchPlaceholder": "Поиск эпиков…", + "noEpicsFound": "Эпики не найдены", + "noEpicsYet": "В этом проекте пока нет эпиков", + "loadingMore": "Загрузка…" + }, "board": { "common": { "none": "Нет" diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 7ac0ad92..6d1435fb 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -739,8 +739,6 @@ "epic": "Epic", "viewEpic": "Xem epic", "removeEpic": "Gỡ epic", - "loadMoreEpics": "Tải thêm epic", - "loadingEpics": "Đang tải…", "parent": "Nhiệm vụ cha", "viewParent": "Xem nhiệm vụ cha", "removeParent": "Gỡ nhiệm vụ cha", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "Thay đổi mô tả" } }, + "epicPicker": { + "searchPlaceholder": "Tìm kiếm epic…", + "noEpicsFound": "Không tìm thấy epic phù hợp", + "noEpicsYet": "Dự án này chưa có epic nào", + "loadingMore": "Đang tải…" + }, "board": { "common": { "none": "Không có" diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 7e0e6907..40ed5e54 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -739,8 +739,6 @@ "epic": "史诗", "viewEpic": "查看史诗", "removeEpic": "移除史诗", - "loadMoreEpics": "加载更多史诗任务", - "loadingEpics": "加载中…", "parent": "父任务", "viewParent": "查看父任务", "removeParent": "移除父任务", @@ -914,6 +912,12 @@ "descriptionChangeDiff": "描述更改差异" } }, + "epicPicker": { + "searchPlaceholder": "搜索史诗…", + "noEpicsFound": "未找到匹配的史诗", + "noEpicsYet": "该项目暂无史诗", + "loadingMore": "加载中…" + }, "board": { "common": { "none": "无" diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts index 067963ac..92fe6d34 100644 --- a/apps/web/src/lib/interaction-api.ts +++ b/apps/web/src/lib/interaction-api.ts @@ -792,6 +792,27 @@ export const epicTasksInfiniteQueryOptions = ( enabled: !!projectId && !!epicTypeId, }); +/** Cursor-paginated search backing the epic picker's search box — queried + * directly (outside the shared useInfiniteQuery above) against the same + * `search` param used elsewhere for task lookup, since a project can have + * far more epics than the picker keeps paginated locally and filtering + * only the loaded page would silently miss most of them. Callers debounce + * input before calling this, and page through matches the same way + * listEpicTasks does. */ +export async function searchEpicTasks( + projectId: string, + epicTypeId: string, + query: string, + opts: { cursor?: string } = {}, +): Promise { + return listAllTasks(projectId, { + taskTypeIds: [epicTypeId], + search: query, + pageSize: EPIC_TASKS_PAGE_SIZE, + cursor: opts.cursor, + }); +} + /** Fetches child tasks of an epic (tasks with parent_task_id = epicId). */ export const epicChildTasksQueryOptions = (projectId: string, epicId: string) => queryOptions({ diff --git a/apps/web/src/lib/mention-api.ts b/apps/web/src/lib/mention-api.ts index 3b60d687..875c0996 100644 --- a/apps/web/src/lib/mention-api.ts +++ b/apps/web/src/lib/mention-api.ts @@ -24,7 +24,6 @@ export interface MentionableDocument { export interface MentionData { teamMembers: TeamMember[]; - tasks: MentionableTask[]; documents: MentionableDocument[]; } @@ -34,11 +33,28 @@ const teamMembersQueryOptions = (projectId: string) => ({ staleTime: 5 * 60 * 1000, }); -const tasksQueryOptions = (projectId: string) => ({ - queryKey: ["projects", projectId, "tasks", "mentions"], - queryFn: () => listAllTasks(projectId, { pageSize: 500 }), - staleTime: 2 * 60 * 1000, -}); +export const MENTIONABLE_TASKS_PAGE_SIZE = 20; + +/** Live server-side search backing the "#" task-reference mention menu. + * Queried fresh for whatever the user has typed after "#" — the project's + * task list has no fixed upper bound and the backend caps page_size at 200, + * so unlike team members/docs this can't just be preloaded once and + * filtered client-side without silently hiding tasks past the first page. */ +export async function searchMentionableTasks( + projectId: string, + query: string, +): Promise { + const result = await listAllTasks(projectId, { + search: query, + pageSize: MENTIONABLE_TASKS_PAGE_SIZE, + }); + return result.items.map((task) => ({ + id: task.id, + title: task.title, + task_number: task.task_number, + status: task.status_id || undefined, + })); +} const documentsQueryOptions = (projectId: string) => ({ queryKey: ["projects", projectId, "docs", "mentions"], @@ -52,11 +68,6 @@ export function useMentionData(projectId?: string | null) { enabled: !!projectId, }); - const { data: tasksResult } = useQuery({ - ...tasksQueryOptions(projectId ?? ""), - enabled: !!projectId, - }); - const { data: documents = [] } = useQuery({ ...documentsQueryOptions(projectId ?? ""), enabled: !!projectId, @@ -72,14 +83,6 @@ export function useMentionData(projectId?: string | null) { avatar: member.full_name.slice(0, 2).toUpperCase() || undefined, })); - const tasks: MentionableTask[] = - tasksResult?.items.map((task) => ({ - id: task.id, - title: task.title, - task_number: task.task_number, - status: task.status_id || undefined, - })) ?? []; - const mentionDocs: MentionableDocument[] = documents.map((doc) => ({ id: doc.id, title: doc.title, @@ -87,7 +90,6 @@ export function useMentionData(projectId?: string | null) { return { teamMembers, - tasks, documents: mentionDocs, isLoading: !projectId, }; From 44ffbf572b378468d478bd407e7e8f0435eceaa4 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 27 Jul 2026 15:25:08 +0000 Subject: [PATCH 3/7] fix: fix lint --- .../components/projects/interactions/task-detail/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/projects/interactions/task-detail/index.tsx b/apps/web/src/components/projects/interactions/task-detail/index.tsx index 853c09c5..f34351bd 100644 --- a/apps/web/src/components/projects/interactions/task-detail/index.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/index.tsx @@ -1,4 +1,9 @@ -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { AlertCircle } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; From 6c6eb04ce37c8dff71f9137c7bceff9c85e1f447 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 27 Jul 2026 15:40:11 +0000 Subject: [PATCH 4/7] feat: add loading more message for user selection in team member dialog --- apps/web/src/i18n/locales/en/projects.json | 1 + apps/web/src/i18n/locales/es/projects.json | 1 + apps/web/src/i18n/locales/fr/projects.json | 1 + apps/web/src/i18n/locales/ja/projects.json | 1 + apps/web/src/i18n/locales/ko/projects.json | 1 + apps/web/src/i18n/locales/pt-BR/projects.json | 1 + apps/web/src/i18n/locales/ru/projects.json | 1 + apps/web/src/i18n/locales/vi/projects.json | 1 + apps/web/src/i18n/locales/zh-CN/projects.json | 1 + apps/web/src/lib/admin-api.ts | 21 +++++++- .../projects/$projectId/team/index.tsx | 48 +++++++++++++++---- 11 files changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 968797bc..29523b84 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "Search by name or username…", "noUsersMatch": "No users match your search.", "noUsersAvailable": "No users available to add.", + "loadingMore": "Loading…", "roleLabel": "Role", "rolePlaceholder": "Select a role…", "cancel": "Cancel", diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index 7b730c08..9484261f 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "Buscar por nombre o nombre de usuario…", "noUsersMatch": "Ningún usuario coincide con tu búsqueda.", "noUsersAvailable": "No hay usuarios disponibles para añadir.", + "loadingMore": "Cargando…", "roleLabel": "Rol", "rolePlaceholder": "Selecciona un rol…", "cancel": "Cancelar", diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 6c78dcbd..ee6759d8 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "Rechercher par nom ou nom d'utilisateur…", "noUsersMatch": "Aucun utilisateur ne correspond à votre recherche.", "noUsersAvailable": "Aucun utilisateur disponible à ajouter.", + "loadingMore": "Chargement…", "roleLabel": "Rôle", "rolePlaceholder": "Sélectionner un rôle…", "cancel": "Annuler", diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index 097187b6..0400f6c3 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "名前またはユーザー名で検索…", "noUsersMatch": "検索条件に一致するユーザーがいません。", "noUsersAvailable": "追加できるユーザーがいません。", + "loadingMore": "読み込み中…", "roleLabel": "ロール", "rolePlaceholder": "ロールを選択…", "cancel": "キャンセル", diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index f78f2524..4d910287 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "이름 또는 사용자 이름으로 검색…", "noUsersMatch": "검색과 일치하는 사용자가 없습니다.", "noUsersAvailable": "추가할 수 있는 사용자가 없습니다.", + "loadingMore": "불러오는 중…", "roleLabel": "역할", "rolePlaceholder": "역할을 선택하세요…", "cancel": "취소", diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index 3ce6d83a..bc1adf3c 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "Buscar por nome ou usuário…", "noUsersMatch": "Nenhum usuário corresponde à sua busca.", "noUsersAvailable": "Nenhum usuário disponível para adicionar.", + "loadingMore": "Carregando…", "roleLabel": "Papel", "rolePlaceholder": "Selecione um papel…", "cancel": "Cancelar", diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index 39fa9164..ce968c53 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -645,6 +645,7 @@ "searchPlaceholder": "Поиск по имени или username…", "noUsersMatch": "Пользователи по запросу не найдены.", "noUsersAvailable": "Нет пользователей, которых можно добавить.", + "loadingMore": "Загрузка…", "roleLabel": "Роль", "rolePlaceholder": "Выберите роль…", "cancel": "Отмена", diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 6d1435fb..2f54d87b 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "Tìm theo tên hoặc tên đăng nhập…", "noUsersMatch": "Không có người dùng nào khớp với tìm kiếm của bạn.", "noUsersAvailable": "Không có người dùng nào để thêm.", + "loadingMore": "Đang tải…", "roleLabel": "Vai trò", "rolePlaceholder": "Chọn một vai trò…", "cancel": "Hủy", diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 40ed5e54..b95b6539 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -631,6 +631,7 @@ "searchPlaceholder": "按姓名或用户名搜索…", "noUsersMatch": "没有符合搜索条件的用户。", "noUsersAvailable": "没有可添加的用户。", + "loadingMore": "加载中…", "roleLabel": "角色", "rolePlaceholder": "选择一个角色…", "cancel": "取消", diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts index 569d2f57..a05848a1 100644 --- a/apps/web/src/lib/admin-api.ts +++ b/apps/web/src/lib/admin-api.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import { apiClient } from "./api-client"; import type { SuccessEnvelope } from "./api-error"; @@ -136,3 +136,22 @@ export function usersQueryOptions(page = 1, pageSize = 20) { queryFn: () => getUsers(page, pageSize), }); } + +export const ADMIN_USERS_PAGE_SIZE = 20; + +/** Infinite-query version of the user list — backs pickers that need to + * page through every user (e.g. the "add team member" dialog), since the + * backend caps page_size at 100 and there's no server-side search to + * narrow the result set. Pages accumulate as the caller scrolls, same + * pattern as the epic picker's infinite query. */ +export const usersInfiniteQueryOptions = () => + infiniteQueryOptions({ + queryKey: ["admin", "users", "all"], + queryFn: ({ pageParam }: { pageParam: number }) => + getUsers(pageParam, ADMIN_USERS_PAGE_SIZE), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page * lastPage.page_size < lastPage.total + ? lastPage.page + 1 + : undefined, + }); diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx index cae589b5..6eb733da 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { Bot, @@ -13,7 +18,7 @@ import { UserRound, Users, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { type UIEvent, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -46,7 +51,7 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { usePermissions } from "@/hooks/use-permissions"; -import { getUsers, type User } from "@/lib/admin-api"; +import { type User, usersInfiniteQueryOptions } from "@/lib/admin-api"; import { currentUserQueryOptions } from "@/lib/auth-api"; import { addProjectMember, @@ -140,14 +145,32 @@ function AddMemberDialog({ const searchRef = useRef(null); const canReadUsers = can("users.read"); - const { data: usersData, isLoading: isLoadingUsers } = useQuery({ - queryKey: ["admin", "users", "all"], - queryFn: () => getUsers(1, 500), + const { + data: usersPages, + isLoading: isLoadingUsers, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + ...usersInfiniteQueryOptions(), enabled: open && canReadUsers, }); + const usersData = useMemo( + () => usersPages?.pages.flatMap((page) => page.items) ?? [], + [usersPages], + ); + + function handleUsersListScroll(e: UIEvent) { + if (!hasNextPage || isFetchingNextPage) return; + const el = e.currentTarget; + if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) { + fetchNextPage(); + } + } + const filteredUsers = useMemo(() => { - const items: User[] = usersData?.items ?? []; + const items: User[] = usersData; const q = userSearch.toLowerCase(); return items .filter((u) => !existingMemberIds.has(u.id)) @@ -262,7 +285,10 @@ function AddMemberDialog({ autoFocus />
-
+
{isLoadingUsers ? (
@@ -283,6 +309,12 @@ function AddMemberDialog({ /> )) )} + {isFetchingNextPage && ( +
+ + {t("team.addMemberDialog.loadingMore")} +
+ )}
)} From b7525d8b9b5039471edc1ee097e1791dafa6fbc5 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 27 Jul 2026 15:46:37 +0000 Subject: [PATCH 5/7] feat: wrap TaskCard tests with QueryClientProvider for epic-picker functionality --- .../projects/interactions/task-card.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/web/src/components/projects/interactions/task-card.test.tsx b/apps/web/src/components/projects/interactions/task-card.test.tsx index 7d2ce84a..80dadd99 100644 --- a/apps/web/src/components/projects/interactions/task-card.test.tsx +++ b/apps/web/src/components/projects/interactions/task-card.test.tsx @@ -1,9 +1,21 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { Task } from "@/lib/interaction-api"; import type { TaskStatus, TaskType } from "@/lib/project-api"; import { TaskCard } from "./task-card"; +// TaskCard's epic-picker field now calls useEpicSearch (useInfiniteQuery) +// unconditionally, so it needs a QueryClientProvider ancestor even though the +// query stays disabled (epicOpen is never toggled true) in these tests. +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {children}; +} + // ── Fixtures ────────────────────────────────────────────────────────────────── const makeTask = (overrides: Partial = {}): Task => ({ @@ -51,6 +63,7 @@ describe("TaskCard", () => { statuses={NO_STATUSES} taskTypes={NO_TYPES} />, + { wrapper }, ); expect(screen.getByText("Fix the login bug")).toBeInTheDocument(); }); @@ -62,6 +75,7 @@ describe("TaskCard", () => { statuses={NO_STATUSES} taskTypes={[bugType]} />, + { wrapper }, ); expect(screen.queryByText("Bug")).not.toBeInTheDocument(); }); @@ -73,6 +87,7 @@ describe("TaskCard", () => { statuses={NO_STATUSES} taskTypes={[bugType]} />, + { wrapper }, ); expect(screen.getByText("Bug")).toBeInTheDocument(); }); @@ -86,6 +101,7 @@ describe("TaskCard", () => { taskTypes={NO_TYPES} onClick={onClick} />, + { wrapper }, ); fireEvent.click(screen.getByText("Fix the login bug")); expect(onClick).toHaveBeenCalledOnce(); @@ -99,6 +115,7 @@ describe("TaskCard", () => { taskTypes={NO_TYPES} canEdit={true} />, + { wrapper }, ); const card = container.querySelector("[data-task-id='task-1']"); expect(card).toHaveAttribute("draggable", "true"); @@ -112,6 +129,7 @@ describe("TaskCard", () => { taskTypes={NO_TYPES} canEdit={false} />, + { wrapper }, ); const card = container.querySelector("[data-task-id='task-1']"); expect(card).toHaveAttribute("draggable", "false"); @@ -127,6 +145,7 @@ describe("TaskCard", () => { canEdit={true} onDragStart={onDragStart} />, + { wrapper }, ); const card = container.querySelector("[data-task-id='task-1']") as Element; fireEvent.dragStart(card); @@ -143,6 +162,7 @@ describe("TaskCard", () => { canEdit={true} onDragEnd={onDragEnd} />, + { wrapper }, ); const card = container.querySelector("[data-task-id='task-1']") as Element; fireEvent.dragEnd(card); @@ -157,6 +177,7 @@ describe("TaskCard", () => { taskTypes={NO_TYPES} isDragging={true} />, + { wrapper }, ); const card = container.querySelector("[data-task-id='task-1']") as Element; // isDragging adds opacity-50 class @@ -170,6 +191,7 @@ describe("TaskCard", () => { statuses={NO_STATUSES} taskTypes={NO_TYPES} />, + { wrapper }, ); // assigned state: filled avatar circle with the primary gradient const assigneeEl = container.querySelector(".from-primary\\/20"); From 6aa6eefd68e8e0415b954e2efd8b4f7350222761 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 27 Jul 2026 16:14:15 +0000 Subject: [PATCH 6/7] feat: implement debounced async callback for improved task search performance --- .../shared/mention-suggestion-menus.tsx | 15 ++++++- apps/web/src/hooks/use-debounced-callback.ts | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/shared/mention-suggestion-menus.tsx b/apps/web/src/components/shared/mention-suggestion-menus.tsx index 2d424adb..47334293 100644 --- a/apps/web/src/components/shared/mention-suggestion-menus.tsx +++ b/apps/web/src/components/shared/mention-suggestion-menus.tsx @@ -4,9 +4,17 @@ import { type DefaultReactSuggestionItem, SuggestionMenuController, } from "@blocknote/react"; +import { useDebouncedAsyncCallback } from "@/hooks/use-debounced-callback"; import { searchMentionableTasks } from "@/lib/mention-api"; import type { customSchema } from "./blocknote-schema"; +/** BlockNote calls getItems on every keystroke after "#" — debounce the + * actual network call so a fast typist doesn't fire one request per + * character. BlockNote's own stale-response check (comparing the query + * active when a call resolves) still discards results for a query the + * user has since typed past. */ +const TASK_SEARCH_DEBOUNCE_MS = 300; + interface MentionSuggestionMenuProps { editor: BlockNoteEditor< typeof customSchema.blockSchema, @@ -32,6 +40,11 @@ export function MentionSuggestionMenus({ projectId, documents, }: MentionSuggestionMenuProps) { + const debouncedSearchMentionableTasks = useDebouncedAsyncCallback( + searchMentionableTasks, + TASK_SEARCH_DEBOUNCE_MS, + ); + const getTeamMentionItems = (): DefaultReactSuggestionItem[] => { return teamMembers.map((member) => ({ title: member.name, @@ -56,7 +69,7 @@ export function MentionSuggestionMenus({ query: string, ): Promise => { if (!projectId) return []; - const tasks = await searchMentionableTasks(projectId, query); + const tasks = await debouncedSearchMentionableTasks(projectId, query); return tasks.map((task) => ({ title: task.title, subtext: `#${task.task_number}`, diff --git a/apps/web/src/hooks/use-debounced-callback.ts b/apps/web/src/hooks/use-debounced-callback.ts index 6032e9e4..838cf8ca 100644 --- a/apps/web/src/hooks/use-debounced-callback.ts +++ b/apps/web/src/hooks/use-debounced-callback.ts @@ -16,3 +16,42 @@ export function useDebouncedCallback( [delay], ); } + +/** Like useDebouncedCallback, but for callbacks the caller needs a result + * back from (e.g. a suggestion menu's getItems, which must return a + * Promise per call). Every call within the debounce window shares the + * single fetch actually fired once the timer settles — appropriate when + * callers only care about the latest value (live search), not a distinct + * response per invocation. Built on useDebouncedCallback for the timer + * itself; this just resolves each caller's own Promise once that fetch + * completes. */ +export function useDebouncedAsyncCallback( + callback: (...args: Args) => Promise, + delay: number, +): (...args: Args) => Promise { + const pendingRef = useRef< + { resolve: (value: R) => void; reject: (reason?: unknown) => void }[] + >([]); + + const debouncedRun = useDebouncedCallback((...args: Args) => { + const pending = pendingRef.current; + pendingRef.current = []; + callback(...args).then( + (result) => { + for (const p of pending) p.resolve(result); + }, + (err) => { + for (const p of pending) p.reject(err); + }, + ); + }, delay); + + return useCallback( + (...args: Args) => + new Promise((resolve, reject) => { + pendingRef.current.push({ resolve, reject }); + debouncedRun(...args); + }), + [debouncedRun], + ); +} From 96b7ccd7238cd7f802cf9cdfc7154e612a2715da Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 27 Jul 2026 16:46:18 +0000 Subject: [PATCH 7/7] feat: implement pagination and search functionality for epic picker --- .../interactions/task-context-menu.tsx | 92 +++++++++++++++---- .../projects/interactions/use-epic-search.ts | 17 +++- .../projects/interactions/view-utils.ts | 37 ++------ .../shared/mention-suggestion-menus.tsx | 12 ++- apps/web/src/hooks/use-debounced-callback.ts | 11 ++- apps/web/src/index.css | 36 ++++++++ apps/web/src/lib/scroll-pagination.ts | 31 +++++++ .../projects/$projectId/team/index.tsx | 15 ++- 8 files changed, 191 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/lib/scroll-pagination.ts diff --git a/apps/web/src/components/projects/interactions/task-context-menu.tsx b/apps/web/src/components/projects/interactions/task-context-menu.tsx index 173d4346..c1dff693 100644 --- a/apps/web/src/components/projects/interactions/task-context-menu.tsx +++ b/apps/web/src/components/projects/interactions/task-context-menu.tsx @@ -4,10 +4,11 @@ import { Layers, Link2, Loader2, + Search, Trash2, User, } from "lucide-react"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { getTaskTypeIconComponent } from "@/components/projects/task-types/task-type-icons"; @@ -24,7 +25,12 @@ import { } from "@/components/ui/context-menu"; import { KbdChord } from "@/components/ui/kbd"; import type { Task } from "@/lib/interaction-api"; -import type { ProjectMember, TaskStatus, TaskType } from "@/lib/project-api"; +import { + findEpicType, + type ProjectMember, + type TaskStatus, + type TaskType, +} from "@/lib/project-api"; import { isMacPlatform, SHORTCUT_DISPLAY } from "@/lib/shortcuts/keymap"; import { @@ -32,6 +38,7 @@ import { IMPORTANCE_BUCKET_VALUES, PRIORITY_LEVELS, } from "./priority"; +import { useEpicSearch } from "./use-epic-search"; import { type ColumnGroupDef, createEpicScrollHandler, @@ -99,6 +106,21 @@ export function TaskContextMenu({ ? `${taskIdPrefix}-${task.task_number}` : `#${task.task_number}`; + const [epicSubOpen, setEpicSubOpen] = useState(false); + const epicTypeId = findEpicType(taskTypes)?.id; + const { + search: epicSearch, + setSearch: setEpicSearch, + isSearching: isEpicSearching, + results: epicSearchResults, + isLoading: epicSearchLoading, + pagination: epicSearchPagination, + } = useEpicSearch(task.project_id, epicTypeId, epicSubOpen); + const displayedEpics = isEpicSearching ? epicSearchResults : epics; + const activeEpicsPagination = isEpicSearching + ? epicSearchPagination + : epicsPagination; + const copyId = () => { navigator.clipboard?.writeText(taskLabel)?.catch(() => {}); }; @@ -278,12 +300,12 @@ export function TaskContextMenu({ )} {canEdit && epics.length > 0 && ( - + {t("board.taskContextMenu.epic")} - + onUpdate?.(task.id, { parent_task_id: null })} > @@ -294,25 +316,57 @@ export function TaskContextMenu({ )} +
+ + e.stopPropagation()} + onKeyDown={(e) => { + // Let Escape still close the menu; swallow everything + // else so typing doesn't trigger the menu's own arrow-key + // navigation / type-ahead item selection. + if (e.key !== "Escape") e.stopPropagation(); + }} + onChange={(e) => setEpicSearch(e.target.value)} + placeholder={t("epicPicker.searchPlaceholder")} + className="w-full rounded-lg border border-border/30 bg-muted/25 py-1.5 pr-2 pl-8 text-sm placeholder:text-muted-foreground/50 transition-all duration-150 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/20" + /> +
- {epics.map((e) => ( - - onUpdate?.(task.id, { parent_task_id: e.id }) - } - > - - {e.title} - {e.id === task.parent_task_id && ( - + {isEpicSearching && epicSearchLoading ? ( +
+ +
+ ) : ( + <> + {displayedEpics.map((e) => ( + + onUpdate?.(task.id, { parent_task_id: e.id }) + } + > + + {e.title} + {e.id === task.parent_task_id && ( + + )} + + ))} + {displayedEpics.length === 0 && ( +

+ {isEpicSearching + ? t("epicPicker.noEpicsFound") + : t("epicPicker.noEpicsYet")} +

)} -
- ))} - {epicsPagination?.isLoadingMore && ( + + )} + {activeEpicsPagination?.isLoadingMore && (
{t("epicPicker.loadingMore")} diff --git a/apps/web/src/components/projects/interactions/use-epic-search.ts b/apps/web/src/components/projects/interactions/use-epic-search.ts index df979003..3b738ef3 100644 --- a/apps/web/src/components/projects/interactions/use-epic-search.ts +++ b/apps/web/src/components/projects/interactions/use-epic-search.ts @@ -6,11 +6,17 @@ import type { EpicsPagination } from "./view-utils"; const SEARCH_DEBOUNCE_MS = 300; +// Below this length, `search` does a leading-wildcard ILIKE scan server-side +// (no trigram index backs it) — cheap enough per-project at 2+ chars, but a +// single character is broad enough to be worth avoiding. +const MIN_SEARCH_LENGTH = 2; + interface UseEpicSearchResult { search: string; setSearch: (value: string) => void; - /** True once the user has typed a non-empty query — callers should swap - * in `results` for their normal paginated epic list while this holds. */ + /** True once the user has typed a query of at least MIN_SEARCH_LENGTH — + * callers should swap in `results` for their normal paginated epic list + * while this holds. */ isSearching: boolean; /** Server-searched matches; only meaningful while isSearching is true. */ results: Task[]; @@ -54,7 +60,7 @@ export function useEpicSearch( } }, [enabled]); - const isSearching = search.trim().length > 0; + const isSearching = search.trim().length >= MIN_SEARCH_LENGTH; const { data, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({ @@ -74,7 +80,10 @@ export function useEpicSearch( initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: - enabled && !!projectId && !!epicTypeId && debouncedSearch.length > 0, + enabled && + !!projectId && + !!epicTypeId && + debouncedSearch.length >= MIN_SEARCH_LENGTH, staleTime: 15_000, }); diff --git a/apps/web/src/components/projects/interactions/view-utils.ts b/apps/web/src/components/projects/interactions/view-utils.ts index d74e8363..4019350d 100644 --- a/apps/web/src/components/projects/interactions/view-utils.ts +++ b/apps/web/src/components/projects/interactions/view-utils.ts @@ -1,5 +1,4 @@ import type { TFunction } from "i18next"; -import type { UIEvent } from "react"; import { type FilterConfig, resolveFilterConfig, @@ -13,6 +12,10 @@ import type { TaskStatus, TaskType, } from "@/lib/project-api"; +import { + createLoadMoreScrollHandler, + type LoadMorePagination, +} from "@/lib/scroll-pagination"; import { getImportanceBucket, @@ -445,32 +448,12 @@ export function buildAllFieldOptions( * epic query. Threaded from the view/layout root, which owns the single * `useInfiniteQuery`, down to whichever picker UI (Popover/DropdownMenu/ * ContextMenu) is currently rendering the epic list, so every epic picker - * app-wide shares one "Load more" affordance instead of duplicating queries. */ -export interface EpicsPagination { - hasMore: boolean; - isLoadingMore: boolean; - onLoadMore: () => void; -} - -const EPIC_SCROLL_LOAD_THRESHOLD_PX = 48; - -/** Builds an onScroll handler that fires `pagination.onLoadMore()` once the - * epic picker's list is scrolled within EPIC_SCROLL_LOAD_THRESHOLD_PX of its - * bottom, instead of requiring an explicit "Load more" click. */ -export function createEpicScrollHandler( - pagination: EpicsPagination | undefined, -): (e: UIEvent) => void { - return (e) => { - if (!pagination?.hasMore || pagination.isLoadingMore) return; - const el = e.currentTarget; - if ( - el.scrollHeight - el.scrollTop - el.clientHeight < - EPIC_SCROLL_LOAD_THRESHOLD_PX - ) { - pagination.onLoadMore(); - } - }; -} + * app-wide shares one "Load more" affordance instead of duplicating queries. + * Re-exported from the generic scroll-pagination helper — kept as a named + * alias here since every epic-picker call site already imports it by this + * name from "./view-utils". */ +export type EpicsPagination = LoadMorePagination; +export const createEpicScrollHandler = createLoadMoreScrollHandler; export type TaskFieldUpdate = Partial<{ status_id: string | null; diff --git a/apps/web/src/components/shared/mention-suggestion-menus.tsx b/apps/web/src/components/shared/mention-suggestion-menus.tsx index 47334293..2c97b31e 100644 --- a/apps/web/src/components/shared/mention-suggestion-menus.tsx +++ b/apps/web/src/components/shared/mention-suggestion-menus.tsx @@ -15,6 +15,11 @@ import type { customSchema } from "./blocknote-schema"; * user has since typed past. */ const TASK_SEARCH_DEBOUNCE_MS = 300; +// Below this length, `search` does a leading-wildcard ILIKE scan server-side +// (no trigram index backs it) — treat a too-short query the same as no query +// (unfiltered first page) rather than firing a broad single-character scan. +const MIN_TASK_SEARCH_LENGTH = 2; + interface MentionSuggestionMenuProps { editor: BlockNoteEditor< typeof customSchema.blockSchema, @@ -69,7 +74,12 @@ export function MentionSuggestionMenus({ query: string, ): Promise => { if (!projectId) return []; - const tasks = await debouncedSearchMentionableTasks(projectId, query); + const effectiveQuery = + query.trim().length >= MIN_TASK_SEARCH_LENGTH ? query : ""; + const tasks = await debouncedSearchMentionableTasks( + projectId, + effectiveQuery, + ); return tasks.map((task) => ({ title: task.title, subtext: `#${task.task_number}`, diff --git a/apps/web/src/hooks/use-debounced-callback.ts b/apps/web/src/hooks/use-debounced-callback.ts index 838cf8ca..7e55abd6 100644 --- a/apps/web/src/hooks/use-debounced-callback.ts +++ b/apps/web/src/hooks/use-debounced-callback.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; export function useDebouncedCallback( callback: (...args: Args) => void, @@ -8,6 +8,15 @@ export function useDebouncedCallback( const callbackRef = useRef(callback); callbackRef.current = callback; + // Without this, a timer scheduled just before unmount still fires and + // invokes callbackRef.current — harmless for a plain state setter, but + // wasteful (or worse, a network call nobody reads) for heavier callbacks. + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + return useCallback( (...args: Args) => { if (timerRef.current) clearTimeout(timerRef.current); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 01999be7..95c37b4e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -542,3 +542,39 @@ a { padding-left: 0; padding-right: 0; } + +/* + * Suggestion menu ("/" slash commands, "@" mentions, "#" task/doc references) + * — @blocknote/shadcn renders item titles at text-base (16px), a size larger + * than every other dropdown/menu in this app (shadcn's own DropdownMenuItem/ + * ContextMenuItem use text-sm/14px). The title and subtext divs both carry + * the item's own "bn-suggestion-menu-item" class alongside their Tailwind + * text-size class, so a compound selector targets just those, not the item's + * outer row or its icon. + */ +.bn-suggestion-menu .bn-suggestion-menu-item.text-base { + font-size: 0.8125rem; + line-height: 1.15rem; +} + +/* + * The loading spinner shown while a debounced/server-searched suggestion + * menu (e.g. the "#" reference search) is fetching has two bugs upstream: + * its SVG hardcodes fill="#e8eaed" instead of currentColor, so it's nearly + * invisible against a light-mode popover background and never follows the + * app's theme; and the row itself has no padding/centering, so it renders + * flush against the item list above it instead of lining up with it. + */ +.bn-suggestion-menu .bn-suggestion-menu-loader { + display: flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0; + color: var(--muted-foreground); +} + +.bn-suggestion-menu .bn-suggestion-menu-loader svg { + width: 14px; + height: 14px; + fill: currentColor; +} diff --git a/apps/web/src/lib/scroll-pagination.ts b/apps/web/src/lib/scroll-pagination.ts new file mode 100644 index 00000000..ac0d3eef --- /dev/null +++ b/apps/web/src/lib/scroll-pagination.ts @@ -0,0 +1,31 @@ +import type { UIEvent } from "react"; + +/** Load-more state/handler for a list backed by a paginated `useInfiniteQuery`. + * Generic across every "scroll near the bottom to load more" list in the + * app (epic pickers, the team-member picker, etc.) so each caller doesn't + * reimplement the same threshold check. */ +export interface LoadMorePagination { + hasMore: boolean; + isLoadingMore: boolean; + onLoadMore: () => void; +} + +const LOAD_MORE_SCROLL_THRESHOLD_PX = 48; + +/** Builds an onScroll handler that fires `pagination.onLoadMore()` once the + * list is scrolled within LOAD_MORE_SCROLL_THRESHOLD_PX of its bottom, + * instead of requiring an explicit "Load more" click. */ +export function createLoadMoreScrollHandler( + pagination: LoadMorePagination | undefined, +): (e: UIEvent) => void { + return (e) => { + if (!pagination?.hasMore || pagination.isLoadingMore) return; + const el = e.currentTarget; + if ( + el.scrollHeight - el.scrollTop - el.clientHeight < + LOAD_MORE_SCROLL_THRESHOLD_PX + ) { + pagination.onLoadMore(); + } + }; +} diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx index 6eb733da..63522f75 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx @@ -18,7 +18,7 @@ import { UserRound, Users, } from "lucide-react"; -import { type UIEvent, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -63,6 +63,7 @@ import { removeProjectMember, updateProjectMemberRole, } from "@/lib/project-api"; +import { createLoadMoreScrollHandler } from "@/lib/scroll-pagination"; export const Route = createFileRoute( "/_authenticated/projects/$projectId/team/", @@ -161,13 +162,11 @@ function AddMemberDialog({ [usersPages], ); - function handleUsersListScroll(e: UIEvent) { - if (!hasNextPage || isFetchingNextPage) return; - const el = e.currentTarget; - if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) { - fetchNextPage(); - } - } + const handleUsersListScroll = createLoadMoreScrollHandler({ + hasMore: !!hasNextPage, + isLoadingMore: isFetchingNextPage, + onLoadMore: () => void fetchNextPage(), + }); const filteredUsers = useMemo(() => { const items: User[] = usersData;