diff --git a/poetry.lock b/poetry.lock index 3dea9fa02250..b176a3798136 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2136,7 +2136,7 @@ optional = false python-versions = ">=3.9,<4" groups = ["main"] files = [ - {file = "a80479402d230f5e097a3052f4fe39647e05250a.zip", hash = "sha256:b3e014d60804be801ff4982322e1e784233d75b391f53cfa45e142807c1188ff"}, + {file = "2868d5981a9398edef1141dc1ddb0d3c24411d92.zip", hash = "sha256:45bffdf7e6d2c235131d7ae4f8409f81906b29d70196631c81d42677c3ef733f"}, ] [package.dependencies] @@ -2164,7 +2164,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/a80479402d230f5e097a3052f4fe39647e05250a.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/2868d5981a9398edef1141dc1ddb0d3c24411d92.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5037,4 +5037,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "acbf73d41870732a03b40dd25d5bc7ae486b2af716a9b8deea0c63a41982aa39" +content-hash = "2af717c53cc158442a0fc64b59bf41d069cc133ef04b599a4fb537613ccfab7b" diff --git a/pyproject.toml b/pyproject.toml index 02ec77401317..13bc4d10da3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ dependencies = [ "djangorestframework-simplejwt[crypto] (>=5.4.0,<6.0.0)", "tldextract (>=5.1.3)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/a80479402d230f5e097a3052f4fe39647e05250a.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/2868d5981a9398edef1141dc1ddb0d3c24411d92.zip", ## HumanSignal repo dependencies :end ] diff --git a/web/apps/labelstudio/src/pages/DataManager/DataManager.jsx b/web/apps/labelstudio/src/pages/DataManager/DataManager.jsx index 96a4a1792c98..e8b49667d06b 100644 --- a/web/apps/labelstudio/src/pages/DataManager/DataManager.jsx +++ b/web/apps/labelstudio/src/pages/DataManager/DataManager.jsx @@ -1,8 +1,8 @@ +import { Button, buttonVariant, ToastContext, ToastType } from "@humansignal/ui"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { generatePath, useHistory } from "react-router"; import { Link, NavLink } from "react-router-dom"; import { Spinner } from "../../components"; -import { Button, buttonVariant } from "@humansignal/ui"; import { modal } from "../../components/Modal/Modal"; import { Space } from "../../components/Space/Space"; import { useAPI } from "../../providers/ApiProvider"; @@ -14,7 +14,6 @@ import { isDefined } from "../../utils/helpers"; import { ImportModal } from "../CreateProject/Import/ImportModal"; import { ExportPage } from "../ExportPage/ExportPage"; import { APIConfig } from "./api-config"; -import { ToastContext, ToastType } from "@humansignal/ui"; import "./DataManager.scss"; @@ -127,6 +126,11 @@ export const DataManagerPage = ({ ...props }) => { history.push(buildLink("/data/import", { id: params.id })); }); + // Navigate to Storage Settings and auto-open Add Source Storage modal + dataManager.on("openSourceStorageModal", () => { + history.push(buildLink("/settings/storage?open=source", { id: params.id })); + }); + dataManager.on("exportClicked", () => { history.push(buildLink("/data/export", { id: params.id })); }); @@ -289,7 +293,13 @@ DataManagerPage.context = ({ dmRef }) => { onClick={() => { modal({ title: "Instructions", - body: () =>
, + body: () => ( +
+ ), }); }} > diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx index 55559412966e..4907e139ea01 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx @@ -1,38 +1,30 @@ -import { useCallback, useContext } from "react"; -import { Columns } from "../../../components"; +import { StorageProviderForm } from "@humansignal/app-common/blocks/StorageProviderForm"; +import { ff } from "@humansignal/core"; import { Button } from "@humansignal/ui"; +import { useAtomValue } from "jotai"; +import { forwardRef, useCallback, useContext, useImperativeHandle } from "react"; +import { Columns } from "../../../components"; import { confirm, modal } from "../../../components/Modal/Modal"; import { Spinner } from "../../../components/Spinner/Spinner"; import { ApiContext } from "../../../providers/ApiProvider"; import { projectAtom } from "../../../providers/ProjectProvider"; -import { StorageCard } from "./StorageCard"; -import { StorageForm } from "./StorageForm"; -import { useAtomValue } from "jotai"; import { useStorageCard } from "./hooks/useStorageCard"; -import { ff } from "@humansignal/core"; -import { StorageProviderForm } from "@humansignal/app-common/blocks/StorageProviderForm"; import { providers } from "./providers"; +import { StorageCard } from "./StorageCard"; +import { StorageForm } from "./StorageForm"; -export const StorageSet = ({ title, target, rootClass, buttonLabel }) => { +export const StorageSet = forwardRef(({ title, target, rootClass, buttonLabel }, ref) => { const api = useContext(ApiContext); const project = useAtomValue(projectAtom); - const storageTypesQueryKey = ["storage-types", target]; - const storagesQueryKey = ["storages", target, project?.id]; + // The useStorageCard hook now consolidates this + // logic providing only the essential state needed by this component/ + const useNewStorageScreen = ff.isActive(ff.FF_NEW_STORAGES); - const { - storageTypes, - storageTypesLoading, - storageTypesLoaded, - reloadStorageTypes, - storages, - storagesLoading, - storagesLoaded, - reloadStoragesList, - loading, - loaded, - fetchStorages, - } = useStorageCard(target, project?.id); + const { storageTypes, storages, storagesLoaded, loading, loaded, fetchStorages } = useStorageCard( + target, + project?.id, + ); const showStorageFormModal = useCallback( (storage) => { @@ -92,12 +84,21 @@ export const StorageSet = ({ title, target, rootClass, buttonLabel }) => { [showStorageFormModal], ); + // Expose showStorageFormModal to parent via ref + useImperativeHandle( + ref, + () => ({ + openAddModal: () => showStorageFormModal(), + }), + [showStorageFormModal], + ); + const onDeleteStorage = useCallback( async (storage) => { confirm({ title: "Deleting storage", body: "This action cannot be undone. Are you sure?", - buttonLook: "destructive", + buttonLook: "negative", onOk: async () => { const response = await api.callApi("deleteStorage", { params: { @@ -141,4 +142,4 @@ export const StorageSet = ({ title, target, rootClass, buttonLabel }) => { )} ); -}; +}); diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx index 55c1d1465f1d..ea82ef3a0d3c 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx @@ -1,12 +1,31 @@ +import { Typography } from "@humansignal/ui"; +import { useEffect, useRef } from "react"; +import { useHistory, useLocation } from "react-router-dom"; import { cn } from "../../../utils/bem"; -import { StorageSet } from "./StorageSet"; import { isInLicense, LF_CLOUD_STORAGE_FOR_MANAGERS } from "../../../utils/license-flags"; -import { Typography } from "@humansignal/ui"; +import { StorageSet } from "./StorageSet"; const isAllowCloudStorage = !isInLicense(LF_CLOUD_STORAGE_FOR_MANAGERS); export const StorageSettings = () => { const rootClass = cn("storage-settings"); // TODO: Remove in the next BEM cleanup + const history = useHistory(); + const location = useLocation(); + const sourceStorageRef = useRef(); + + // Handle auto-open query parameter + useEffect(() => { + const urlParams = new URLSearchParams(location.search); + if (urlParams.get("open") === "source") { + // Auto-trigger "Add Source Storage" modal + setTimeout(() => { + sourceStorageRef.current?.openAddModal(); + }, 100); // Small delay to ensure component is mounted + + // Clean URL by removing the query parameter + history.replace(location.pathname); + } + }, [location, history]); return isAllowCloudStorage ? (
@@ -18,7 +37,12 @@ export const StorageSettings = () => {
- + ); + // 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 ( +
+
{content}
+
+ ); + } + + // 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";