@startsimpli/hooks 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/hooks",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "Shared React hooks for StartSimpli apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -23,7 +23,7 @@
23
23
  "@tanstack/react-query": ">=5.0.0",
24
24
  "react": ">=18.0.0",
25
25
  "react-native": ">=0.74.0",
26
- "@startsimpli/api": "0.5.19"
26
+ "@startsimpli/api": "0.5.22"
27
27
  },
28
28
  "peerDependenciesMeta": {
29
29
  "@tanstack/react-query": {
@@ -46,7 +46,7 @@
46
46
  "tsup": "^8.5.1",
47
47
  "typescript": "^6.0.3",
48
48
  "vitest": "^4.1.5",
49
- "@startsimpli/api": "0.5.19"
49
+ "@startsimpli/api": "0.5.22"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsup",
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { renderHook, act, waitFor } from '@testing-library/react'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import React from 'react'
5
+ import {
6
+ useDecks,
7
+ useDeck,
8
+ useCreateDeck,
9
+ useGenerateDeck,
10
+ useRegenerateSlide,
11
+ useCompileDeck,
12
+ deckStatusRefetchInterval,
13
+ } from '../usePresentations'
14
+ import type { PresentationsApi } from '@startsimpli/api'
15
+
16
+ function mkClient() {
17
+ return new QueryClient({
18
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
19
+ })
20
+ }
21
+ function wrap(qc: QueryClient) {
22
+ return ({ children }: { children: React.ReactNode }) =>
23
+ React.createElement(QueryClientProvider, { client: qc }, children)
24
+ }
25
+ function trackInvalidations(qc: QueryClient): unknown[][] {
26
+ const calls: unknown[][] = []
27
+ const orig = qc.invalidateQueries.bind(qc)
28
+ qc.invalidateQueries = ((arg: { queryKey: unknown[] }) => {
29
+ calls.push(arg.queryKey)
30
+ return orig(arg)
31
+ }) as typeof qc.invalidateQueries
32
+ return calls
33
+ }
34
+ function fakeApi(overrides: Partial<PresentationsApi> = {}): PresentationsApi {
35
+ return {
36
+ listDecks: vi.fn().mockResolvedValue({ results: [], count: 0 }),
37
+ getDeck: vi.fn().mockResolvedValue({ id: 'd1', title: 'Pitch' }),
38
+ createDeck: vi.fn().mockResolvedValue({ id: 'd1' }),
39
+ generateDeck: vi.fn().mockResolvedValue({ id: 'd1', status: 'generating' }),
40
+ regenerateSlide: vi.fn().mockResolvedValue({ id: 's2', slideNumber: 2 }),
41
+ compileDeck: vi.fn().mockResolvedValue({ status: 'ready', pptxUrl: 'p', pdfUrl: 'q' }),
42
+ getDeckStatus: vi.fn().mockResolvedValue({ status: 'generating', slides: [] }),
43
+ ...overrides,
44
+ } as unknown as PresentationsApi
45
+ }
46
+
47
+ describe('presentation query hooks', () => {
48
+ it('useDecks fetches the deck list', async () => {
49
+ const api = fakeApi()
50
+ const { result } = renderHook(() => useDecks(api, { page: 1 }), { wrapper: wrap(mkClient()) })
51
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
52
+ expect(api.listDecks).toHaveBeenCalledWith({ page: 1 })
53
+ })
54
+
55
+ it('useDeck fetches one deck and is disabled without an id', async () => {
56
+ const api = fakeApi()
57
+ const { result } = renderHook(() => useDeck(api, ''), { wrapper: wrap(mkClient()) })
58
+ // disabled — never fetches
59
+ expect(result.current.fetchStatus).toBe('idle')
60
+ expect(api.getDeck).not.toHaveBeenCalled()
61
+ })
62
+ })
63
+
64
+ describe('presentation mutation hooks invalidate the right queries', () => {
65
+ it('useCreateDeck invalidates the decks list', async () => {
66
+ const qc = mkClient()
67
+ const seen = trackInvalidations(qc)
68
+ const { result } = renderHook(() => useCreateDeck(fakeApi()), { wrapper: wrap(qc) })
69
+ await act(async () => { result.current.mutate({ title: 'Pitch' }) })
70
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
71
+ expect(seen).toEqual(expect.arrayContaining([['presentations', 'decks']]))
72
+ })
73
+
74
+ it('useGenerateDeck invalidates the deck detail + status', async () => {
75
+ const qc = mkClient()
76
+ const seen = trackInvalidations(qc)
77
+ const { result } = renderHook(() => useGenerateDeck(fakeApi(), 'd1'), { wrapper: wrap(qc) })
78
+ await act(async () => { result.current.mutate({ outline: 'Slide 1' }) })
79
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
80
+ expect(seen).toEqual(
81
+ expect.arrayContaining([
82
+ ['presentations', 'deck', 'd1'],
83
+ ['presentations', 'deck-status', 'd1'],
84
+ ])
85
+ )
86
+ })
87
+
88
+ it('useRegenerateSlide invalidates the deck detail + status', async () => {
89
+ const qc = mkClient()
90
+ const seen = trackInvalidations(qc)
91
+ const { result } = renderHook(() => useRegenerateSlide(fakeApi(), 'd1'), { wrapper: wrap(qc) })
92
+ await act(async () => { result.current.mutate({ slideNumber: 2, instruction: 'punchier' }) })
93
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
94
+ expect(seen).toEqual(
95
+ expect.arrayContaining([
96
+ ['presentations', 'deck', 'd1'],
97
+ ['presentations', 'deck-status', 'd1'],
98
+ ])
99
+ )
100
+ })
101
+
102
+ it('useCompileDeck invalidates the deck detail', async () => {
103
+ const qc = mkClient()
104
+ const seen = trackInvalidations(qc)
105
+ const { result } = renderHook(() => useCompileDeck(fakeApi(), 'd1'), { wrapper: wrap(qc) })
106
+ await act(async () => { result.current.mutate() })
107
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
108
+ expect(seen).toEqual(expect.arrayContaining([['presentations', 'deck', 'd1']]))
109
+ })
110
+ })
111
+
112
+ describe('deckStatusRefetchInterval', () => {
113
+ it('polls while generating/draft and stops when ready/error', () => {
114
+ expect(deckStatusRefetchInterval('generating')).toBeGreaterThan(0)
115
+ expect(deckStatusRefetchInterval('draft')).toBeGreaterThan(0)
116
+ expect(deckStatusRefetchInterval('ready')).toBe(false)
117
+ expect(deckStatusRefetchInterval('error')).toBe(false)
118
+ expect(deckStatusRefetchInterval(undefined)).toBe(false)
119
+ })
120
+ })
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { renderHook, act, waitFor } from '@testing-library/react'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import React from 'react'
5
+ import {
6
+ useWorkflows,
7
+ useWorkflow,
8
+ useWorkflowNodeTypes,
9
+ useCreateWorkflow,
10
+ useUpdateWorkflow,
11
+ useDeleteWorkflow,
12
+ useExecuteWorkflow,
13
+ useWorkflowExecution,
14
+ useWorkflowExecutions,
15
+ useCancelExecution,
16
+ useRetryExecution,
17
+ executionRefetchInterval,
18
+ } from '../useWorkflows'
19
+ import type { WorkflowsApi } from '@startsimpli/api'
20
+
21
+ function mkClient() {
22
+ return new QueryClient({
23
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
24
+ })
25
+ }
26
+ function wrap(qc: QueryClient) {
27
+ return ({ children }: { children: React.ReactNode }) =>
28
+ React.createElement(QueryClientProvider, { client: qc }, children)
29
+ }
30
+ function trackInvalidations(qc: QueryClient): unknown[][] {
31
+ const calls: unknown[][] = []
32
+ const orig = qc.invalidateQueries.bind(qc)
33
+ qc.invalidateQueries = ((arg: { queryKey: unknown[] }) => {
34
+ calls.push(arg.queryKey)
35
+ return orig(arg)
36
+ }) as typeof qc.invalidateQueries
37
+ return calls
38
+ }
39
+ function fakeApi(overrides: Partial<WorkflowsApi> = {}): WorkflowsApi {
40
+ return {
41
+ listWorkflows: vi.fn().mockResolvedValue({ results: [], count: 0 }),
42
+ getWorkflow: vi.fn().mockResolvedValue({ id: 'w1', name: 'Onboarding' }),
43
+ createWorkflow: vi.fn().mockResolvedValue({ id: 'w1' }),
44
+ updateWorkflow: vi.fn().mockResolvedValue({ id: 'w1', name: 'New' }),
45
+ deleteWorkflow: vi.fn().mockResolvedValue(undefined),
46
+ getNodeTypes: vi.fn().mockResolvedValue([{ slug: 'http', category: 'action', label: 'HTTP' }]),
47
+ executeWorkflow: vi.fn().mockResolvedValue({ executionId: 'e1', status: 'running' }),
48
+ getExecution: vi.fn().mockResolvedValue({ status: 'running', state: {}, nodeExecutions: [] }),
49
+ listExecutions: vi.fn().mockResolvedValue({ results: [], count: 0 }),
50
+ cancelExecution: vi.fn().mockResolvedValue({ id: 'e1', status: 'cancelled' }),
51
+ retryExecution: vi.fn().mockResolvedValue({ executionId: 'e2', status: 'pending' }),
52
+ ...overrides,
53
+ } as unknown as WorkflowsApi
54
+ }
55
+
56
+ describe('workflow query hooks', () => {
57
+ it('useWorkflows fetches the workflow list', async () => {
58
+ const api = fakeApi()
59
+ const { result } = renderHook(() => useWorkflows(api, { page: 1 }), { wrapper: wrap(mkClient()) })
60
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
61
+ expect(api.listWorkflows).toHaveBeenCalledWith({ page: 1 })
62
+ })
63
+
64
+ it('useWorkflow fetches one workflow and is disabled without an id', async () => {
65
+ const api = fakeApi()
66
+ const { result } = renderHook(() => useWorkflow(api, ''), { wrapper: wrap(mkClient()) })
67
+ expect(result.current.fetchStatus).toBe('idle')
68
+ expect(api.getWorkflow).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('useWorkflowNodeTypes fetches the node-type registry', async () => {
72
+ const api = fakeApi()
73
+ const { result } = renderHook(() => useWorkflowNodeTypes(api), { wrapper: wrap(mkClient()) })
74
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
75
+ expect(api.getNodeTypes).toHaveBeenCalled()
76
+ })
77
+
78
+ it('useWorkflowExecution fetches the execution detail and is disabled without an id', async () => {
79
+ const api = fakeApi()
80
+ const { result } = renderHook(() => useWorkflowExecution(api, ''), { wrapper: wrap(mkClient()) })
81
+ expect(result.current.fetchStatus).toBe('idle')
82
+ expect(api.getExecution).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('useWorkflowExecutions fetches executions for a workflow', async () => {
86
+ const api = fakeApi()
87
+ const { result } = renderHook(() => useWorkflowExecutions(api, 'w1', { page: 1 }), {
88
+ wrapper: wrap(mkClient()),
89
+ })
90
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
91
+ expect(api.listExecutions).toHaveBeenCalledWith('w1', { page: 1 })
92
+ })
93
+ })
94
+
95
+ describe('workflow mutation hooks invalidate the right queries', () => {
96
+ it('useCreateWorkflow invalidates the workflows list', async () => {
97
+ const qc = mkClient()
98
+ const seen = trackInvalidations(qc)
99
+ const { result } = renderHook(() => useCreateWorkflow(fakeApi()), { wrapper: wrap(qc) })
100
+ await act(async () => { result.current.mutate({ name: 'Onboarding' }) })
101
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
102
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'list']]))
103
+ })
104
+
105
+ it('useUpdateWorkflow invalidates the workflow detail', async () => {
106
+ const qc = mkClient()
107
+ const seen = trackInvalidations(qc)
108
+ const { result } = renderHook(() => useUpdateWorkflow(fakeApi(), 'w1'), { wrapper: wrap(qc) })
109
+ await act(async () => { result.current.mutate({ name: 'New' }) })
110
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
111
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'detail', 'w1']]))
112
+ })
113
+
114
+ it('useDeleteWorkflow invalidates the workflows list', async () => {
115
+ const qc = mkClient()
116
+ const seen = trackInvalidations(qc)
117
+ const { result } = renderHook(() => useDeleteWorkflow(fakeApi()), { wrapper: wrap(qc) })
118
+ await act(async () => { result.current.mutate('w1') })
119
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
120
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'list']]))
121
+ })
122
+
123
+ it('useExecuteWorkflow surfaces the executionId', async () => {
124
+ const qc = mkClient()
125
+ const api = fakeApi()
126
+ const { result } = renderHook(() => useExecuteWorkflow(api, 'w1'), { wrapper: wrap(qc) })
127
+ await act(async () => { result.current.mutate({ foo: 'bar' }) })
128
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
129
+ expect(api.executeWorkflow).toHaveBeenCalledWith('w1', { foo: 'bar' })
130
+ expect(result.current.data?.executionId).toBe('e1')
131
+ })
132
+
133
+ it('useCancelExecution invalidates the execution query key', async () => {
134
+ const qc = mkClient()
135
+ const seen = trackInvalidations(qc)
136
+ const { result } = renderHook(() => useCancelExecution(fakeApi()), { wrapper: wrap(qc) })
137
+ await act(async () => { result.current.mutate('e1') })
138
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
139
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'execution', 'e1']]))
140
+ })
141
+
142
+ it('useRetryExecution invalidates the execution query key', async () => {
143
+ const qc = mkClient()
144
+ const seen = trackInvalidations(qc)
145
+ const { result } = renderHook(() => useRetryExecution(fakeApi()), { wrapper: wrap(qc) })
146
+ await act(async () => { result.current.mutate('e1') })
147
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
148
+ expect(seen).toEqual(expect.arrayContaining([['workflows', 'execution', 'e1']]))
149
+ })
150
+ })
151
+
152
+ describe('executionRefetchInterval', () => {
153
+ it('polls while running/pending/waiting and stops when terminal', () => {
154
+ expect(executionRefetchInterval('running')).toBeGreaterThan(0)
155
+ expect(executionRefetchInterval('pending')).toBeGreaterThan(0)
156
+ expect(executionRefetchInterval('waiting')).toBeGreaterThan(0)
157
+ expect(executionRefetchInterval('completed')).toBe(false)
158
+ expect(executionRefetchInterval('failed')).toBe(false)
159
+ expect(executionRefetchInterval('cancelled')).toBe(false)
160
+ expect(executionRefetchInterval(undefined)).toBe(false)
161
+ })
162
+ })
package/src/index.ts CHANGED
@@ -86,3 +86,5 @@ export {
86
86
  useDeleteAccessKey,
87
87
  useAuditLog,
88
88
  } from './useVault'
