Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) => (
<ActionButton key={action.id} action={action} parentRef={formRef} store={store} formRef={formRef} />
));
Expand All @@ -228,8 +228,8 @@ export const ActionsButton = injector(
<Dropdown.Trigger
content={
<Menu size="compact">
{isLoading ? (
<Menu.Item disabled data-testid="loading-actions">
{isLoading || isFetching ? (
<Menu.Item data-testid="loading-actions" disabled>
Loading actions...
</Menu.Item>
) : (
Expand Down
147 changes: 147 additions & 0 deletions web/libs/datamanager/src/hooks/README.md
Original file line number Diff line number Diff line change
@@ -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 <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;

return (
<div>
{actions.map((action) => (
<button key={action.id}>{action.title}</button>
))}
</div>
);
}
```

#### 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";

<QueryClientProvider client={queryClient}>
<Provider store={app}>
{/* App content */}
</Provider>
</QueryClientProvider>
```

This ensures all hooks have access to the shared query cache.

91 changes: 91 additions & 0 deletions web/libs/datamanager/src/hooks/useActions.ts
Original file line number Diff line number Diff line change
@@ -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<any>;
};
apiCall?: (method: string, params?: any) => Promise<any>;
};
}
}

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,
};
};
18 changes: 13 additions & 5 deletions web/libs/datamanager/src/stores/AppStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading