+ )
+}
diff --git a/packages/core/components/FileDetails/MetadataList/Value.module.css b/packages/core/components/FileDetails/MetadataList/Value.module.css
new file mode 100644
index 000000000..27b376291
--- /dev/null
+++ b/packages/core/components/FileDetails/MetadataList/Value.module.css
@@ -0,0 +1,20 @@
+.link {
+ color: var(--aqua);
+}
+
+.link:hover {
+ color: var(--bright-aqua);
+}
+
+.value-truncated {
+ min-height: 30px;
+ max-height: 90px;
+ white-space: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box !important;
+ -webkit-line-clamp: 4 !important; /* number of lines to show */
+ line-clamp: 4;
+ -webkit-box-orient: vertical !important;
+ word-wrap: break-word !important;
+}
diff --git a/packages/core/components/FileDetails/MetadataList/Value.tsx b/packages/core/components/FileDetails/MetadataList/Value.tsx
new file mode 100644
index 000000000..78beb7e23
--- /dev/null
+++ b/packages/core/components/FileDetails/MetadataList/Value.tsx
@@ -0,0 +1,73 @@
+import classNames from "classnames";
+import * as React from "react";
+
+import ContentLengthToggle from "./ContentLengthToggle";
+import MarkdownText from "../../MarkdownText";
+import Annotation from "../../../entity/Annotation";
+
+import styles from "./Value.module.css";
+
+
+interface Props {
+ annotation: Annotation | undefined;
+ value: string;
+ emphasize?: boolean;
+ isExpanded: boolean;
+ isLongValue: boolean;
+ setIsExpanded: (isExpanded: boolean) => void;
+ onContextMenu: (evt: React.MouseEvent) => void;
+}
+
+/**
+ * Renders the value of a metadata annotation, which may be a long string or markdown text, and may have an associated link.
+ * If the value is long, it will be truncated with an option to expand and view the full text.
+ * If the value is markdown, it will be rendered as markdown.
+ * If the value has an associated link, a "View link" anchor will be shown that opens the link in a new tab.
+ */
+export default function Value(props: Props) {
+ let content: React.ReactNode;
+ if (props.annotation?.isOpenFileLink) {
+ content = (
+
+ View link
+
+ )
+ } else if (props.annotation?.isMarkdown) {
+ content = (
+
+ );
+ } else {
+ content = (
+
+ )
+ }
+
+ return (
+
+ {content}
+ {props.isLongValue && (
+
+ )}
+
+ );
+}
diff --git a/packages/core/components/FileDetails/MetadataList/index.tsx b/packages/core/components/FileDetails/MetadataList/index.tsx
new file mode 100644
index 000000000..e5b2a393a
--- /dev/null
+++ b/packages/core/components/FileDetails/MetadataList/index.tsx
@@ -0,0 +1,84 @@
+import { isEmpty, isObject } from "lodash";
+import * as React from "react";
+
+import Row from "./Row";
+import Section from "./Section";
+import AnnotationName from "../../../entity/Annotation/AnnotationName";
+import FileDetail from "../../../entity/FileDetail";
+import { NestedMetadataValue } from "../../../services/FileService";
+
+import styles from "./MetadataList.module.css";
+
+
+// Keys of annotations that should not be included the list
+const EXCLUDED_KEYS = new Set([AnnotationName.FILE_NAME, "File Name"]);
+
+interface Props {
+ file: FileDetail | null;
+ isLoading: boolean;
+}
+
+/**
+ * Component responsible for rendering the metadata pertaining to a file inside the file
+ * details pane on right hand side of the application.
+ */
+export default function MetadataList(props: Props) {
+ const { file, isLoading } = props;
+
+ // Group metadata fields into sections based on their annotation names. If a field's annotation name
+ // doesn't fall into any predefined section, put it in an "uncategorized" section at the top.
+ const content: JSX.Element | JSX.Element[] | null = React.useMemo(() => {
+ if (isLoading) return
Loading...
;
+ if (!file) return null;
+
+ const uncategorizedSection: NestedMetadataValue = {};
+ const keyToSectionMap: Record = {};
+ for (const [key, values] of file.metadata.entries()) {
+ // Track uncategorized rows that don't fall into any of the predefined sections
+ if (values.length < 1 || EXCLUDED_KEYS.has(key)) continue;
+ if (isObject(values[0])) {
+ keyToSectionMap[key] = values as NestedMetadataValue[];
+ } else {
+ uncategorizedSection[key] = values;
+ }
+ }
+
+ // A file actively downloading to local (VAST) storage may not yet have a local-path
+ // annotation. Surface a placeholder row so the "copying in progress" affordance shows;
+ // useDisplayText renders the progress message regardless of this value.
+ if (file.downloadInProgress && !(AnnotationName.LOCAL_FILE_PATH in uncategorizedSection)) {
+ uncategorizedSection[AnnotationName.LOCAL_FILE_PATH] = [""];
+ }
+ const sections = Object.keys(keyToSectionMap).sort().map((sectionName) => (
+ {sectionName}}
+ childRows={keyToSectionMap[sectionName]}
+ >
+ {(rowProps) => (
+
+ )}
+
+ ));
+
+ // If any annotations were not able to be categorized into sections,
+ // show them at the top in a generic "Metadata" section.
+ if (!isEmpty(uncategorizedSection)) {
+ sections.unshift(
+ Metadata}
+ childRows={[uncategorizedSection]}
+ >
+ {(rowProps) => (
+
+ )}
+
+ );
+ }
+
+ return sections;
+ }, [file, isLoading]);
+
+ return
{content}
;
+}
diff --git a/packages/core/components/FileDetails/MetadataList/test/ContentLengthToggle.test.tsx b/packages/core/components/FileDetails/MetadataList/test/ContentLengthToggle.test.tsx
new file mode 100644
index 000000000..bce65f67b
--- /dev/null
+++ b/packages/core/components/FileDetails/MetadataList/test/ContentLengthToggle.test.tsx
@@ -0,0 +1,32 @@
+import { fireEvent, render } from "@testing-library/react";
+import { expect } from "chai";
+import * as React from "react";
+import sinon from "sinon";
+
+import ContentLengthToggle from "../ContentLengthToggle";
+
+
+describe("", () => {
+ it("renders expand icon when collapsed", () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId("expand-nested-fields")).to.exist;
+ });
+
+ it("renders collapse icon when expanded", () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId("collapse-nested-fields")).to.exist;
+ });
+
+ it("calls setIsExpanded with toggled value on click", () => {
+ const setIsExpanded = sinon.spy();
+ const { getByTestId } = render(
+
+ );
+ fireEvent.click(getByTestId("expand-nested-fields"));
+ expect(setIsExpanded.calledOnceWith(true)).to.be.true;
+ });
+});
diff --git a/packages/core/components/FileDetails/test/FileAnnotationList.test.tsx b/packages/core/components/FileDetails/MetadataList/test/MetadataList.test.tsx
similarity index 83%
rename from packages/core/components/FileDetails/test/FileAnnotationList.test.tsx
rename to packages/core/components/FileDetails/MetadataList/test/MetadataList.test.tsx
index cadbd9b6a..7a30dc70a 100644
--- a/packages/core/components/FileDetails/test/FileAnnotationList.test.tsx
+++ b/packages/core/components/FileDetails/MetadataList/test/MetadataList.test.tsx
@@ -4,34 +4,35 @@ import { expect } from "chai";
import * as React from "react";
import { Provider } from "react-redux";
-import FileAnnotationList from "../FileAnnotationList";
-import FileDetail from "../../../entity/FileDetail";
-import ExecutionEnvServiceNoop from "../../../services/ExecutionEnvService/ExecutionEnvServiceNoop";
-import { initialState } from "../../../state";
-import { Environment, TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants";
-import AnnotationName from "../../../entity/Annotation/AnnotationName";
-import Annotation from "../../../entity/Annotation";
-import { AnnotationType } from "../../../entity/AnnotationFormatter";
-
-describe("", () => {
+import MetadataList from "..";
+import { Environment, TOP_LEVEL_FILE_ANNOTATIONS } from "../../../../constants";
+import AnnotationName from "../../../../entity/Annotation/AnnotationName";
+import Annotation from "../../../../entity/Annotation";
+import { AnnotationType } from "../../../../entity/AnnotationFormatter";
+import FileDetail from "../../../../entity/FileDetail";
+import ExecutionEnvServiceNoop from "../../../../services/ExecutionEnvService/ExecutionEnvServiceNoop";
+import { initialState } from "../../../../state";
+
+
+describe("", () => {
describe("file path representation", () => {
const annotations = [
...TOP_LEVEL_FILE_ANNOTATIONS,
new Annotation({
- annotationDisplayName: "Cache Eviction Date",
- annotationName: AnnotationName.CACHE_EVICTION_DATE,
+ annotationDisplayName: AnnotationName.CACHE_EVICTION_DATE,
+ path: [AnnotationName.CACHE_EVICTION_DATE],
description: "Indicates when the cache for this file should be evicted.",
type: AnnotationType.STRING,
}),
new Annotation({
- annotationDisplayName: "File Path (Local VAST)",
- annotationName: AnnotationName.LOCAL_FILE_PATH,
+ annotationDisplayName: AnnotationName.LOCAL_FILE_PATH,
+ path: [AnnotationName.LOCAL_FILE_PATH],
description: "Local path for the file on the host machine.",
type: AnnotationType.STRING,
}),
new Annotation({
- annotationDisplayName: "Should Be in Local Cache",
- annotationName: AnnotationName.SHOULD_BE_IN_LOCAL,
+ annotationDisplayName: AnnotationName.SHOULD_BE_IN_LOCAL,
+ path: [AnnotationName.SHOULD_BE_IN_LOCAL],
description: "Indicates if the file should be cached locally.",
type: AnnotationType.BOOLEAN,
}),
@@ -61,7 +62,7 @@ describe("", () => {
const fileName = "MyFile.txt";
const relativePath = `/test/${fileName}`;
const filePath = `test.files.allencell.org/${relativePath}`;
- const fileDetails = new FileDetail(
+ const file = new FileDetail(
{
file_path: filePath,
file_id: "c32e3eed66e4416d9532d369ffe1636f",
@@ -80,7 +81,7 @@ describe("", () => {
// Act
const { findByText } = render(
-
+
);
@@ -116,7 +117,7 @@ describe("", () => {
const filePathInsideAllenDrive = "path/to/MyFile.txt";
const filePath = `test.files.allencell.org/${filePathInsideAllenDrive}`;
- const fileDetails = new FileDetail(
+ const file = new FileDetail(
{
file_path: filePath,
file_id: "abc123",
@@ -131,7 +132,7 @@ describe("", () => {
// Act
const { getByText } = render(
-
+
);
@@ -164,7 +165,7 @@ describe("", () => {
}),
});
- const fileDetails = new FileDetail(
+ const file = new FileDetail(
{
file_path: "path/to/file",
file_id: "abc123",
@@ -179,7 +180,7 @@ describe("", () => {
// Act
const { findByText } = render(
-
+
);
diff --git a/packages/core/components/FileDetails/MetadataList/test/Section.test.tsx b/packages/core/components/FileDetails/MetadataList/test/Section.test.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/core/components/FileDetails/MetadataList/test/Value.test.tsx b/packages/core/components/FileDetails/MetadataList/test/Value.test.tsx
new file mode 100644
index 000000000..1b6b72104
--- /dev/null
+++ b/packages/core/components/FileDetails/MetadataList/test/Value.test.tsx
@@ -0,0 +1,83 @@
+import { render } from "@testing-library/react";
+import { expect } from "chai";
+import * as React from "react";
+import sinon from "sinon";
+
+import Value from "../Value";
+import Annotation from "../../../../entity/Annotation";
+import { AnnotationType } from "../../../../entity/AnnotationFormatter";
+
+function makeAnnotation(overrides: Partial<{ type: AnnotationType; annotationName: string }> = {}) {
+ return new Annotation({
+ path: [overrides.annotationName ?? "test"],
+ description: "",
+ type: overrides.type ?? AnnotationType.STRING,
+ });
+}
+
+
+describe("", () => {
+ it("renders plain text value when annotation is a string", () => {
+ const { getByText } = render(
+ sinon.stub()}
+ />
+ );
+ expect(getByText("hello world")).to.exist;
+ });
+
+ it("renders a link when annotation is an open-file-link type", () => {
+ const annotation = new Annotation({
+ path: ["link"],
+ description: "",
+ type: AnnotationType.OPEN_FILE_LINK,
+ });
+ const { getByText } = render(
+ sinon.stub()}
+ />
+ );
+ const link = getByText("View link") as HTMLAnchorElement;
+ expect(link.href).to.equal("https://example.com/");
+ expect(link.target).to.equal("_blank");
+ });
+
+ it("shows ContentLengthToggle when value is long", () => {
+ const { getByTestId } = render(
+ sinon.stub()}
+ />
+ );
+ expect(getByTestId("expand-nested-fields")).to.exist;
+ });
+
+ it("does not show ContentLengthToggle when value is short", () => {
+ const { queryByTestId } = render(
+ sinon.stub()}
+ />
+ );
+ expect(queryByTestId("expand-nested-fields")).not.to.exist;
+ expect(queryByTestId("collapse-nested-fields")).not.to.exist;
+ });
+});
diff --git a/packages/core/components/FileDetails/MetadataList/test/useDisplayText.test.tsx b/packages/core/components/FileDetails/MetadataList/test/useDisplayText.test.tsx
new file mode 100644
index 000000000..76133a89d
--- /dev/null
+++ b/packages/core/components/FileDetails/MetadataList/test/useDisplayText.test.tsx
@@ -0,0 +1,321 @@
+import { configureMockStore, mergeState } from "@aics/redux-utils";
+import { render, waitFor } from "@testing-library/react";
+import { expect } from "chai";
+import * as React from "react";
+import { Provider } from "react-redux";
+
+import useDisplayText from "../useDisplayText";
+import Annotation from "../../../../entity/Annotation";
+import AnnotationName from "../../../../entity/Annotation/AnnotationName";
+import { AnnotationType } from "../../../../entity/AnnotationFormatter";
+import FileDetail, { FmsFile } from "../../../../entity/FileDetail";
+import { Environment } from "../../../../constants";
+import { MetadataValue, NestedMetadataValue } from "../../../../services/FileService";
+import ExecutionEnvServiceNoop from "../../../../services/ExecutionEnvService/ExecutionEnvServiceNoop";
+import { initialState } from "../../../../state";
+
+// Helper component that exercises the hook and renders the result
+function TestComponent(props: {
+ file: FileDetail;
+ metadataKey: string;
+ value: MetadataValue;
+ annotation: Annotation | undefined;
+ childRows: NestedMetadataValue[];
+}) {
+ const { text, emphasize } = useDisplayText(
+ props.file,
+ props.metadataKey,
+ props.value,
+ props.annotation,
+ props.childRows
+ );
+ return (
+
+ {text ?? ""}
+ {String(emphasize)}
+
+ );
+}
+
+
+describe("useDisplayText", () => {
+ const testAnnotation = new Annotation({
+ path: ["test"],
+ description: "",
+ type: AnnotationType.STRING,
+ });
+ const localFilePathAnnotation = new Annotation({
+ path: [AnnotationName.LOCAL_FILE_PATH],
+ description: "",
+ type: AnnotationType.STRING,
+ });
+ const emptyFile: FmsFile = {
+ file_path: "test.files.allencell.org/path/to/file.txt",
+ file_id: "abc123",
+ file_name: "file.txt",
+ file_size: 100,
+ uploaded: "01/01/01",
+ annotations: [],
+ };
+
+ describe("default value display", () => {
+ it("joins values using annotation formatter", () => {
+ // Arrange
+ const file = new FileDetail(emptyFile, Environment.TEST);
+ const { store } = configureMockStore({
+ state: mergeState(initialState, {
+ interaction: {
+ platformDependentServices: {
+ executionEnvService: new ExecutionEnvServiceNoop(),
+ },
+ },
+ }),
+ });
+
+ // Act
+ const { getByTestId} = render(
+
+
+
+ );
+
+ // Assert
+ expect(getByTestId("text").textContent).to.equal("hello, world");
+ expect(getByTestId("emphasize").textContent).to.equal("false");
+ });
+
+ it("falls back to comma-separated join when annotation is undefined", () => {
+ // Arrange
+ const file = new FileDetail(emptyFile, Environment.TEST);
+ const { store } = configureMockStore({
+ state: mergeState(initialState, {
+ interaction: {
+ platformDependentServices: {
+ executionEnvService: new ExecutionEnvServiceNoop(),
+ },
+ },
+ }),
+ });
+
+ // Act
+ const { getByTestId} = render(
+
+
+
+ );
+
+ // Assert
+ expect(getByTestId("text").textContent).to.equal("a, b, c");
+ });
+ });
+
+ describe("nested metadata (childRows)", () => {
+ it("displays singular 'entry' when there is 1 child row", () => {
+ // Arrange
+ const file = new FileDetail(emptyFile, Environment.TEST);
+ const childRows: NestedMetadataValue[] = [{ Dose: ["10mg"] }];
+ const { store } = configureMockStore({
+ state: mergeState(initialState, {
+ interaction: {
+ platformDependentServices: {
+ executionEnvService: new ExecutionEnvServiceNoop(),
+ },
+ },
+ }),
+ });
+
+ // Act
+ const { getByTestId} = render(
+
+
+
+ );
+
+ // Assert
+ expect(getByTestId("text").textContent).to.equal("1 entry");
+ });
+
+ it("displays plural 'entries' when there are multiple child rows", () => {
+ // Arrange
+ const file = new FileDetail(emptyFile, Environment.TEST);
+ const childRows: NestedMetadataValue[] = [
+ { Dose: ["10mg"] },
+ { Dose: ["20mg"] },
+ { Dose: ["30mg"] },
+ ];
+ const { store } = configureMockStore({
+ state: mergeState(initialState, {
+ interaction: {
+ platformDependentServices: {
+ executionEnvService: new ExecutionEnvServiceNoop(),
+ },
+ },
+ }),
+ });
+
+ // Act
+ const { getByTestId} = render(
+
+
+
+ );
+
+ // Assert
+ expect(getByTestId("text").textContent).to.equal("3 entries");
+ });
+ });
+
+ describe("local file path handling", () => {
+ it("shows download in progress message when file is being downloaded", () => {
+ // Arrange
+ const file = new FileDetail({
+ ...emptyFile,
+ annotations: [
+ { name: AnnotationName.SHOULD_BE_IN_LOCAL, values: [true] },
+ ]
+ }, Environment.TEST);
+ const { store } = configureMockStore({
+ state: mergeState(initialState, {
+ interaction: {
+ platformDependentServices: {
+ executionEnvService: new ExecutionEnvServiceNoop(),
+ },
+ },
+ }),
+ });
+
+ // Act
+ const { getByTestId} = render(
+
+
+
+ );
+
+ // Assert
+ expect(getByTestId("text").textContent).to.equal(
+ "Copying to VAST in progress…"
+ );
+ expect(getByTestId("emphasize").textContent).to.equal("true");
+ });
+
+ it("displays formatted local path once resolved", async () => {
+ // Arrange
+ class FakeExecutionEnvService extends ExecutionEnvServiceNoop {
+ public formatPathForHost(posixPath: string): Promise {
+ return Promise.resolve(posixPath.replace("/test", "/mounted"));
+ }
+ }
+
+ const { store } = configureMockStore({
+ state: mergeState(initialState, {
+ interaction: {
+ platformDependentServices: {
+ executionEnvService: new FakeExecutionEnvService(),
+ },
+ },
+ }),
+ });
+
+ const file = new FileDetail({
+ ...emptyFile,
+ annotations: [
+ { name: AnnotationName.SHOULD_BE_IN_LOCAL, values: [true] },
+ { name: AnnotationName.CACHE_EVICTION_DATE, values: ["2026-01-01"] },
+ { name: AnnotationName.LOCAL_FILE_PATH, values: ["/test/my_file.czi"] },
+ ]
+ }, Environment.TEST);
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(getByTestId("text").textContent).to.equal("/mounted/my_file.czi");
+ });
+ expect(getByTestId("emphasize").textContent).to.equal("false");
+ });
+
+ it("returns null text when local path has not yet resolved", () => {
+ // Using a service that never resolves to simulate loading state
+ class NeverResolveService extends ExecutionEnvServiceNoop {
+ public formatPathForHost(): Promise {
+ // Intentionally never resolves, to simulate the loading state
+ return new Promise(() => undefined);
+ }
+ }
+
+ const { store } = configureMockStore({
+ state: mergeState(initialState, {
+ interaction: {
+ platformDependentServices: {
+ executionEnvService: new NeverResolveService(),
+ },
+ },
+ }),
+ });
+
+ const file = new FileDetail({
+ ...emptyFile,
+ annotations: [
+ { name: AnnotationName.SHOULD_BE_IN_LOCAL, values: [true] },
+ { name: AnnotationName.CACHE_EVICTION_DATE, values: ["2026-01-01"] },
+ { name: AnnotationName.LOCAL_FILE_PATH, values: ["/test/my_file.czi"] },
+ ]
+ }, Environment.TEST);
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ // Initially null since the async call hasn't resolved
+ expect(getByTestId("text").textContent).to.equal("");
+ expect(getByTestId("emphasize").textContent).to.equal("false");
+ });
+ });
+});
diff --git a/packages/core/components/FileDetails/MetadataList/useDisplayText.ts b/packages/core/components/FileDetails/MetadataList/useDisplayText.ts
new file mode 100644
index 000000000..9f8f69fa7
--- /dev/null
+++ b/packages/core/components/FileDetails/MetadataList/useDisplayText.ts
@@ -0,0 +1,78 @@
+import * as React from "react";
+import { useSelector } from "react-redux";
+
+import Annotation from "../../../entity/Annotation";
+import AnnotationName from "../../../entity/Annotation/AnnotationName";
+import FileDetail from "../../../entity/FileDetail";
+import { MetadataValue, NestedMetadataValue, PrimitiveMetadataValue } from "../../../services/FileService";
+import { interaction } from "../../../state";
+
+/**
+ * Custom hook responsible for determining the appropriate text to display for a given metadata value in the file details pane.
+ * This is responsible for handling any special cases for certain annotation keys where we want to display something other than the default text (e.g. file paths).
+ * It also returns whether the text should be emphasized (e.g. italicized) to indicate important information to the user (e.g. that a file is currently being downloaded and its local path is not yet available).
+ */
+export default function useDisplayText(file: FileDetail, key: string, value: MetadataValue, annotation: Annotation | undefined, childRows: NestedMetadataValue[]): { text: string | null, emphasize: boolean } {
+ const { executionEnvService } = useSelector(interaction.selectors.getPlatformDependentServices);
+
+ // The path to this file on the host this application is running on
+ // may not match the path to this file stored in the database.
+ // Determine this local path.
+ const [localPath, setLocalPath] = React.useState(null);
+ React.useEffect(() => {
+ if (key !== AnnotationName.LOCAL_FILE_PATH) return;
+
+ let active = true;
+ const pathAsIs = value[0] as string | undefined;
+ if (!pathAsIs || typeof pathAsIs !== "string") return;
+ executionEnvService.formatPathForHost(pathAsIs)
+ .then((localPath) => {
+ if (!active) return;
+ setLocalPath(localPath);
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [key, value, executionEnvService]);
+
+ return React.useMemo(() => {
+ // Handle special cases for certain annotation keys (e.g., file paths)
+ // where we want to display something other than the default text
+ if (key === AnnotationName.LOCAL_FILE_PATH) {
+ // Show a special message to indicate the path is
+ // being prepared still
+ if (!!file?.downloadInProgress) {
+ return { text: "Copying to VAST in progress…", emphasize: true };
+ }
+ // localPath hasn't loaded yet or there is no local path annotation
+ if (localPath === null) {
+ return { text: null, emphasize: false };
+ }
+ // Use the user's /allen mount point, if known
+ return { text: localPath, emphasize: false };
+ }
+
+ // Cloud file path: render the canonical S3 URL rather than the raw stored path
+ // (FileDetail.path performs the S3-bucket → https URL conversion).
+ if (key === AnnotationName.FILE_PATH) {
+ return { text: file.path, emphasize: false };
+ }
+
+ // When rendering metadata fields that are arrays of objects, we want to show the number of entries in the array
+ // rather than trying to join the objects into a string
+ if (childRows.length > 0) {
+ const numEntries = childRows.length;
+ return { text: `${numEntries} ${numEntries === 1 ? "entry" : "entries"}`, emphasize: false };
+ };
+
+ // If for some reason we don't have the annotation handy (which would be an error case)
+ // lets at least try to display the value in a reasonable way instead of crashing out
+ if (!annotation) {
+ console.error(`Unexpected scenario: No annotation found for metadata key: ${key}`);
+ return { text: (value as PrimitiveMetadataValue[]).join(Annotation.SEPARATOR), emphasize: false };
+ }
+
+ return { text: annotation.joinValuesForDisplay(value as PrimitiveMetadataValue[]), emphasize: false };
+ }, [file, key, value, annotation, childRows, localPath])
+}
diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx
index 3e7086f3a..a5a41cd71 100644
--- a/packages/core/components/FileDetails/index.tsx
+++ b/packages/core/components/FileDetails/index.tsx
@@ -3,7 +3,7 @@ import classNames from "classnames";
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";
-import FileAnnotationList from "./FileAnnotationList";
+import MetadataList from "./MetadataList";
import Pagination from "./Pagination";
import useThumbnailPath from "./useThumbnailPath";
import { PrimaryButton, TertiaryButton, TransparentIconButton } from "../Buttons";
@@ -11,17 +11,18 @@ import Tooltip from "../Tooltip";
import { ROOT_ELEMENT_ID } from "../../App";
import FileThumbnail from "../../components/FileThumbnail";
import FileDetail from "../../entity/FileDetail";
+import Tutorial from "../../entity/Tutorial";
import useDownloadFiles from "../../hooks/useDownloadFiles";
import useOpenWithMenuItems from "../../hooks/useOpenWithMenuItems";
import useTruncatedString from "../../hooks/useTruncatedString";
import { interaction, selection } from "../../state";
import styles from "./FileDetails.module.css";
-import Tutorial from "../../entity/Tutorial";
+
interface Props {
className?: string;
- fileDetails: FileDetail | undefined;
+ file: FileDetail | undefined;
isLoading?: boolean;
onClose?: () => void;
}
@@ -80,11 +81,11 @@ export default function FileDetails(props: Props) {
const dispatch = useDispatch();
const hasProvenanceSource = useSelector(selection.selectors.hasProvenanceSource);
- const openWithMenuItems = useOpenWithMenuItems(props.fileDetails);
- const truncatedFileName = useTruncatedString(props.fileDetails?.name || "", 30);
- const { isThumbnailLoading, thumbnailPath } = useThumbnailPath(props.fileDetails);
+ const openWithMenuItems = useOpenWithMenuItems(props.file);
+ const truncatedFileName = useTruncatedString(props.file?.name || "", 30);
+ const { isThumbnailLoading, thumbnailPath } = useThumbnailPath(props.file);
const { isDownloadDisabled, disabledDownloadReason, onDownload } = useDownloadFiles(
- props.fileDetails
+ props.file
);
const [isFullscreenThumbnail, setIsFullscreenThumbnail] = React.useState(false);
const isThumbnailClickable = !!thumbnailPath && !isThumbnailLoading;
@@ -101,131 +102,127 @@ export default function FileDetails(props: Props) {
>
-