diff --git a/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx b/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx index 9fee5753f349..dcd16c1c19dd 100644 --- a/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx +++ b/web/libs/datamanager/src/components/DataManager/Toolbar/ActionsButton.jsx @@ -1,7 +1,8 @@ import { IconChevronDown, IconChevronRight, IconTrash } from "@humansignal/icons"; import { Button, Spinner, Tooltip } from "@humansignal/ui"; import { inject, observer } from "mobx-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useActions } from "../../../hooks/useActions"; import { Block, Elem } from "../../../utils/bem"; import { FF_LOPS_E_3, isFF } from "../../../utils/feature-flags"; import { Dropdown } from "../../Common/Dropdown/DropdownComponent"; @@ -204,21 +205,20 @@ export const ActionsButton = injector( const formRef = useRef(); const selectedCount = store.currentView.selectedCount; const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const fetchedActionsRef = useRef(false); - const actions = store.availableActions.filter((a) => !a.hidden).sort((a, b) => a.order - b.order); - - useEffect(() => { - if (isOpen && !fetchedActionsRef.current) { - setIsLoading(true); - store.fetchActions().finally(() => { - setIsLoading(false); - }); - fetchedActionsRef.current = true; - } - }, [isOpen, store]); + // Use TanStack Query hook for fetching actions + const { + actions: serverActions, + isLoading, + isFetching, + } = useActions({ + enabled: isOpen, + projectId: store.SDK.projectId, + }); + const actions = useMemo(() => { + return [...store.availableActions, ...serverActions].filter((a) => !a.hidden).sort((a, b) => a.order - b.order); + }, [store.availableActions, serverActions]); const actionButtons = actions.map((action) => ( )); @@ -228,8 +228,8 @@ export const ActionsButton = injector( - {isLoading ? ( - + {isLoading || isFetching ? ( + Loading actions... ) : ( diff --git a/web/libs/datamanager/src/hooks/README.md b/web/libs/datamanager/src/hooks/README.md new file mode 100644 index 000000000000..5c2a559b608c --- /dev/null +++ b/web/libs/datamanager/src/hooks/README.md @@ -0,0 +1,147 @@ +# DataManager Hooks + +This directory contains React hooks for the DataManager library, following modern React patterns with TanStack Query for data fetching. + +## Available Hooks + +### `useActions` + +A hook for fetching available actions from the DataManager API using TanStack Query. + +#### Features + +- **Automatic caching**: Actions are cached for 5 minutes by default +- **Lazy loading**: Only fetches when enabled (e.g., when dropdown is opened) +- **Error handling**: Built-in error states +- **Loading states**: Provides both initial loading and refetching states +- **Type safety**: Full TypeScript support + +#### Usage + +```tsx +import { useActions } from "../hooks/useActions"; + +function ActionsDropdown({ projectId }) { + const { + actions, + isLoading, + isError, + error, + refetch, + isFetching, + } = useActions({ + projectId, // Optional: Used for cache scoping per project + enabled: isOpen, // Only fetch when dropdown is opened + staleTime: 5 * 60 * 1000, // Optional: 5 minutes + cacheTime: 10 * 60 * 1000, // Optional: 10 minutes + }); + + if (isLoading) return
Loading...
; + if (isError) return
Error: {error.message}
; + + return ( +
+ {actions.map((action) => ( + + ))} +
+ ); +} +``` + +#### Parameters + +- `options.projectId` (string, optional): Project ID for scoping the query cache. When provided, actions are cached per project, preventing cache conflicts in multi-project scenarios +- `options.enabled` (boolean, default: `true`): Whether to enable the query +- `options.staleTime` (number, default: `5 * 60 * 1000`): Time in ms before data is considered stale +- `options.cacheTime` (number, default: `10 * 60 * 1000`): Time in ms before unused data is garbage collected + +#### Return Value + +- `actions` (Action[]): Array of available actions +- `isLoading` (boolean): True on first load +- `isFetching` (boolean): True whenever data is being fetched +- `isError` (boolean): True if the query failed +- `error` (Error): The error object if query failed +- `refetch` (function): Function to manually refetch the data + +### `useDataManagerUsers` + +A hook for fetching users from the DataManager API with infinite pagination support. + +See `useUsers.ts` for documentation. + +### Other Hooks + +- `useFirstMountState`: Utility hook to detect first mount +- `useUpdateEffect`: Effect hook that skips the first render + +## Migration from MobX to TanStack Query + +The DataManager is gradually migrating from MobX State Tree flows to TanStack Query hooks for better performance, caching, and developer experience. + +### Why TanStack Query? + +1. **Automatic caching**: Reduces unnecessary API calls +2. **Better loading states**: Built-in loading, error, and refetching states +3. **Background refetching**: Keeps data fresh automatically +4. **Query invalidation**: Easy cache management +5. **TypeScript support**: Full type safety out of the box +6. **React best practices**: Follows modern React patterns recommended in project rules + +### Coexistence with MobX + +The hooks replace the need for MobX flows for data fetching: + +- **Old code**: `store.fetchActions()` is now deprecated but kept for backward compatibility +- **New code**: Should always use `useActions()` hook +- **Migration complete**: The actions endpoint is now only called via the `useActions` hook, preventing duplicate API calls + +### Example Migration + +**Before (MobX):** +```javascript +useEffect(() => { + if (isOpen && actions.length === 0) { + setIsLoading(true); + store.fetchActions().finally(() => { + setIsLoading(false); + }); + } +}, [isOpen, actions.length, store]); +``` + +**After (TanStack Query):** +```typescript +const { actions, isLoading, isFetching } = useActions({ + projectId, // Optional: for cache scoping + enabled: isOpen, +}); +``` + +## Best Practices + +1. **Use lazy loading**: Set `enabled: false` for data that's not immediately needed +2. **Scope by projectId**: Always pass `projectId` when working with project-specific data to prevent cache conflicts +3. **Configure cache times**: Adjust `staleTime` and `cacheTime` based on data freshness requirements +4. **Handle loading states**: Always provide loading UI for better UX +5. **Handle errors**: Display user-friendly error messages +6. **Type everything**: Use TypeScript interfaces for type safety + +## QueryClient Setup + +The QueryClient is configured at the app level in `App.tsx`: + +```typescript +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "@humansignal/core/lib/utils/query-client"; + + + + {/* App content */} + + +``` + +This ensures all hooks have access to the shared query cache. + diff --git a/web/libs/datamanager/src/hooks/useActions.ts b/web/libs/datamanager/src/hooks/useActions.ts new file mode 100644 index 000000000000..69323d6ba8ca --- /dev/null +++ b/web/libs/datamanager/src/hooks/useActions.ts @@ -0,0 +1,91 @@ +import { useQuery } from "@tanstack/react-query"; + +// Extend Window interface to include DataManager properties +declare global { + interface Window { + DM?: { + store?: { + apiCall: (method: string, params?: any) => Promise; + }; + apiCall?: (method: string, params?: any) => Promise; + }; + } +} + +interface Action { + id: string; + title: string; + order: number; + hidden?: boolean; + dialog?: { + type?: string; + text?: string; + form?: any; + title?: string; + }; + children?: Action[]; + disabled?: boolean; + disabledReason?: string; + isSeparator?: boolean; + isTitle?: boolean; + callback?: (selection: any, action: Action) => void; +} + +interface UseActionsOptions { + projectId?: string; + enabled?: boolean; + staleTime?: number; + cacheTime?: number; +} + +/** + * Hook to fetch available actions from the DataManager API + * Uses TanStack Query for data fetching and caching + * + * @param options - Configuration options for the query + * @returns Object containing actions data, loading state, error state, and refetch function + */ +export const useActions = (options: UseActionsOptions = {}) => { + const { + enabled = true, + staleTime = 5 * 60 * 1000, // 5 minutes + cacheTime = 10 * 60 * 1000, // 10 minutes + projectId, + } = options; + + const queryKey = ["actions", projectId]; + + const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ + queryKey, + queryFn: async () => { + // Use the correct DataManager API pattern - window.DM is the AppStore + const store = window?.DM?.store || window?.DM; + + if (!store) { + throw new Error("DataManager store not available"); + } + + const response = await store.apiCall?.("actions"); + + if (!response) { + throw new Error("No actions found in response or response is invalid"); + } + + return response as Action[]; + }, + enabled, + staleTime, + cacheTime, + }); + + const actions = data ?? []; + + return { + actions, + isLoading, + isError, + error, + refetch, + isFetching, + }; +}; diff --git a/web/libs/datamanager/src/stores/AppStore.js b/web/libs/datamanager/src/stores/AppStore.js index a49ed1a60bd6..6ca6a10fe258 100644 --- a/web/libs/datamanager/src/stores/AppStore.js +++ b/web/libs/datamanager/src/stores/AppStore.js @@ -542,14 +542,22 @@ export const AppStore = types return true; }), + /** + * @deprecated Use the useActions hook instead for better caching and performance + * This method is kept for backward compatibility but is no longer actively used + */ fetchActions: flow(function* () { - const serverActions = yield self.apiCall("actions"); + try { + const serverActions = yield self.apiCall("actions"); - const actions = (serverActions ?? []).map((action) => { - return [action, undefined]; - }); + const actions = (serverActions ?? []).map((action) => { + return [action, undefined]; + }); - self.SDK.updateActions(actions); + self.SDK.updateActions(actions); + } catch (error) { + console.error("Error fetching actions:", error); + } }), fetchActionForm: flow(function* (actionId) {