-
+
);
+ // Support both deprecated danger prop and new variant prop
+ const isNegative = danger || variant === "negative";
+
const linkAttributes = {
className: rootClass
.mod({
active: isActive || active,
- look: danger && "danger",
+ look: isNegative && "danger", // Keep existing CSS class for compatibility
})
.mix(className),
onClick,
@@ -51,7 +55,9 @@ export const MenuItem = ({
};
if (forceReload) {
- linkAttributes.onClick = () => (window.location.href = to ?? href);
+ linkAttributes.onClick = () => {
+ window.location.href = to ?? href;
+ };
}
return (
diff --git a/web/libs/datamanager/src/components/Common/Modal/Modal.jsx b/web/libs/datamanager/src/components/Common/Modal/Modal.jsx
index f1d106b85dfe..ab36d31e9613 100644
--- a/web/libs/datamanager/src/components/Common/Modal/Modal.jsx
+++ b/web/libs/datamanager/src/components/Common/Modal/Modal.jsx
@@ -1,7 +1,7 @@
+import { Button } from "@humansignal/ui";
import { createRef } from "react";
import { render } from "react-dom";
import { cn } from "../../../utils/bem";
-import { Button } from "@humansignal/ui";
import { Space } from "../Space/Space";
import { Modal } from "./ModalPopup";
@@ -51,10 +51,10 @@ export const confirm = ({ okText, onOk, cancelText, onCancel, buttonLook, ...pro
onCancel?.();
modal.close();
}}
- size="small"
look="outlined"
autoFocus
aria-label="Cancel"
+ data-testid="dialog-cancel-button"
>
{cancelText ?? "Cancel"}
@@ -64,8 +64,9 @@ export const confirm = ({ okText, onOk, cancelText, onCancel, buttonLook, ...pro
onOk?.();
modal.close();
}}
- size="small"
+ variant={buttonLook === "negative" ? "negative" : "primary"}
aria-label={okText ?? "OK"}
+ data-testid="dialog-ok-button"
>
{okText ?? "OK"}
@@ -86,8 +87,8 @@ export const info = ({ okText, onOkPress, ...props }) => {
onOkPress?.();
modal.close();
}}
- size="small"
aria-label="OK"
+ data-testid="dialog-ok-button"
>
{okText ?? "OK"}
diff --git a/web/libs/datamanager/src/components/Common/Modal/Modal.scss b/web/libs/datamanager/src/components/Common/Modal/Modal.scss
index cbfe75aefc92..7bedb93d9509 100644
--- a/web/libs/datamanager/src/components/Common/Modal/Modal.scss
+++ b/web/libs/datamanager/src/components/Common/Modal/Modal.scss
@@ -24,8 +24,8 @@
}
&__content {
- width: 400px;
- min-width: 400px;
+ width: 600px;
+ min-width: 600px;
min-height: 100px;
margin: 0 auto;
background-color: var(--color-neutral-background);
diff --git a/web/libs/datamanager/src/components/Common/SDKButtons.jsx b/web/libs/datamanager/src/components/Common/SDKButtons.jsx
index 398c006dfe46..4fe3bdb436e7 100644
--- a/web/libs/datamanager/src/components/Common/SDKButtons.jsx
+++ b/web/libs/datamanager/src/components/Common/SDKButtons.jsx
@@ -1,7 +1,7 @@
import { useSDK } from "../../providers/SDKProvider";
import { Button } from "@humansignal/ui";
-const SDKButton = ({ eventName, ...props }) => {
+const SDKButton = ({ eventName, testId, ...props }) => {
const sdk = useSDK();
return sdk.hasHandler(eventName) ? (
@@ -11,6 +11,7 @@ const SDKButton = ({ eventName, ...props }) => {
look={props.look ?? "outlined"}
variant={props.variant ?? "neutral"}
aria-label={`${eventName.replace("Clicked", "")} button`}
+ data-testid={testId}
onClick={() => {
sdk.invoke(eventName);
}}
@@ -23,9 +24,9 @@ export const SettingsButton = ({ ...props }) => {
};
export const ImportButton = ({ ...props }) => {
- return
;
+ return
;
};
export const ExportButton = ({ ...props }) => {
- return
;
+ return
;
};
diff --git a/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx b/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx
index a8327b3c80c8..38985c22eb8c 100644
--- a/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx
+++ b/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx
@@ -1,9 +1,9 @@
+import { IconChevronDown, IconChevronRight, IconTrash } from "@humansignal/icons";
+import { Button, Spinner, Tooltip } from "@humansignal/ui";
import { inject, observer } from "mobx-react";
-import { useCallback, useRef, useEffect, useState, useMemo } from "react";
-import { IconChevronRight, IconChevronDown, IconTrash } from "@humansignal/icons";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Block, Elem } from "../../../utils/bem";
import { FF_LOPS_E_3, isFF } from "../../../utils/feature-flags";
-import { Button, Spinner, Tooltip } from "@humansignal/ui";
import { Dropdown } from "../../Common/Dropdown/DropdownComponent";
import Form from "../../Common/Form/Form";
import { Menu } from "../../Common/Menu/Menu";
@@ -32,9 +32,9 @@ const DialogContent = ({ text, form, formRef, store, action }) => {
setIsLoading(false);
});
}
- }, [formData]);
+ }, [formData, store, action.id]);
- const fields = formData && formData.toJSON ? formData.toJSON() : formData;
+ const fields = formData?.toJSON ? formData.toJSON() : formData;
return (
@@ -126,7 +126,7 @@ const ActionButton = ({ action, parentRef, store, formRef }) => {
{
const { type: dialogType, text, form, title } = action.dialog;
const dialog = Modal[dialogType] ?? Modal.confirm;
+ // Generate dynamic content for destructive actions
+ let dialogTitle = title;
+ let dialogText = text;
+ let okButtonText = "OK";
+
+ if (destructive && !title) {
+ // Extract object type from action ID and title
+ const objectMap = {
+ delete_tasks: "tasks",
+ delete_annotations: "annotations",
+ delete_predictions: "predictions",
+ delete_reviews: "reviews",
+ delete_reviewers: "review assignments",
+ delete_annotators: "annotator assignments",
+ delete_ground_truths: "ground truths",
+ };
+
+ const objectType = objectMap[action.id] || action.title.toLowerCase().replace("delete ", "");
+ dialogTitle = `Delete selected ${objectType}?`;
+
+ // Convert to title case for button text
+ const titleCaseObject = objectType
+ .split(" ")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+ okButtonText = `Delete ${titleCaseObject}`;
+ }
+
+ if (destructive && !form) {
+ // Use standardized warning message for simple delete actions
+ const objectType = dialogTitle ? dialogTitle.replace("Delete selected ", "").replace("?", "") : "items";
+ dialogText = `You are about to delete the selected ${objectType}.\n\nThis can't be undone.`;
+ }
+
dialog({
- title: title ? title : destructive ? "Destructive action" : "Confirm action",
- body: ,
- buttonLook: destructive ? "destructive" : "primary",
+ title: dialogTitle ? dialogTitle : destructive ? "Destructive action" : "Confirm action",
+ body: ,
+ buttonLook: destructive ? "negative" : "primary",
+ okText: destructive ? okButtonText : undefined,
onOk() {
const body = formRef.current?.assembleFormData({ asJSON: true });
@@ -182,7 +217,7 @@ export const ActionsButton = injector(
setIsLoading(false);
});
}
- }, [isOpen, actions]);
+ }, [isOpen, actions, store]);
const actionButtons = actions.map((action) => (
diff --git a/web/libs/datamanager/src/components/MainView/DataView/Table.jsx b/web/libs/datamanager/src/components/MainView/DataView/Table.jsx
index 37c53f582510..03cb583d2a8e 100644
--- a/web/libs/datamanager/src/components/MainView/DataView/Table.jsx
+++ b/web/libs/datamanager/src/components/MainView/DataView/Table.jsx
@@ -1,3 +1,5 @@
+import { IconQuestionOutline } from "@humansignal/icons";
+import { Tooltip } from "@humansignal/ui";
import { inject } from "mobx-react";
import { getRoot } from "mobx-state-tree";
import { useCallback, useMemo } from "react";
@@ -6,20 +8,20 @@ import { Block, Elem } from "../../../utils/bem";
import { FF_DEV_2536, isFF } from "../../../utils/feature-flags";
import * as CellViews from "../../CellViews";
import { Icon } from "../../Common/Icon/Icon";
-import { ImportButton } from "../../Common/SDKButtons";
import { Spinner } from "../../Common/Spinner";
import { Table } from "../../Common/Table/Table";
import { Tag } from "../../Common/Tag/Tag";
-import { Tooltip } from "@humansignal/ui";
-import { IconQuestionOutline } from "@humansignal/icons";
import { GridView } from "../GridView/GridView";
import "./Table.scss";
import { Button } from "@humansignal/ui";
-import { useState } from "react";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
+import { EmptyState } from "./empty-state";
const injector = inject(({ store }) => {
const { dataStore, currentView } = store;
+ const totalTasks = store.project?.task_count ?? store.project?.task_number ?? 0;
+ const foundTasks = dataStore?.total ?? 0;
+
const props = {
store,
dataStore,
@@ -37,6 +39,11 @@ const injector = inject(({ store }) => {
isLocked: currentView?.locked ?? false,
hasData: (store.project?.task_count ?? store.project?.task_number ?? dataStore?.total ?? 0) > 0,
focusedItem: dataStore?.selected ?? dataStore?.highlighted,
+ // Role-based empty state props
+ role: store.SDK?.role ?? null,
+ project: store.project ?? {},
+ hasFilters: (currentView?.filtersApplied ?? 0) > 0,
+ canLabel: totalTasks > 0 && foundTasks > 0,
};
return props;
@@ -57,6 +64,10 @@ export const DataView = injector(
hiddenColumns = [],
hasData = false,
isLocked,
+ role,
+ project,
+ hasFilters,
+ canLabel,
...props
}) => {
const [datasetStatusID, setDatasetStatusID] = useState(store.SDK.dataset?.status?.id);
@@ -89,7 +100,13 @@ export const DataView = injector(
{original?.readableType ?? parent.title}
,
@@ -171,7 +188,10 @@ export const DataView = injector(
size="small"
look="outlined"
onClick={async () => {
- await store.fetchProject({ force: true, interaction: "refresh" });
+ await store.fetchProject({
+ force: true,
+ interaction: "refresh",
+ });
await store.currentView?.reload();
}}
>
@@ -180,33 +200,51 @@ export const DataView = injector(
);
}
+ // Unified empty state handling - EmptyState now handles all cases internally
if (total === 0 || !hasData) {
+ // Use unified EmptyState for all cases
return (
-
- {hasData ? (
- <>
- Nothing found
- Try adjusting the filter
- >
- ) : (
- "Looks like you have not imported any data yet"
- )}
-
- {!hasData && !!store.interfaces.get("import") && (
-
-
- Go to import
-
-
- )}
+ getRoot(store)?.SDK?.invoke?.("openSourceStorageModal")}
+ onOpenImportModal={() => getRoot(store)?.SDK?.invoke?.("importClicked")}
+ // Role-based functionality props
+ userRole={role}
+ project={project}
+ hasData={hasData}
+ hasFilters={hasFilters}
+ canLabel={canLabel}
+ onLabelAllTasks={() => {
+ // Use the same logic as the main Label All Tasks button
+ // Set localStorage to indicate "label all" mode (same as main button)
+ localStorage.setItem("dm:labelstream:mode", "all");
+
+ // Start label stream mode (DataManager's equivalent of navigating to labeling)
+ store.startLabelStream();
+ }}
+ onClearFilters={() => {
+ // Clear all filters from the current view
+ const currentView = store.currentView;
+ if (currentView && currentView.filters) {
+ // Create a copy of the filters array to avoid modification during iteration
+ const filtersToDelete = [...currentView.filters];
+ filtersToDelete.forEach((filter) => {
+ currentView.deleteFilter(filter);
+ });
+ // Reload the view to refresh the data
+ currentView.reload();
+ }
+ }}
+ />
);
}
return content;
},
- [hasData, isLabeling, isLoading, total, datasetStatusID],
+ [hasData, isLabeling, isLoading, total, datasetStatusID, role, project, hasFilters, canLabel],
);
const decorationContent = (col) => {
diff --git a/web/libs/datamanager/src/components/MainView/DataView/Table.scss b/web/libs/datamanager/src/components/MainView/DataView/Table.scss
index 14177faebd03..cb8477de9a80 100644
--- a/web/libs/datamanager/src/components/MainView/DataView/Table.scss
+++ b/web/libs/datamanager/src/components/MainView/DataView/Table.scss
@@ -13,17 +13,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
+ position: relative;
- &__description {
- h3 {
- color: var(--color-neutral-content);
- }
-
- font-size: 16px;
- text-align: center;
- color: var(--color-neutral-content-subtler);
- margin-bottom: 8px;
- }
}
.syncInProgress {
diff --git a/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.test.tsx b/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.test.tsx
new file mode 100644
index 000000000000..0ffb9072f4fb
--- /dev/null
+++ b/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.test.tsx
@@ -0,0 +1,372 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import { EmptyState } from "./EmptyState";
+
+// Mock the external dependencies
+jest.mock("@humansignal/ui", () => ({
+ Button: ({ children, onClick, disabled, "data-testid": testId, ...props }: any) => (
+
+ ),
+ Typography: ({ children, className, ...props }: any) => (
+
+ {children}
+
+ ),
+ IconExternal: ({ width, height }: any) =>
,
+ Tooltip: ({ children, title }: any) =>
{children}
,
+}));
+
+jest.mock("@humansignal/icons", () => ({
+ IconUpload: () =>
,
+ IconLsLabeling: ({ width, height }: any) =>
,
+ IconCheck: ({ width, height }: any) =>
,
+ IconSearch: ({ width, height }: any) =>
,
+ IconInbox: ({ width, height }: any) =>
,
+ IconCloudProviderS3: ({ width, height, className }: any) => (
+
+ ),
+ IconCloudProviderGCS: ({ width, height, className }: any) => (
+
+ ),
+ IconCloudProviderAzure: ({ width, height, className }: any) => (
+
+ ),
+ IconCloudProviderRedis: ({ width, height, className }: any) => (
+
+ ),
+}));
+
+jest.mock("../../../../../../editor/src/utils/docs", () => ({
+ getDocsUrl: (path: string) => `https://docs.example.com/${path}`,
+}));
+
+// Mock global window.APP_SETTINGS
+Object.defineProperty(window, "APP_SETTINGS", {
+ value: { whitelabel_is_active: false },
+ writable: true,
+});
+
+describe("EmptyState Component", () => {
+ const defaultProps = {
+ canImport: true,
+ onOpenSourceStorageModal: jest.fn(),
+ onOpenImportModal: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Basic Import Functionality", () => {
+ it("should render the default import state when no role is specified", () => {
+ render(
);
+
+ // Check main title and description
+ expect(screen.getByText("Import data to get your project started")).toBeInTheDocument();
+ expect(screen.getByText("Connect your cloud storage or upload files from your computer")).toBeInTheDocument();
+
+ // Check that storage provider icons are present
+ expect(screen.getByTestId("dm-storage-provider-icons")).toBeInTheDocument();
+
+ // Check that both buttons are present
+ expect(screen.getByTestId("dm-connect-source-storage-button")).toBeInTheDocument();
+ expect(screen.getByTestId("dm-import-button")).toBeInTheDocument();
+
+ // Check accessibility features
+ expect(screen.getByTestId("empty-state-label")).toHaveAttribute("aria-labelledby", "dm-empty-title");
+ expect(screen.getByTestId("empty-state-label")).toHaveAttribute("aria-describedby", "dm-empty-desc");
+ });
+
+ it("should render non-interactive state when canImport is false", () => {
+ render(
);
+
+ const label = screen.getByTestId("empty-state-label");
+ expect(label).toHaveAttribute("aria-labelledby", "dm-empty-title");
+ expect(label).toHaveAttribute("aria-describedby", "dm-empty-desc");
+
+ // Import button should not be present when canImport is false
+ expect(screen.queryByTestId("dm-import-button")).not.toBeInTheDocument();
+ // Connect Storage button should still be present
+ expect(screen.getByTestId("dm-connect-source-storage-button")).toBeInTheDocument();
+ });
+
+ it("should render interactive state when canImport is true", () => {
+ render(
);
+
+ const label = screen.getByTestId("empty-state-label");
+ expect(label).toHaveAttribute("aria-labelledby", "dm-empty-title");
+ expect(label).toHaveAttribute("aria-describedby", "dm-empty-desc");
+
+ // Both buttons should be present
+ expect(screen.getByTestId("dm-connect-source-storage-button")).toBeInTheDocument();
+ expect(screen.getByTestId("dm-import-button")).toBeInTheDocument();
+ });
+ });
+
+ describe("Button Interactions", () => {
+ it("should call onOpenSourceStorageModal when Connect Storage button is clicked", async () => {
+ const user = userEvent.setup();
+ const mockOpenStorage = jest.fn();
+
+ render(
);
+
+ const connectButton = screen.getByTestId("dm-connect-source-storage-button");
+ await user.click(connectButton);
+
+ expect(mockOpenStorage).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onOpenImportModal when Import button is clicked", async () => {
+ const user = userEvent.setup();
+ const mockOpenImport = jest.fn();
+
+ render(
);
+
+ const importButton = screen.getByTestId("dm-import-button");
+ await user.click(importButton);
+
+ expect(mockOpenImport).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Role-Based Empty States", () => {
+ describe("Filter-based Empty State", () => {
+ it("should render filter empty state when hasFilters is true", () => {
+ render(
);
+
+ expect(screen.getByText("No tasks found")).toBeInTheDocument();
+ expect(screen.getByText("Try adjusting or clearing the filters to see more results")).toBeInTheDocument();
+ expect(screen.getByTestId("dm-clear-filters-button")).toBeInTheDocument();
+ expect(screen.getByTestId("icon-search")).toBeInTheDocument();
+ });
+
+ it("should call onClearFilters when Clear Filters button is clicked", async () => {
+ const user = userEvent.setup();
+ const mockClearFilters = jest.fn();
+
+ render(
);
+
+ const clearButton = screen.getByTestId("dm-clear-filters-button");
+ await user.click(clearButton);
+
+ expect(mockClearFilters).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Reviewer Role", () => {
+ it("should render reviewer empty state", () => {
+ render(
);
+
+ expect(screen.getByText("No tasks available for review or labeling")).toBeInTheDocument();
+ expect(screen.getByText("Tasks imported to this project will appear here")).toBeInTheDocument();
+ expect(screen.getByTestId("icon-check")).toBeInTheDocument();
+ });
+ });
+
+ describe("Annotator Role", () => {
+ it("should render annotator auto-distribution state with Label All Tasks button", () => {
+ const mockLabelAllTasks = jest.fn();
+ const project = {
+ assignment_settings: {
+ label_stream_task_distribution: "auto_distribution",
+ },
+ };
+
+ render(
+
,
+ );
+
+ expect(screen.getByText("Start labeling tasks")).toBeInTheDocument();
+ expect(screen.getByText("Tasks you've labeled will appear here")).toBeInTheDocument();
+ expect(screen.getByTestId("dm-label-all-tasks-button")).toBeInTheDocument();
+ expect(screen.getByTestId("icon-ls-labeling")).toBeInTheDocument();
+ });
+
+ it("should call onLabelAllTasks when Label All Tasks button is clicked", async () => {
+ const user = userEvent.setup();
+ const mockLabelAllTasks = jest.fn();
+ const project = {
+ assignment_settings: {
+ label_stream_task_distribution: "auto_distribution",
+ },
+ };
+
+ render(
+
,
+ );
+
+ const labelButton = screen.getByTestId("dm-label-all-tasks-button");
+ await user.click(labelButton);
+
+ expect(mockLabelAllTasks).toHaveBeenCalledTimes(1);
+ });
+
+ it("should render annotator manual distribution state without button", () => {
+ const project = {
+ assignment_settings: {
+ label_stream_task_distribution: "assigned_only",
+ },
+ };
+
+ render(
);
+
+ expect(screen.getByText("No tasks available")).toBeInTheDocument();
+ expect(screen.getByText("Tasks assigned to you will appear here")).toBeInTheDocument();
+ expect(screen.getByTestId("icon-inbox")).toBeInTheDocument();
+ expect(screen.queryByTestId("dm-label-all-tasks-button")).not.toBeInTheDocument();
+ });
+
+ it("should render fallback annotator state for unknown distribution setting", () => {
+ const project = {
+ assignment_settings: {
+ label_stream_task_distribution: "unknown_setting",
+ },
+ };
+
+ render(
);
+
+ expect(screen.getByText("No tasks available")).toBeInTheDocument();
+ expect(screen.getByText("Tasks will appear here when they become available")).toBeInTheDocument();
+ expect(screen.getByTestId("icon-inbox")).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("should have proper ARIA attributes", () => {
+ render(
);
+
+ const label = screen.getByTestId("empty-state-label");
+ const title = screen.getByText("Import data to get your project started");
+ const description = screen.getByText("Connect your cloud storage or upload files from your computer");
+
+ expect(label).toHaveAttribute("aria-labelledby", "dm-empty-title");
+ expect(label).toHaveAttribute("aria-describedby", "dm-empty-desc");
+ expect(title).toHaveAttribute("id", "dm-empty-title");
+ expect(description).toHaveAttribute("id", "dm-empty-desc");
+ });
+
+ it("should render documentation link with proper accessibility", () => {
+ render(
);
+
+ const docLink = screen.getByTestId("dm-docs-data-import-link");
+ expect(docLink).toHaveAttribute("href", "https://docs.example.com/guide/tasks");
+ expect(docLink).toHaveAttribute("target", "_blank");
+ expect(docLink).toHaveAttribute("rel", "noopener noreferrer");
+
+ const srText = docLink.querySelector(".sr-only");
+ expect(srText).toHaveTextContent("(opens in a new tab)");
+ });
+ });
+
+ describe("Conditional Content", () => {
+ it("should hide documentation link when whitelabel is active", () => {
+ // Mock whitelabel active
+ Object.defineProperty(window, "APP_SETTINGS", {
+ value: { whitelabel_is_active: true },
+ writable: true,
+ });
+
+ render(
);
+
+ expect(screen.queryByTestId("dm-docs-data-import-link")).not.toBeInTheDocument();
+
+ // Reset for other tests
+ Object.defineProperty(window, "APP_SETTINGS", {
+ value: { whitelabel_is_active: false },
+ writable: true,
+ });
+ });
+
+ it("should show documentation link when whitelabel is not active", () => {
+ render(
);
+
+ expect(screen.getByTestId("dm-docs-data-import-link")).toBeInTheDocument();
+ });
+ });
+
+ describe("Storage Provider Icons", () => {
+ it("should render storage provider icons with proper tooltips", () => {
+ render(
);
+
+ const iconsContainer = screen.getByTestId("dm-storage-provider-icons");
+ expect(iconsContainer).toBeInTheDocument();
+
+ // Check for aria-labels on storage provider containers
+ const s3Container = screen.getByLabelText("Amazon S3");
+ const gcsContainer = screen.getByLabelText("Google Cloud Storage");
+ const azureContainer = screen.getByLabelText("Azure Blob Storage");
+ const redisContainer = screen.getByLabelText("Redis Storage");
+
+ expect(s3Container).toBeInTheDocument();
+ expect(gcsContainer).toBeInTheDocument();
+ expect(azureContainer).toBeInTheDocument();
+ expect(redisContainer).toBeInTheDocument();
+ });
+
+ it("should show storage icons in correct order", () => {
+ render(
);
+
+ const iconsContainer = screen.getByTestId("dm-storage-provider-icons");
+ const iconContainers = iconsContainer.querySelectorAll("[aria-label]");
+
+ expect(iconContainers).toHaveLength(4);
+ expect(iconContainers[0]).toHaveAttribute("aria-label", "Amazon S3");
+ expect(iconContainers[1]).toHaveAttribute("aria-label", "Google Cloud Storage");
+ expect(iconContainers[2]).toHaveAttribute("aria-label", "Azure Blob Storage");
+ expect(iconContainers[3]).toHaveAttribute("aria-label", "Redis Storage");
+ });
+ });
+
+ describe("Button States and Props", () => {
+ it("should render buttons with correct text content", () => {
+ render(
);
+
+ expect(screen.getByTestId("dm-connect-source-storage-button")).toHaveTextContent("Connect Cloud Storage");
+ expect(screen.getByTestId("dm-import-button")).toHaveTextContent("Import");
+ });
+
+ it("should render Clear Filters button with correct text", () => {
+ render(
);
+
+ expect(screen.getByTestId("dm-clear-filters-button")).toHaveTextContent("Clear Filters");
+ });
+
+ it("should render Label All Tasks button with correct text and state", () => {
+ const project = {
+ assignment_settings: {
+ label_stream_task_distribution: "auto_distribution",
+ },
+ };
+
+ render(
);
+
+ const labelButton = screen.getByTestId("dm-label-all-tasks-button");
+ expect(labelButton).toHaveTextContent("Label All Tasks");
+ expect(labelButton).not.toBeDisabled();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle missing project object gracefully", () => {
+ render(
);
+
+ // Should render fallback state
+ expect(screen.getByText("No tasks available")).toBeInTheDocument();
+ expect(screen.getByText("Tasks will appear here when they become available")).toBeInTheDocument();
+ });
+
+ it("should handle missing assignment settings gracefully", () => {
+ const project = {}; // No assignment_settings
+
+ render(
);
+
+ // Should render fallback state
+ expect(screen.getByText("No tasks available")).toBeInTheDocument();
+ expect(screen.getByText("Tasks will appear here when they become available")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.tsx b/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.tsx
new file mode 100644
index 000000000000..623acb1ab4ab
--- /dev/null
+++ b/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.tsx
@@ -0,0 +1,323 @@
+import React, { type FC, type ReactNode } from "react";
+import {
+ IconUpload,
+ IconLsLabeling,
+ IconCheck,
+ IconSearch,
+ IconInbox,
+ IconCloudProviderS3,
+ IconCloudProviderGCS,
+ IconCloudProviderAzure,
+ IconCloudProviderRedis,
+} from "@humansignal/icons";
+import { Button, IconExternal, Typography, Tooltip } from "@humansignal/ui";
+import { getDocsUrl } from "../../../../../../editor/src/utils/docs";
+
+declare global {
+ interface Window {
+ APP_SETTINGS?: {
+ whitelabel_is_active?: boolean;
+ };
+ }
+}
+
+// TypeScript interfaces for props
+interface EmptyStateProps {
+ canImport: boolean;
+ onOpenSourceStorageModal?: () => void;
+ onOpenImportModal?: () => void;
+ // Role-based props (optional)
+ userRole?: string;
+ project?: {
+ assignment_settings?: {
+ label_stream_task_distribution?: "auto_distribution" | "assigned_only" | string;
+ };
+ };
+ hasData?: boolean;
+ hasFilters?: boolean;
+ canLabel?: boolean;
+ onLabelAllTasks?: () => void;
+ onClearFilters?: () => void;
+}
+
+// Internal helper interfaces and types
+interface EmptyStateLayoutProps {
+ icon: ReactNode;
+ iconBackground?: string;
+ iconColor?: string;
+ title: string;
+ description: string;
+ actions?: ReactNode;
+ additionalContent?: ReactNode;
+ footer?: ReactNode;
+ testId?: string;
+ ariaLabelledBy?: string;
+ ariaDescribedBy?: string;
+ wrapperClassName?: string;
+}
+
+// Internal helper function to render common empty state structure
+const renderEmptyStateLayout = ({
+ icon,
+ iconBackground = "bg-primary-emphasis",
+ iconColor = "text-primary-icon",
+ title,
+ description,
+ actions,
+ additionalContent,
+ footer,
+ testId,
+ ariaLabelledBy,
+ ariaDescribedBy,
+ wrapperClassName = "w-full h-full flex flex-col items-center justify-center text-center p-wide",
+}: EmptyStateLayoutProps) => {
+ // Clone the icon and ensure it has consistent 40x40 size
+ const iconWithSize = React.cloneElement(icon as React.ReactElement, {
+ width: 40,
+ height: 40,
+ });
+
+ const content = (
+
+
+ {iconWithSize}
+
+
+
+ {title}
+
+
+
+ {description}
+
+
+ {additionalContent}
+
+ {actions &&
+ (() => {
+ // Flatten children and filter out null/false values to count actual rendered elements
+ const flattenedActions = React.Children.toArray(actions).flat().filter(Boolean);
+ const actualActionCount = flattenedActions.length;
+ const isSingleAction = actualActionCount === 1;
+
+ return (
+
+ {actions}
+
+ );
+ })()}
+
+ {footer &&
{footer}
}
+
+ );
+
+ // For import state, we need special wrapper structure
+ if (testId === "empty-state-label") {
+ return (
+
+ );
+ }
+
+ // For all other states
+ return content;
+};
+
+// Storage provider icons component
+const StorageProviderIcons = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+// Documentation link component
+const DocumentationLink = () => {
+ if (window.APP_SETTINGS?.whitelabel_is_active) {
+ return null;
+ }
+
+ return (
+
+
+ See docs on importing data
+ (opens in a new tab)
+
+
+
+ );
+};
+
+/**
+ * Unified empty state for Data Manager
+ * Handles different empty states based on user role and context
+ *
+ * Props:
+ * - canImport: boolean — whether import is enabled in interfaces
+ * - onOpenSourceStorageModal: () => void — opens Connect Source Storage modal
+ * - onOpenImportModal: () => void — opens Import modal
+ * - userRole: string — User role (REVIEWER, ANNOTATOR, etc.) - optional
+ * - project: object — Project object with assignment settings - optional
+ * - hasData: boolean — Whether the project has any tasks - optional
+ * - hasFilters: boolean — Whether filters are currently applied - optional
+ * - canLabel: boolean — Whether the Label All Tasks button would be enabled - optional
+ * - onLabelAllTasks: function — Callback for Label All Tasks action - optional
+ * - onClearFilters: function — Callback to clear all applied filters - optional
+ */
+
+export const EmptyState: FC
= ({
+ canImport,
+ onOpenSourceStorageModal,
+ onOpenImportModal,
+ // Role-based props (optional)
+ userRole,
+ project,
+ hasData: _hasData,
+ hasFilters,
+ canLabel: _canLabel,
+ onLabelAllTasks,
+ onClearFilters,
+}) => {
+ const isImportEnabled = Boolean(canImport);
+
+ // If filters are applied, show the filter-specific empty state (regardless of user role)
+ if (hasFilters) {
+ return renderEmptyStateLayout({
+ icon: ,
+ iconBackground: "bg-warning-background",
+ iconColor: "text-warning-icon",
+ title: "No tasks found",
+ description: "Try adjusting or clearing the filters to see more results",
+ actions: (
+
+ ),
+ });
+ }
+
+ // Role-based empty state logic (from RoleBasedEmptyState)
+ // For service roles (reviewers/annotators), show role-specific empty states when they have no visible tasks
+ // This applies whether the project has tasks or not - what matters is what's visible to this user
+ if (userRole === "REVIEWER" || userRole === "ANNOTATOR") {
+ // Reviewer empty state
+ if (userRole === "REVIEWER") {
+ return renderEmptyStateLayout({
+ icon: ,
+ title: "No tasks available for review or labeling",
+ description: "Tasks imported to this project will appear here",
+ });
+ }
+
+ // Annotator empty state
+ if (userRole === "ANNOTATOR") {
+ const isAutoDistribution = project?.assignment_settings?.label_stream_task_distribution === "auto_distribution";
+ const isManualDistribution = project?.assignment_settings?.label_stream_task_distribution === "assigned_only";
+
+ if (isAutoDistribution) {
+ return renderEmptyStateLayout({
+ icon: ,
+ title: "Start labeling tasks",
+ description: "Tasks you've labeled will appear here",
+ actions: (
+
+ ),
+ });
+ }
+
+ if (isManualDistribution) {
+ return renderEmptyStateLayout({
+ icon: ,
+ title: "No tasks available",
+ description: "Tasks assigned to you will appear here",
+ });
+ }
+
+ // Fallback for annotators with unknown distribution setting
+ return renderEmptyStateLayout({
+ icon: ,
+ title: "No tasks available",
+ description: "Tasks will appear here when they become available",
+ });
+ }
+ }
+
+ // Default case: show import functionality (existing behavior for Owners/Admins/Managers)
+ return renderEmptyStateLayout({
+ icon: ,
+ title: "Import data to get your project started",
+ description: "Connect your cloud storage or upload files from your computer",
+ testId: "empty-state-label",
+ ariaLabelledBy: "dm-empty-title",
+ ariaDescribedBy: "dm-empty-desc",
+ additionalContent: ,
+ actions: (
+ <>
+
+
+ {isImportEnabled && (
+
+ )}
+ >
+ ),
+ footer: ,
+ });
+};
diff --git a/web/libs/datamanager/src/components/MainView/DataView/empty-state/index.ts b/web/libs/datamanager/src/components/MainView/DataView/empty-state/index.ts
new file mode 100644
index 000000000000..aac2d4d6760a
--- /dev/null
+++ b/web/libs/datamanager/src/components/MainView/DataView/empty-state/index.ts
@@ -0,0 +1 @@
+export { EmptyState } from "./EmptyState";
diff --git a/web/libs/datamanager/src/sdk/dm-sdk.js b/web/libs/datamanager/src/sdk/dm-sdk.js
index 7b1dea778210..59102a564554 100644
--- a/web/libs/datamanager/src/sdk/dm-sdk.js
+++ b/web/libs/datamanager/src/sdk/dm-sdk.js
@@ -137,6 +137,9 @@ export class DataManager {
/** @type {"dm" | "labelops"} */
type = "dm";
+ /** @type {string} */
+ role = null;
+
/**
* Constructor
* @param {DMConfig} config
@@ -162,6 +165,7 @@ export class DataManager {
this.instruments = prepareInstruments(config.instruments ?? {});
this.apiTransform = config.apiTransform ?? {};
this.preload = config.preload ?? {};
+ this.role = config.role ?? null;
this.interfaces = objectToMap({
tabs: true,
toolbar: true,
diff --git a/web/libs/ui/src/assets/icons/inbox.svg b/web/libs/ui/src/assets/icons/inbox.svg
new file mode 100644
index 000000000000..f98f647a78fa
--- /dev/null
+++ b/web/libs/ui/src/assets/icons/inbox.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/web/libs/ui/src/assets/icons/index.ts b/web/libs/ui/src/assets/icons/index.ts
index 07bd15dc36b3..135e3b0200c8 100644
--- a/web/libs/ui/src/assets/icons/index.ts
+++ b/web/libs/ui/src/assets/icons/index.ts
@@ -135,6 +135,7 @@ export { ReactComponent as IconHistoryRewind } from "./history-rewind.svg";
export { ReactComponent as IconHome } from "./home.svg";
export { ReactComponent as IconHotkeys } from "./hotkeys.svg";
export { ReactComponent as IconHumanSignal } from "./humansignal.svg";
+export { ReactComponent as IconInbox } from "./inbox.svg";
export { ReactComponent as IconInfo } from "./info.svg";
export { ReactComponent as IconInfoConfig } from "./info-config.svg";
export { ReactComponent as IconInfoFilled } from "./info-filled.svg";