diff --git a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx index 9bab912e747b..44ba3893a460 100644 --- a/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx +++ b/web/apps/labelstudio/src/pages/Settings/StorageSettings/StorageSet.jsx @@ -8,138 +8,147 @@ import { confirm, modal } from "../../../components/Modal/Modal"; import { Spinner } from "../../../components/Spinner/Spinner"; import { ApiContext } from "../../../providers/ApiProvider"; import { projectAtom } from "../../../providers/ProjectProvider"; -import { useStorageCard } from "./hooks/useStorageCard"; import { providers } from "./providers"; import { StorageCard } from "./StorageCard"; import { StorageForm } from "./StorageForm"; -export const StorageSet = forwardRef(({ title, target, rootClass, buttonLabel }, ref) => { - const api = useContext(ApiContext); - const project = useAtomValue(projectAtom); - // The useStorageCard hook now consolidates this - // logic providing only the essential state needed by this component/ +export const StorageSet = forwardRef( + ( + { + title, + target, + rootClass, + buttonLabel, + // Props from parent for lifted state + storageTypes, + storages, + storagesLoaded, + loading, + loaded, + fetchStorages, + }, + ref, + ) => { + const api = useContext(ApiContext); + const project = useAtomValue(projectAtom); - const useNewStorageScreen = ff.isActive(ff.FF_NEW_STORAGES); + const useNewStorageScreen = ff.isActive(ff.FF_NEW_STORAGES); - const { storageTypes, storages, storagesLoaded, loading, loaded, fetchStorages } = useStorageCard( - target, - project?.id, - ); + const showStorageFormModal = useCallback( + (storage) => { + const action = storage ? "Edit" : "Connect"; + const actionTarget = target === "export" ? "Target" : "Source"; + const title = `${action} ${actionTarget} Storage`; - const showStorageFormModal = useCallback( - (storage) => { - const action = storage ? "Edit" : "Connect"; - const actionTarget = target === "export" ? "Target" : "Source"; - const title = `${action} ${actionTarget} Storage`; + const modalRef = modal({ + title, + closeOnClickOutside: false, + style: { width: 840 }, + bare: useNewStorageScreen, + onHidden: () => { + // Reset state when modal is closed (including Escape key) + // This ensures clean state for next modal open + }, + body: useNewStorageScreen ? ( + { + modalRef.close(); + fetchStorages(); + }} + onHide={() => { + // This will be called when the modal is closed via Escape key + // The state reset is handled inside StorageProviderForm + }} + /> + ) : ( + { + await fetchStorages(); + modalRef.close(); + }} + /> + ), + }); + }, + [project, fetchStorages, target, rootClass], + ); - const modalRef = modal({ - title, - closeOnClickOutside: false, - style: { width: 840 }, - bare: useNewStorageScreen, - onHidden: () => { - // Reset state when modal is closed (including Escape key) - // This ensures clean state for next modal open - }, - body: useNewStorageScreen ? ( - { - modalRef.close(); - fetchStorages(); - }} - onHide={() => { - // This will be called when the modal is closed via Escape key - // The state reset is handled inside StorageProviderForm - }} - /> - ) : ( - { - await fetchStorages(); - modalRef.close(); - }} - /> - ), - }); - }, - [project, fetchStorages, target, rootClass], - ); + const onEditStorage = useCallback( + async (storage) => { + showStorageFormModal(storage); + }, + [showStorageFormModal], + ); - const onEditStorage = useCallback( - async (storage) => { - showStorageFormModal(storage); - }, - [showStorageFormModal], - ); + // Expose showStorageFormModal to parent via ref + useImperativeHandle( + ref, + () => ({ + openAddModal: () => showStorageFormModal(), + }), + [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: "negative", + onOk: async () => { + const response = await api.callApi("deleteStorage", { + params: { + type: storage.type, + pk: storage.id, + target, + }, + }); - const onDeleteStorage = useCallback( - async (storage) => { - confirm({ - title: "Deleting storage", - body: "This action cannot be undone. Are you sure?", - buttonLook: "negative", - onOk: async () => { - const response = await api.callApi("deleteStorage", { - params: { - type: storage.type, - pk: storage.id, - target, - }, - }); + if (response !== null) fetchStorages(); + }, + }); + }, + [fetchStorages], + ); - if (response !== null) fetchStorages(); - }, - }); - }, - [fetchStorages], - ); - - return ( - -
- -
- - {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..6bd4be15d35f 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("", project?.id); + const targetStorage = useStorageCard("export", 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 + + + + ) + } + /> +
+ )}
); }; 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.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 }, 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 && (