@startsimpli/hooks 0.4.6 → 0.4.8

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.
@@ -0,0 +1,134 @@
1
+ import { useState, useCallback, useRef, useMemo } from 'react'
2
+ import { useCSVExport } from './useCSV'
3
+
4
+ export interface UseEntityTableOptions<T> {
5
+ /** All items currently available (from Redux store, React Query, etc.) */
6
+ items: T[]
7
+ /** Field to use as the unique identifier (defaults to 'id') */
8
+ idField?: keyof T
9
+ /** Filename for CSV exports (date suffix added automatically) */
10
+ csvFilename: string
11
+ /** CSV column headers */
12
+ csvColumns: string[]
13
+ /** Maps an entity to an array of CSV cell values */
14
+ csvRowMapper: (item: T) => string[]
15
+ }
16
+
17
+ export interface UseEntityTableReturn<T> {
18
+ /** Currently selected item for viewing */
19
+ selectedItem: T | null
20
+ /** Currently selected item for editing */
21
+ editingItem: T | null
22
+ /** Current view mode */
23
+ viewMode: 'view' | 'edit' | null
24
+ /** Whether the create form is open */
25
+ showCreateForm: boolean
26
+ /** Ref to set selected IDs for CSV export filtering */
27
+ exportIdsRef: React.MutableRefObject<Set<string> | null>
28
+ /** Export CSV, optionally filtered to the given IDs */
29
+ exportCSV: (selectedIds?: Set<string>) => Promise<void>
30
+ /** Whether a CSV export is in progress */
31
+ isExporting: boolean
32
+ /** Open the view panel for an item */
33
+ openView: (item: T) => void
34
+ /** Open the edit panel for an item */
35
+ openEdit: (item: T) => void
36
+ /** Open the create form */
37
+ openCreate: () => void
38
+ /** Close view/edit panel and create form */
39
+ closePanel: () => void
40
+ }
41
+
42
+ export function useEntityTable<T>({
43
+ items,
44
+ idField = 'id' as keyof T,
45
+ csvFilename,
46
+ csvColumns,
47
+ csvRowMapper,
48
+ }: UseEntityTableOptions<T>): UseEntityTableReturn<T> {
49
+ const [selectedItem, setSelectedItem] = useState<T | null>(null)
50
+ const [editingItem, setEditingItem] = useState<T | null>(null)
51
+ const [viewMode, setViewMode] = useState<'view' | 'edit' | null>(null)
52
+ const [showCreateForm, setShowCreateForm] = useState(false)
53
+ const exportIdsRef = useRef<Set<string> | null>(null)
54
+
55
+ const buildCSV = useCallback(
56
+ (rows: T[]): string => {
57
+ const dataRows = rows.map((item) =>
58
+ csvRowMapper(item).map((cell) => `"${cell}"`)
59
+ )
60
+ return [
61
+ csvColumns.map((col) => `"${col}"`).join(','),
62
+ ...dataRows.map((row) => row.join(',')),
63
+ ].join('\n')
64
+ },
65
+ [csvColumns, csvRowMapper]
66
+ )
67
+
68
+ const dateSuffix = useMemo(() => new Date().toISOString().split('T')[0], [])
69
+ const filename = `${csvFilename}-${dateSuffix}.csv`
70
+
71
+ const { exportCSV: doExport, isExporting } = useCSVExport({
72
+ exportFn: async () => {
73
+ const ids = exportIdsRef.current
74
+ const filtered = ids
75
+ ? items.filter((item) => ids.has(String(item[idField])))
76
+ : items
77
+ return buildCSV(filtered)
78
+ },
79
+ filename,
80
+ })
81
+
82
+ const exportCSV = useCallback(
83
+ async (selectedIds?: Set<string>) => {
84
+ if (selectedIds) {
85
+ exportIdsRef.current = selectedIds
86
+ }
87
+ await doExport()
88
+ exportIdsRef.current = null
89
+ },
90
+ [doExport]
91
+ )
92
+
93
+ const openView = useCallback((item: T) => {
94
+ setSelectedItem(item)
95
+ setEditingItem(null)
96
+ setViewMode('view')
97
+ setShowCreateForm(false)
98
+ }, [])
99
+
100
+ const openEdit = useCallback((item: T) => {
101
+ setSelectedItem(null)
102
+ setEditingItem(item)
103
+ setViewMode('edit')
104
+ setShowCreateForm(false)
105
+ }, [])
106
+
107
+ const openCreate = useCallback(() => {
108
+ setSelectedItem(null)
109
+ setEditingItem(null)
110
+ setViewMode(null)
111
+ setShowCreateForm(true)
112
+ }, [])
113
+
114
+ const closePanel = useCallback(() => {
115
+ setSelectedItem(null)
116
+ setEditingItem(null)
117
+ setViewMode(null)
118
+ setShowCreateForm(false)
119
+ }, [])
120
+
121
+ return {
122
+ selectedItem,
123
+ editingItem,
124
+ viewMode,
125
+ showCreateForm,
126
+ exportIdsRef,
127
+ exportCSV,
128
+ isExporting,
129
+ openView,
130
+ openEdit,
131
+ openCreate,
132
+ closePanel,
133
+ }
134
+ }
@@ -0,0 +1,67 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import type { MessageTemplatesApi, MessageTemplateFilters, CreateMessageTemplateInput } from '@startsimpli/api'
3
+
4
+ /**
5
+ * List message templates with optional filters (paginated)
6
+ */
7
+ export function useMessageTemplates(api: MessageTemplatesApi, filters?: MessageTemplateFilters) {
8
+ return useQuery({
9
+ queryKey: ['messageTemplates', filters],
10
+ queryFn: () => api.list(filters),
11
+ })
12
+ }
13
+
14
+ /**
15
+ * Get a single message template by ID
16
+ */
17
+ export function useMessageTemplate(api: MessageTemplatesApi, id: string) {
18
+ return useQuery({
19
+ queryKey: ['messageTemplates', id],
20
+ queryFn: () => api.get(id),
21
+ enabled: !!id,
22
+ })
23
+ }
24
+
25
+ /**
26
+ * Create a new message template
27
+ */
28
+ export function useCreateMessageTemplate(api: MessageTemplatesApi) {
29
+ const queryClient = useQueryClient()
30
+
31
+ return useMutation({
32
+ mutationFn: (data: CreateMessageTemplateInput) => api.create(data),
33
+ onSuccess: () => {
34
+ queryClient.invalidateQueries({ queryKey: ['messageTemplates'] })
35
+ },
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Update an existing message template
41
+ */
42
+ export function useUpdateMessageTemplate(api: MessageTemplatesApi) {
43
+ const queryClient = useQueryClient()
44
+
45
+ return useMutation({
46
+ mutationFn: ({ id, data }: { id: string; data: Partial<CreateMessageTemplateInput> }) =>
47
+ api.update(id, data),
48
+ onSuccess: (_, variables) => {
49
+ queryClient.invalidateQueries({ queryKey: ['messageTemplates'] })
50
+ queryClient.invalidateQueries({ queryKey: ['messageTemplates', variables.id] })
51
+ },
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Delete a message template
57
+ */
58
+ export function useDeleteMessageTemplate(api: MessageTemplatesApi) {
59
+ const queryClient = useQueryClient()
60
+
61
+ return useMutation({
62
+ mutationFn: (id: string) => api.delete(id),
63
+ onSuccess: () => {
64
+ queryClient.invalidateQueries({ queryKey: ['messageTemplates'] })
65
+ },
66
+ })
67
+ }
@@ -0,0 +1,86 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import type { MessagesApi, MessageFilters, CreateMessageInput, ScheduleMessageInput, SendTestInput } from '@startsimpli/api'
3
+
4
+ /**
5
+ * List messages with optional filters (paginated)
6
+ */
7
+ export function useMessages(api: MessagesApi, filters?: MessageFilters) {
8
+ return useQuery({
9
+ queryKey: ['messages', filters],
10
+ queryFn: () => api.list(filters),
11
+ })
12
+ }
13
+
14
+ /**
15
+ * Get a single message by ID
16
+ */
17
+ export function useMessage(api: MessagesApi, id: string) {
18
+ return useQuery({
19
+ queryKey: ['messages', id],
20
+ queryFn: () => api.get(id),
21
+ enabled: !!id,
22
+ })
23
+ }
24
+
25
+ /**
26
+ * Create a new message (draft)
27
+ */
28
+ export function useCreateMessage(api: MessagesApi) {
29
+ const queryClient = useQueryClient()
30
+
31
+ return useMutation({
32
+ mutationFn: (data: CreateMessageInput) => api.create(data),
33
+ onSuccess: () => {
34
+ queryClient.invalidateQueries({ queryKey: ['messages'] })
35
+ },
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Send a message immediately
41
+ */
42
+ export function useSendMessage(api: MessagesApi, id: string) {
43
+ const queryClient = useQueryClient()
44
+
45
+ return useMutation({
46
+ mutationFn: () => api.sendNow(id),
47
+ onSuccess: () => {
48
+ queryClient.invalidateQueries({ queryKey: ['messages'] })
49
+ queryClient.invalidateQueries({ queryKey: ['messages', id] })
50
+ },
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Schedule a message for future sending
56
+ */
57
+ export function useScheduleMessage(api: MessagesApi, id: string) {
58
+ const queryClient = useQueryClient()
59
+
60
+ return useMutation({
61
+ mutationFn: (input: ScheduleMessageInput) => api.schedule(id, input),
62
+ onSuccess: () => {
63
+ queryClient.invalidateQueries({ queryKey: ['messages'] })
64
+ queryClient.invalidateQueries({ queryKey: ['messages', id] })
65
+ },
66
+ })
67
+ }
68
+
69
+ /**
70
+ * Send a test message
71
+ */
72
+ export function useSendTestMessage(api: MessagesApi, id: string) {
73
+ return useMutation({
74
+ mutationFn: (input?: SendTestInput) => api.sendTest(id, input),
75
+ })
76
+ }
77
+
78
+ /**
79
+ * List available messaging channels
80
+ */
81
+ export function useMessageChannels(api: MessagesApi) {
82
+ return useQuery({
83
+ queryKey: ['messages', 'channels'],
84
+ queryFn: () => api.getChannels(),
85
+ })
86
+ }
@@ -0,0 +1,132 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import type {
3
+ TargetList,
4
+ TargetListMember,
5
+ CreateTargetListInput,
6
+ UpdateTargetListInput,
7
+ TargetListFilters,
8
+ TargetListMemberFilters,
9
+ AddMembersResult,
10
+ RemoveMembersResult,
11
+ RefreshResult,
12
+ PaginatedResponse,
13
+ } from '@startsimpli/api'
14
+
15
+ export const TARGET_LIST_KEYS = {
16
+ all: ['target-lists'] as const,
17
+ lists: (filters?: TargetListFilters) => [...TARGET_LIST_KEYS.all, filters] as const,
18
+ detail: (id: string) => ['target-list', id] as const,
19
+ members: (listId: string, filters?: TargetListMemberFilters) =>
20
+ ['list-members', listId, filters] as const,
21
+ }
22
+
23
+ export interface TargetListApiFns {
24
+ list: (filters?: TargetListFilters) => Promise<PaginatedResponse<TargetList>>
25
+ get: (id: string) => Promise<TargetList>
26
+ create: (data: CreateTargetListInput) => Promise<TargetList>
27
+ update: (id: string, data: UpdateTargetListInput) => Promise<TargetList>
28
+ delete: (id: string) => Promise<void>
29
+ getMembers: (listId: string, filters?: TargetListMemberFilters) => Promise<PaginatedResponse<TargetListMember>>
30
+ addMembers: (listId: string, contactIds: string[]) => Promise<AddMembersResult>
31
+ removeMembers: (listId: string, contactIds: string[]) => Promise<RemoveMembersResult>
32
+ refresh: (listId: string) => Promise<RefreshResult>
33
+ }
34
+
35
+ export function useTargetListDetail(
36
+ id: string,
37
+ apiFns: Pick<TargetListApiFns, 'get'>
38
+ ) {
39
+ return useQuery({
40
+ queryKey: TARGET_LIST_KEYS.detail(id),
41
+ queryFn: () => apiFns.get(id),
42
+ enabled: !!id,
43
+ })
44
+ }
45
+
46
+ export interface UseTargetListMutationsOptions {
47
+ apiFns: Pick<TargetListApiFns, 'create' | 'update' | 'delete' | 'addMembers' | 'removeMembers' | 'refresh'>
48
+ onSuccess?: (action: string) => void
49
+ onError?: (action: string) => void
50
+ }
51
+
52
+ export function useTargetListMutations({
53
+ apiFns,
54
+ onSuccess,
55
+ onError,
56
+ }: UseTargetListMutationsOptions) {
57
+ const queryClient = useQueryClient()
58
+
59
+ const invalidateList = (listId?: string) => {
60
+ queryClient.invalidateQueries({ queryKey: TARGET_LIST_KEYS.all })
61
+ if (listId) {
62
+ queryClient.invalidateQueries({ queryKey: TARGET_LIST_KEYS.detail(listId) })
63
+ queryClient.invalidateQueries({ queryKey: ['list-members', listId] })
64
+ }
65
+ }
66
+
67
+ const createList = useMutation({
68
+ mutationFn: (data: CreateTargetListInput) => apiFns.create(data),
69
+ onSuccess: () => {
70
+ invalidateList()
71
+ onSuccess?.('List created')
72
+ },
73
+ onError: () => onError?.('Failed to create list'),
74
+ })
75
+
76
+ const updateList = useMutation({
77
+ mutationFn: ({ id, data }: { id: string; data: UpdateTargetListInput }) =>
78
+ apiFns.update(id, data),
79
+ onSuccess: (_, variables) => {
80
+ invalidateList(variables.id)
81
+ onSuccess?.('List updated')
82
+ },
83
+ onError: () => onError?.('Failed to update list'),
84
+ })
85
+
86
+ const deleteList = useMutation({
87
+ mutationFn: (id: string) => apiFns.delete(id),
88
+ onSuccess: () => {
89
+ invalidateList()
90
+ onSuccess?.('List deleted')
91
+ },
92
+ onError: () => onError?.('Failed to delete list'),
93
+ })
94
+
95
+ const addMembersMutation = useMutation({
96
+ mutationFn: ({ listId, contactIds }: { listId: string; contactIds: string[] }) =>
97
+ apiFns.addMembers(listId, contactIds),
98
+ onSuccess: (_, variables) => {
99
+ invalidateList(variables.listId)
100
+ onSuccess?.('Members added')
101
+ },
102
+ onError: () => onError?.('Failed to add members'),
103
+ })
104
+
105
+ const removeMembersMutation = useMutation({
106
+ mutationFn: ({ listId, contactIds }: { listId: string; contactIds: string[] }) =>
107
+ apiFns.removeMembers(listId, contactIds),
108
+ onSuccess: (_, variables) => {
109
+ invalidateList(variables.listId)
110
+ onSuccess?.('Members removed')
111
+ },
112
+ onError: () => onError?.('Failed to remove members'),
113
+ })
114
+
115
+ const refreshList = useMutation({
116
+ mutationFn: (listId: string) => apiFns.refresh(listId),
117
+ onSuccess: (_, listId) => {
118
+ invalidateList(listId)
119
+ onSuccess?.('List refreshed')
120
+ },
121
+ onError: () => onError?.('Failed to refresh list'),
122
+ })
123
+
124
+ return {
125
+ createList,
126
+ updateList,
127
+ deleteList,
128
+ addMembers: addMembersMutation,
129
+ removeMembers: removeMembersMutation,
130
+ refreshList,
131
+ }
132
+ }
package/src/useWizard.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useRef } from 'react'
2
+
3
+ export interface WizardOptions<TStep extends string> {
4
+ validate?: Partial<Record<TStep, () => Record<string, string> | null>>
5
+ }
2
6
 
3
7
  export interface WizardState<TStep extends string> {
4
8
  currentStep: TStep
@@ -8,23 +12,41 @@ export interface WizardState<TStep extends string> {
8
12
  isLastStep: boolean
9
13
  canGoBack: boolean
10
14
  canGoNext: boolean
15
+ errors: Record<string, string> | null
11
16
  goTo: (step: TStep) => void
12
- next: () => void
17
+ next: () => Record<string, string> | null
13
18
  prev: () => void
14
19
  reset: () => void
20
+ clearErrors: () => void
15
21
  }
16
22
 
17
23
  export function useWizard<TStep extends string>(
18
24
  steps: readonly TStep[],
19
- initialStep?: TStep
25
+ initialStepOrOpts?: TStep | WizardOptions<TStep>,
26
+ opts?: WizardOptions<TStep>
20
27
  ): WizardState<TStep> {
28
+ // Support both old signature (steps, initialStep?) and new (steps, opts?) and (steps, initialStep, opts?)
29
+ const initialStep: TStep | undefined =
30
+ typeof initialStepOrOpts === 'string' ? initialStepOrOpts : undefined
31
+ const options: WizardOptions<TStep> | undefined =
32
+ typeof initialStepOrOpts === 'object' ? initialStepOrOpts : opts
33
+
21
34
  const [currentStep, setCurrentStep] = useState<TStep>(initialStep ?? steps[0])
35
+ const [errors, setErrors] = useState<Record<string, string> | null>(null)
36
+
37
+ // Keep validate ref stable to avoid stale closures while keeping validators current
38
+ const validateRef = useRef(options?.validate)
39
+ validateRef.current = options?.validate
22
40
 
23
41
  const stepIndex = steps.indexOf(currentStep)
24
42
  const totalSteps = steps.length
25
43
  const isFirstStep = stepIndex === 0
26
44
  const isLastStep = stepIndex === totalSteps - 1
27
45
 
46
+ const clearErrors = useCallback(() => {
47
+ setErrors(null)
48
+ }, [])
49
+
28
50
  const goTo = useCallback(
29
51
  (step: TStep) => {
30
52
  if (steps.includes(step)) setCurrentStep(step)
@@ -32,8 +54,18 @@ export function useWizard<TStep extends string>(
32
54
  [steps]
33
55
  )
34
56
 
35
- const next = useCallback(() => {
57
+ const next = useCallback((): Record<string, string> | null => {
58
+ const validator = validateRef.current?.[steps[stepIndex] as TStep]
59
+ if (validator) {
60
+ const result = validator()
61
+ if (result && Object.keys(result).length > 0) {
62
+ setErrors(result)
63
+ return result
64
+ }
65
+ }
66
+ setErrors(null)
36
67
  if (!isLastStep) setCurrentStep(steps[stepIndex + 1])
68
+ return null
37
69
  }, [isLastStep, stepIndex, steps])
38
70
 
39
71
  const prev = useCallback(() => {
@@ -42,6 +74,7 @@ export function useWizard<TStep extends string>(
42
74
 
43
75
  const reset = useCallback(() => {
44
76
  setCurrentStep(initialStep ?? steps[0])
77
+ setErrors(null)
45
78
  }, [initialStep, steps])
46
79
 
47
80
  return {
@@ -52,9 +85,11 @@ export function useWizard<TStep extends string>(
52
85
  isLastStep,
53
86
  canGoBack: !isFirstStep,
54
87
  canGoNext: !isLastStep,
88
+ errors,
55
89
  goTo,
56
90
  next,
57
91
  prev,
58
92
  reset,
93
+ clearErrors,
59
94
  }
60
95
  }