@svadmin/core 0.0.6 → 0.3.0

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
+ /**
2
+ * LiveProvider unit tests
3
+ * Tests the subscription management logic without requiring actual WebSocket/SSE connections.
4
+ */
5
+ import { describe, test, expect, mock } from 'bun:test';
6
+ import type { LiveProvider, LiveEvent } from './live';
7
+
8
+ // Helper: create a minimal in-memory LiveProvider for testing subscription logic
9
+ function createMockLiveProvider(): LiveProvider & { emit: (event: LiveEvent) => void } {
10
+ type Callback = (event: LiveEvent) => void;
11
+ const subscribers = new Map<string, Set<Callback>>();
12
+
13
+ return {
14
+ subscribe({ resource, callback }) {
15
+ if (!subscribers.has(resource)) {
16
+ subscribers.set(resource, new Set());
17
+ }
18
+ subscribers.get(resource)!.add(callback);
19
+
20
+ return () => {
21
+ const callbacks = subscribers.get(resource);
22
+ if (callbacks) {
23
+ callbacks.delete(callback);
24
+ if (callbacks.size === 0) subscribers.delete(resource);
25
+ }
26
+ };
27
+ },
28
+
29
+ emit(event: LiveEvent) {
30
+ const callbacks = subscribers.get(event.resource);
31
+ if (callbacks) {
32
+ for (const cb of callbacks) cb(event);
33
+ }
34
+ // Wildcard
35
+ const wildcardCallbacks = subscribers.get('*');
36
+ if (wildcardCallbacks) {
37
+ for (const cb of wildcardCallbacks) cb(event);
38
+ }
39
+ },
40
+ };
41
+ }
42
+
43
+ describe('LiveProvider subscription', () => {
44
+ test('subscribe receives events for correct resource', () => {
45
+ const provider = createMockLiveProvider();
46
+ const received: LiveEvent[] = [];
47
+
48
+ provider.subscribe({
49
+ resource: 'posts',
50
+ callback: (event) => received.push(event),
51
+ });
52
+
53
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: { id: 1 } });
54
+ provider.emit({ type: 'UPDATE', resource: 'comments', payload: { id: 2 } });
55
+
56
+ expect(received.length).toBe(1);
57
+ expect(received[0].type).toBe('INSERT');
58
+ expect(received[0].resource).toBe('posts');
59
+ });
60
+
61
+ test('wildcard subscriber receives all events', () => {
62
+ const provider = createMockLiveProvider();
63
+ const received: LiveEvent[] = [];
64
+
65
+ provider.subscribe({
66
+ resource: '*',
67
+ callback: (event) => received.push(event),
68
+ });
69
+
70
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
71
+ provider.emit({ type: 'DELETE', resource: 'users', payload: {} });
72
+
73
+ expect(received.length).toBe(2);
74
+ expect(received[0].resource).toBe('posts');
75
+ expect(received[1].resource).toBe('users');
76
+ });
77
+
78
+ test('unsubscribe stops receiving events', () => {
79
+ const provider = createMockLiveProvider();
80
+ const received: LiveEvent[] = [];
81
+
82
+ const unsub = provider.subscribe({
83
+ resource: 'posts',
84
+ callback: (event) => received.push(event),
85
+ });
86
+
87
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
88
+ expect(received.length).toBe(1);
89
+
90
+ unsub();
91
+
92
+ provider.emit({ type: 'UPDATE', resource: 'posts', payload: {} });
93
+ expect(received.length).toBe(1); // No new events after unsub
94
+ });
95
+
96
+ test('multiple subscribers for same resource', () => {
97
+ const provider = createMockLiveProvider();
98
+ const received1: LiveEvent[] = [];
99
+ const received2: LiveEvent[] = [];
100
+
101
+ provider.subscribe({ resource: 'posts', callback: (e) => received1.push(e) });
102
+ provider.subscribe({ resource: 'posts', callback: (e) => received2.push(e) });
103
+
104
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: { id: 1 } });
105
+
106
+ expect(received1.length).toBe(1);
107
+ expect(received2.length).toBe(1);
108
+ });
109
+
110
+ test('LiveEvent types are correct', () => {
111
+ const insertEvent: LiveEvent = { type: 'INSERT', resource: 'posts', payload: { title: 'New' } };
112
+ const updateEvent: LiveEvent = { type: 'UPDATE', resource: 'posts', payload: { title: 'Updated' } };
113
+ const deleteEvent: LiveEvent = { type: 'DELETE', resource: 'posts', payload: { id: 1 } };
114
+
115
+ expect(insertEvent.type).toBe('INSERT');
116
+ expect(updateEvent.type).toBe('UPDATE');
117
+ expect(deleteEvent.type).toBe('DELETE');
118
+ });
119
+
120
+ test('unsubscribing one does not affect others', () => {
121
+ const provider = createMockLiveProvider();
122
+ const received1: LiveEvent[] = [];
123
+ const received2: LiveEvent[] = [];
124
+
125
+ const unsub1 = provider.subscribe({ resource: 'posts', callback: (e) => received1.push(e) });
126
+ provider.subscribe({ resource: 'posts', callback: (e) => received2.push(e) });
127
+
128
+ unsub1();
129
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
130
+
131
+ expect(received1.length).toBe(0);
132
+ expect(received2.length).toBe(1);
133
+ });
134
+ });
package/src/live.ts CHANGED
@@ -1,10 +1,10 @@
1
- // LiveProvider — Real-time subscription interface + useLive hook
1
+ // LiveProvider — Real-time subscription interface + hooks
2
2
 