89
+ export * from './usePresentations'
90
+ export * from './useWorkflows'
@@ -0,0 +1,120 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import type {
3
+ PresentationsApi,
4
+ DeckInput,
5
+ DeckListParams,
6
+ DeckStatus,
7
+ GenerateDeckInput,
8
+ RegenerateSlideInput,
9
+ SlideInput,
10
+ } from '@startsimpli/api'
11
+
12
+ /** TanStack Query hooks over PresentationsApi (epic startsim-3ks).
13
+ *
14
+ * Each hook takes the PresentationsApi instance (e.g. `api.presentations`),
15
+ * matching the useVault / useMessages convention. Generation + compile are
16
+ * async on the backend, so the deck detail and the lightweight status query
17
+ * are invalidated together and the status query self-polls while in flight.
18
+ */
19
+
20
+ const DECKS_KEY = ['presentations', 'decks'] as const
21
+ const deckKey = (id: string) => ['presentations', 'deck', id]
22
+ const deckStatusKey = (id: string) => ['presentations', 'deck-status', id]
23
+
24
+ const POLL_MS = 1500
25
+
26
+ /** Refetch interval for the status query: poll while work is in flight. */
27
+ export function deckStatusRefetchInterval(status: DeckStatus | undefined): number | false {
28
+ return status === 'generating' || status === 'draft' ? POLL_MS : false
29
+ }
30
+
31
+ function invalidateDeck(qc: ReturnType<typeof useQueryClient>, id: string) {
32
+ qc.invalidateQueries({ queryKey: deckKey(id) })
33
+ qc.invalidateQueries({ queryKey: deckStatusKey(id) })
34
+ qc.invalidateQueries({ queryKey: DECKS_KEY })
35
+ }
36
+
37
+ // --- Decks ---
38
+ export function useDecks(api: PresentationsApi, params?: DeckListParams) {
39
+ return useQuery({
40
+ queryKey: ['presentations', 'decks', params],
41
+ queryFn: () => api.listDecks(params),
42
+ })
43
+ }
44
+
45
+ export function useDeck(api: PresentationsApi, id: string) {
46
+ return useQuery({
47
+ queryKey: deckKey(id),
48
+ queryFn: () => api.getDeck(id),
49
+ enabled: !!id,
50
+ })
51
+ }
52
+
53
+ /** Self-polling per-slide generation status; stops when ready/error. */
54
+ export function useDeckStatus(api: PresentationsApi, id: string, enabled = true) {
55
+ return useQuery({
56
+ queryKey: deckStatusKey(id),
57
+ queryFn: () => api.getDeckStatus(id),
58
+ enabled: enabled && !!id,
59
+ refetchInterval: (query) => deckStatusRefetchInterval(query.state.data?.status),
60
+ })
61
+ }
62
+
63
+ export function useCreateDeck(api: PresentationsApi) {
64
+ const qc = useQueryClient()
65
+ return useMutation({
66
+ mutationFn: (data: DeckInput) => api.createDeck(data),
67
+ onSuccess: () => qc.invalidateQueries({ queryKey: DECKS_KEY }),
68
+ })
69
+ }
70
+
71
+ export function useUpdateDeck(api: PresentationsApi, id: string) {
72
+ const qc = useQueryClient()
73
+ return useMutation({
74
+ mutationFn: (data: Partial<DeckInput>) => api.updateDeck(id, data),
75
+ onSuccess: () => invalidateDeck(qc, id),
76
+ })
77
+ }
78
+
79
+ export function useDeleteDeck(api: PresentationsApi) {
80
+ const qc = useQueryClient()
81
+ return useMutation({
82
+ mutationFn: (id: string) => api.deleteDeck(id),
83
+ onSuccess: () => qc.invalidateQueries({ queryKey: DECKS_KEY }),
84
+ })
85
+ }
86
+
87
+ // --- Generation pipeline ---
88
+ export function useGenerateDeck(api: PresentationsApi, id: string) {
89
+ const qc = useQueryClient()
90
+ return useMutation({
91
+ mutationFn: (data?: GenerateDeckInput) => api.generateDeck(id, data),
92
+ onSuccess: () => invalidateDeck(qc, id),
93
+ })
94
+ }
95
+
96
+ export function useRegenerateSlide(api: PresentationsApi, id: string) {
97
+ const qc = useQueryClient()
98
+ return useMutation({
99
+ mutationFn: ({ slideNumber, ...data }: { slideNumber: number } & RegenerateSlideInput) =>
100
+ api.regenerateSlide(id, slideNumber, data),
101
+ onSuccess: () => invalidateDeck(qc, id),
102
+ })
103
+ }
104
+
105
+ export function useUpdateSlide(api: PresentationsApi, id: string) {
106
+ const qc = useQueryClient()
107
+ return useMutation({
108
+ mutationFn: ({ slideNumber, ...data }: { slideNumber: number } & SlideInput) =>
109
+ api.updateSlide(id, slideNumber, data),
110
+ onSuccess: () => invalidateDeck(qc, id),
111
+ })
112
+ }
113
+
114
+ export function useCompileDeck(api: PresentationsApi, id: string) {
115
+ const qc = useQueryClient()
116
+ return useMutation({
117
+ mutationFn: () => api.compileDeck(id),
118
+ onSuccess: () => invalidateDeck(qc, id),
119
+ })
120
+ }
@@ -0,0 +1,122 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import type {
3
+ WorkflowsApi,
4
+ WorkflowInput,
5
+ WorkflowListParams,
6
+ ExecutionListParams,
7
+ RunStatus,
8
+ } from '@startsimpli/api'
9
+
10
+ /** TanStack Query hooks over WorkflowsApi (epic startsim-xsh.12).
11
+ *
12
+ * Each hook takes the WorkflowsApi instance (e.g. `api.workflows`), matching
13
+ * the usePresentations / useVault convention. Executions are async on the
14
+ * backend, so the execution detail query self-polls while a run is in flight
15
+ * and stops on a terminal status.
16
+ */
17
+
18
+ const WORKFLOWS_KEY = ['workflows', 'list'] as const
19
+ const workflowKey = (id: string) => ['workflows', 'detail', id]
20
+ const executionKey = (executionId: string) => ['workflows', 'execution', executionId]
21
+ const executionsKey = (workflowId: string) => ['workflows', 'executions', workflowId]
22
+
23
+ const POLL_MS = 1000
24
+
25
+ /** Refetch interval for the execution detail query: poll while a run is live. */
26
+ export function executionRefetchInterval(status: RunStatus | undefined): number | false {
27
+ return status === 'running' || status === 'pending' || status === 'waiting' ? POLL_MS : false
28
+ }
29
+
30
+ // --- Workflows ---
31
+ export function useWorkflows(api: WorkflowsApi, params?: WorkflowListParams) {
32
+ return useQuery({
33
+ queryKey: ['workflows', 'list', params],
34
+ queryFn: () => api.listWorkflows(params),
35
+ })
36
+ }
37
+
38
+ export function useWorkflow(api: WorkflowsApi, id: string) {
39
+ return useQuery({
40
+ queryKey: workflowKey(id),
41
+ queryFn: () => api.getWorkflow(id),
42
+ enabled: !!id,
43
+ })
44
+ }
45
+
46
+ export function useWorkflowNodeTypes(api: WorkflowsApi) {
47
+ return useQuery({
48
+ queryKey: ['workflows', 'node-types'],
49
+ queryFn: () => api.getNodeTypes(),
50
+ })
51
+ }
52
+
53
+ export function useCreateWorkflow(api: WorkflowsApi) {
54
+ const qc = useQueryClient()
55
+ return useMutation({
56
+ mutationFn: (data: WorkflowInput) => api.createWorkflow(data),
57
+ onSuccess: () => qc.invalidateQueries({ queryKey: WORKFLOWS_KEY }),
58
+ })
59
+ }
60
+
61
+ export function useUpdateWorkflow(api: WorkflowsApi, id: string) {
62
+ const qc = useQueryClient()
63
+ return useMutation({
64
+ mutationFn: (data: Partial<WorkflowInput>) => api.updateWorkflow(id, data),
65
+ onSuccess: () => {
66
+ qc.invalidateQueries({ queryKey: workflowKey(id) })
67
+ qc.invalidateQueries({ queryKey: WORKFLOWS_KEY })
68
+ },
69
+ })
70
+ }
71
+
72
+ export function useDeleteWorkflow(api: WorkflowsApi) {
73
+ const qc = useQueryClient()
74
+ return useMutation({
75
+ mutationFn: (id: string) => api.deleteWorkflow(id),
76
+ onSuccess: () => qc.invalidateQueries({ queryKey: WORKFLOWS_KEY }),
77
+ })
78
+ }
79
+
80
+ // --- Execution ---
81
+ /** Kick off a run; the mutation result surfaces the executionId. */
82
+ export function useExecuteWorkflow(api: WorkflowsApi, id: string) {
83
+ const qc = useQueryClient()
84
+ return useMutation({
85
+ mutationFn: (contextData?: Record<string, unknown>) => api.executeWorkflow(id, contextData),
86
+ onSuccess: () => qc.invalidateQueries({ queryKey: executionsKey(id) }),
87
+ })
88
+ }
89
+
90
+ /** Self-polling execution detail; stops when the run reaches a terminal state. */
91
+ export function useWorkflowExecution(api: WorkflowsApi, executionId: string, enabled = true) {
92
+ return useQuery({
93
+ queryKey: executionKey(executionId),
94
+ queryFn: () => api.getExecution(executionId),
95
+ enabled: enabled && !!executionId,
96
+ refetchInterval: (query) => executionRefetchInterval(query.state.data?.status),
97
+ })
98
+ }
99
+
100
+ export function useWorkflowExecutions(api: WorkflowsApi, workflowId: string, params?: ExecutionListParams) {
101
+ return useQuery({
102
+ queryKey: ['workflows', 'executions', workflowId, params],
103
+ queryFn: () => api.listExecutions(workflowId, params),
104
+ enabled: !!workflowId,
105
+ })
106
+ }
107
+
108
+ export function useCancelExecution(api: WorkflowsApi) {
109
+ const qc = useQueryClient()
110
+ return useMutation({
111
+ mutationFn: (executionId: string) => api.cancelExecution(executionId),
112
+ onSuccess: (_data, executionId) => qc.invalidateQueries({ queryKey: executionKey(executionId) }),
113
+ })
114
+ }
115
+
116
+ export function useRetryExecution(api: WorkflowsApi) {
117
+ const qc = useQueryClient()
118
+ return useMutation({
119
+ mutationFn: (executionId: string) => api.retryExecution(executionId),
120
+ onSuccess: (_data, executionId) => qc.invalidateQueries({ queryKey: executionKey(executionId) }),
121
+ })
122
+ }