@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 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.11",
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
- ".": "./src/index.ts"
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
+ })