@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 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.9",
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
- ".": "./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.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
- 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
- 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
+ }
@@ -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
+ }