@startsimpli/hooks 0.1.1

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/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ export { useTableFilters } from './useTableFilters'
2
+
3
+ // Filter types (generic, no domain-specific fields)
4
+ export type {
5
+ FilterOperator,
6
+ FilterValue,
7
+ FilterCondition,
8
+ FilterGroup,
9
+ FilterConfig,
10
+ EncodedFilterState,
11
+ FilterValidationError,
12
+ FilterValidationResult,
13
+ } from './filter-types'
14
+
15
+ // URL filter encoding utilities (pure functions, no React/Next.js dependencies)
16
+ export {
17
+ encodeFilterConfig,
18
+ decodeFilterConfig,
19
+ parseUrlFilters,
20
+ createSimpleFilter,
21
+ mergeFilters,
22
+ getFilterDescription,
23
+ } from './filter-encoding'
24
+ export { useCRUDMutation } from './useCRUDMutation'
25
+ export { useSavedViews } from './useSavedViews'
26
+ export type { SavedView as SavedViewEntry } from './useSavedViews'
27
+ export { useRecentlyViewed } from './useRecentlyViewed'
28
+ export { useWizard } from './useWizard'
29
+ export type { WizardState } from './useWizard'
30
+ export { useCSVImport, useCSVExport } from './useCSV'
31
+ export type {
32
+ CSVColumnMapping,
33
+ CSVPreviewResult,
34
+ CSVImportResult,
35
+ CSVImportStep,
36
+ UseCSVImportState,
37
+ } from './useCSV'
@@ -0,0 +1,26 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import type { QueryKey, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'
3
+
4
+ interface UseCRUDMutationOptions<TInput, TOutput> {
5
+ invalidateKeys: QueryKey[]
6
+ onSuccess?: (data: TOutput, variables: TInput) => void
7
+ onError?: (error: Error, variables: TInput) => void
8
+ }
9
+
10
+ export function useCRUDMutation<TInput, TOutput>(
11
+ mutationFn: (input: TInput) => Promise<TOutput>,
12
+ opts: UseCRUDMutationOptions<TInput, TOutput>
13
+ ): UseMutationResult<TOutput, Error, TInput> {
14
+ const queryClient = useQueryClient()
15
+
16
+ return useMutation({
17
+ mutationFn,
18
+ onSuccess: (data, variables) => {
19
+ for (const key of opts.invalidateKeys) {
20
+ queryClient.invalidateQueries({ queryKey: key })
21
+ }
22
+ opts.onSuccess?.(data, variables)
23
+ },
24
+ onError: opts.onError,
25
+ })
26
+ }
package/src/useCSV.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { useState, useCallback } from 'react'
2
+
3
+ export interface CSVColumnMapping {
4
+ csv_column: string
5
+ target_field: string
6
+ }
7
+
8
+ export interface CSVPreviewResult<TField extends string = string> {
9
+ columns: string[]
10
+ sample_rows: Record<string, string>[]
11
+ suggested_mappings?: CSVColumnMapping[]
12
+ total_rows?: number
13
+ }
14
+
15
+ export interface CSVImportResult {
16
+ total_rows: 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.suggested_mappings ?? [])
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.csv_column === csvColumn)
80
+ if (existing) {
81
+ return prev.map((m) =>
82
+ m.csv_column === csvColumn ? { ...m, target_field: targetField } : m
83
+ )
84
+ }
85
+ return [...prev, { csv_column: csvColumn, target_field: 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
+
138
+ export function useCSVExport({ exportFn, filename = 'export.csv' }: UseCSVExportOptions) {
139
+ const [isExporting, setIsExporting] = useState(false)
140
+
141
+ const exportCSV = useCallback(async () => {
142
+ setIsExporting(true)
143
+ try {
144
+ const data = await exportFn()
145
+ const blob = typeof data === 'string' ? new Blob([data], { type: 'text/csv' }) : data
146
+ const url = URL.createObjectURL(blob)
147
+ const a = document.createElement('a')
148
+ a.href = url
149
+ a.download = filename
150
+ a.click()
151
+ URL.revokeObjectURL(url)
152
+ } finally {
153
+ setIsExporting(false)
154
+ }
155
+ }, [exportFn, filename])
156
+
157
+ return { exportCSV, isExporting }
158
+ }
@@ -0,0 +1,67 @@
1
+ import { useState, useCallback, useEffect } from 'react'
2
+
3
+ interface StoredItem<T> {
4
+ item: T
5
+ viewedAt: number
6
+ }
7
+
8
+ export function useRecentlyViewed<T extends { id: string }>(
9
+ storageKey: string,
10
+ maxItems: number = 5
11
+ ) {
12
+ const [items, setItems] = useState<StoredItem<T>[]>([])
13
+
14
+ // Load from localStorage on mount
15
+ useEffect(() => {
16
+ if (typeof window === 'undefined') return
17
+ try {
18
+ const raw = localStorage.getItem(storageKey)
19
+ if (raw) {
20
+ setItems(JSON.parse(raw) as StoredItem<T>[])
21
+ }
22
+ } catch {
23
+ // Corrupted storage, start fresh
24
+ }
25
+ }, [storageKey])
26
+
27
+ const persist = useCallback(
28
+ (updated: StoredItem<T>[]) => {
29
+ if (typeof window === 'undefined') return
30
+ try {
31
+ localStorage.setItem(storageKey, JSON.stringify(updated))
32
+ } catch {
33
+ // Storage quota exceeded
34
+ }
35
+ },
36
+ [storageKey]
37
+ )
38
+
39
+ const trackView = useCallback(
40
+ (item: T) => {
41
+ setItems(prev => {
42
+ const deduped = prev.filter(entry => entry.item.id !== item.id)
43
+ const updated = [{ item, viewedAt: Date.now() }, ...deduped].slice(0, maxItems)
44
+ persist(updated)
45
+ return updated
46
+ })
47
+ },
48
+ [maxItems, persist]
49
+ )
50
+
51
+ const clear = useCallback(() => {
52
+ setItems([])
53
+ if (typeof window === 'undefined') return
54
+ try {
55
+ localStorage.removeItem(storageKey)
56
+ } catch {
57
+ // ignore
58
+ }
59
+ }, [storageKey])
60
+
61
+ return {
62
+ items: items.map(entry => entry.item),
63
+ timestamps: items.map(entry => ({ id: entry.item.id, viewedAt: entry.viewedAt })),
64
+ trackView,
65
+ clear,
66
+ }
67
+ }
@@ -0,0 +1,117 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+
3
+ export interface SavedView {
4
+ id: string
5
+ name: string
6
+ isDefault?: boolean
7
+ createdAt?: string
8
+ [key: string]: unknown
9
+ }
10
+
11
+ interface SavedViewsState<T extends SavedView> {
12
+ views: T[]
13
+ currentViewId: string | null
14
+ loading: boolean
15
+ error: string | null
16
+ }
17
+
18
+ interface UseSavedViewsOptions<T extends SavedView> {
19
+ resource: string
20
+ loadFn: (resource: string) => Promise<T[]>
21
+ saveFn: (resource: string, view: Omit<T, 'id' | 'createdAt'>) => Promise<T>
22
+ updateFn: (resource: string, viewId: string, updates: Partial<T>) => Promise<T>
23
+ deleteFn: (resource: string, viewId: string) => Promise<void>
24
+ }
25
+
26
+ export function useSavedViews<T extends SavedView>({
27
+ resource,
28
+ loadFn,
29
+ saveFn,
30
+ updateFn,
31
+ deleteFn,
32
+ }: UseSavedViewsOptions<T>) {
33
+ const [state, setState] = useState<SavedViewsState<T>>({
34
+ views: [],
35
+ currentViewId: null,
36
+ loading: true,
37
+ error: null,
38
+ })
39
+
40
+ const fetchViews = useCallback(async () => {
41
+ setState(prev => ({ ...prev, loading: true, error: null }))
42
+ try {
43
+ const views = await loadFn(resource)
44
+ const defaultView = views.find(v => v.isDefault)
45
+ setState(prev => ({
46
+ ...prev,
47
+ views,
48
+ currentViewId: defaultView?.id || null,
49
+ loading: false,
50
+ }))
51
+ } catch (error) {
52
+ setState(prev => ({
53
+ ...prev,
54
+ loading: false,
55
+ error: error instanceof Error ? error.message : 'Failed to load views',
56
+ }))
57
+ }
58
+ }, [resource, loadFn])
59
+
60
+ useEffect(() => {
61
+ fetchViews()
62
+ }, [fetchViews])
63
+
64
+ const saveView = useCallback(
65
+ async (viewData: Omit<T, 'id' | 'createdAt'>): Promise<T> => {
66
+ const newView = await saveFn(resource, viewData)
67
+ setState(prev => ({
68
+ ...prev,
69
+ views: [...prev.views, newView],
70
+ currentViewId: newView.id,
71
+ }))
72
+ return newView
73
+ },
74
+ [resource, saveFn]
75
+ )
76
+
77
+ const updateView = useCallback(
78
+ async (viewId: string, updates: Partial<T>): Promise<void> => {
79
+ await updateFn(resource, viewId, updates)
80
+ await fetchViews()
81
+ },
82
+ [resource, updateFn, fetchViews]
83
+ )
84
+
85
+ const deleteView = useCallback(
86
+ async (viewId: string): Promise<void> => {
87
+ await deleteFn(resource, viewId)
88
+ setState(prev => ({
89
+ ...prev,
90
+ views: prev.views.filter(v => v.id !== viewId),
91
+ currentViewId: prev.currentViewId === viewId ? null : prev.currentViewId,
92
+ }))
93
+ },
94
+ [resource, deleteFn]
95
+ )
96
+
97
+ const loadView = useCallback((viewId: string) => {
98
+ setState(prev => ({ ...prev, currentViewId: viewId }))
99
+ }, [])
100
+
101
+ const getCurrentView = useCallback((): T | null => {
102
+ return state.views.find(v => v.id === state.currentViewId) || null
103
+ }, [state.views, state.currentViewId])
104
+
105
+ return {
106
+ views: state.views,
107
+ currentViewId: state.currentViewId,
108
+ loading: state.loading,
109
+ error: state.error,
110
+ saveView,
111
+ updateView,
112
+ deleteView,
113
+ loadView,
114
+ getCurrentView,
115
+ refreshViews: fetchViews,
116
+ }
117
+ }
@@ -0,0 +1,67 @@
1
+ import { useState, useCallback } from 'react'
2
+
3
+ interface TableFilterBase {
4
+ page: number
5
+ pageSize: number
6
+ search?: string
7
+ sortField?: string
8
+ sortDirection?: 'asc' | 'desc'
9
+ }
10
+
11
+ type TableFilters<TFilters extends Record<string, unknown>> = TFilters & TableFilterBase
12
+
13
+ interface UseTableFiltersReturn<TFilters extends Record<string, unknown>> {
14
+ filters: TableFilters<TFilters>
15
+ setFilter: <K extends keyof TFilters>(key: K, value: TFilters[K]) => void
16
+ setPage: (page: number) => void
17
+ setPageSize: (pageSize: number) => void
18
+ setSearch: (search: string) => void
19
+ setSort: (field: string, direction: 'asc' | 'desc') => void
20
+ resetFilters: () => void
21
+ }
22
+
23
+ export function useTableFilters<TFilters extends Record<string, unknown>>(
24
+ initial: TFilters & { page?: number; pageSize?: number; sortField?: string; sortDirection?: 'asc' | 'desc' }
25
+ ): UseTableFiltersReturn<TFilters> {
26
+ const defaultFilters: TableFilters<TFilters> = {
27
+ page: 1,
28
+ pageSize: 20,
29
+ ...initial,
30
+ } as TableFilters<TFilters>
31
+
32
+ const [filters, setFilters] = useState<TableFilters<TFilters>>(defaultFilters)
33
+
34
+ const setFilter = useCallback(<K extends keyof TFilters>(key: K, value: TFilters[K]) => {
35
+ setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
36
+ }, [])
37
+
38
+ const setPage = useCallback((page: number) => {
39
+ setFilters(prev => ({ ...prev, page }))
40
+ }, [])
41
+
42
+ const setPageSize = useCallback((pageSize: number) => {
43
+ setFilters(prev => ({ ...prev, pageSize, page: 1 }))
44
+ }, [])
45
+
46
+ const setSearch = useCallback((search: string) => {
47
+ setFilters(prev => ({ ...prev, search, page: 1 }))
48
+ }, [])
49
+
50
+ const setSort = useCallback((field: string, direction: 'asc' | 'desc') => {
51
+ setFilters(prev => ({ ...prev, sortField: field, sortDirection: direction }))
52
+ }, [])
53
+
54
+ const resetFilters = useCallback(() => {
55
+ setFilters(defaultFilters)
56
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
57
+
58
+ return {
59
+ filters,
60
+ setFilter,
61
+ setPage,
62
+ setPageSize,
63
+ setSearch,
64
+ setSort,
65
+ resetFilters,
66
+ }
67
+ }
@@ -0,0 +1,60 @@
1
+ import { useState, useCallback } from 'react'
2
+
3
+ export interface WizardState<TStep extends string> {
4
+ currentStep: TStep
5
+ stepIndex: number
6
+ totalSteps: number
7
+ isFirstStep: boolean
8
+ isLastStep: boolean
9
+ canGoBack: boolean
10
+ canGoNext: boolean
11
+ goTo: (step: TStep) => void
12
+ next: () => void
13
+ prev: () => void
14
+ reset: () => void
15
+ }
16
+
17
+ export function useWizard<TStep extends string>(
18
+ steps: readonly TStep[],
19
+ initialStep?: TStep
20
+ ): WizardState<TStep> {
21
+ const [currentStep, setCurrentStep] = useState<TStep>(initialStep ?? steps[0])
22
+
23
+ const stepIndex = steps.indexOf(currentStep)
24
+ const totalSteps = steps.length
25
+ const isFirstStep = stepIndex === 0
26
+ const isLastStep = stepIndex === totalSteps - 1
27
+
28
+ const goTo = useCallback(
29
+ (step: TStep) => {
30
+ if (steps.includes(step)) setCurrentStep(step)
31
+ },
32
+ [steps]
33
+ )
34
+
35
+ const next = useCallback(() => {
36
+ if (!isLastStep) setCurrentStep(steps[stepIndex + 1])
37
+ }, [isLastStep, stepIndex, steps])
38
+
39
+ const prev = useCallback(() => {
40
+ if (!isFirstStep) setCurrentStep(steps[stepIndex - 1])
41
+ }, [isFirstStep, stepIndex, steps])
42
+
43
+ const reset = useCallback(() => {
44
+ setCurrentStep(initialStep ?? steps[0])
45
+ }, [initialStep, steps])
46
+
47
+ return {
48
+ currentStep,
49
+ stepIndex,
50
+ totalSteps,
51
+ isFirstStep,
52
+ isLastStep,
53
+ canGoBack: !isFirstStep,
54
+ canGoNext: !isLastStep,
55
+ goTo,
56
+ next,
57
+ prev,
58
+ reset,
59
+ }
60
+ }