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
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import { Link2, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import {
type DisplayLinkType,
type LinkType,
listAllTasks,
type Task,
} from "@/lib/interaction-api";
import { createLoadMoreScrollHandler } from "@/lib/scroll-pagination";

export interface AddTaskLinkPayload {
sourceTaskId: string;
Expand Down Expand Up @@ -66,22 +69,26 @@ const DISPLAY_TO_CANONICAL: Partial<
duplicates: { linkType: "duplicates", otherTaskIsSource: false },
};

// The task list API caps page_size at 200, so the search box needs to page
// through the full project rather than fetching a single page - otherwise
// tasks past the first 200 are invisible to the search.
const MAX_TASK_PAGES = 25;

async function fetchAllProjectTasks(projectId: string): Promise<Task[]> {
const all: Task[] = [];
let cursor: string | undefined;
for (let page = 0; page < MAX_TASK_PAGES; page++) {
const result = await listAllTasks(projectId, { pageSize: 200, cursor });
all.push(...result.items);
const next = result.next_cursor;
if (!next) break;
cursor = next;
const TASK_PAGE_SIZE = 20;
const SEARCH_DEBOUNCE_MS = 300;
// Hiding the current task can leave a page with few rows (and nothing to
// scroll), so keep loading until a few rows are visible.
const MIN_VISIBLE_TASK_ROWS = 5;

/** The server matches the title and "#<number>", so a full display id such as
* "PRJ-12" is rewritten to "#12" to keep matching what users see in the UI. */
function toServerSearch(
query: string,
taskIdPrefix: string | undefined,
): string | undefined {
if (!query) return undefined;
if (taskIdPrefix) {
const m = query.match(/^(.+)-(\d+)$/);
if (m && m[1].toLowerCase() === taskIdPrefix.toLowerCase()) {
return `#${m[2]}`;
}
}
return all;
return query;
}

export function AddTaskLinkModal({
Expand All @@ -96,34 +103,85 @@ export function AddTaskLinkModal({
const [selectedLinkType, setSelectedLinkType] =
useState<DisplayLinkType>("blocks");
const [query, setQuery] = useState("");
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [debouncedQuery, setDebouncedQuery] = useState("");
const applyQuery = useDebouncedCallback(
setDebouncedQuery,
SEARCH_DEBOUNCE_MS,
);
const searchRef = useRef<HTMLInputElement>(null);

// Load tasks once when modal opens
useEffect(() => {
if (!open) return;
setLoading(true);
fetchAllProjectTasks(projectId)
.then(setTasks)
.catch(() => setTasks([]))
.finally(() => setLoading(false));
setTimeout(() => searchRef.current?.focus(), 50);
}, [open, projectId]);

const filteredTasks = useMemo(() => {
const q = query.trim().toLowerCase();
return tasks.filter((t) => {
if (t.id === currentTaskId) return false;
if (!q) return true;
const prefix = taskIdPrefix
? `${taskIdPrefix}-${t.task_number}`
: String(t.task_number);
return (
t.title.toLowerCase().includes(q) || prefix.toLowerCase().includes(q)
);
});
}, [tasks, query, currentTaskId, taskIdPrefix]);
if (!open) {
setQuery("");
setDebouncedQuery("");
// Supersede any pending debounce so it can't restore the old term.
applyQuery("");
return;
}
const timer = setTimeout(() => searchRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, [open, applyQuery]);

// Cursor-paginated, server-side search (matches title and "#<number>"),
// so every task in the project is reachable without loading them all.
const {
data,
isLoading: loading,
isError,
isFetchNextPageError,
isPlaceholderData,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["projects", projectId, "tasks", "link-picker", debouncedQuery],
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
listAllTasks(projectId, {
search: toServerSearch(debouncedQuery, taskIdPrefix),

pageSize: TASK_PAGE_SIZE,
cursor: pageParam,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
enabled: open && !!projectId,
placeholderData: keepPreviousData,
});

const filteredTasks = useMemo<Task[]>(
() =>
(data?.pages.flatMap((page) => page.items) ?? []).filter(
(t) => t.id !== currentTaskId,
),
[data, currentTaskId],
);

useEffect(() => {
if (
hasNextPage &&
!isFetchingNextPage &&
!isFetchNextPageError &&
!isPlaceholderData &&
!loading &&
filteredTasks.length < MIN_VISIBLE_TASK_ROWS
) {
void fetchNextPage();
}
}, [
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
isPlaceholderData,
loading,
filteredTasks.length,
fetchNextPage,
]);

const handleListScroll = createLoadMoreScrollHandler({
hasMore: !!hasNextPage,
isLoadingMore: isFetchingNextPage,
onLoadMore: () => void fetchNextPage(),
});

function handleSelect(task: Task) {
const canonical = DISPLAY_TO_CANONICAL[selectedLinkType];
Expand Down Expand Up @@ -200,21 +258,32 @@ export function AddTaskLinkModal({
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
applyQuery(e.target.value.trim());
}}
placeholder={t("taskDetail.addTaskLinkModal.searchPlaceholder")}
className="w-full pl-9 pr-3 py-2.5 rounded-lg border border-border/30 bg-muted/20 text-sm placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all duration-150"
/>
</div>
</div>

{/* Task list */}
<div className="mx-5 mb-5 rounded-xl border border-border/20 overflow-hidden max-h-64 overflow-y-auto [scrollbar-gutter:stable]">
<div
className="mx-5 mb-5 rounded-xl border border-border/20 overflow-hidden max-h-64 overflow-y-auto [scrollbar-gutter:stable]"
onScroll={handleListScroll}
>
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground/50 text-sm">
{t("taskDetail.addTaskLinkModal.loadingTasks")}
</div>
)}
{!loading && filteredTasks.length === 0 && (
{!loading && isError && filteredTasks.length === 0 && (
<div className="flex items-center justify-center py-8 text-destructive/80 text-sm">
{t("taskDetail.addTaskLinkModal.loadError")}
</div>
)}
{!loading && !isError && filteredTasks.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground/45 text-sm italic">
{t("taskDetail.addTaskLinkModal.noTasksFound")}
</div>
Expand All @@ -240,6 +309,11 @@ export function AddTaskLinkModal({
</button>
);
})}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-3 text-muted-foreground/50 text-xs">
{t("taskDetail.addTaskLinkModal.loadingTasks")}
</div>
)}
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/en/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "Search tasks by title or number...",
"loadingTasks": "Loading tasks…",
"noTasksFound": "No tasks found",
"loadError": "Couldn't load tasks. Please try again.",
"linkTypes": {
"blocks": {
"label": "Blocks",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/es/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "Buscar tareas por título o número...",
"loadingTasks": "Cargando tareas…",
"noTasksFound": "No se encontraron tareas",
"loadError": "No se pudieron cargar las tareas. Inténtalo de nuevo.",
"linkTypes": {
"blocks": {
"label": "Bloquea",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/fr/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "Rechercher des tâches par titre ou numéro...",
"loadingTasks": "Chargement des tâches…",
"noTasksFound": "Aucune tâche trouvée",
"loadError": "Impossible de charger les tâches. Veuillez réessayer.",
"linkTypes": {
"blocks": {
"label": "Bloque",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/ja/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "タイトルまたは番号でタスクを検索...",
"loadingTasks": "タスクを読み込み中…",
"noTasksFound": "タスクが見つかりません",
"loadError": "タスクを読み込めませんでした。もう一度お試しください。",
"linkTypes": {
"blocks": {
"label": "ブロックする",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/ko/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "제목 또는 번호로 작업 검색...",
"loadingTasks": "작업을 불러오는 중…",
"noTasksFound": "작업을 찾을 수 없습니다",
"loadError": "작업을 불러오지 못했습니다. 다시 시도해 주세요.",
"linkTypes": {
"blocks": {
"label": "차단함",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/pt-BR/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "Buscar tarefas por título ou número...",
"loadingTasks": "Carregando tarefas…",
"noTasksFound": "Nenhuma tarefa encontrada",
"loadError": "Não foi possível carregar as tarefas. Tente novamente.",
"linkTypes": {
"blocks": {
"label": "Bloqueia",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/ru/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,7 @@
"searchPlaceholder": "Поиск задач по названию или номеру...",
"loadingTasks": "Загрузка задач…",
"noTasksFound": "Задачи не найдены",
"loadError": "Не удалось загрузить задачи. Повторите попытку.",
"linkTypes": {
"blocks": {
"label": "Блокирует",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/vi/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "Tìm nhiệm vụ theo tiêu đề hoặc số...",
"loadingTasks": "Đang tải nhiệm vụ…",
"noTasksFound": "Không tìm thấy nhiệm vụ nào",
"loadError": "Không thể tải công việc. Vui lòng thử lại.",
"linkTypes": {
"blocks": {
"label": "Chặn",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/i18n/locales/zh-CN/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,7 @@
"searchPlaceholder": "按标题或编号搜索任务...",
"loadingTasks": "正在加载任务…",
"noTasksFound": "未找到任务",
"loadError": "无法加载任务。请重试。",
"linkTypes": {
"blocks": {
"label": "阻塞",
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/lib/admin-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ import {
getGlobalRoles,
getMyGlobalPermissions,
getUsers,
getUsersByCursor,
globalRolesQueryOptions,
myPermissionsQueryOptions,
resetUserPassword,
setDefaultGlobalRole,
type User,
updateGlobalRole,
updateUser,
usersInfiniteQueryOptions,
usersQueryOptions,
} from "./admin-api";

Expand Down Expand Up @@ -213,6 +215,34 @@ describe("admin-api", () => {
});
});

it("sends cursor, search and page_size to getUsersByCursor only when set", async () => {
const page = { items: [], page_size: 20, next_cursor: "c2" };
mockGet.mockResolvedValue({
data: { data: page, error_code: null, message: "ok" },
});

await expect(getUsersByCursor(null)).resolves.toEqual(page);
expect(mockGet).toHaveBeenLastCalledWith("/admin/users/cursor", {
params: { page_size: 20 },
});

await getUsersByCursor("c1", 5, { search: "alice" });
expect(mockGet).toHaveBeenLastCalledWith("/admin/users/cursor", {
params: { page_size: 5, cursor: "c1", search: "alice" },
});
});

it("usersInfiniteQueryOptions follows next_cursor and stops when null", () => {
const opts = usersInfiniteQueryOptions("al");
expect(opts.queryKey).toEqual(["admin", "users", "cursor", "al"]);
expect(opts.initialPageParam).toBeNull();
const next = opts.getNextPageParam as (p: unknown) => unknown;
expect(next({ items: [], page_size: 20, next_cursor: "abc" })).toBe("abc");
expect(
next({ items: [], page_size: 20, next_cursor: null }),
).toBeUndefined();
});

it("sends search and role to getUsers only when set", async () => {
mockGet.mockResolvedValue({
data: {
Expand Down
46 changes: 32 additions & 14 deletions apps/web/src/lib/admin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,28 @@ export async function getUsers(
return data.data;
}

export interface CursorUsersResponse {
items: User[];
page_size: number;
/** Opaque token for the next page; null on the last page. */
next_cursor: string | null;
}

export async function getUsersByCursor(
cursor: string | null,
pageSize = 20,
filter: UsersFilter = {},
): Promise<CursorUsersResponse> {
const params: Record<string, string | number> = { page_size: pageSize };
if (cursor) params.cursor = cursor;
if (filter.search) params.search = filter.search;
if (filter.role) params.role = filter.role;
const { data } = await apiClient.instance.get<
SuccessEnvelope<CursorUsersResponse>
>("/admin/users/cursor", { params });
return data.data;
}

/** Creates a user with the default USER role. To give them another role, call
* {@link assignUserGlobalRole} afterwards — assigning a role needs
* `global_roles.assign`, so the server no longer accepts `role` here. */
Expand Down Expand Up @@ -203,19 +225,15 @@ export function usersQueryOptions(

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 = () =>
/** Cursor-paginated infinite query over the user list — backs pickers that
* page through every user (e.g. the "add team member" dialog). `search` is
* sent to the server (it matches username, full name and email) so users
* beyond the first page are still findable. */
export const usersInfiniteQueryOptions = (search = "") =>
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,
queryKey: ["admin", "users", "cursor", search],
queryFn: ({ pageParam }: { pageParam: string | null }) =>
getUsersByCursor(pageParam, ADMIN_USERS_PAGE_SIZE, { search }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
});
Loading
Loading