프로젝트 작업 목록: useTasks
useTasks(project)는 지정한 프로젝트의 작업 첫 페이지를 읽고 생성자와 담당자의 사용자 정보를 결합합니다. 작업은 OsdkITask 인터페이스로 조회하며, 코딩 작업과 학습 작업을 같은 목록에서 다룹니다.
입력과 반환
입력은 프로젝트 훅이 반환한 IProject입니다. project.$primaryKey를 projectId 필터와 SWR 키에 사용합니다.
| 반환값 | 의미 |
|---|---|
tasks | ITask[]이며 데이터가 없을 때는 빈 배열입니다. |
isLoading | 처음 데이터를 읽는 상태입니다. |
isValidating | 초기 조회나 재검증이 진행 중인 상태입니다. |
isError | 작업과 사용자 정보를 읽는 fetcher에서 발생한 오류입니다. |
metadata | 작업 인터페이스의 표시 이름과 속성 설명 등입니다. 별도로 로드하므로 목록보다 늦게 도착할 수 있습니다. |
ITask는 원래 SDK 객체인 osdkTask와 createdBy, assignedTo를 포함합니다. 사용자 필드가 없거나 조회 결과에서 해당 사용자를 찾지 못하면 두 사용자 값은 undefined일 수 있습니다. 사용자 요청 자체의 실패는 fetcher의 실패로 전달합니다.
조회 절차
OsdkITask를projectId로 제한합니다.dueDate내림차순,status오름차순으로 첫 페이지를 읽습니다.$includeAllBaseObjectProperties: true로 실제 객체 유형의 속성도 읽습니다. 상세 화면에서$as로 해당 객체 유형을 사용할 때 필요한 데이터입니다.- 생성자 ID와 담당자 ID에서 각각 중복과 빈 값을 제거합니다. useAdmin의 배치 함수가 캐시를 확인하고 필요한 사용자를 조회합니다.
- SDK 작업과 사용자 정보를 결합하여 SWR에 저장합니다.
프로젝트 필터와 정렬은 서버에서 수행합니다. 아래 화면의 All, Completed, In Progress 선택은 이미 읽은 페이지 안에서 추가로 적용하는 클라이언트 필터입니다.
구독과 재검증
같은 프로젝트의 객체 집합을 구독합니다. ADDED_OR_UPDATED는 객체의 추가나 갱신이고, REMOVED는 구독 집합에서 빠졌음을 뜻합니다. 객체가 삭제된 경우뿐 아니라 필터를 더 이상 충족하지 않는 경우에도 제거 통지가 발생할 수 있습니다.
구독 통지의 객체는 요청한 속성 범위에 따른 부분 인스턴스일 수 있습니다. 이 구현은 기존 fetcher를 재검증하여 정렬된 첫 페이지와 기반 객체 속성을 다시 읽습니다. 따라서 새 항목이 들어오거나 정렬 속성이 바뀌면 서버 조회 결과가 화면을 결정합니다.
onOutOfDate는 변경을 모두 추적하지 못했을 때뿐 아니라 최초 구독 시에도 발생합니다. 이때도 페이지를 재조회합니다. 조회 결과가 바뀔 때마다 구독을 새로 만들지 않으며, 프로젝트나 클라이언트가 바뀌거나 컴포넌트가 해제되면 이전 구독을 해제합니다.
훅 구현
dataServices/useTasks.ts에 둡니다. useAdmin.ts와 useProjects.ts는 같은 디렉터리에 둡니다.
/*
* Copyright 2025 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* Modified example: Revalidate the first page on subscription notifications, preserve base properties, and type missing users.
*/
import useSWR from "swr";
import { useOsdkClient } from "@osdk/react";
import { OsdkITask } from "@advanced-to-do-application/sdk";
import type { IProject } from "./useProjects";
import { useCallback, useEffect, useState } from "react";
import useAdmin from "./useAdmin";
import _ from "lodash";
import type { User } from "@osdk/foundry.admin";
import type { InterfaceMetadata } from "@osdk/api";
export interface ITask {
osdkTask: OsdkITask.OsdkInstance;
createdBy: User | undefined;
assignedTo: User | undefined;
}
function useTasks(project: IProject) {
const client = useOsdkClient();
const [metadata, setMetadata] = useState<InterfaceMetadata>();
const { getBatchUserDetails } = useAdmin();
const fetcher = useCallback(async (): Promise<ITask[]> => {
const tasksPage = await client(OsdkITask).where({
projectId: { $eq: project.$primaryKey },
}).fetchPage({
$includeAllBaseObjectProperties: true,
$orderBy: { dueDate: "desc", status: "asc" },
});
const createdByIds = _.compact(_.uniq(tasksPage.data.map(task => task.createdBy)));
const createdByUsers = await getBatchUserDetails(createdByIds);
const assignedToIds = _.compact(_.uniq(tasksPage.data.map(task => task.assignedTo)));
const assignedToUsers = await getBatchUserDetails(assignedToIds);
return tasksPage.data.map(task => ({
osdkTask: task,
createdBy: task.createdBy == null ? undefined : createdByUsers[task.createdBy],
assignedTo: task.assignedTo == null ? undefined : assignedToUsers[task.assignedTo],
}));
}, [client, project.$primaryKey, getBatchUserDetails]);
const { data, isLoading, isValidating, error, mutate } = useSWR<ITask[]>(
["tasks", project.$primaryKey],
fetcher,
{ revalidateOnFocus: false },
);
useEffect(() => {
let active = true;
setMetadata(undefined);
void client.fetchMetadata(OsdkITask).then(value => {
if (active) setMetadata(value);
}).catch(console.error);
return () => { active = false; };
}, [client]);
useEffect(() => {
const revalidate = () => {
void mutate().catch(console.error);
};
const subscription = client(OsdkITask)
.where({ projectId: { $eq: project.$primaryKey } })
.subscribe({
onChange() {
revalidate();
},
onOutOfDate() {
revalidate();
},
onError(error) {
console.error(error);
},
});
return () => subscription.unsubscribe();
}, [client, project.$primaryKey, mutate]);
return {
tasks: data ?? [],
isLoading,
isValidating,
isError: error,
metadata,
};
}
export default useTasks;
SWR 키는 ["tasks", project.$primaryKey]이며 revalidateOnFocus를 끕니다. 구독이 여러 번 연속으로 통지하면 재검증 요청도 늘 수 있습니다. 이 구현은 하나의 원자적 스냅샷이나 이벤트 순서 전체를 저장하는 변경 로그를 제공하지 않습니다.
캐시를 직접 수정하는 방식에서는 mutate(updater, { revalidate: false })로 네트워크 요청 없이 값을 바꿀 수 있습니다. 이때 신규 항목의 삽입, 제거, 정렬 변경, 부분 속성 병합과 비동기 사용자 조회의 완료 순서를 호출자가 처리해야 합니다. 위 구현은 기반 속성이 필요한 상세 화면과 목록 순서를 유지하기 위해 조회 경로를 다시 사용합니다.
구독 오류와 메타데이터 오류는 Console에 기록합니다. 이 오류들은 반환한 isError에 합치지 않습니다. 닫힌 구독에서 이후 변경을 받으려면 다시 구독해야 하며, 이 훅에는 별도의 재연결 루프가 없습니다.
사용자 이미지가 있는 목록
User에는 이미지가 없으므로 사용자마다 프로필 이미지를 따로 요청합니다. 같은 사용자가 여러 행에 등장하면 ID로 중복을 제거합니다. 이미지가 실패하면 사용자 이름은 표시하고 오류는 Console에 기록합니다.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { User } from '@osdk/foundry.admin';
import useTasks from '../dataServices/useTasks';
import useAdmin from '../dataServices/useAdmin';
import type { IProject } from '../dataServices/useProjects';
function useProfilePictureUrls(users: User[]) {
const { getCurrentProfilePictureUrl } = useAdmin();
const [profilePictureUrls, setProfilePictureUrls] = useState<Record<string, string>>({});
// Kept in a ref so the effect can check which pictures are already loaded
// without depending on the state that it sets.
const loadedUrls = useRef<Record<string, string>>({});
useEffect(() => {
let isActive = true;
async function loadProfilePictures() {
const usersToLoad = users.filter((user) => loadedUrls.current[user.id] === undefined);
if (usersToLoad.length === 0) {
return;
}
await Promise.all(
usersToLoad.map(async (user) => {
try {
const url = await getCurrentProfilePictureUrl(user);
if (!isActive) {
URL.revokeObjectURL(url);
return;
}
loadedUrls.current[user.id] = url;
} catch (error) {
console.error("Profile picture request failed", error);
}
}),
);
if (isActive) {
setProfilePictureUrls({ ...loadedUrls.current });
}
}
loadProfilePictures();
return () => {
isActive = false;
};
}, [users, getCurrentProfilePictureUrl]);
// `getCurrentProfilePictureUrl` returns blob URLs from `URL.createObjectURL`, which
// the browser retains until they are revoked.
useEffect(() => () => {
Object.values(loadedUrls.current).forEach((url) => URL.revokeObjectURL(url));
loadedUrls.current = {};
}, []);
return profilePictureUrls;
}
function UserCell({ user, fallback, profilePictureUrls }: {
user: User | undefined;
fallback: string;
profilePictureUrls: Record<string, string>;
}) {
if (user == null) {
return <div className="user-info"><span>{fallback}</span></div>;
}
const profilePictureUrl = profilePictureUrls[user.id];
return (
<div className="user-info">
{profilePictureUrl != null && (
<img
src={profilePictureUrl}
alt={`Profile picture for ${user.username}.`}
className="user-avatar"
/>
)}
<span>{user.username}</span>
</div>
);
}
function TaskList({ project }: { project: IProject }) {
const { tasks, isLoading, isError, metadata } = useTasks(project);
const [filter, setFilter] = useState('ALL');
// Deduplicate creators and assignees so each picture is requested only once,
// in the same spirit as the batched user lookup inside the hook.
const taskUsers = useMemo(() => {
const usersById = new Map<string, User>();
tasks.forEach((task) => {
[task.createdBy, task.assignedTo].forEach((user) => {
if (user != null) {
usersById.set(user.id, user);
}
});
});
return Array.from(usersById.values());
}, [tasks]);
const profilePictureUrls = useProfilePictureUrls(taskUsers);
if (isLoading) return <div>Loading tasks...</div>;
if (isError) return <div>Error loading tasks: {isError.message}</div>;
// Filter tasks based on the selected filter
const filteredTasks = filter === 'ALL'
? tasks
: tasks.filter(task => task.osdkTask.status === filter);
return (
<div className="task-list">
<h2>Tasks for {project.name}</h2>
<div className="filter-controls">
<button
className={filter === 'ALL' ? 'active' : ''}
onClick={() => setFilter('ALL')}
>
All ({tasks.length})
</button>
<button
className={filter === 'COMPLETED' ? 'active' : ''}
onClick={() => setFilter('COMPLETED')}
>
Completed ({tasks.filter(t => t.osdkTask.status === 'COMPLETED').length})
</button>
<button
className={filter === 'IN PROGRESS' ? 'active' : ''}
onClick={() => setFilter('IN PROGRESS')}
>
In Progress ({tasks.filter(t => t.osdkTask.status === 'IN PROGRESS').length})
</button>
</div>
<table className="task-table">
<thead>
<tr>
<th>{metadata?.properties?.title?.displayName || 'Title'}</th>
<th>Status</th>
<th>Due Date</th>
<th>Assigned To</th>
<th>Created By</th>
</tr>
</thead>
<tbody>
{filteredTasks.map((task) => (
<tr key={`${task.osdkTask.$objectType}:${task.osdkTask.$primaryKey}`}>
<td>{task.osdkTask.title}</td>
<td>
<span className={`status-badge ${task.osdkTask.status?.toLowerCase().replace(' ', '-') ?? 'unknown'}`}>
{task.osdkTask.status}
</span>
</td>
<td>
{task.osdkTask.dueDate
? new Date(task.osdkTask.dueDate).toLocaleDateString()
: 'Not set'}
</td>
<td>
<UserCell
user={task.assignedTo}
fallback="Unassigned"
profilePictureUrls={profilePictureUrls}
/>
</td>
<td>
<UserCell
user={task.createdBy}
fallback="Unknown"
profilePictureUrls={profilePictureUrls}
/>
</td>
</tr>
))}
</tbody>
</table>
{filteredTasks.length === 0 && (
<div className="empty-state">
No {filter !== 'ALL' ? filter.toLowerCase() : ''} tasks found.
</div>
)}
</div>
);
}
export default TaskList;
프로필 이미지의 Blob URL은 컴포넌트가 해제될 때 해제합니다. 컴포넌트 해제 뒤 완료된 요청도 생성된 URL을 즉시 해제합니다. 컴포넌트가 유지되는 동안에는 이전에 읽은 이미지 URL도 보관하므로, 여러 프로젝트를 오가면 보관하는 이미지가 늘어날 수 있습니다.
조회 범위와 오류
fetchPage()를 한 번 호출하므로 전체 작업을 읽는 구현은 아닙니다. 페이지 이동을 추가하려면 토큰과 화면 상태를 별도로 관리해야 합니다. 화면에 표시하는 필터별 개수도 로드한 페이지의 개수입니다.
작업 조회나 사용자 배치가 실패하면 isError를 확인합니다. 오류가 있어도 이전 데이터가 캐시에 남을 수 있으므로 빈 배열 또는 이전 행의 존재를 성공 판정으로 사용하지 않습니다. 오류 재시도는 SWR의 기본 정책을 따릅니다.