@svadmin/core 0.0.3 → 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.
Files changed (41) hide show
  1. package/package.json +4 -1
  2. package/src/audit.ts +37 -0
  3. package/src/auth-hooks.svelte.ts +293 -0
  4. package/src/auth-hooks.test.ts +205 -0
  5. package/src/context.ts +77 -15
  6. package/src/data-transfer.test.ts +118 -0
  7. package/src/data-transfer.ts +148 -70
  8. package/src/form-hooks.svelte.ts +266 -0
  9. package/src/helpers.ts +157 -0
  10. package/src/hook-utils.svelte.ts +121 -0
  11. package/src/hooks.svelte.ts +278 -496
  12. package/src/http-error.test.ts +85 -0
  13. package/src/i18n.svelte.ts +24 -4
  14. package/src/i18n.test.ts +90 -0
  15. package/src/index.ts +51 -8
  16. package/src/inferencer-openapi.test.ts +192 -0
  17. package/src/inferencer-openapi.ts +290 -0
  18. package/src/inferencer.test.ts +189 -0
  19. package/src/inferencer.ts +247 -0
  20. package/src/live-sse.ts +146 -0
  21. package/src/live-websocket.ts +141 -0
  22. package/src/live.test.ts +134 -0
  23. package/src/live.ts +60 -8
  24. package/src/mutation-hooks.svelte.ts +301 -0
  25. package/src/options.ts +95 -0
  26. package/src/permissions.test.ts +55 -0
  27. package/src/query-hooks.svelte.ts +213 -0
  28. package/src/query-keys.ts +22 -0
  29. package/src/resource-types.test.ts +79 -0
  30. package/src/router-provider.test.ts +71 -0
  31. package/src/router-provider.ts +100 -0
  32. package/src/router.ts +28 -4
  33. package/src/routing-hooks.svelte.ts +99 -0
  34. package/src/table-hooks.svelte.ts +178 -0
  35. package/src/toast.svelte.ts +8 -4
  36. package/src/types.ts +124 -47
  37. package/src/unsaved-changes.svelte.ts +62 -0
  38. package/src/url-sync.ts +15 -0
  39. package/src/useParsed.ts +110 -0
  40. package/src/useStepsForm.svelte.ts +71 -0
  41. package/src/utility-hooks.svelte.ts +161 -0
