@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
package/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# @startsimpli/hooks
|
|
2
|
+
|
|
3
|
+
Shared React hooks for every StartSimpli app. This is where dashboard plumbing lives — entity tables with CSV export, paginated table filters, saved views, recently-viewed, wizards, TanStack Query wrappers over `@startsimpli/api` (messages, target lists, vault), enrichment state machines, and small a11y/lifecycle helpers (`useReducedMotion`, `useRefetchOnFocus`). Reach for this package whenever you're building a list/detail/edit screen, a multi-step form, or anything that talks to the Django backend through `@startsimpli/api`.
|
|
4
|
+
|
|
5
|
+
Consumed today by `raise-simpli/web-app`, `market-simpli`, `vault-web`, and the React Native preview in `examples/mobile-rn`. Web-only hooks have `.native.ts` siblings so React Native bundlers resolve a safe stub (or platform-appropriate implementation) automatically.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Workspace dep — in the app's `package.json`:
|
|
10
|
+
```json
|
|
11
|
+
{ "dependencies": { "@startsimpli/hooks": "workspace:*" } }
|
|
12
|
+
```
|
|
13
|
+
Add `transpilePackages: ['@startsimpli/hooks']` to `next.config.ts` (Next apps only). Peer deps are `react`, `@tanstack/react-query` (for any hook that talks to the API), and `@startsimpli/api` (for the typed-API hooks); all are marked optional so a leaf app that only wants `useReducedMotion` doesn't have to install the world.
|
|
14
|
+
|
|
15
|
+
## Public surface
|
|
16
|
+
|
|
17
|
+
### Data + mutations (TanStack Query over `@startsimpli/api`)
|
|
18
|
+
|
|
19
|
+
| Export | Signature | Consumed by |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `useCRUDMutation` | `(mutationFn, { invalidateKeys, onSuccess?, onError? }) => UseMutationResult` — generic mutation that invalidates a list of query keys on success. | available; no app consumers yet |
|
|
22
|
+
| `useMessages` | `(api: MessagesApi, filters?) => UseQueryResult<PaginatedResponse<Message>>` — paginated message list. | market-simpli, raise-simpli |
|
|
23
|
+
| `useMessage` | `(api, id) => UseQueryResult<Message>` — single message detail. | market-simpli |
|
|
24
|
+
| `useCreateMessage` / `useSendMessage` / `useScheduleMessage` / `useSendTestMessage` | message lifecycle mutations; invalidate the `messages` cache. | market-simpli |
|
|
25
|
+
| `useMessageChannels` | `(api) => UseQueryResult<Channel[]>` — available send channels. | market-simpli |
|
|
26
|
+
| `useMessageTemplates` / `useMessageTemplate` / `useCreateMessageTemplate` / `useUpdateMessageTemplate` / `useDeleteMessageTemplate` | template CRUD over `MessageTemplatesApi`. | market-simpli |
|
|
27
|
+
| `useTargetListDetail` | `(id, { get }) => UseQueryResult<TargetList>` — fetch one list. | market-simpli |
|
|
28
|
+
| `useTargetListMutations` | `({ apiFns, onSuccess?, onError? }) => { createList, updateList, deleteList, addMembers, removeMembers, refreshList }` — bundled list/member mutations with shared invalidation. | market-simpli |
|
|
29
|
+
| `TARGET_LIST_KEYS` | query-key factory `{ all, lists(filters), detail(id), members(listId, filters) }`. | market-simpli |
|
|
30
|
+
|
|
31
|
+
### Vault (`startsim-d30.3.2`) — over `VaultApi`
|
|
32
|
+
|
|
33
|
+
| Export | Signature | Consumed by |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `useEnvironments` / `useEnvironment` | list and detail queries for vault environments. | vault-web |
|
|
36
|
+
| `useCreateEnvironment` / `useUpdateEnvironment` / `useDeleteEnvironment` | environment mutations; invalidate the environments cache. | vault-web |
|
|
37
|
+
| `useSecrets` / `useCreateSecret` / `useUpdateSecret` / `useDeleteSecret` | per-environment secret CRUD; also refresh the env detail/list so `secret_count` stays accurate. | vault-web |
|
|
38
|
+
| `useRevealSecret` | on-demand mutation — values are never auto-fetched. | vault-web |
|
|
39
|
+
| `useAccessKeys` / `useCreateAccessKey` / `useDeleteAccessKey` | access-key CRUD per environment. | vault-web |
|
|
40
|
+
| `useAuditLog` | paginated audit log for an environment. | vault-web |
|
|
41
|
+
|
|
42
|
+
### Tables, filters, saved views
|
|
43
|
+
|
|
44
|
+
| Export | Signature | Consumed by |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `useEntityTable<T>` | `({ items, idField?, csvFilename, csvColumns, csvRowMapper }) => { selectedItem, editingItem, viewMode, showCreateForm, exportIdsRef, exportCSV, isExporting, openView, openEdit, openCreate, closePanel }` — list/detail/edit + CSV export state for an entity grid. | market-simpli (prospects, organizations, deals, lists, …) |
|
|
47
|
+
| `useTableFilters<TFilters>` | `(initial) => { filters, setFilter, setPage, setPageSize, setSearch, setSort, resetFilters }` — generic paginated table filter state; mutating a filter resets `page` to 1. | market-simpli (wrapped as `useMarketTableFilters`) |
|
|
48
|
+
| `useSavedViews<T>` | `({ resource, loadFn, saveFn, updateFn, deleteFn }) => { views, currentViewId, loading, error, saveView, updateView, deleteView, loadView, getCurrentView, refreshViews }` — persisted named filter views. | market-simpli (`SavedViewsMenu`) |
|
|
49
|
+
| `useRecentlyViewed<T>` | `(storageKey, maxItems=5) => { items, timestamps, trackView, clear }` — localStorage-backed recents list. | raise-simpli (`RecentlyViewedSidebar`) |
|
|
50
|
+
|
|
51
|
+
### URL-encoded filter state (pure functions, no React)
|
|
52
|
+
|
|
53
|
+
| Export | Purpose |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `encodeFilterConfig` / `decodeFilterConfig` | URL-safe base64 (de)serializer for a `FilterConfig`. |
|
|
56
|
+
| `parseUrlFilters` | `URLSearchParams -> EncodedFilterState`. |
|
|
57
|
+
| `createSimpleFilter` / `mergeFilters` | helpers for constructing/combining filter graphs. |
|
|
58
|
+
| `getFilterDescription` | human-readable summary for a saved filter chip. |
|
|
59
|
+
| `FilterOperator`, `FilterValue`, `FilterCondition`, `FilterGroup`, `FilterConfig`, `EncodedFilterState`, `FilterValidationError`, `FilterValidationResult` | types. |
|
|
60
|
+
|
|
61
|
+
Used by `raise-simpli/web-app/src/lib/filtering/parser.ts` (which re-exports them for backwards-compat).
|
|
62
|
+
|
|
63
|
+
### Wizards + forms
|
|
64
|
+
|
|
65
|
+
| Export | Signature | Consumed by |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `useWizard<TStep>` | `(steps, initialStep?, opts?)` or `(steps, opts?)` → `{ currentStep, stepIndex, totalSteps, isFirstStep, isLastStep, canGoBack, canGoNext, errors, goTo, next, prev, reset, clearErrors }` — typed step machine with per-step validators. | market-simpli (`CampaignForm`) |
|
|
68
|
+
| `useAsyncOptions<T>` | `(fetcher, { enabled?, defaultValue? }?) => { data, loading, error, refresh }` — load select options from an async source. | market-simpli (`CampaignForm`) |
|
|
69
|
+
|
|
70
|
+
### CSV import / export
|
|
71
|
+
|
|
72
|
+
| Export | Signature | Consumed by |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `useCSVImport<TField>` | `({ previewFn, importFn, onSuccess? }) => UseCSVImportState` — platform-neutral upload → preview → mapping → import state machine; safe on web and React Native. | market-simpli (`ImportCSV`) |
|
|
75
|
+
| `useCSVExport` | `({ exportFn, filename? }) => { exportCSV, isExporting }` — web-only download via Blob + anchor; React Native resolves a stub that throws. | market-simpli (also driven by `useEntityTable`) |
|
|
76
|
+
|
|
77
|
+
### Enrichment
|
|
78
|
+
|
|
79
|
+
| Export | Purpose | Consumed by |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `useEnrichment` | single-contact enrichment state. | market-simpli (settings/enrichment page) |
|
|
82
|
+
| `useBatchEnrichment` | batched enrichment with progress + cancel. | market-simpli |
|
|
83
|
+
| `useQueueStatus` | poll the enrichment queue depth. | market-simpli |
|
|
84
|
+
|
|
85
|
+
### Lifecycle + a11y
|
|
86
|
+
|
|
87
|
+
| Export | Signature | Consumed by |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `useReducedMotion` | `() => boolean` — tracks `(prefers-reduced-motion: reduce)`. | raise-simpli (`toast-notification`, `InvestorCard`, `PipelineColumn`), `examples/mobile-rn` |
|
|
90
|
+
| `useRefetchOnFocus` | `(refetch, pathname, enabled=true) => void` — re-runs `refetch` on tab-focus and route change, skipping initial mount, debounced 2s. | raise-simpli (dashboard, messages, fundraises, calendar, outreach pages) |
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
### Entity table + CSV export (market-simpli)
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
// market-simpli/src/modules/prospects/components/ProspectsTable.tsx
|
|
98
|
+
import { useEntityTable } from '@startsimpli/hooks'
|
|
99
|
+
|
|
100
|
+
const {
|
|
101
|
+
selectedItem, editingItem, viewMode, showCreateForm,
|
|
102
|
+
openView, openEdit, openCreate, closePanel,
|
|
103
|
+
} = useEntityTable<Prospect>({
|
|
104
|
+
items: data?.results || [],
|
|
105
|
+
idField: 'internalId',
|
|
106
|
+
csvFilename: 'prospects',
|
|
107
|
+
csvColumns: ['Name', 'Email', 'Phone', 'Company', 'Stage', 'Lead Score', 'Created'],
|
|
108
|
+
csvRowMapper: (p) => [
|
|
109
|
+
p.name || '',
|
|
110
|
+
p.email || '',
|
|
111
|
+
p.phone || '',
|
|
112
|
+
p.companyName || '',
|
|
113
|
+
p.stage || '',
|
|
114
|
+
p.leadScore?.toString() || '',
|
|
115
|
+
new Date(p.createdAt).toLocaleDateString(),
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Vault environments (vault-web)
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
// vault-web/src/app/(dashboard)/environments/page.tsx
|
|
124
|
+
import {
|
|
125
|
+
useCreateEnvironment,
|
|
126
|
+
useDeleteEnvironment,
|
|
127
|
+
useEnvironments,
|
|
128
|
+
useUpdateEnvironment,
|
|
129
|
+
} from '@startsimpli/hooks'
|
|
130
|
+
import { api } from '@/lib/api'
|
|
131
|
+
|
|
132
|
+
const { data, isLoading, isError } = useEnvironments(api.vault)
|
|
133
|
+
const createEnv = useCreateEnvironment(api.vault)
|
|
134
|
+
const updateEnv = useUpdateEnvironment(api.vault)
|
|
135
|
+
const deleteEnv = useDeleteEnvironment(api.vault)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Secret CRUD takes the env slug once and returns mutations scoped to it; deleting/creating a secret automatically refreshes the env detail + the environments list so `secret_count` stays accurate:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
// vault-web/src/app/(dashboard)/environments/[slug]/page.tsx
|
|
142
|
+
import {
|
|
143
|
+
useCreateSecret,
|
|
144
|
+
useDeleteSecret,
|
|
145
|
+
useRevealSecret,
|
|
146
|
+
useSecrets,
|
|
147
|
+
useUpdateSecret,
|
|
148
|
+
} from '@startsimpli/hooks'
|
|
149
|
+
|
|
150
|
+
const { data, isLoading } = useSecrets(api.vault, slug)
|
|
151
|
+
const createSecret = useCreateSecret(api.vault, slug)
|
|
152
|
+
const updateSecret = useUpdateSecret(api.vault, slug)
|
|
153
|
+
const deleteSecret = useDeleteSecret(api.vault, slug)
|
|
154
|
+
const reveal = useRevealSecret(api.vault, slug)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Wizard with per-step validation (market-simpli)
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
// market-simpli/src/modules/campaigns/components/CampaignForm.tsx
|
|
161
|
+
import { useWizard } from '@startsimpli/hooks'
|
|
162
|
+
|
|
163
|
+
const STEPS = ['details', 'sender', 'sequence'] as const
|
|
164
|
+
type CampaignStep = (typeof STEPS)[number]
|
|
165
|
+
|
|
166
|
+
const wizard = useWizard<CampaignStep>(STEPS, {
|
|
167
|
+
validate: {
|
|
168
|
+
details: () => {
|
|
169
|
+
const errs: Record<string, string> = {}
|
|
170
|
+
if (!formDataRef.current.name.trim()) errs.name = 'Campaign name is required'
|
|
171
|
+
if (!formDataRef.current.channel) errs.channel = 'Please select a messaging channel'
|
|
172
|
+
return Object.keys(errs).length ? errs : null
|
|
173
|
+
},
|
|
174
|
+
sequence: () => {
|
|
175
|
+
const errs: Record<string, string> = {}
|
|
176
|
+
if (formDataRef.current.sequence.length === 0) {
|
|
177
|
+
errs.sequence = 'Please add at least one step to the campaign sequence'
|
|
178
|
+
}
|
|
179
|
+
return Object.keys(errs).length ? errs : null
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Refetch on tab focus / route change (raise-simpli)
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
// raise-simpli/web-app/src/app/(dashboard)/messages/page.tsx
|
|
189
|
+
import { useRefetchOnFocus } from '@startsimpli/hooks'
|
|
190
|
+
import { usePathname } from 'next/navigation'
|
|
191
|
+
|
|
192
|
+
useRefetchOnFocus(fetchMessages, usePathname() ?? '')
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Saved views (market-simpli)
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
// market-simpli/src/shared/components/SavedViewsMenu.tsx
|
|
199
|
+
import { useSavedViews } from '@startsimpli/hooks'
|
|
200
|
+
|
|
201
|
+
const {
|
|
202
|
+
views, currentViewId, loading,
|
|
203
|
+
saveView: saveNewView, deleteView: removeView, loadView,
|
|
204
|
+
} = useSavedViews<MarketSavedView>({
|
|
205
|
+
resource,
|
|
206
|
+
loadFn: loadViews,
|
|
207
|
+
saveFn: saveView,
|
|
208
|
+
updateFn: updateView,
|
|
209
|
+
deleteFn: deleteView,
|
|
210
|
+
})
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Verification
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
cd packages/hooks
|
|
217
|
+
pnpm vitest run
|
|
218
|
+
pnpm tsc --noEmit
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
10 test files, 83 tests passing (`useAsyncOptions`, `useCRUDMutation`, `useEntityTable`, `useRecentlyViewed`, `useReducedMotion`, `useRefetchOnFocus`, `useSavedViews`, `useTableFilters`, `useVault`, `useWizard`).
|
|
222
|
+
|
|
223
|
+
## Shared-first
|
|
224
|
+
|
|
225
|
+
See monorepo CLAUDE.md rule 9: hooks live here, not in any app's `src/`. If a hook is used by one app today but plausibly useful to another (a paginated table filter, a CSV importer, a saved-view menu, a wizard, a TanStack Query wrapper over `@startsimpli/api`), write it in this package from day one. Don't wrap a shared hook in an app-local context just to add a field — extend the shared signature.
|
package/package.json
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/hooks",
|
|
3
|
-
"version": "0.4.
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"react-native": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
9
14
|
},
|
|
10
15
|
"files": [
|
|
11
16
|
"src",
|
|
@@ -14,18 +19,11 @@
|
|
|
14
19
|
"publishConfig": {
|
|
15
20
|
"access": "public"
|
|
16
21
|
},
|
|
17
|
-
"scripts": {
|
|
18
|
-
"build": "tsup",
|
|
19
|
-
"dev": "tsup --watch",
|
|
20
|
-
"type-check": "tsc --noEmit",
|
|
21
|
-
"test": "vitest run",
|
|
22
|
-
"test:watch": "vitest",
|
|
23
|
-
"clean": "rm -rf dist"
|
|
24
|
-
},
|
|
25
22
|
"peerDependencies": {
|
|
26
|
-
"@startsimpli/api": "workspace:*",
|
|
27
23
|
"@tanstack/react-query": ">=5.0.0",
|
|
28
|
-
"react": ">=18.0.0"
|
|
24
|
+
"react": ">=18.0.0",
|
|
25
|
+
"react-native": ">=0.74.0",
|
|
26
|
+
"@startsimpli/api": "0.5.22"
|
|
29
27
|
},
|
|
30
28
|
"peerDependenciesMeta": {
|
|
31
29
|
"@tanstack/react-query": {
|
|
@@ -33,10 +31,12 @@
|
|
|
33
31
|
},
|
|
34
32
|
"@startsimpli/api": {
|
|
35
33
|
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"react-native": {
|
|
36
|
+
"optional": true
|
|
36
37
|
}
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@startsimpli/api": "workspace:*",
|
|
40
40
|
"@tanstack/react-query": "^5.99.2",
|
|
41
41
|
"@testing-library/react": "^16.3.2",
|
|
42
42
|
"@types/node": "^20.19.39",
|
|
@@ -45,6 +45,15 @@
|
|
|
45
45
|
"react-dom": "^19.2.5",
|
|
46
46
|
"tsup": "^8.5.1",
|
|
47
47
|
"typescript": "^6.0.3",
|
|
48
|
-
"vitest": "^4.1.5"
|
|
48
|
+
"vitest": "^4.1.5",
|
|
49
|
+
"@startsimpli/api": "0.5.22"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"type-check": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:watch": "vitest",
|
|
57
|
+
"clean": "rm -rf dist"
|
|
49
58
|
}
|
|
50
|
-
}
|
|
59
|
+
}
|
|
@@ -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,107 @@
|
|
|
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 { useCreateSecret, useDeleteSecret, useUpdateSecret } from '../useVault'
|
|
6
|
+
import type { VaultApi } from '@startsimpli/api'
|
|
7
|
+
|
|
8
|
+
/** startsim-drl: secret mutations must invalidate the env LIST (carries
|
|
9
|
+
* secret_count) and env DETAIL too, not only the secrets list — otherwise
|
|
10
|
+
* the /environments page sticks on a stale count.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function mkClient() {
|
|
14
|
+
const qc = new QueryClient({
|
|
15
|
+
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
|
|
16
|
+
})
|
|
17
|
+
return qc
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wrap(qc: QueryClient) {
|
|
21
|
+
return ({ children }: { children: React.ReactNode }) =>
|
|
22
|
+
React.createElement(QueryClientProvider, { client: qc }, children)
|
|
23
|
+
}
|
|
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
|
+
|
|
35
|
+
function fakeApi(overrides: Partial<VaultApi> = {}): VaultApi {
|
|
36
|
+
return {
|
|
37
|
+
createSecret: vi.fn().mockResolvedValue({ id: 'sec-1' }),
|
|
38
|
+
updateSecret: vi.fn().mockResolvedValue({ id: 'sec-1' }),
|
|
39
|
+
deleteSecret: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
...overrides,
|
|
41
|
+
} as unknown as VaultApi
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('secret mutations also invalidate environment queries (startsim-drl)', () => {
|
|
45
|
+
it('useCreateSecret invalidates secrets + environments + environment(slug)', async () => {
|
|
46
|
+
const qc = mkClient()
|
|
47
|
+
const seen = trackInvalidations(qc)
|
|
48
|
+
const { result } = renderHook(() => useCreateSecret(fakeApi(), 'quinns-mac'), {
|
|
49
|
+
wrapper: wrap(qc),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await act(async () => {
|
|
53
|
+
result.current.mutate({ key: 'K', value: 'v' })
|
|
54
|
+
})
|
|
55
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
56
|
+
|
|
57
|
+
expect(seen).toEqual(
|
|
58
|
+
expect.arrayContaining([
|
|
59
|
+
['vault', 'secrets', 'quinns-mac'],
|
|
60
|
+
['vault', 'environments'],
|
|
61
|
+
['vault', 'environment', 'quinns-mac'],
|
|
62
|
+
]),
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('useUpdateSecret invalidates the same three keys', async () => {
|
|
67
|
+
const qc = mkClient()
|
|
68
|
+
const seen = trackInvalidations(qc)
|
|
69
|
+
const { result } = renderHook(() => useUpdateSecret(fakeApi(), 'quinns-mac'), {
|
|
70
|
+
wrapper: wrap(qc),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await act(async () => {
|
|
74
|
+
result.current.mutate({ id: 'sec-1', data: { value: 'v2' } })
|
|
75
|
+
})
|
|
76
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
77
|
+
|
|
78
|
+
expect(seen).toEqual(
|
|
79
|
+
expect.arrayContaining([
|
|
80
|
+
['vault', 'secrets', 'quinns-mac'],
|
|
81
|
+
['vault', 'environments'],
|
|
82
|
+
['vault', 'environment', 'quinns-mac'],
|
|
83
|
+
]),
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('useDeleteSecret invalidates the same three keys', async () => {
|
|
88
|
+
const qc = mkClient()
|
|
89
|
+
const seen = trackInvalidations(qc)
|
|
90
|
+
const { result } = renderHook(() => useDeleteSecret(fakeApi(), 'quinns-mac'), {
|
|
91
|
+
wrapper: wrap(qc),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await act(async () => {
|
|
95
|
+
result.current.mutate('sec-1')
|
|
96
|
+
})
|
|
97
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
|
98
|
+
|
|
99
|
+
expect(seen).toEqual(
|
|
100
|
+
expect.arrayContaining([
|
|
101
|
+
['vault', 'secrets', 'quinns-mac'],
|
|
102
|
+
['vault', 'environments'],
|
|
103
|
+
['vault', 'environment', 'quinns-mac'],
|
|
104
|
+
]),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
})
|