@startsimpli/hooks 0.4.9 → 0.4.14
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__/useVault.test.ts +107 -0
- package/src/index.ts +18 -0
- package/src/useCSV-core.ts +144 -0
- package/src/useCSV.native.ts +26 -0
- package/src/useCSV.ts +12 -136
- 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/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.14",
|
|
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.19"
|
|
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.19"
|
|
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,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
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -68,3 +68,21 @@ 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'
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useReducedMotion — React Native implementation.
|
|
3
|
+
*
|
|
4
|
+
* Web uses window.matchMedia('(prefers-reduced-motion: reduce)'); React Native
|
|
5
|
+
* exposes the same preference through AccessibilityInfo. Metro resolves this
|
|
6
|
+
* file over useReducedMotion.ts on native builds.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import { AccessibilityInfo } from 'react-native'
|
|
11
|
+
|
|
12
|
+
export function useReducedMotion(): boolean {
|
|
13
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let active = true
|
|
17
|
+
|
|
18
|
+
AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
|
|
19
|
+
if (active) setPrefersReducedMotion(enabled)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
23
|
+
'reduceMotionChanged',
|
|
24
|
+
(enabled) => setPrefersReducedMotion(enabled)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
active = false
|
|
29
|
+
subscription.remove()
|
|
30
|
+
}
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
return prefersReducedMotion
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { AppState } from 'react-native'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* useRefetchOnFocus — React Native implementation.
|
|
6
|
+
*
|
|
7
|
+
* Web listens for document 'visibilitychange'; React Native has no document, so
|
|
8
|
+
* this watches AppState for the app returning to the foreground ('active'). The
|
|
9
|
+
* pathname-change behaviour and 2s debounce are identical to the web version.
|
|
10
|
+
* Metro resolves this file over useRefetchOnFocus.ts on native builds.
|
|
11
|
+
*/
|
|
12
|
+
export function useRefetchOnFocus(
|
|
13
|
+
refetch: () => void,
|
|
14
|
+
pathname: string,
|
|
15
|
+
enabled = true
|
|
16
|
+
): void {
|
|
17
|
+
const mounted = useRef(false)
|
|
18
|
+
const lastRefetch = useRef(0)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!enabled) return
|
|
22
|
+
|
|
23
|
+
if (!mounted.current) {
|
|
24
|
+
mounted.current = true
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
if (now - lastRefetch.current < 2000) return
|
|
30
|
+
|
|
31
|
+
lastRefetch.current = now
|
|
32
|
+
refetch()
|
|
33
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
|
+
}, [pathname, enabled])
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!enabled) return
|
|
38
|
+
|
|
39
|
+
const subscription = AppState.addEventListener('change', (state) => {
|
|
40
|
+
if (state === 'active' && mounted.current) {
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
if (now - lastRefetch.current < 2000) return
|
|
43
|
+
lastRefetch.current = now
|
|
44
|
+
refetch()
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return () => subscription.remove()
|
|
49
|
+
}, [refetch, enabled])
|
|
50
|
+
}
|
package/src/useVault.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type {
|
|
3
|
+
VaultAccessKeyInput,
|
|
4
|
+
VaultApi,
|
|
5
|
+
VaultEnvironmentInput,
|
|
6
|
+
VaultListParams,
|
|
7
|
+
VaultSecretInput,
|
|
8
|
+
} from '@startsimpli/api'
|
|
9
|
+
|
|
10
|
+
/** TanStack Query hooks over VaultApi (startsim-d30.3.2).
|
|
11
|
+
*
|
|
12
|
+
* Each hook takes the VaultApi instance (e.g. `api.vault`), matching the
|
|
13
|
+
* useMessages convention. Secrets are revealed on demand (a mutation), never
|
|
14
|
+
* auto-fetched, so values are only pulled when the user asks.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ENVIRONMENTS_KEY = ['vault', 'environments'] as const
|
|
18
|
+
const environmentKey = (slug: string) => ['vault', 'environment', slug]
|
|
19
|
+
const secretsKey = (slug: string) => ['vault', 'secrets', slug]
|
|
20
|
+
const accessKeysKey = (slug: string) => ['vault', 'access-keys', slug]
|
|
21
|
+
|
|
22
|
+
/** Secret mutations alter the env's `secret_count`, so refresh the list +
|
|
23
|
+
* detail too — otherwise the /environments page shows a stale count
|
|
24
|
+
* (startsim-drl). */
|
|
25
|
+
function invalidateSecretQueries(qc: ReturnType<typeof useQueryClient>, slug: string) {
|
|
26
|
+
qc.invalidateQueries({ queryKey: secretsKey(slug) })
|
|
27
|
+
qc.invalidateQueries({ queryKey: ENVIRONMENTS_KEY })
|
|
28
|
+
qc.invalidateQueries({ queryKey: environmentKey(slug) })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Environments ---
|
|
32
|
+
export function useEnvironments(api: VaultApi, params?: VaultListParams) {
|
|
33
|
+
return useQuery({
|
|
34
|
+
queryKey: ['vault', 'environments', params],
|
|
35
|
+
queryFn: () => api.listEnvironments(params),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useEnvironment(api: VaultApi, slug: string) {
|
|
40
|
+
return useQuery({
|
|
41
|
+
queryKey: ['vault', 'environment', slug],
|
|
42
|
+
queryFn: () => api.getEnvironment(slug),
|
|
43
|
+
enabled: !!slug,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useCreateEnvironment(api: VaultApi) {
|
|
48
|
+
const qc = useQueryClient()
|
|
49
|
+
return useMutation({
|
|
50
|
+
mutationFn: (data: VaultEnvironmentInput) => api.createEnvironment(data),
|
|
51
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ENVIRONMENTS_KEY }),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useUpdateEnvironment(api: VaultApi) {
|
|
56
|
+
const qc = useQueryClient()
|
|
57
|
+
return useMutation({
|
|
58
|
+
mutationFn: ({ slug, data }: { slug: string; data: Partial<VaultEnvironmentInput> }) =>
|
|
59
|
+
api.updateEnvironment(slug, data),
|
|
60
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ENVIRONMENTS_KEY }),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useDeleteEnvironment(api: VaultApi) {
|
|
65
|
+
const qc = useQueryClient()
|
|
66
|
+
return useMutation({
|
|
67
|
+
mutationFn: (slug: string) => api.deleteEnvironment(slug),
|
|
68
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ENVIRONMENTS_KEY }),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Secrets ---
|
|
73
|
+
export function useSecrets(api: VaultApi, slug: string, params?: VaultListParams) {
|
|
74
|
+
return useQuery({
|
|
75
|
+
queryKey: ['vault', 'secrets', slug, params],
|
|
76
|
+
queryFn: () => api.listSecrets(slug, params),
|
|
77
|
+
enabled: !!slug,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useCreateSecret(api: VaultApi, slug: string) {
|
|
82
|
+
const qc = useQueryClient()
|
|
83
|
+
return useMutation({
|
|
84
|
+
mutationFn: (data: VaultSecretInput) => api.createSecret(slug, data),
|
|
85
|
+
onSuccess: () => invalidateSecretQueries(qc, slug),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function useUpdateSecret(api: VaultApi, slug: string) {
|
|
90
|
+
const qc = useQueryClient()
|
|
91
|
+
return useMutation({
|
|
92
|
+
mutationFn: ({ id, data }: { id: string; data: Partial<VaultSecretInput> }) =>
|
|
93
|
+
api.updateSecret(slug, id, data),
|
|
94
|
+
onSuccess: () => invalidateSecretQueries(qc, slug),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useDeleteSecret(api: VaultApi, slug: string) {
|
|
99
|
+
const qc = useQueryClient()
|
|
100
|
+
return useMutation({
|
|
101
|
+
mutationFn: (id: string) => api.deleteSecret(slug, id),
|
|
102
|
+
onSuccess: () => invalidateSecretQueries(qc, slug),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function useRevealSecret(api: VaultApi, slug: string) {
|
|
107
|
+
return useMutation({ mutationFn: (id: string) => api.revealSecret(slug, id) })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Access keys ---
|
|
111
|
+
export function useAccessKeys(api: VaultApi, slug: string, params?: VaultListParams) {
|
|
112
|
+
return useQuery({
|
|
113
|
+
queryKey: ['vault', 'access-keys', slug, params],
|
|
114
|
+
queryFn: () => api.listAccessKeys(slug, params),
|
|
115
|
+
enabled: !!slug,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function useCreateAccessKey(api: VaultApi, slug: string) {
|
|
120
|
+
const qc = useQueryClient()
|
|
121
|
+
return useMutation({
|
|
122
|
+
mutationFn: (data: VaultAccessKeyInput) => api.createAccessKey(slug, data),
|
|
123
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeysKey(slug) }),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function useDeleteAccessKey(api: VaultApi, slug: string) {
|
|
128
|
+
const qc = useQueryClient()
|
|
129
|
+
return useMutation({
|
|
130
|
+
mutationFn: (id: string) => api.deleteAccessKey(slug, id),
|
|
131
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeysKey(slug) }),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Audit ---
|
|
136
|
+
export function useAuditLog(api: VaultApi, slug: string, params?: VaultListParams) {
|
|
137
|
+
return useQuery({
|
|
138
|
+
queryKey: ['vault', 'audit', slug, params],
|
|
139
|
+
queryFn: () => api.listAudit(slug, params),
|
|
140
|
+
enabled: !!slug,
|
|
141
|
+
})
|
|
142
|
+
}
|