Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

학습 작업과 미디어: useLearningTask

useLearningTask(task)는 작업의 기본 키로 osdkLearningTask를 다시 조회하고, 사용자 정보와 미디어를 결합합니다. 미디어 집합(Media set)의 항목을 Blob으로 읽거나 작업의 contentUrl을 링크로 사용합니다.

입력과 처리 순서

입력은 작업 목록의 ITask이며, 이 예제의 학습 작업 기본 키는 String입니다. 실제 생성 SDK의 객체 유형과 키가 이 계약에 맞아야 합니다.

  1. fetchOneWithErrors로 학습 작업을 읽습니다.
  2. Result에 오류가 있으면 예외를 발생시켜 SWR의 오류 상태로 전달합니다.
  3. 생성자와 담당자 ID에서 없는 값을 제외하고 useAdmin으로 사용자 정보를 읽습니다.
  4. mediaReference가 있으면 그 미디어를 우선 사용합니다. 내용과 메타데이터를 읽고 MIME 유형을 판별합니다.
  5. 미디어 참조가 없으면 비어 있지 않은 contentUrl을 LINK로 사용합니다. 둘 다 없으면 NONE입니다.

Result의 오류 분기와 Promise 거부는 별개로 처리됩니다. 미디어 내용 읽기나 메타데이터 조회에서 발생한 예외도 fetcher의 실패로 전달하며, 성공한 미디어인 것처럼 빈 값을 돌려주지 않습니다.

미디어 유형

MIME 유형SupportedMediaType화면
application/pdfPDFPDF iframe
image/jpeg, image/png, image/gifIMAGE이미지
video/mp4VIDEO재생 컨트롤이 있는 비디오
text/htmlLINK새 탭으로 여는 링크
미디어 참조 없이 제공된 contentUrlLINK외부 자료 링크
그 밖의 형식 또는 내용 없음NONE표시할 미디어 없음

분류는 코드의 정규식이 판별하는 범위입니다. 표 밖의 형식을 자동으로 지원하지 않습니다. 브라우저의 PDF 표시와 비디오 코덱 지원, 앱 CSP도 실제 표시 여부에 영향을 줍니다. HTML 자료와 외부 URL은 앱에서 신뢰하고 허용한 콘텐츠 범위에서 제공해야 합니다.

반환값과 URL 수명

반환값의미
learningTaskSDK 객체, 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에 기록합니다. 제목과 사용자 데이터는 실제 조회 값이며, 설명이나 담당자 정보가 없을 때의 문구는 화면에서 빈 값을 안내하기 위한 것입니다.