학습 작업과 미디어: useLearningTask
useLearningTask(task)는 작업의 기본 키로 osdkLearningTask를 다시 조회하고, 사용자 정보와 미디어를 결합합니다. 미디어 집합(Media set)의 항목을 Blob으로 읽거나 작업의 contentUrl을 링크로 사용합니다.
입력과 처리 순서
입력은 작업 목록의 ITask이며, 이 예제의 학습 작업 기본 키는 String입니다. 실제 생성 SDK의 객체 유형과 키가 이 계약에 맞아야 합니다.
fetchOneWithErrors로 학습 작업을 읽습니다.Result에 오류가 있으면 예외를 발생시켜 SWR의 오류 상태로 전달합니다.- 생성자와 담당자 ID에서 없는 값을 제외하고 useAdmin으로 사용자 정보를 읽습니다.
mediaReference가 있으면 그 미디어를 우선 사용합니다. 내용과 메타데이터를 읽고 MIME 유형을 판별합니다.- 미디어 참조가 없으면 비어 있지 않은
contentUrl을 LINK로 사용합니다. 둘 다 없으면 NONE입니다.
Result의 오류 분기와 Promise 거부는 별개로 처리됩니다. 미디어 내용 읽기나 메타데이터 조회에서 발생한 예외도 fetcher의 실패로 전달하며, 성공한 미디어인 것처럼 빈 값을 돌려주지 않습니다.
미디어 유형
| MIME 유형 | SupportedMediaType | 화면 |
|---|---|---|
application/pdf | PDF | PDF iframe |
image/jpeg, image/png, image/gif | IMAGE | 이미지 |
video/mp4 | VIDEO | 재생 컨트롤이 있는 비디오 |
text/html | LINK | 새 탭으로 여는 링크 |
미디어 참조 없이 제공된 contentUrl | LINK | 외부 자료 링크 |
| 그 밖의 형식 또는 내용 없음 | NONE | 표시할 미디어 없음 |
분류는 코드의 정규식이 판별하는 범위입니다. 표 밖의 형식을 자동으로 지원하지 않습니다. 브라우저의 PDF 표시와 비디오 코덱 지원, 앱 CSP도 실제 표시 여부에 영향을 줍니다. HTML 자료와 외부 URL은 앱에서 신뢰하고 허용한 콘텐츠 범위에서 제공해야 합니다.
반환값과 URL 수명
| 반환값 | 의미 |
|---|---|
learningTask | SDK 객체, mediaUrl, mediaType, 생성자와 담당자입니다. 준비 전에는 undefined입니다. |
isLoading | 첫 데이터나 해당 Blob의 표시 URL을 준비하는 상태입니다. |
isValidating | 초기 조회 또는 재검증이 진행 중인 상태입니다. |
isError | 작업, 사용자 또는 미디어를 읽는 fetcher의 오류입니다. |
metadata | 객체 유형의 표시 정보이며 미디어 메타데이터와는 별도로 조회합니다. |
SWR에는 Blob 데이터나 외부 URL 문자열을 저장합니다. Blob URL은 훅을 사용하는 컴포넌트가 별도로 만들고, 내용이 바뀌거나 컴포넌트가 해제되면 해제합니다. 같은 Blob을 두 화면에서 사용하더라도 각 화면의 URL 수명은 독립적입니다.
훅 구현
dataServices/useLearningTask.ts에 둡니다. Response.blob()은 응답 내용을 끝까지 읽고 Blob을 반환합니다. 큰 파일을 모두 메모리에 올리는 방식이므로 스트리밍 재생이나 점진적 로딩의 대체 수단으로 보아서는 안 됩니다.
/*
* 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: Cache Blob data, own object URLs per mounted hook, separate cache keys, and omit absent user IDs.
*/
import { useCallback, useEffect, useState } from "react";
import useSWR from "swr";
import { useOsdkClient } from "@osdk/react";
import type { ITask } from "./useTasks";
import { osdkLearningTask } from "@advanced-to-do-application/sdk";
import useAdmin from "./useAdmin";
import type { ObjectMetadata, Osdk, Result } from "@osdk/client";
import type { User } from "@osdk/foundry.admin";
export const SupportedMediaType = {
PDF: "PDF", IMAGE: "IMAGE", LINK: "LINK", VIDEO: "VIDEO", NONE: "NONE",
} as const;
export type SupportedMediaType = typeof SupportedMediaType[keyof typeof SupportedMediaType];
interface LearningTaskEnriched {
osdkLearningTask: osdkLearningTask.OsdkInstance;
mediaUrl: string;
createdBy: User | undefined;
assignedTo: User | undefined;
mediaType: SupportedMediaType;
}
type LearningTaskData = Omit<LearningTaskEnriched, "mediaUrl"> & {
content: Blob | string;
};
function useLearningTask(task: ITask) {
const client = useOsdkClient();
const [metadata, setMetadata] = useState<ObjectMetadata>();
const [objectUrl, setObjectUrl] = useState<{ blob: Blob; url: string }>();
const { getBatchUserDetails } = useAdmin();
const fetcher = useCallback(async (): Promise<LearningTaskData> => {
const result: Result<Osdk.Instance<osdkLearningTask>> = await client(osdkLearningTask)
.fetchOneWithErrors(task.osdkTask.$primaryKey as string);
if (result.error) throw new Error(result.error.message);
const learningTask = result.value;
const userIds = [learningTask.createdBy, learningTask.assignedTo]
.filter((id): id is string => id != null && id !== "");
const users = await getBatchUserDetails(userIds);
const people = {
createdBy: learningTask.createdBy == null ? undefined : users[learningTask.createdBy],
assignedTo: learningTask.assignedTo == null ? undefined : users[learningTask.assignedTo],
};
if (learningTask.mediaReference == null) {
return {
osdkLearningTask: learningTask,
...people,
content: learningTask.contentUrl ?? "",
mediaType: learningTask.contentUrl ? SupportedMediaType.LINK : SupportedMediaType.NONE,
};
}
const response = await learningTask.mediaReference.fetchContents();
const blob = await response.blob();
const mediaMetadata = await learningTask.mediaReference.fetchMetadata();
return {
osdkLearningTask: learningTask,
...people,
content: blob,
mediaType: getMediaTypeFromMimeType(mediaMetadata.mediaType),
};
}, [client, task.osdkTask.$primaryKey, getBatchUserDetails]);
const { data, error, isLoading, isValidating } = useSWR<LearningTaskData>(
["learningTask", task.osdkTask.$objectType, task.osdkTask.$primaryKey],
fetcher,
{ revalidateOnFocus: false, revalidateOnReconnect: false },
);
const content = data?.content;
useEffect(() => {
if (!(content instanceof Blob)) {
setObjectUrl(undefined);
return;
}
const url = URL.createObjectURL(content);
setObjectUrl({ blob: content, url });
return () => URL.revokeObjectURL(url);
}, [content]);
const mediaUrl = typeof content === "string"
? content
: content != null && objectUrl?.blob === content ? objectUrl.url : undefined;
const learningTask: LearningTaskEnriched | undefined = data != null && mediaUrl != null
? {
osdkLearningTask: data.osdkLearningTask,
createdBy: data.createdBy,
assignedTo: data.assignedTo,
mediaType: data.mediaType,
mediaUrl,
}
: undefined;
useEffect(() => {
let active = true;
setMetadata(undefined);
void client.fetchMetadata(osdkLearningTask).then(value => {
if (active) setMetadata(value);
}).catch(console.error);
return () => { active = false; };
}, [client]);
return {
learningTask,
isLoading: isLoading || (data != null && mediaUrl == null),
isValidating,
isError: error,
metadata,
};
}
export default useLearningTask;
const getMediaTypeFromMimeType = (mimeType: string): SupportedMediaType => {
if (/application\/pdf/.test(mimeType)) return SupportedMediaType.PDF;
if (/image\/(jpeg|png|gif)/.test(mimeType)) return SupportedMediaType.IMAGE;
if (/video\/mp4/.test(mimeType)) return SupportedMediaType.VIDEO;
if (/text\/html/.test(mimeType)) return SupportedMediaType.LINK;
return SupportedMediaType.NONE;
};
캐시 키는 상세 종류, 실제 객체 유형과 기본 키를 사용합니다. 포커스 복귀와 네트워크 재연결에 따른 재검증은 끄며, 별도의 미디어 TTL이나 부분 다운로드 정책은 없습니다. 캐시가 유지되면 이미 읽은 Blob을 다시 사용할 수 있지만, 재검증하면 조회 절차를 다시 수행합니다.
사용자 정보나 미디어 요청은 서로 이어지므로 앞선 요청이 실패하면 뒤의 결과를 완성할 수 없습니다. 컴포넌트가 해제되어도 진행 중인 HTTP 요청은 취소하지 않습니다. 개별 사용자 정보가 없으면 undefined로 표시하고, 배치 요청 전체가 실패하면 오류를 표시합니다.
미디어 표시
PDF는 높이 500px의 iframe에 넣고 #toolbar=0을 요청합니다. 이미지와 비디오는 별도 요소로 표시하며, 링크는 noopener noreferrer를 사용해 새 탭에서 엽니다. NONE에는 내용 없음 문구를 표시합니다.
import useLearningTask, { SupportedMediaType } from '../dataServices/useLearningTask';
import type { ITask } from '../dataServices/useTasks';
function LearningTaskViewer({ task }: { task: ITask }) {
const { learningTask, isLoading, isError, metadata } = useLearningTask(task);
if (isLoading) return <div>Loading learning materials...</div>;
if (isError) return <div>Error loading content: {isError.message}</div>;
if (!learningTask) return <div>No learning content available</div>;
// Render different media types appropriately
const renderMedia = () => {
switch (learningTask.mediaType) {
case SupportedMediaType.PDF:
return (
<div className="pdf-viewer">
<iframe
src={`${learningTask.mediaUrl}#toolbar=0`}
title="PDF Content"
width="100%"
height="500px"
/>
</div>
);
case SupportedMediaType.IMAGE:
return (
<div className="image-viewer">
<img
src={learningTask.mediaUrl}
alt="Learning content"
className="learning-image"
/>
</div>
);
case SupportedMediaType.VIDEO:
return (
<div className="video-player">
<video
src={learningTask.mediaUrl}
controls
width="100%"
/>
</div>
);
case SupportedMediaType.LINK:
return (
<div className="external-link">
<a
href={learningTask.mediaUrl}
target="_blank"
rel="noopener noreferrer"
>
Open learning resource
</a>
</div>
);
default:
return <div>No media content available</div>;
}
};
return (
<div className="learning-task-viewer">
<h2>{learningTask.osdkLearningTask.title}</h2>
<div className="content-section">
<div className="content-type-badge">{learningTask.mediaType}</div>
{renderMedia()}
</div>
<div className="description-section">
<h3>Description</h3>
<p>{learningTask.osdkLearningTask.description || "No description provided"}</p>
</div>
<div className="people-section">
<div className="assigned-to">
<span>Assigned to: </span>
<span>{learningTask.assignedTo?.username ?? "Unassigned"}</span>
</div>
<div className="created-by">
<span>Created by: </span>
<span>{learningTask.createdBy?.username ?? "Unknown"}</span>
</div>
</div>
</div>
);
}
객체 유형 메타데이터는 본문 데이터와 별도로 읽고, 그 오류는 Console에 기록합니다. 제목과 사용자 데이터는 실제 조회 값이며, 설명이나 담당자 정보가 없을 때의 문구는 화면에서 빈 값을 안내하기 위한 것입니다.