@startsimpli/hooks 0.4.11 → 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/README.md +225 -0
- package/package.json +24 -15
- package/src/__tests__/usePresentations.test.ts +120 -0
- package/src/__tests__/useVault.test.ts +107 -0
- package/src/__tests__/useWorkflows.test.ts +162 -0
- package/src/index.ts +20 -0
- package/src/useCSV-core.ts +144 -0
- package/src/useCSV.native.ts +26 -0
- package/src/useCSV.ts +12 -136
- package/src/usePresentations.ts +120 -0
- package/src/useRecentlyViewed.native.ts +56 -0
- package/src/useReducedMotion.native.ts +34 -0
- package/src/useRefetchOnFocus.native.ts +50 -0
- package/src/useVault.ts +142 -0
- package/src/useWorkflows.ts +122 -0
|
@@ -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
|
@@ -68,3 +68,23 @@ export type {
|
|
|
68
68
|
} from './useCSV'
|
|
69
69
|
export { useAsyncOptions } from './useAsyncOptions'
|
|
70
70
|
export type { UseAsyncOptionsResult } from './useAsyncOptions'
|
|
71
|
+
|
|
72
|
+
// Vault — environments, secrets, access keys, audit (startsim-d30.3.2)
|
|
73
|
+
export {
|
|
74
|
+
useEnvironments,
|
|
75
|
+
useEnvironment,
|
|
76
|
+
useCreateEnvironment,
|
|
77
|
+
useUpdateEnvironment,
|
|
78
|
+
useDeleteEnvironment,
|
|
79
|
+
useSecrets,
|
|
80
|
+
useCreateSecret,
|
|
81
|
+
useUpdateSecret,
|
|
82
|
+
useDeleteSecret,
|
|
83
|
+
useRevealSecret,
|
|
84
|
+
useAccessKeys,
|
|
85
|
+
useCreateAccessKey,
|
|
86
|
+
useDeleteAccessKey,
|
|
87
|
+
useAuditLog,
|
|
88
|
+
} from './useVault'
|
|
89
|
+
export * from './usePresentations'
|
|
90
|
+
export * from './useWorkflows'
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface CSVColumnMapping {
|
|
4
|
+
csvColumn: string
|
|
5
|
+
targetField: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CSVPreviewResult<TField extends string = string> {
|
|
9
|
+
columns: string[]
|
|
10
|
+
sampleRows: Record<string, string>[]
|
|
11
|
+
suggestedMappings?: CSVColumnMapping[]
|
|
12
|
+
totalRows?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CSVImportResult {
|
|
16
|
+
totalRows: number
|
|
17
|
+
successful: number
|
|
18
|
+
failed: number
|
|
19
|
+
errors: Array<{ row: number; message: string }>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CSVImportStep = 'upload' | 'mapping' | 'importing' | 'complete'
|
|
23
|
+
|
|
24
|
+
export interface UseCSVImportOptions<TField extends string = string> {
|
|
25
|
+
previewFn: (file: File) => Promise<CSVPreviewResult<TField>>
|
|
26
|
+
importFn: (file: File, mappings: CSVColumnMapping[]) => Promise<CSVImportResult>
|
|
27
|
+
onSuccess?: (result: CSVImportResult) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseCSVImportState {
|
|
31
|
+
step: CSVImportStep
|
|
32
|
+
file: File | null
|
|
33
|
+
preview: CSVPreviewResult | null
|
|
34
|
+
mappings: CSVColumnMapping[]
|
|
35
|
+
result: CSVImportResult | null
|
|
36
|
+
isLoading: boolean
|
|
37
|
+
error: string | null
|
|
38
|
+
handleFileSelect: (file: File) => Promise<void>
|
|
39
|
+
updateMapping: (csvColumn: string, targetField: string) => void
|
|
40
|
+
startImport: () => Promise<void>
|
|
41
|
+
reset: () => void
|
|
42
|
+
goBack: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Platform-neutral CSV import state machine.
|
|
47
|
+
*
|
|
48
|
+
* The actual file reading is delegated to the caller-provided previewFn/importFn,
|
|
49
|
+
* so this hook never touches the DOM — `File` is used only as a type. It is safe
|
|
50
|
+
* on web and React Native alike, which is why it lives in the shared core rather
|
|
51
|
+
* than the platform-specific useCSV entries.
|
|
52
|
+
*/
|
|
53
|
+
export function useCSVImport<TField extends string = string>({
|
|
54
|
+
previewFn,
|
|
55
|
+
importFn,
|
|
56
|
+
onSuccess,
|
|
57
|
+
}: UseCSVImportOptions<TField>): UseCSVImportState {
|
|
58
|
+
const [step, setStep] = useState<CSVImportStep>('upload')
|
|
59
|
+
const [file, setFile] = useState<File | null>(null)
|
|
60
|
+
const [preview, setPreview] = useState<CSVPreviewResult | null>(null)
|
|
61
|
+
const [mappings, setMappings] = useState<CSVColumnMapping[]>([])
|
|
62
|
+
const [result, setResult] = useState<CSVImportResult | null>(null)
|
|
63
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
64
|
+
const [error, setError] = useState<string | null>(null)
|
|
65
|
+
|
|
66
|
+
const handleFileSelect = useCallback(
|
|
67
|
+
async (selectedFile: File) => {
|
|
68
|
+
setFile(selectedFile)
|
|
69
|
+
setError(null)
|
|
70
|
+
setIsLoading(true)
|
|
71
|
+
try {
|
|
72
|
+
const previewData = await previewFn(selectedFile)
|
|
73
|
+
setPreview(previewData)
|
|
74
|
+
setMappings(previewData.suggestedMappings ?? [])
|
|
75
|
+
setStep('mapping')
|
|
76
|
+
} catch {
|
|
77
|
+
setError('Failed to preview CSV file')
|
|
78
|
+
} finally {
|
|
79
|
+
setIsLoading(false)
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[previewFn]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const updateMapping = useCallback((csvColumn: string, targetField: string) => {
|
|
86
|
+
setMappings((prev) => {
|
|
87
|
+
const existing = prev.find((m) => m.csvColumn === csvColumn)
|
|
88
|
+
if (existing) {
|
|
89
|
+
return prev.map((m) =>
|
|
90
|
+
m.csvColumn === csvColumn ? { ...m, targetField: targetField } : m
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
return [...prev, { csvColumn: csvColumn, targetField: targetField }]
|
|
94
|
+
})
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
const startImport = useCallback(async () => {
|
|
98
|
+
if (!file) return
|
|
99
|
+
setStep('importing')
|
|
100
|
+
setError(null)
|
|
101
|
+
try {
|
|
102
|
+
const importResult = await importFn(file, mappings)
|
|
103
|
+
setResult(importResult)
|
|
104
|
+
setStep('complete')
|
|
105
|
+
onSuccess?.(importResult)
|
|
106
|
+
} catch {
|
|
107
|
+
setError('Failed to import CSV')
|
|
108
|
+
setStep('mapping')
|
|
109
|
+
}
|
|
110
|
+
}, [file, mappings, importFn, onSuccess])
|
|
111
|
+
|
|
112
|
+
const reset = useCallback(() => {
|
|
113
|
+
setStep('upload')
|
|
114
|
+
setFile(null)
|
|
115
|
+
setPreview(null)
|
|
116
|
+
setMappings([])
|
|
117
|
+
setResult(null)
|
|
118
|
+
setError(null)
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
const goBack = useCallback(() => {
|
|
122
|
+
if (step === 'mapping') setStep('upload')
|
|
123
|
+
}, [step])
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
step,
|
|
127
|
+
file,
|
|
128
|
+
preview,
|
|
129
|
+
mappings,
|
|
130
|
+
result,
|
|
131
|
+
isLoading,
|
|
132
|
+
error,
|
|
133
|
+
handleFileSelect,
|
|
134
|
+
updateMapping,
|
|
135
|
+
startImport,
|
|
136
|
+
reset,
|
|
137
|
+
goBack,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface UseCSVExportOptions {
|
|
142
|
+
exportFn: () => Promise<Blob | string>
|
|
143
|
+
filename?: string
|
|
144
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
import type { UseCSVExportOptions } from './useCSV-core'
|
|
3
|
+
|
|
4
|
+
// Types + the platform-neutral useCSVImport state machine live in the core.
|
|
5
|
+
export * from './useCSV-core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* useCSVExport — React Native stub.
|
|
9
|
+
*
|
|
10
|
+
* The web version triggers a browser file download (Blob + anchor click), which
|
|
11
|
+
* has no direct React Native equivalent. Rather than silently no-op, exportCSV
|
|
12
|
+
* fails loud so callers wire up a native save/share flow (expo-file-system +
|
|
13
|
+
* expo-sharing) explicitly. Metro resolves this file over useCSV.ts on native.
|
|
14
|
+
*/
|
|
15
|
+
export function useCSVExport({ exportFn: _exportFn, filename: _filename }: UseCSVExportOptions) {
|
|
16
|
+
const [isExporting] = useState(false)
|
|
17
|
+
|
|
18
|
+
const exportCSV = useCallback(async () => {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'useCSVExport: browser-download export is web-only. On React Native, ' +
|
|
21
|
+
'save the CSV with expo-file-system and share it via expo-sharing.'
|
|
22
|
+
)
|
|
23
|
+
}, [])
|
|
24
|
+
|
|
25
|
+
return { exportCSV, isExporting }
|
|
26
|
+
}
|
package/src/useCSV.ts
CHANGED
|
@@ -1,140 +1,16 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export interface CSVImportResult {
|
|
16
|
-
totalRows: number
|
|
17
|
-
successful: number
|
|
18
|
-
failed: number
|
|
19
|
-
errors: Array<{ row: number; message: string }>
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export type CSVImportStep = 'upload' | 'mapping' | 'importing' | 'complete'
|
|
23
|
-
|
|
24
|
-
export interface UseCSVImportOptions<TField extends string = string> {
|
|
25
|
-
previewFn: (file: File) => Promise<CSVPreviewResult<TField>>
|
|
26
|
-
importFn: (file: File, mappings: CSVColumnMapping[]) => Promise<CSVImportResult>
|
|
27
|
-
onSuccess?: (result: CSVImportResult) => void
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface UseCSVImportState {
|
|
31
|
-
step: CSVImportStep
|
|
32
|
-
file: File | null
|
|
33
|
-
preview: CSVPreviewResult | null
|
|
34
|
-
mappings: CSVColumnMapping[]
|
|
35
|
-
result: CSVImportResult | null
|
|
36
|
-
isLoading: boolean
|
|
37
|
-
error: string | null
|
|
38
|
-
handleFileSelect: (file: File) => Promise<void>
|
|
39
|
-
updateMapping: (csvColumn: string, targetField: string) => void
|
|
40
|
-
startImport: () => Promise<void>
|
|
41
|
-
reset: () => void
|
|
42
|
-
goBack: () => void
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function useCSVImport<TField extends string = string>({
|
|
46
|
-
previewFn,
|
|
47
|
-
importFn,
|
|
48
|
-
onSuccess,
|
|
49
|
-
}: UseCSVImportOptions<TField>): UseCSVImportState {
|
|
50
|
-
const [step, setStep] = useState<CSVImportStep>('upload')
|
|
51
|
-
const [file, setFile] = useState<File | null>(null)
|
|
52
|
-
const [preview, setPreview] = useState<CSVPreviewResult | null>(null)
|
|
53
|
-
const [mappings, setMappings] = useState<CSVColumnMapping[]>([])
|
|
54
|
-
const [result, setResult] = useState<CSVImportResult | null>(null)
|
|
55
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
56
|
-
const [error, setError] = useState<string | null>(null)
|
|
57
|
-
|
|
58
|
-
const handleFileSelect = useCallback(
|
|
59
|
-
async (selectedFile: File) => {
|
|
60
|
-
setFile(selectedFile)
|
|
61
|
-
setError(null)
|
|
62
|
-
setIsLoading(true)
|
|
63
|
-
try {
|
|
64
|
-
const previewData = await previewFn(selectedFile)
|
|
65
|
-
setPreview(previewData)
|
|
66
|
-
setMappings(previewData.suggestedMappings ?? [])
|
|
67
|
-
setStep('mapping')
|
|
68
|
-
} catch {
|
|
69
|
-
setError('Failed to preview CSV file')
|
|
70
|
-
} finally {
|
|
71
|
-
setIsLoading(false)
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
[previewFn]
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
const updateMapping = useCallback((csvColumn: string, targetField: string) => {
|
|
78
|
-
setMappings((prev) => {
|
|
79
|
-
const existing = prev.find((m) => m.csvColumn === csvColumn)
|
|
80
|
-
if (existing) {
|
|
81
|
-
return prev.map((m) =>
|
|
82
|
-
m.csvColumn === csvColumn ? { ...m, targetField: targetField } : m
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
return [...prev, { csvColumn: csvColumn, targetField: targetField }]
|
|
86
|
-
})
|
|
87
|
-
}, [])
|
|
88
|
-
|
|
89
|
-
const startImport = useCallback(async () => {
|
|
90
|
-
if (!file) return
|
|
91
|
-
setStep('importing')
|
|
92
|
-
setError(null)
|
|
93
|
-
try {
|
|
94
|
-
const importResult = await importFn(file, mappings)
|
|
95
|
-
setResult(importResult)
|
|
96
|
-
setStep('complete')
|
|
97
|
-
onSuccess?.(importResult)
|
|
98
|
-
} catch {
|
|
99
|
-
setError('Failed to import CSV')
|
|
100
|
-
setStep('mapping')
|
|
101
|
-
}
|
|
102
|
-
}, [file, mappings, importFn, onSuccess])
|
|
103
|
-
|
|
104
|
-
const reset = useCallback(() => {
|
|
105
|
-
setStep('upload')
|
|
106
|
-
setFile(null)
|
|
107
|
-
setPreview(null)
|
|
108
|
-
setMappings([])
|
|
109
|
-
setResult(null)
|
|
110
|
-
setError(null)
|
|
111
|
-
}, [])
|
|
112
|
-
|
|
113
|
-
const goBack = useCallback(() => {
|
|
114
|
-
if (step === 'mapping') setStep('upload')
|
|
115
|
-
}, [step])
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
step,
|
|
119
|
-
file,
|
|
120
|
-
preview,
|
|
121
|
-
mappings,
|
|
122
|
-
result,
|
|
123
|
-
isLoading,
|
|
124
|
-
error,
|
|
125
|
-
handleFileSelect,
|
|
126
|
-
updateMapping,
|
|
127
|
-
startImport,
|
|
128
|
-
reset,
|
|
129
|
-
goBack,
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface UseCSVExportOptions {
|
|
134
|
-
exportFn: () => Promise<Blob | string>
|
|
135
|
-
filename?: string
|
|
136
|
-
}
|
|
137
|
-
|
|
2
|
+
import type { UseCSVExportOptions } from './useCSV-core'
|
|
3
|
+
|
|
4
|
+
// Types + the platform-neutral useCSVImport state machine live in the core.
|
|
5
|
+
export * from './useCSV-core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* useCSVExport — web implementation.
|
|
9
|
+
*
|
|
10
|
+
* Builds a Blob and triggers a browser download via an anchor click. This is
|
|
11
|
+
* inherently DOM-bound; React Native resolves useCSV.native.ts instead (which
|
|
12
|
+
* throws, since downloading a file has no direct RN equivalent).
|
|
13
|
+
*/
|
|
138
14
|
export function useCSVExport({ exportFn, filename = 'export.csv' }: UseCSVExportOptions) {
|
|
139
15
|
const [isExporting, setIsExporting] = useState(false)
|
|
140
16
|
|
|
@@ -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,56 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
interface StoredItem<T> {
|
|
4
|
+
item: T
|
|
5
|
+
viewedAt: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* useRecentlyViewed — React Native implementation.
|
|
10
|
+
*
|
|
11
|
+
* Web persists to localStorage. React Native has no synchronous storage, so this
|
|
12
|
+
* keeps an in-memory store keyed by storageKey at module scope — which mirrors
|
|
13
|
+
* localStorage's app-wide sharing within a session but does NOT survive an app
|
|
14
|
+
* restart. Persistence across launches needs an async backend (AsyncStorage);
|
|
15
|
+
* tracked as a follow-up (startsim-sw1.5 notes). Metro resolves this file over
|
|
16
|
+
* useRecentlyViewed.ts on native builds.
|
|
17
|
+
*/
|
|
18
|
+
const memoryStore = new Map<string, StoredItem<unknown>[]>()
|
|
19
|
+
|
|
20
|
+
export function useRecentlyViewed<T extends { id: string }>(
|
|
21
|
+
storageKey: string,
|
|
22
|
+
maxItems: number = 5
|
|
23
|
+
) {
|
|
24
|
+
const [items, setItems] = useState<StoredItem<T>[]>(
|
|
25
|
+
() => (memoryStore.get(storageKey) as StoredItem<T>[]) ?? []
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// Re-sync if the key changes.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setItems((memoryStore.get(storageKey) as StoredItem<T>[]) ?? [])
|
|
31
|
+
}, [storageKey])
|
|
32
|
+
|
|
33
|
+
const trackView = useCallback(
|
|
34
|
+
(item: T) => {
|
|
35
|
+
setItems((prev) => {
|
|
36
|
+
const deduped = prev.filter((entry) => entry.item.id !== item.id)
|
|
37
|
+
const updated = [{ item, viewedAt: Date.now() }, ...deduped].slice(0, maxItems)
|
|
38
|
+
memoryStore.set(storageKey, updated as StoredItem<unknown>[])
|
|
39
|
+
return updated
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
[maxItems, storageKey]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const clear = useCallback(() => {
|
|
46
|
+
setItems([])
|
|
47
|
+
memoryStore.delete(storageKey)
|
|
48
|
+
}, [storageKey])
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
items: items.map((entry) => entry.item),
|
|
52
|
+
timestamps: items.map((entry) => ({ id: entry.item.id, viewedAt: entry.viewedAt })),
|
|
53
|
+
trackView,
|
|
54
|
+
clear,
|
|
55
|
+
}
|
|
56
|
+
}
|