3
3
  import { useQueryClient } from '@tanstack/svelte-query';
4
4
 
5
- export interface LiveProvider {
6
- subscribe(params: { resource: string; callback: (event: LiveEvent) => void }): () => void;
7
- }
5
+ // ─── Types ──────────────────────────────────────────────────────
6
+
7
+ export type LiveMode = 'auto' | 'manual' | 'off';
8
8
 
9
9
  export interface LiveEvent {
10
10
  type: 'INSERT' | 'UPDATE' | 'DELETE';
@@ -12,17 +12,69 @@ export interface LiveEvent {
12
12
  payload: Record<string, unknown>;
13
13
  }
14
14
 
15
- // Hook to auto-invalidate queries on real-time changes
16
- export function useLive(liveProvider: LiveProvider, resource: string): void {
15
+ export interface LiveProvider {
16
+ subscribe(params: { resource: string; callback: (event: LiveEvent) => void }): () => void;
17
+ unsubscribe?(params: { resource: string }): void;
18
+ publish?(event: LiveEvent): void;
19
+ }
20
+
21
+ // ─── useLive — auto-invalidate queries on real-time events ──────
22
+
23
+ export function useLive(
24
+ liveProvider: LiveProvider,
25
+ resource: string,
26
+ options?: { liveMode?: LiveMode; onLiveEvent?: (event: LiveEvent) => void }
27
+ ): void {
17
28
  const queryClient = useQueryClient();
29
+ const liveMode = options?.liveMode ?? 'auto';
30
+
31
+ if (liveMode === 'off') return;
18
32
 
19
33
  $effect(() => {
20
34
  const unsubscribe = liveProvider.subscribe({
21
35
  resource,
22
- callback: () => {
23
- queryClient.invalidateQueries({ queryKey: [resource] });
36
+ callback: (event) => {
37
+ options?.onLiveEvent?.(event);
38
+ if (liveMode === 'auto') {
39
+ queryClient.invalidateQueries({ queryKey: [resource] });
40
+ }
24
41
  },
25
42
  });
26
43
  return unsubscribe;
27
44
  });
28
45
  }
46
+
47
+ // ─── useSubscription — manual channel subscription ──────────────
48
+
49
+ interface UseSubscriptionOptions {
50
+ resource: string;
51
+ liveProvider: LiveProvider;
52
+ onLiveEvent: (event: LiveEvent) => void;
53
+ enabled?: boolean;
54
+ }
55
+
56
+ export function useSubscription(options: UseSubscriptionOptions): void {
57
+ const { resource, liveProvider, onLiveEvent, enabled = true } = options;
58
+
59
+ if (!enabled) return;
60
+
61
+ $effect(() => {
62
+ const unsubscribe = liveProvider.subscribe({
63
+ resource,
64
+ callback: onLiveEvent,
65
+ });
66
+ return unsubscribe;
67
+ });
68
+ }
69
+
70
+ // ─── usePublish — publish custom events ─────────────────────────
71
+
72
+ export function usePublish(liveProvider: LiveProvider) {
73
+ return (event: LiveEvent) => {
74
+ if (liveProvider.publish) {
75
+ liveProvider.publish(event);
76
+ } else {
77
+ console.warn('[svadmin] LiveProvider.publish() not implemented');
78
+ }
79
+ };
80
+ }
@@ -0,0 +1,301 @@
1
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
+ import { getAdminOptions } from './options';
3
+ import { getDataProviderForResource } from './context';
4
+ import { useParsed } from './useParsed';
5
+ import { audit } from './audit';
6
+ import { UndoError } from './types';
7
+ import type { BaseRecord, HttpError, CreateParams, UpdateParams, DeleteParams, KnownResources } from './types';
8
+ import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
9
+ import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
10
+ import { toast } from './toast.svelte';
11
+
12
+ // ─── useCreate ─────────────────────────────────────────────────
13
+
14
+ export interface UseCreateOptions {
15
+ resource?: KnownResources;
16
+ mutationOptions?: Record<string, unknown>;
17
+ overtimeOptions?: OvertimeOptions;
18
+ }
19
+
20
+ export interface UseCreateMutateParams<TVariables> {
21
+ resource?: KnownResources;
22
+ variables: TVariables;
23
+ successNotification?: NotificationConfig;
24
+ errorNotification?: NotificationConfig;
25
+ invalidates?: string[] | false;
26
+ meta?: Record<string, unknown>;
27
+ dataProviderName?: string;
28
+ }
29
+
30
+ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: UseCreateOptions = {}) {
31
+ const parsed = useParsed();
32
+ const defaultResource = options.resource ?? parsed.resource ?? '';
33
+ const adminOptions = getAdminOptions();
34
+ const queryClient = useQueryClient();
35
+
36
+ let isMutating = $state(false);
37
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
38
+
39
+ const mutation = createMutation<
40
+ { data: TData },
41
+ TError,
42
+ UseCreateMutateParams<TVariables>
43
+ >(() => ({
44
+ mutationFn: async (params) => {
45
+ isMutating = true;
46
+ const resName = params.resource ?? defaultResource;
47
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
48
+ try {
49
+ return await provider.create<TData, TVariables>({
50
+ resource: resName,
51
+ variables: params.variables,
52
+ meta: params.meta,
53
+ });
54
+ } finally {
55
+ isMutating = false;
56
+ }
57
+ },
58
+ onSuccess: (data, params) => {
59
+ const resName = params.resource ?? defaultResource;
60
+ if (params.invalidates !== false) {
61
+ queryClient.invalidateQueries({ queryKey: [resName] });
62
+ }
63
+ fireSuccessNotification(params.successNotification, 'Created successfully', data.data, params.variables, resName);
64
+ audit({ action: 'create', resource: resName, recordId: String(data.data.id) });
65
+ },
66
+ onError: (error, params) => {
67
+ fireErrorNotification(params.errorNotification, 'Create failed', error);
68
+ },
69
+ }));
70
+
71
+ return { mutation, get overtime() { return overtime; } };
72
+ }
73
+
74
+ // ─── useUpdate ─────────────────────────────────────────────────
75
+
76
+ export interface UseUpdateOptions {
77
+ resource?: KnownResources;
78
+ id?: string | number;
79
+ mutationMode?: 'pessimistic' | 'optimistic' | 'undoable';
80
+ undoableTimeout?: number;
81
+ mutationOptions?: Record<string, unknown>;
82
+ overtimeOptions?: OvertimeOptions;
83
+ }
84
+
85
+ export interface UseUpdateMutateParams<TVariables> {
86
+ resource?: KnownResources;
87
+ id?: string | number;
88
+ variables: TVariables;
89
+ successNotification?: NotificationConfig;
90
+ errorNotification?: NotificationConfig;
91
+ invalidates?: string[] | false;
92
+ meta?: Record<string, unknown>;
93
+ dataProviderName?: string;
94
+ onCancel?: () => void;
95
+ optimisticUpdateMap?: {
96
+ detail?: (previous: unknown, variables: TVariables, id?: string | number) => unknown;
97
+ list?: (previous: unknown, variables: TVariables, id?: string | number) => unknown;
98
+ };
99
+ }
100
+
101
+ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: UseUpdateOptions = {}) {
102
+ const parsed = useParsed();
103
+ const defaultResource = options.resource ?? parsed.resource ?? '';
104
+ const defaultId = options.id ?? parsed.id;
105
+ const adminOptions = getAdminOptions();
106
+ const queryClient = useQueryClient();
107
+ const mutationMode = options.mutationMode ?? adminOptions.mutationMode ?? 'pessimistic';
108
+ const undoableTimeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
109
+
110
+ let isMutating = $state(false);
111
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
112
+
113
+ const mutation = createMutation<
114
+ { data: TData },
115
+ TError,
116
+ UseUpdateMutateParams<TVariables>
117
+ >(() => ({
118
+ mutationFn: async (params) => {
119
+ isMutating = true;
120
+ const resName = params.resource ?? defaultResource;
121
+ const targetId = params.id ?? defaultId;
122
+ if (targetId == null) throw new Error('useUpdate requires an id');
123
+
124
+ if (mutationMode === 'undoable') {
125
+ await new Promise<void>((resolve, reject) => {
126
+ toast.undoable('Action can be undone', undoableTimeout, () => {
127
+ params.onCancel?.();
128
+ reject(new UndoError());
129
+ }, resolve);
130
+ });
131
+ }
132
+
133
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
134
+ try {
135
+ return await provider.update<TData, TVariables>({
136
+ resource: resName,
137
+ id: targetId,
138
+ variables: params.variables,
139
+ meta: params.meta,
140
+ });
141
+ } finally {
142
+ isMutating = false;
143
+ }
144
+ },
145
+ onMutate: async (params) => {
146
+ if (mutationMode === 'pessimistic') return undefined;
147
+ const resName = params.resource ?? defaultResource;
148
+ const targetId = params.id ?? defaultId;
149
+
150
+ await queryClient.cancelQueries({ queryKey: [resName] });
151
+ const previousDetail = queryClient.getQueryData([resName, 'one', targetId]);
152
+ const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
153
+
154
+ const updater = params.optimisticUpdateMap;
155
+ if (updater?.detail) {
156
+ queryClient.setQueryData([resName, 'one', targetId], (old: unknown) => updater.detail!(old, params.variables, targetId));
157
+ } else {
158
+ queryClient.setQueryData([resName, 'one', targetId], (old: Record<string, unknown> | undefined) => old ? { ...old, ...params.variables } : old);
159
+ }
160
+
161
+ if (updater?.list) {
162
+ queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => updater.list!(old, params.variables, targetId));
163
+ } else {
164
+ queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
165
+ if (!old || typeof old !== 'object' || !('data' in old)) return old;
166
+ const o = old as { data: Record<string, unknown>[] };
167
+ return { ...o, data: o.data.map((item) => String(item.id) === String(targetId) ? { ...item, ...params.variables } : item) };
168
+ });
169
+ }
170
+
171
+ return { previousDetail, previousList };
172
+ },
173
+ onSuccess: (data, params) => {
174
+ const resName = params.resource ?? defaultResource;
175
+ const targetId = params.id ?? defaultId;
176
+ if (params.invalidates !== false) {
177
+ queryClient.invalidateQueries({ queryKey: [resName] });
178
+ }
179
+ fireSuccessNotification(params.successNotification, 'Updated successfully', data.data, params.variables, resName);
180
+ audit({ action: 'update', resource: resName, recordId: String(targetId) });
181
+ },
182
+ onError: (error, params, context: unknown) => {
183
+ const resName = params.resource ?? defaultResource;
184
+ const targetId = params.id ?? defaultId;
185
+ const ctx = context as { previousDetail?: unknown; previousList?: [unknown, unknown][] } | undefined;
186
+ if (ctx?.previousDetail) queryClient.setQueryData([resName, 'one', targetId], ctx.previousDetail);
187
+ if (ctx?.previousList) {
188
+ ctx.previousList.forEach(([qk, data]) => queryClient.setQueryData(qk as string[], data));
189
+ }
190
+ if (error instanceof UndoError) return;
191
+ fireErrorNotification(params.errorNotification, 'Update failed', error);
192
+ },
193
+ }));
194
+
195
+ return { mutation, get overtime() { return overtime; } };
196
+ }
197
+
198
+ // ─── useDelete ─────────────────────────────────────────────────
199
+
200
+ export interface UseDeleteOptions {
201
+ resource?: KnownResources;
202
+ id?: string | number;
203
+ mutationMode?: 'pessimistic' | 'optimistic' | 'undoable';
204
+ undoableTimeout?: number;
205
+ mutationOptions?: Record<string, unknown>;
206
+ overtimeOptions?: OvertimeOptions;
207
+ }
208
+
209
+ export interface UseDeleteMutateParams<TVariables> {
210
+ resource?: KnownResources;
211
+ id?: string | number;
212
+ variables?: TVariables;
213
+ successNotification?: NotificationConfig;
214
+ errorNotification?: NotificationConfig;
215
+ invalidates?: string[] | false;
216
+ meta?: Record<string, unknown>;
217
+ dataProviderName?: string;
218
+ onCancel?: () => void;
219
+ }
220
+
221
+ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: UseDeleteOptions = {}) {
222
+ const parsed = useParsed();
223
+ const defaultResource = options.resource ?? parsed.resource ?? '';
224
+ const defaultId = options.id ?? parsed.id;
225
+ const adminOptions = getAdminOptions();
226
+ const queryClient = useQueryClient();
227
+ const mutationMode = options.mutationMode ?? adminOptions.mutationMode ?? 'pessimistic';
228
+ const undoableTimeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
229
+
230
+ let isMutating = $state(false);
231
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
232
+
233
+ const mutation = createMutation<
234
+ { data: TData },
235
+ TError,
236
+ UseDeleteMutateParams<TVariables>
237
+ >(() => ({
238
+ mutationFn: async (params) => {
239
+ isMutating = true;
240
+ const resName = params.resource ?? defaultResource;
241
+ const targetId = params.id ?? defaultId;
242
+ if (targetId == null) throw new Error('useDelete requires an id');
243
+
244
+ if (mutationMode === 'undoable') {
245
+ await new Promise<void>((resolve, reject) => {
246
+ toast.undoable('Action can be undone', undoableTimeout, () => {
247
+ params.onCancel?.();
248
+ reject(new UndoError());
249
+ }, resolve);
250
+ });
251
+ }
252
+
253
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
254
+ try {
255
+ return await provider.deleteOne<TData, TVariables>({
256
+ resource: resName,
257
+ id: targetId,
258
+ variables: params.variables,
259
+ meta: params.meta,
260
+ });
261
+ } finally {
262
+ isMutating = false;
263
+ }
264
+ },
265
+ onMutate: async (params) => {
266
+ if (mutationMode === 'pessimistic') return undefined;
267
+ const resName = params.resource ?? defaultResource;
268
+ const targetId = params.id ?? defaultId;
269
+
270
+ await queryClient.cancelQueries({ queryKey: [resName] });
271
+ const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
272
+
273
+ queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
274
+ if (!old || typeof old !== 'object' || !('data' in old)) return old;
275
+ const o = old as { data: Record<string, unknown>[] };
276
+ return { ...o, data: o.data.filter((item) => String(item.id) !== String(targetId)) };
277
+ });
278
+
279
+ return { previousList };
280
+ },
281
+ onSuccess: (data, params) => {
282
+ const resName = params.resource ?? defaultResource;
283
+ const targetId = params.id ?? defaultId;
284
+ if (params.invalidates !== false) {
285
+ queryClient.invalidateQueries({ queryKey: [resName] });
286
+ }
287
+ fireSuccessNotification(params.successNotification, 'Deleted successfully', data.data, params.variables, resName);
288
+ audit({ action: 'delete', resource: resName, recordId: String(targetId) });
289
+ },
290
+ onError: (error, params, context: unknown) => {
291
+ const ctx = context as { previousList?: [unknown, unknown][] } | undefined;
292
+ if (ctx?.previousList) {
293
+ ctx.previousList.forEach(([qk, data]) => queryClient.setQueryData(qk as string[], data));
294
+ }
295
+ if (error instanceof UndoError) return;
296
+ fireErrorNotification(params.errorNotification, 'Delete failed', error);
297
+ },
298
+ }));
299
+
300
+ return { mutation, get overtime() { return overtime; } };
301
+ }
package/src/options.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Global admin options — centralized configuration
2
+ // Provides defaults that individual hooks can override
3
+
4
+ import { setContext, getContext } from 'svelte';
5
+ import type { MutationMode } from './types';
6
+
7
+ export type InvalidateScope = 'all' | 'resourceAll' | 'detail' | 'list' | false | string[];
8
+
9
+ const OPTIONS_KEY = Symbol('admin-options');
10
+
11
+ export interface TextTransformers {
12
+ humanize: (text: string) => string;
13
+ plural: (text: string) => string;
14
+ singular: (text: string) => string;
15
+ }
16
+
17
+ export interface OvertimeConfig {
18
+ enabled?: boolean;
19
+ interval?: number;
20
+ onInterval?: (elapsedInterval: number, context?: { resource?: string; action?: string; id?: string | number }) => void;
21
+ }
22
+
23
+ export interface AdminOptions {
24
+ mutationMode?: MutationMode;
25
+ warnWhenUnsavedChanges?: boolean;
26
+ syncWithLocation?: boolean;
27
+ liveMode?: 'auto' | 'manual' | 'off';
28
+ disableServerSideValidation?: boolean;
29
+ undoableTimeout?: number;
30
+ // v0.2.26 additions
31
+ textTransformers?: Partial<TextTransformers>;
32
+ redirect?: {
33
+ afterCreate?: 'list' | 'edit' | 'show' | false;
34
+ afterEdit?: 'list' | 'edit' | 'show' | false;
35
+ afterClone?: 'list' | 'edit' | 'show' | false;
36
+ };
37
+ reactQuery?: {
38
+ staleTime?: number;
39
+ cacheTime?: number;
40
+ refetchOnWindowFocus?: boolean;
41
+ };
42
+ title?: {
43
+ text?: string;
44
+ icon?: string;
45
+ };
46
+ overtime?: OvertimeConfig;
47
+ breadcrumb?: false | 'default';
48
+ disableRouteChangeHandler?: boolean;
49
+ onLiveEvent?: (event: unknown) => void;
50
+ menuItems?: (items: { name: string; route: string; label: string; icon?: string; parentName?: string; order?: number }[]) => { name: string; route: string; label: string; icon?: string; parentName?: string; order?: number }[];
51
+ }
52
+
53
+ const defaultTextTransformers: TextTransformers = {
54
+ humanize: (text: string) => text.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
55
+ plural: (text: string) => text.endsWith('s') ? text : text + 's',
56
+ singular: (text: string) => text.endsWith('s') ? text.slice(0, -1) : text,
57
+ };
58
+
59
+ const defaultOptions: AdminOptions = {
60
+ mutationMode: 'pessimistic',
61
+ warnWhenUnsavedChanges: false,
62
+ syncWithLocation: false,
63
+ liveMode: 'auto',
64
+ disableServerSideValidation: false,
65
+ undoableTimeout: 5000,
66
+ textTransformers: defaultTextTransformers,
67
+ redirect: { afterCreate: 'list', afterEdit: 'list', afterClone: 'list' },
68
+ breadcrumb: 'default',
69
+ disableRouteChangeHandler: false,
70
+ };
71
+
72
+ export function setAdminOptions(options: AdminOptions): void {
73
+ const merged = {
74
+ ...defaultOptions,
75
+ ...options,
76
+ textTransformers: { ...defaultTextTransformers, ...options.textTransformers },
77
+ };
78
+ setContext(OPTIONS_KEY, merged);
79
+ }
80
+
81
+ export function getAdminOptions(): AdminOptions {
82
+ try {
83
+ const opts = getContext<AdminOptions>(OPTIONS_KEY);
84
+ return opts ?? defaultOptions;
85
+ } catch {
86
+ return defaultOptions;
87
+ }
88
+ }
89
+
90
+ /** Get the resolved text transformers (always returns full set) */
91
+ export function getTextTransformers(): TextTransformers {
92
+ const opts = getAdminOptions();
93
+ return { ...defaultTextTransformers, ...opts.textTransformers };
94
+ }
95
+