From 2a4c46f56141d260a9e680e8bc9859a3202c5552 Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Fri, 24 Oct 2025 16:50:19 +0200 Subject: [PATCH 1/4] feat: UTC-344: Improve empty Cloud Storage project settings page --- web/libs/core/src/providers/AuthProvider.tsx | 5 ++ .../datamanager/src/components/App/App.tsx | 50 ++++++++++--------- .../DataView/empty-state/EmptyState.tsx | 22 ++++---- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/web/libs/core/src/providers/AuthProvider.tsx b/web/libs/core/src/providers/AuthProvider.tsx index 26ff73d5dc29..ea23c2ff3e83 100644 --- a/web/libs/core/src/providers/AuthProvider.tsx +++ b/web/libs/core/src/providers/AuthProvider.tsx @@ -14,6 +14,11 @@ export enum ABILITY { can_delete_projects = "projects.delete", can_reset_project_cache = "projects.reset_cache", can_reset_dm_views = "views.reset", + + // Cloud Storage + can_view_storage = "storages.view", + can_manage_storage = "storages.change", + can_sync_storage = "storages.sync", } export type Ability = ABILITY; diff --git a/web/libs/datamanager/src/components/App/App.tsx b/web/libs/datamanager/src/components/App/App.tsx index 9be1f9223f8a..9b18d991ff94 100644 --- a/web/libs/datamanager/src/components/App/App.tsx +++ b/web/libs/datamanager/src/components/App/App.tsx @@ -9,6 +9,7 @@ import { Labeling } from "../Label/Label"; import "./App.scss"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@humansignal/core/lib/utils/query-client"; +import { AuthProvider } from "@humansignal/core/providers/AuthProvider"; interface ErrorBoundaryProps { children: React.ReactNode; @@ -49,34 +50,35 @@ interface AppComponentProps { const AppComponent: React.FC = ({ app }) => { const rootCN = cn("root"); const rootClassName = rootCN.mod({ mode: app.SDK.mode }).toString(); - const crashCN = cn("crash"); return ( - - -
- {app.crashed ? ( -
- Oops... - - Project has been deleted or not yet created. - -
- ) : app.loading ? ( -
- -
- ) : app.isLabeling ? ( - - ) : ( - - )} -
-
- - + + + +
+ {app.crashed ? ( +
+ Oops... + + Project has been deleted or not yet created. + +
+ ) : app.loading ? ( +
+ +
+ ) : app.isLabeling ? ( + + ) : ( + + )} +
+
+ + + ); 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 index 623acb1ab4ab..38e436ea1d6b 100644 --- a/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.tsx +++ b/web/libs/datamanager/src/components/MainView/DataView/empty-state/EmptyState.tsx @@ -12,6 +12,7 @@ import { } from "@humansignal/icons"; import { Button, IconExternal, Typography, Tooltip } from "@humansignal/ui"; import { getDocsUrl } from "../../../../../../editor/src/utils/docs"; +import { ABILITY, useAuth } from "@humansignal/core/providers/AuthProvider"; declare global { interface Window { @@ -213,6 +214,7 @@ export const EmptyState: FC = ({ onClearFilters, }) => { const isImportEnabled = Boolean(canImport); + const { permissions } = useAuth(); // If filters are applied, show the filter-specific empty state (regardless of user role) if (hasFilters) { @@ -295,15 +297,17 @@ export const EmptyState: FC = ({ additionalContent: , actions: ( <> - + {permissions.can(ABILITY.can_manage_storage) && ( + + )} {isImportEnabled && ( -
- - {loading && !loaded ? ( -
- + return ( + +
+
- ) : storagesLoaded && storages.length === 0 ? null : ( - storages?.map?.((storage) => ( - - )) - )} -
- ); -}); + + {loading && !loaded ? ( +
+ +
+ ) : storagesLoaded && storages.length === 0 ? null : ( + storages?.map?.((storage) => ( + + )) + )} + + ); + }, +); diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx index 7c6a6712d481..749c63acfa48 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx @@ -1,10 +1,24 @@ -import { Typography } from "@humansignal/ui"; +import { + Button, + EmptyState, + IconCloudCustom, + IconCloudProviderAzure, + IconCloudProviderGCS, + IconCloudProviderRedis, + IconCloudProviderS3, + IconExternal, + SimpleCard, + Spinner, + Tooltip, + Typography, +} from "@humansignal/ui"; import { useEffect, useRef } from "react"; import { useHistory, useLocation } from "react-router-dom"; import { useUpdatePageTitle, createTitleFromSegments } from "@humansignal/core"; import { useProject } from "../../../providers/ProjectProvider"; import { cn } from "../../../utils/bem"; import { StorageSet } from "./StorageSet"; +import { useStorageCard } from "./hooks/useStorageCard"; export const StorageSettings = () => { const { project } = useProject(); @@ -12,13 +26,23 @@ export const StorageSettings = () => { const history = useHistory(); const location = useLocation(); const sourceStorageRef = useRef(); + const targetStorageRef = useRef(); useUpdatePageTitle(createTitleFromSegments([project?.title, "Cloud Storage Settings"])); + // Fetch storage data at parent level + const sourceStorage = useStorageCard("import", project?.id); + const targetStorage = useStorageCard("", project?.id); + + // Check if any storages exist + const hasAnyStorages = sourceStorage.storages?.length > 0 || targetStorage.storages?.length > 0; + const isLoading = sourceStorage.loading || targetStorage.loading; + const isLoaded = sourceStorage.loaded && targetStorage.loaded; + // Handle auto-open query parameter useEffect(() => { const urlParams = new URLSearchParams(location.search); - if (urlParams.get("open") === "source") { + if (urlParams.get("open") === "source" && isLoaded) { // Auto-trigger "Add Source Storage" modal setTimeout(() => { sourceStorageRef.current?.openAddModal(); @@ -27,32 +51,121 @@ export const StorageSettings = () => { // Clean URL by removing the query parameter history.replace(location.pathname); } - }, [location, history]); + }, [location, history, isLoaded]); return (
Cloud Storage - - Use cloud or database storage as the source for your labeling tasks or the target of your completed annotations. - + {hasAnyStorages && ( + + Use cloud or database storage as the source for your labeling tasks or the target of your completed + annotations. + + )} -
- - - + {isLoading && !isLoaded && ( +
+ +
+ )} + + {/* Always render StorageSet components (hidden when showing EmptyState) so refs are populated */} +
+
+ + + +
+ + {/* Show EmptyState when no storages exist */} + {!hasAnyStorages && isLoaded && !isLoading && ( + + } + title="Add your first cloud storage" + description="Use cloud or database storage as the source for your labeling tasks or the target of your completed annotations." + additionalContent={ +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ } + actions={ +
+ + +
+ } + footer={ + !window.APP_SETTINGS?.whitelabel_is_active && ( + + + Learn more + + + + ) + } + /> +
+ )}
); }; From bb23412cdaf340837a94ea3f2969980cffb603ad Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Mon, 27 Oct 2025 11:52:03 +0100 Subject: [PATCH 3/4] fixed tests --- .../DataView/empty-state/EmptyState.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 index 0ffb9072f4fb..348701d23863 100644 --- 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 @@ -43,6 +43,20 @@ jest.mock("../../../../../../editor/src/utils/docs", () => ({ getDocsUrl: (path: string) => `https://docs.example.com/${path}`, })); +// Mock AuthProvider/useAuth +jest.mock("@humansignal/core/providers/AuthProvider", () => ({ + useAuth: () => ({ + user: { id: 1, username: "testuser" }, + permissions: { + can: (ability: string) => ability === "can_manage_storage", // Grant storage management permission by default + }, + isLoading: false, + }), + ABILITY: { + can_manage_storage: "can_manage_storage", + }, +})); + // Mock global window.APP_SETTINGS Object.defineProperty(window, "APP_SETTINGS", { value: { whitelabel_is_active: false }, From b968f88ce5a96b9a5bf9ebd7bb7c0783fd3a58c1 Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Mon, 27 Oct 2025 15:49:32 +0100 Subject: [PATCH 4/4] fixed export storage card --- .../src/pages/Settings/StorageSettings/StorageSettings.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx index 749c63acfa48..6bd4be15d35f 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSettings.jsx @@ -31,8 +31,8 @@ export const StorageSettings = () => { useUpdatePageTitle(createTitleFromSegments([project?.title, "Cloud Storage Settings"])); // Fetch storage data at parent level - const sourceStorage = useStorageCard("import", project?.id); - const targetStorage = useStorageCard("", project?.id); + const sourceStorage = useStorageCard("", project?.id); + const targetStorage = useStorageCard("export", project?.id); // Check if any storages exist const hasAnyStorages = sourceStorage.storages?.length > 0 || targetStorage.storages?.length > 0; @@ -137,7 +137,7 @@ export const StorageSettings = () => {
} actions={ -
+