@@ -0,0 +1,266 @@
1
+ import { useQueryClient } from '@tanstack/svelte-query';
2
+ import { useParsed } from './useParsed';
3
+ import { getAdminOptions } from './options';
4
+ import { getDataProviderForResource } from './context';
5
+ import { createQuery, createMutation } from '@tanstack/svelte-query';
6
+ import { toast } from './toast.svelte';
7
+ import { t } from './i18n.svelte';
8
+ import { audit } from './audit';
9
+ import { navigate } from './router';
10
+ import { HttpError } from './types';
11
+ import type { BaseRecord, MutationMode, KnownResources } from './types';
12
+
13
+ export interface UseFormOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> {
14
+ resource?: KnownResources;
15
+ action?: 'create' | 'edit' | 'clone';
16
+ id?: string | number;
17
+ redirect?: 'list' | 'edit' | 'show' | false;
18
+ successNotification?: string | false;
19
+ errorNotification?: string | false;
20
+ dataProviderName?: string;
21
+ onMutationSuccess?: (data: unknown) => void;
22
+ onMutationError?: (error: Error) => void;
23
+ meta?: Record<string, unknown>;
24
+ queryMeta?: Record<string, unknown>;
25
+ mutationMeta?: Record<string, unknown>;
26
+ disableServerSideValidation?: boolean;
27
+ validate?: (values: TVariables) => Record<string, string> | null;
28
+ autoSave?: {
29
+ enabled: boolean;
30
+ debounce?: number;
31
+ onFinish?: (values: TVariables) => TVariables;
32
+ invalidateOnUnmount?: boolean;
33
+ invalidates?: string[];
34
+ };
35
+ mutationMode?: MutationMode;
36
+ undoableTimeout?: number;
37
+ invalidates?: string[] | false;
38
+ optimisticUpdateMap?: {
39
+ list?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
40
+ detail?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
41
+ };
42
+ queryOptions?: { staleTime?: number; enabled?: boolean };
43
+ createMutationOptions?: Record<string, unknown>;
44
+ updateMutationOptions?: Record<string, unknown>;
45
+ warnWhenUnsavedChanges?: boolean;
46
+ }
47
+
48
+ export type UseFormResult<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>, TData extends BaseRecord = TQueryFnData> = {
49
+ readonly query: unknown;
50
+ readonly mutation: unknown;
51
+ readonly formLoading: boolean;
52
+ readonly errors: Record<string, string>;
53
+ setFieldError: (field: string, message: string) => void;
54
+ clearErrors: () => void;
55
+ clearFieldError: (field: string) => void;
56
+ onFinish: (values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) => Promise<void>;
57
+ onFinishAutoSave: (values: TVariables) => Promise<void>;
58
+ triggerAutoSave: (values: TVariables) => void;
59
+ readonly autoSaveStatus: 'idle' | 'saving' | 'saved' | 'error';
60
+ readonly resource: string;
61
+ readonly action: 'create' | 'edit' | 'clone';
62
+ readonly id: string | number | undefined;
63
+ setId: (newId: string | number) => void;
64
+ readonly mutationMode: MutationMode;
65
+ redirect: (to: 'list' | 'edit' | 'show' | false) => void;
66
+ readonly autoSaveProps: { status: 'idle' | 'loading' | 'success' | 'error'; data: unknown; error: unknown };
67
+ };
68
+
69
+ export function useForm<
70
+ TQueryFnData extends BaseRecord = BaseRecord,
71
+ TError = HttpError,
72
+ TVariables = Record<string, unknown>,
73
+ TData extends BaseRecord = TQueryFnData
74
+ >(options: UseFormOptions<TQueryFnData, TError, TVariables, TData> = {}): UseFormResult<TQueryFnData, TError, TVariables, TData> {
75
+ const queryClient = useQueryClient();
76
+ const parsed = useParsed();
77
+ const adminOptions = getAdminOptions();
78
+
79
+ const resource = options.resource ?? parsed.resource ?? '';
80
+ const action = options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create';
81
+ let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
82
+
83
+ const {
84
+ redirect: redirectDefault = 'list',
85
+ successNotification, errorNotification,
86
+ onMutationSuccess, onMutationError,
87
+ meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
88
+ disableServerSideValidation, validate, autoSave,
89
+ mutationMode = adminOptions.mutationMode ?? 'pessimistic',
90
+ undoableTimeout = adminOptions.undoableTimeout ?? 5000,
91
+ dataProviderName, invalidates: invalidateScopes,
92
+ optimisticUpdateMap,
93
+ queryOptions,
94
+ } = options;
95
+
96
+ const provider = getDataProviderForResource(resource, dataProviderName);
97
+ const parsedMeta = typeof window !== 'undefined' ? Object.fromEntries(new URLSearchParams(window.location.search).entries()) : {};
98
+ const queryMeta = { ...parsedMeta, ...hookMeta, ...hookQueryMeta };
99
+ const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
100
+
101
+ function setId(newId: string | number) { currentId = newId; }
102
+
103
+ let errors = $state<Record<string, string>>({});
104
+ function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
105
+ function clearErrors() { errors = {}; }
106
+ function clearFieldError(field: string) { const next = { ...errors }; delete next[field]; errors = next; }
107
+
108
+ function runValidation(values: TVariables): boolean {
109
+ clearErrors();
110
+ if (validate) {
111
+ const result = validate(values);
112
+ if (result && Object.keys(result).length > 0) {
113
+ errors = result;
114
+ return false;
115
+ }
116
+ }
117
+ return true;
118
+ }
119
+
120
+ function handleHttpError(error: Error) {
121
+ if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
122
+ for (const [field, messages] of Object.entries(error.errors)) {
123
+ const msg = Array.isArray(messages) ? messages[0] : messages;
124
+ setFieldError(field, msg);
125
+ }
126
+ if (errorNotification !== false) toast.error(errorNotification || error.message || t('common.operationFailed'));
127
+ } else {
128
+ if (errorNotification !== false) toast.error(errorNotification || t('common.operationFailed') + ': ' + error.message);
129
+ }
130
+ }
131
+
132
+ const query = (action === 'edit' || action === 'clone') && currentId != null
133
+ ? createQuery(() => ({
134
+ queryKey: [resource, 'one', currentId],
135
+ queryFn: async () => {
136
+ const result = await provider.getOne<TQueryFnData>({ resource, id: currentId!, meta: queryMeta });
137
+ return result.data;
138
+ },
139
+ enabled: (queryOptions?.enabled ?? true) && currentId != null,
140
+ staleTime: queryOptions?.staleTime,
141
+ }))
142
+ : null;
143
+
144
+ const createMut = createMutation(() => ({
145
+ ...options.createMutationOptions,
146
+ mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
147
+ onSuccess: (data: { data: TData }) => {
148
+ if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
149
+ if (successNotification !== false) toast.success(successNotification || t('common.createSuccess'));
150
+ audit({ action: 'create', resource, recordId: String(data.data.id) });
151
+ onMutationSuccess?.(data);
152
+ if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
153
+ },
154
+ onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
155
+ }));
156
+
157
+ const updateMut = createMutation(() => ({
158
+ ...options.updateMutationOptions,
159
+ mutationFn: (variables: TVariables) => provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta }),
160
+ onSuccess: (data: { data: TData }) => {
161
+ if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
162
+ if (successNotification !== false) toast.success(successNotification || t('common.updateSuccess'));
163
+ audit({ action: 'update', resource, recordId: String(currentId) });
164
+ onMutationSuccess?.(data);
165
+ if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
166
+ },
167
+ onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
168
+ }));
169
+
170
+ let redirectOverride: 'list' | 'edit' | 'show' | false | undefined;
171
+
172
+ function doRedirect(to: 'list' | 'edit' | 'show' | false) {
173
+ if (to === 'list') navigate(`/${resource}`);
174
+ else if (to === 'edit' && currentId) navigate(`/${resource}/edit/${currentId}`);
175
+ else if (to === 'show' && currentId) navigate(`/${resource}/show/${currentId}`);
176
+ }
177
+
178
+ async function onFinish(values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) {
179
+ if (!runValidation(values)) { toast.warning(t('validation.required')); return; }
180
+ redirectOverride = finishOptions?.redirect;
181
+ if (action === 'create' || action === 'clone') await (createMut as any).mutateAsync(values);
182
+ else await (updateMut as any).mutateAsync(values);
183
+ }
184
+
185
+ async function onFinishAutoSave(values: TVariables) {
186
+ if (action === 'create' || action === 'clone') return; // Only autosave on edit
187
+ redirectOverride = false; // Never redirect on autoSave
188
+ await (updateMut as any).mutateAsync(values);
189
+ }
190
+
191
+ let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
192
+ let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
193
+
194
+ function triggerAutoSave(values: TVariables) {
195
+ if (!autoSave?.enabled || action === 'create') return;
196
+ if (autoSaveTimer) clearTimeout(autoSaveTimer);
197
+
198
+ autoSaveTimer = setTimeout(async () => {
199
+ const finalValues = autoSave.onFinish ? autoSave.onFinish(values) : values;
200
+ autoSaveStatus = 'saving';
201
+ try {
202
+ await provider.update<TData, TVariables>({ resource, id: currentId!, variables: finalValues, meta: mutationMeta });
203
+ const scopes = autoSave.invalidates ?? ['resourceAll'];
204
+ for (const scope of scopes) {
205
+ if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [resource] });
206
+ else if (scope === 'detail' && currentId) queryClient.invalidateQueries({ queryKey: [resource, 'one', currentId] });
207
+ else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [resource, 'list'] });
208
+ }
209
+ autoSaveStatus = 'saved';
210
+ lastAutoSaveData = undefined; // placeholder for returned data
211
+ lastAutoSaveError = null;
212
+ setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
213
+ } catch (e) {
214
+ autoSaveStatus = 'error';
215
+ lastAutoSaveError = e;
216
+ }
217
+ }, autoSave.debounce ?? 1000);
218
+ }
219
+
220
+ let lastAutoSaveData = $state<unknown>(null);
221
+ let lastAutoSaveError = $state<unknown>(null);
222
+
223
+ if (autoSave?.invalidateOnUnmount) {
224
+ $effect(() => {
225
+ return () => queryClient.invalidateQueries({ queryKey: [resource] });
226
+ });
227
+ }
228
+
229
+ // warnWhenUnsavedChanges
230
+ const shouldWarn = options.warnWhenUnsavedChanges ?? adminOptions.warnWhenUnsavedChanges;
231
+ if (shouldWarn && typeof window !== 'undefined') {
232
+ $effect(() => {
233
+ const handler = (e: BeforeUnloadEvent) => {
234
+ // Here we'd typically check a dirty state, but useForm itself doesn't track dirty fields.
235
+ // Usually the form library (svelte-forms etc) handles dirty state.
236
+ // Assuming we always warn if enabled for now.
237
+ e.preventDefault();
238
+ e.returnValue = '';
239
+ };
240
+ window.addEventListener('beforeunload', handler);
241
+ return () => window.removeEventListener('beforeunload', handler);
242
+ });
243
+ }
244
+
245
+ return {
246
+ query,
247
+ get formLoading() { return ((query as any)?.isLoading ?? false) || (createMut as any).isPending || (updateMut as any).isPending; },
248
+ mutation: action === 'edit' ? updateMut : createMut,
249
+ onFinish,
250
+ onFinishAutoSave,
251
+ get errors() { return errors; },
252
+ setFieldError, clearErrors, clearFieldError,
253
+ triggerAutoSave,
254
+ get autoSaveStatus() { return autoSaveStatus; },
255
+ resource, action,
256
+ get id() { return currentId; },
257
+ setId, mutationMode, redirect: doRedirect,
258
+ get autoSaveProps() {
259
+ return {
260
+ status: autoSaveStatus as 'idle' | 'loading' | 'success' | 'error',
261
+ data: lastAutoSaveData,
262
+ error: lastAutoSaveError,
263
+ };
264
+ },
265
+ };
266
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Refine v5 compatible utility helpers
2
+
3
+ import type { Filter, Sort, CrudOperator } from './types';
4
+ import { t } from './i18n.svelte';
5
+ import { getTextTransformers } from './options';
6
+
7
+ // ─── Table Helpers ────────────────────────────────────────────
8
+
9
+ /**
10
+ * Get the current filter value for a field from a filter array.
11
+ * Useful for setting default values in filter inputs.
12
+ */
13
+ export function getDefaultFilter(
14
+ columnName: string,
15
+ filters?: Filter[],
16
+ operatorType: CrudOperator = 'eq',
17
+ ): unknown | undefined {
18
+ if (!filters) return undefined;
19
+ const filter = filters.find((f) => {
20
+ if ('field' in f) {
21
+ return f.field === columnName && f.operator === operatorType;
22
+ }
23
+ return false;
24
+ });
25
+ return filter && 'value' in filter ? filter.value : undefined;
26
+ }
27
+
28
+ /**
29
+ * Get the current sort order for a field from a sorter array.
30
+ * Returns 'asc', 'desc', or undefined.
31
+ */
32
+ export function getDefaultSortOrder(
33
+ columnName: string,
34
+ sorters?: Sort[],
35
+ ): 'asc' | 'desc' | undefined {
36
+ if (!sorters) return undefined;
37
+ const sortItem = sorters.find((item) => item.field === columnName);
38
+ return sortItem?.order as 'asc' | 'desc' | undefined;
39
+ }
40
+
41
+ /**
42
+ * Merge two filter arrays, deduplicating by field+operator.
43
+ * Filters in `newFilters` take priority over `prevFilters`.
44
+ * `permanentFilter` items are always preserved.
45
+ * Filters with undefined values are removed from the result.
46
+ */
47
+ export function unionFilters(
48
+ permanentFilter: Filter[],
49
+ newFilters: Filter[],
50
+ prevFilters: Filter[] = [],
51
+ ): Filter[] {
52
+ const compareFilters = (a: Filter, b: Filter): boolean => {
53
+ if ('field' in a && 'field' in b) {
54
+ return a.field === b.field && a.operator === b.operator;
55
+ }
56
+ return false;
57
+ };
58
+
59
+ // Start with previous filters
60
+ let result = [...prevFilters];
61
+
62
+ // Apply new filters (override previous by field+operator)
63
+ for (const newFilter of newFilters) {
64
+ const idx = result.findIndex((f) => compareFilters(f, newFilter));
65
+ if (idx >= 0) {
66
+ result[idx] = newFilter;
67
+ } else {
68
+ result.push(newFilter);
69
+ }
70
+ }
71
+
72
+ // Ensure permanent filters are always present
73
+ for (const perm of permanentFilter) {
74
+ const idx = result.findIndex((f) => compareFilters(f, perm));
75
+ if (idx >= 0) {
76
+ result[idx] = perm;
77
+ } else {
78
+ result.push(perm);
79
+ }
80
+ }
81
+
82
+ // Remove filters with undefined values
83
+ return result.filter((f) => !('value' in f && f.value === undefined));
84
+ }
85
+
86
+ /**
87
+ * Merge two sorter arrays, deduplicating by field.
88
+ * Sorters in `newSorters` take priority.
89
+ */
90
+ export function unionSorters(
91
+ permanentSorters: Sort[],
92
+ newSorters: Sort[],
93
+ ): Sort[] {
94
+ const result = [...newSorters];
95
+
96
+ for (const perm of permanentSorters) {
97
+ if (!result.some((s) => s.field === perm.field)) {
98
+ result.push(perm);
99
+ }
100
+ }
101
+
102
+ return result;
103
+ }
104
+
105
+ // ─── Upload Helpers ───────────────────────────────────────────
106
+
107
+ /**
108
+ * Convert a File (or file-like object) to a base64 data URL string.
109
+ * Useful for image uploads and previews.
110
+ */
111
+ export function file2Base64(file: File | { uid?: string } & Blob): Promise<string> {
112
+ return new Promise((resolve, reject) => {
113
+ const reader = new FileReader();
114
+ reader.readAsDataURL(file as Blob);
115
+ reader.addEventListener('load', () => {
116
+ resolve(reader.result as string);
117
+ });
118
+ reader.addEventListener('error', () => {
119
+ reject(reader.error);
120
+ });
121
+ });
122
+ }
123
+
124
+ // ─── Document Title Helper ────────────────────────────────────
125
+
126
+ /**
127
+ * Generate a default document title for a resource page.
128
+ * e.g., "Edit Post #5 | Admin" or "Posts | Admin"
129
+ */
130
+ export function generateDefaultDocumentTitle(options?: {
131
+ resource?: string;
132
+ action?: string;
133
+ id?: string | number;
134
+ suffix?: string;
135
+ }): string {
136
+ const { resource, action, id, suffix = ' | Admin' } = options ?? {};
137
+ const transformers = getTextTransformers();
138
+
139
+ if (!resource) {
140
+ return t('documentTitle.default', {}) || `Admin${suffix}`;
141
+ }
142
+
143
+ const resourceLabel = transformers.humanize
144
+ ? transformers.humanize(resource)
145
+ : resource.charAt(0).toUpperCase() + resource.slice(1);
146
+
147
+ const actionPrefixes: Record<string, string> = {
148
+ create: `${t('common.create', {}) || 'Create'} `,
149
+ clone: `#${id ?? ''} ${t('common.clone', {}) || 'Clone'} `,
150
+ edit: `#${id ?? ''} ${t('common.edit', {}) || 'Edit'} `,
151
+ show: `#${id ?? ''} ${t('common.detail', {}) || 'Show'} `,
152
+ list: '',
153
+ };
154
+
155
+ const prefix = action ? (actionPrefixes[action] ?? '') : '';
156
+ return `${prefix}${resourceLabel}${suffix}`;
157
+ }
@@ -0,0 +1,121 @@
1
+ // Hook Utilities — shared reactive helpers for all hooks
2
+ // Uses Svelte 5 runes ($state, $effect) for automatic lifecycle management
3
+
4
+ import { useQueryClient } from '@tanstack/svelte-query';
5
+ import type { LiveProvider, LiveEvent, LiveMode } from './live';
6
+ import { toast } from './toast.svelte';
7
+
8
+ // ─── Overtime Tracker ───────────────────────────────────────────
9
+ // Tracks elapsed time during loading, auto-cleans via $effect
10
+
11
+ export interface OvertimeOptions {
12
+ interval?: number; // ms, default 1000
13
+ onInterval?: (elapsedTime: number) => void;
14
+ }
15
+
16
+ export interface OvertimeResult {
17
+ readonly elapsedTime: number;
18
+ }
19
+
20
+ export function createOvertimeTracker(
21
+ isLoadingFn: () => boolean,
22
+ options: OvertimeOptions = {},
23
+ ): OvertimeResult {
24
+ const { interval = 1000, onInterval } = options;
25
+ let elapsedTime = $state(0);
26
+
27
+ $effect(() => {
28
+ if (isLoadingFn()) {
29
+ elapsedTime = 0;
30
+ const timer = setInterval(() => {
31
+ elapsedTime += interval;
32
+ onInterval?.(elapsedTime);
33
+ }, interval);
34
+ return () => clearInterval(timer);
35
+ } else {
36
+ elapsedTime = 0;
37
+ }
38
+ });
39
+
40
+ return {
41
+ get elapsedTime() { return elapsedTime; },
42
+ };
43
+ }
44
+
45
+ // ─── Live Subscription ──────────────────────────────────────────
46
+ // Auto-subscribes to realtime events, invalidates queries on 'auto' mode
47
+
48
+ export interface LiveSubscriptionParams {
49
+ resource: string;
50
+ liveProvider?: LiveProvider;
51
+ liveMode?: LiveMode;
52
+ onLiveEvent?: (event: LiveEvent) => void;
53
+ liveParams?: Record<string, unknown>;
54
+ enabled?: boolean;
55
+ }
56
+
57
+ export function createLiveSubscription(params: LiveSubscriptionParams): void {
58
+ const {
59
+ resource,
60
+ liveProvider,
61
+ liveMode = 'off',
62
+ onLiveEvent,
63
+ enabled = true,
64
+ } = params;
65
+
66
+ if (!liveProvider || liveMode === 'off' || !enabled) return;
67
+
68
+ const queryClient = useQueryClient();
69
+
70
+ $effect(() => {
71
+ const unsubscribe = liveProvider.subscribe({
72
+ resource,
73
+ callback: (event: LiveEvent) => {
74
+ onLiveEvent?.(event);
75
+ if (liveMode === 'auto') {
76
+ queryClient.invalidateQueries({ queryKey: [resource] });
77
+ }
78
+ },
79
+ });
80
+ return unsubscribe;
81
+ });
82
+ }
83
+
84
+ // ─── Notification Helpers ───────────────────────────────────────
85
+
86
+ export type NotificationConfig =
87
+ | string
88
+ | false
89
+ | ((data?: unknown, values?: unknown, resource?: string) => { message: string; description?: string; type?: 'success' | 'error' })
90
+ | undefined;
91
+
92
+ export function fireSuccessNotification(
93
+ config: NotificationConfig,
94
+ defaultMessage: string,
95
+ data?: unknown,
96
+ values?: unknown,
97
+ resource?: string,
98
+ ): void {
99
+ if (config === false) return;
100
+ if (typeof config === 'function') {
101
+ const result = config(data, values, resource);
102
+ toast.success(result.message);
103
+ return;
104
+ }
105
+ toast.success(config || defaultMessage);
106
+ }
107
+
108
+ export function fireErrorNotification(
109
+ config: NotificationConfig,
110
+ defaultMessage: string,
111
+ error?: unknown,
112
+ ): void {
113
+ if (config === false) return;
114
+ if (typeof config === 'function') {
115
+ const result = config(error);
116
+ toast.error(result.message);
117
+ return;
118
+ }
119
+ const errMsg = error instanceof Error ? error.message : String(error ?? '');
120
+ toast.error(config || `${defaultMessage}${errMsg ? ': ' + errMsg : ''}`);
121
+ }