@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.
@@ -1,12 +1,14 @@
1
1
  // Toast notification system — Svelte 5 runes-based
2
2
 
3
- export type ToastType = 'success' | 'error' | 'info' | 'warning';
3
+ export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
4
4
 
5
5
  export interface Toast {
6
6
  id: number;
7
7
  type: ToastType;
8
8
  message: string;
9
9
  duration: number;
10
+ onUndo?: () => void;
11
+ onTimeout?: () => void;
10
12
  }
11
13
 
12
14
  let nextId = 0;
@@ -16,10 +18,10 @@ export function getToasts(): Toast[] {
16
18
  return toasts;
17
19
  }
18
20
 
19
- export function addToast(type: ToastType, message: string, duration = 3000): void {
21
+ export function addToast(type: ToastType, message: string, duration = 3000, options?: { onUndo?: () => void, onTimeout?: () => void }): void {
20
22
  const id = nextId++;
21
- toasts = [...toasts, { id, type, message, duration }];
22
- if (duration > 0) {
23
+ toasts = [...toasts, { id, type, message, duration, ...options }];
24
+ if (duration > 0 && type !== 'undoable') {
23
25
  setTimeout(() => removeToast(id), duration);
24
26
  }
25
27
  }
@@ -34,4 +36,6 @@ export const toast = {
34
36
  error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
35
37
  info: (msg: string, duration?: number) => addToast('info', msg, duration),
36
38
  warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
39
+ undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
40
+ addToast('undoable', msg, duration, { onUndo, onTimeout }),
37
41
  };
package/src/types.ts CHANGED
@@ -1,4 +1,31 @@
1
- // Core type definitions — 100% Refine-compatible DataProvider + AuthProvider + Providers
1
+ // Core type definitions — DataProvider + AuthProvider + Providers
2
+
3
+ // ─── HttpError ─────────────────────────────────────────────────
4
+
5
+ export type ValidationErrors = Record<string, string | string[]>;
6
+
7
+ export class HttpError extends Error {
8
+ statusCode: number;
9
+ errors?: ValidationErrors;
10
+
11
+ constructor(message: string, statusCode: number, errors?: ValidationErrors) {
12
+ super(message);
13
+ this.name = 'HttpError';
14
+ this.statusCode = statusCode;
15
+ this.errors = errors;
16
+ }
17
+ }
18
+
19
+ export class UndoError extends Error {
20
+ constructor() {
21
+ super('Mutation undone');
22
+ this.name = 'UndoError';
23
+ }
24
+ }
25
+
26
+ // ─── Base Types ───────────────────────────────────────────────
27
+
28
+ export type BaseRecord = Record<string, unknown>;
2
29
 
3
30
  // ─── DataProvider ─────────────────────────────────────────────
4
31
 
@@ -13,12 +40,25 @@ export interface Sort {
13
40
  order: 'asc' | 'desc';
14
41
  }
15
42
 
43
+ export type CrudOperator =
44
+ | 'eq' | 'ne' | 'lt' | 'gt' | 'lte' | 'gte'
45
+ | 'contains' | 'ncontains'
46
+ | 'startswith' | 'endswith'
47
+ | 'in' | 'nin'
48
+ | 'null' | 'nnull'
49
+ | 'between' | 'nbetween';
50
+
16
51
  export interface Filter {
17
52
  field: string;
18
- operator: 'eq' | 'ne' | 'lt' | 'gt' | 'lte' | 'gte' | 'contains' | 'in' | 'null' | 'between';
53
+ operator: CrudOperator;
19
54
  value: unknown;
20
55
  }
21
56
 
57
+ export interface LogicalFilter {
58
+ operator: 'or' | 'and';
59
+ value: Filter[];
60
+ }
61
+
22
62
  export interface GetListParams {
23
63
  resource: string;
24
64
  pagination?: Pagination;
@@ -27,8 +67,8 @@ export interface GetListParams {
27
67
  meta?: Record<string, unknown>;
28
68
  }
29
69
 
30
- export interface GetListResult<T = Record<string, unknown>> {
31
- data: T[];
70
+ export interface GetListResult<TData extends BaseRecord = BaseRecord> {
71
+ data: TData[];
32
72
  total: number;
33
73
  }
34
74
 
@@ -38,8 +78,8 @@ export interface GetOneParams {
38
78
  meta?: Record<string, unknown>;
39
79
  }
40
80
 
41
- export interface GetOneResult<T = Record<string, unknown>> {
42
- data: T;
81
+ export interface GetOneResult<TData extends BaseRecord = BaseRecord> {
82
+ data: TData;
43
83
  }
44
84
 
45
85
  export interface GetManyParams {
@@ -48,78 +88,78 @@ export interface GetManyParams {
48
88
  meta?: Record<string, unknown>;
49
89
  }
50
90
 
51
- export interface GetManyResult<T = Record<string, unknown>> {
52
- data: T[];
91
+ export interface GetManyResult<TData extends BaseRecord = BaseRecord> {
92
+ data: TData[];
53
93
  }
54
94
 
55
- export interface CreateParams {
95
+ export interface CreateParams<TVariables = unknown> {
56
96
  resource: string;
57
- variables: Record<string, unknown>;
97
+ variables: TVariables;
58
98
  meta?: Record<string, unknown>;
59
99
  }
60
100
 
61
- export interface CreateResult<T = Record<string, unknown>> {
62
- data: T;
101
+ export interface CreateResult<TData extends BaseRecord = BaseRecord> {
102
+ data: TData;
63
103
  }
64
104
 
65
- export interface CreateManyParams {
105
+ export interface CreateManyParams<TVariables = unknown> {
66
106
  resource: string;
67
- variables: Record<string, unknown>[];
107
+ variables: TVariables[];
68
108
  meta?: Record<string, unknown>;
69
109
  }
70
110
 
71
- export interface CreateManyResult<T = Record<string, unknown>> {
72
- data: T[];
111
+ export interface CreateManyResult<TData extends BaseRecord = BaseRecord> {
112
+ data: TData[];
73
113
  }
74
114
 
75
- export interface UpdateParams {
115
+ export interface UpdateParams<TVariables = unknown> {
76
116
  resource: string;
77
117
  id: string | number;
78
- variables: Record<string, unknown>;
118
+ variables: TVariables;
79
119
  meta?: Record<string, unknown>;
80
120
  }
81
121
 
82
- export interface UpdateResult<T = Record<string, unknown>> {
83
- data: T;
122
+ export interface UpdateResult<TData extends BaseRecord = BaseRecord> {
123
+ data: TData;
84
124
  }
85
125
 
86
- export interface UpdateManyParams {
126
+ export interface UpdateManyParams<TVariables = unknown> {
87
127
  resource: string;
88
128
  ids: (string | number)[];
89
- variables: Record<string, unknown>;
129
+ variables: TVariables;
90
130
  meta?: Record<string, unknown>;
91
131
  }
92
132
 
93
- export interface UpdateManyResult<T = Record<string, unknown>> {
94
- data: T[];
133
+ export interface UpdateManyResult<TData extends BaseRecord = BaseRecord> {
134
+ data: TData[];
95
135
  }
96
136
 
97
- export interface DeleteParams {
137
+ export interface DeleteParams<TVariables = unknown> {
98
138
  resource: string;
99
139
  id: string | number;
100
- variables?: Record<string, unknown>;
140
+ variables?: TVariables;
101
141
  meta?: Record<string, unknown>;
102
142
  }
103
143
 
104
- export interface DeleteResult<T = Record<string, unknown>> {
105
- data: T;
144
+ export interface DeleteResult<TData extends BaseRecord = BaseRecord> {
145
+ data: TData;
106
146
  }
107
147
 
108
- export interface DeleteManyParams {
148
+ export interface DeleteManyParams<TVariables = unknown> {
109
149
  resource: string;
110
150
  ids: (string | number)[];
111
- variables?: Record<string, unknown>;
151
+ variables?: TVariables;
112
152
  meta?: Record<string, unknown>;
113
153
  }
114
154
 
115
- export interface DeleteManyResult<T = Record<string, unknown>> {
116
- data: T[];
155
+ export interface DeleteManyResult<TData extends BaseRecord = BaseRecord> {
156
+ data: TData[];
117
157
  }
118
158
 
119
- export interface CustomParams {
159
+ export interface CustomParams<TVariables = unknown> {
120
160
  url: string;
121
161
  method: 'get' | 'post' | 'put' | 'patch' | 'delete';
122
- payload?: Record<string, unknown>;
162
+ payload?: TVariables;
123
163
  query?: Record<string, unknown>;
124
164
  headers?: Record<string, string>;
125
165
  sorters?: Sort[];
@@ -127,27 +167,27 @@ export interface CustomParams {
127
167
  meta?: Record<string, unknown>;
128
168
  }
129
169
 
130
- export interface CustomResult<T = unknown> {
131
- data: T;
170
+ export interface CustomResult<TData = unknown> {
171
+ data: TData;
132
172
  }
133
173
 
134
174
  export interface DataProvider {
135
175
  // Required methods
136
- getList: <T = Record<string, unknown>>(params: GetListParams) => Promise<GetListResult<T>>;
137
- getOne: <T = Record<string, unknown>>(params: GetOneParams) => Promise<GetOneResult<T>>;
138
- create: <T = Record<string, unknown>>(params: CreateParams) => Promise<CreateResult<T>>;
139
- update: <T = Record<string, unknown>>(params: UpdateParams) => Promise<UpdateResult<T>>;
140
- deleteOne: <T = Record<string, unknown>>(params: DeleteParams) => Promise<DeleteResult<T>>;
176
+ getList: <TData extends BaseRecord = BaseRecord>(params: GetListParams) => Promise<GetListResult<TData>>;
177
+ getOne: <TData extends BaseRecord = BaseRecord>(params: GetOneParams) => Promise<GetOneResult<TData>>;
178
+ create: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: CreateParams<TVariables>) => Promise<CreateResult<TData>>;
179
+ update: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: UpdateParams<TVariables>) => Promise<UpdateResult<TData>>;
180
+ deleteOne: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteParams<TVariables>) => Promise<DeleteResult<TData>>;
141
181
  getApiUrl: () => string;
142
182
 
143
183
  // Optional bulk methods
144
- getMany?: <T = Record<string, unknown>>(params: GetManyParams) => Promise<GetManyResult<T>>;
145
- createMany?: <T = Record<string, unknown>>(params: CreateManyParams) => Promise<CreateManyResult<T>>;
146
- updateMany?: <T = Record<string, unknown>>(params: UpdateManyParams) => Promise<UpdateManyResult<T>>;
147
- deleteMany?: <T = Record<string, unknown>>(params: DeleteManyParams) => Promise<DeleteManyResult<T>>;
184
+ getMany?: <TData extends BaseRecord = BaseRecord>(params: GetManyParams) => Promise<GetManyResult<TData>>;
185
+ createMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: CreateManyParams<TVariables>) => Promise<CreateManyResult<TData>>;
186
+ updateMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: UpdateManyParams<TVariables>) => Promise<UpdateManyResult<TData>>;
187
+ deleteMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteManyParams<TVariables>) => Promise<DeleteManyResult<TData>>;
148
188
 
149
189
  // Optional custom method
150
- custom?: <T = unknown>(params: CustomParams) => Promise<CustomResult<T>>;
190
+ custom?: <TData = unknown, TVariables = unknown>(params: CustomParams<TVariables>) => Promise<CustomResult<TData>>;
151
191
  }
152
192
 
153
193
  // ─── AuthProvider ─────────────────────────────────────────────
@@ -200,6 +240,8 @@ export type MutationMode = 'pessimistic' | 'optimistic' | 'undoable';
200
240
 
201
241
  export interface ResourceDefinition {
202
242
  name: string;
243
+ /** Unique identifier — use when multiple resources share the same `name` but target different DataProviders */
244
+ identifier?: string;
203
245
  label: string;
204
246
  icon?: string;
205
247
  primaryKey?: string;
@@ -210,7 +252,10 @@ export interface ResourceDefinition {
210
252
  canEdit?: boolean;
211
253
  canDelete?: boolean;
212
254
  canShow?: boolean;
213
- meta?: Record<string, unknown>;
255
+ showInMenu?: boolean;
256
+ parentName?: string;
257
+ menuOrder?: number;
258
+ meta?: Record<string, unknown> & { dataProviderName?: string; parent?: string };
214
259
  }
215
260
 
216
261
  export interface FieldDefinition {
@@ -236,3 +281,35 @@ export interface FieldDefinition {
236
281
  // Validation
237
282
  validate?: (value: unknown) => string | null;
238
283
  }
284
+
285
+ // ─── Resource Type Registry ───────────────────────────────────
286
+
287
+ /**
288
+ * Extend this interface via declaration merging to register resource types.
289
+ * When registered, all hooks automatically infer data types from resource names.
290
+ *
291
+ * @example
292
+ * ```ts
293
+ * declare module '@svadmin/core' {
294
+ * interface ResourceTypeMap {
295
+ * users: { id: string; name: string; email: string }
296
+ * posts: { id: string; title: string; content: string }
297
+ * }
298
+ * }
299
+ *
300
+ * // Now hooks auto-infer:
301
+ * const list = useList({ resource: 'users' })
302
+ * // list.data → { id: string; name: string; email: string }[]
303
+ * ```
304
+ */
305
+ export interface ResourceTypeMap {}
306
+
307
+ /** When ResourceTypeMap is empty → string (backward compatible); otherwise → registered keys */
308
+ export type KnownResources = keyof ResourceTypeMap extends never
309
+ ? string
310
+ : Extract<keyof ResourceTypeMap, string>
311
+
312
+ /** Infer data type from resource name. Falls back to Record<string, unknown> for unregistered resources */
313
+ export type InferData<R extends string> = R extends keyof ResourceTypeMap
314
+ ? ResourceTypeMap[R]
315
+ : Record<string, unknown>
@@ -0,0 +1,62 @@
1
+ // UnsavedChangesNotifier — warns user before leaving page with unsaved changes
2
+ // Svelte 5 component using runes
3
+
4
+ import { onDestroy, onMount } from 'svelte';
5
+
6
+ let warnWhenUnsavedChanges = $state(false);
7
+
8
+ /**
9
+ * Call this to mark the form as dirty (has unsaved changes).
10
+ * When set to true, navigating away or closing the tab will trigger a warning.
11
+ */
12
+ export function setUnsavedChanges(dirty: boolean) {
13
+ warnWhenUnsavedChanges = dirty;
14
+ }
15
+
16
+ export function getUnsavedChanges(): boolean {
17
+ return warnWhenUnsavedChanges;
18
+ }
19
+
20
+ const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
21
+ if (warnWhenUnsavedChanges) {
22
+ e.preventDefault();
23
+ // Modern browsers ignore custom messages but require returnValue to be set
24
+ e.returnValue = '';
25
+ return '';
26
+ }
27
+ };
28
+
29
+ /**
30
+ * Initialize the UnsavedChangesNotifier.
31
+ * Call this in your root layout or AdminApp component.
32
+ * It handles both browser tab close and in-app navigation.
33
+ */
34
+ export function initUnsavedChangesNotifier() {
35
+ if (typeof window === 'undefined') return;
36
+
37
+ window.addEventListener('beforeunload', beforeUnloadHandler);
38
+
39
+ // For hash-based navigation, intercept hashchange
40
+ const hashChangeHandler = (e: HashChangeEvent) => {
41
+ if (warnWhenUnsavedChanges) {
42
+ const confirmed = confirm('You have unsaved changes. Are you sure you want to leave?');
43
+ if (!confirmed) {
44
+ e.preventDefault();
45
+ // Restore original hash
46
+ if (e.oldURL) {
47
+ const oldHash = new URL(e.oldURL).hash;
48
+ history.replaceState(null, '', oldHash || '#');
49
+ }
50
+ } else {
51
+ warnWhenUnsavedChanges = false;
52
+ }
53
+ }
54
+ };
55
+
56
+ window.addEventListener('hashchange', hashChangeHandler);
57
+
58
+ return () => {
59
+ window.removeEventListener('beforeunload', beforeUnloadHandler);
60
+ window.removeEventListener('hashchange', hashChangeHandler);
61
+ };
62
+ }
package/src/url-sync.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  // URL state sync — sync pagination/sort/filters with hash params
2
2
 
3
+ import type { Filter } from './types';
4
+
3
5
  export interface URLState {
4
6
  page?: number;
5
7
  pageSize?: number;
6
8
  sortField?: string;
7
9
  sortOrder?: 'asc' | 'desc';
8
10
  search?: string;
11
+ filters?: Filter[];
9
12
  }
10
13
 
11
14
  export function readURLState(): URLState {
15
+ if (typeof window === 'undefined') return {};
12
16
  const hash = window.location.hash;
13
17
  const queryIdx = hash.indexOf('?');
14
18
  if (queryIdx === -1) return {};
@@ -31,10 +35,18 @@ export function readURLState(): URLState {
31
35
  const search = params.get('q');
32
36
  if (search) state.search = search;
33
37
 
38
+ const filtersRaw = params.get('filters');
39
+ if (filtersRaw) {
40
+ try {
41
+ state.filters = JSON.parse(filtersRaw);
42
+ } catch { /* ignore invalid json */ }
43
+ }
44
+
34
45
  return state;
35
46
  }
36
47
 
37
48
  export function writeURLState(state: URLState): void {
49
+ if (typeof window === 'undefined') return;
38
50
  const hash = window.location.hash;
39
51
  const pathIdx = hash.indexOf('?');
40
52
  const basePath = pathIdx === -1 ? hash : hash.slice(0, pathIdx);
@@ -45,6 +57,9 @@ export function writeURLState(state: URLState): void {
45
57
  if (state.sortField) params.set('sort', state.sortField);
46
58
  if (state.sortOrder) params.set('order', state.sortOrder);
47
59
  if (state.search) params.set('q', state.search);
60
+ if (state.filters && state.filters.length > 0) {
61
+ params.set('filters', JSON.stringify(state.filters));
62
+ }
48
63
 
49
64
  const qs = params.toString();
50
65
  const newHash = qs ? `${basePath}?${qs}` : basePath;
package/src/useParsed.ts CHANGED
@@ -40,9 +40,46 @@ export function useParsed(): ParsedRoute {
40
40
  const segments = pathname.split('/').filter(Boolean);
41
41
  if (segments.length === 0) return result;
42
42
 
43
- // First segment is typically the resource name
43
+ // Find the rightmost segment that matches a known resource name
44
44
  const resourceNames = resources.map(r => r.name);
45
- if (resourceNames.includes(segments[0])) {
45
+ let resourceIndex = -1;
46
+ for (let i = segments.length - 1; i >= 0; i--) {
47
+ if (resourceNames.includes(segments[i])) {
48
+ resourceIndex = i;
49
+ break;
50
+ }
51
+ }
52
+
53
+ if (resourceIndex !== -1) {
54
+ result.resource = segments[resourceIndex];
55
+
56
+ // Add parent path params based on `<parentResource>/<parentId>` structure
57
+ for (let i = 0; i < resourceIndex; i += 2) {
58
+ if (segments[i] && segments[i + 1]) {
59
+ const parentName = segments[i];
60
+ const singular = parentName.endsWith('s') ? parentName.slice(0, -1) : parentName;
61
+ result.params[`${singular}Id`] = segments[i + 1];
62
+ }
63
+ }
64
+
65
+ const restSegments = segments.slice(resourceIndex + 1);
66
+ if (restSegments.length === 0) {
67
+ result.action = 'list';
68
+ } else if (restSegments[0] === 'create') {
69
+ result.action = 'create';
70
+ } else if (restSegments[0] === 'edit' && restSegments[1]) {
71
+ result.action = 'edit';
72
+ result.id = restSegments[1];
73
+ } else if (restSegments[0] === 'show' && restSegments[1]) {
74
+ result.action = 'show';
75
+ result.id = restSegments[1];
76
+ } else if (restSegments[0]) {
77
+ // Legacy: /:resource/:id
78
+ result.action = 'show';
79
+ result.id = restSegments[0];
80
+ }
81
+ } else {
82
+ // Fallback if resource definitions are not loaded yet
46
83
  result.resource = segments[0];
47
84
 
48
85
  if (segments.length === 1) {
@@ -56,7 +93,6 @@ export function useParsed(): ParsedRoute {
56
93
  result.action = 'show';
57
94
  result.id = segments[2];
58
95
  } else if (segments[1]) {
59
- // Legacy: /:resource/:id
60
96
  result.action = 'show';
61
97
  result.id = segments[1];
62
98
  }
@@ -0,0 +1,71 @@
1
+ import { useForm } from './form-hooks.svelte';
2
+ import type { UseFormOptions, UseFormResult } from './form-hooks.svelte';
3
+ import type { BaseRecord, HttpError } from './types';
4
+
5
+ export interface UseStepsFormOptions<
6
+ TQueryFnData extends BaseRecord = BaseRecord,
7
+ TError = HttpError,
8
+ TVariables = Record<string, unknown>,
9
+ TData extends BaseRecord = TQueryFnData
10
+ > extends UseFormOptions<TQueryFnData, TError, TVariables, TData> {
11
+ /** Default step index. Starts at 0. */
12
+ defaultStep?: number;
13
+ /** Validate the form when navigating back? */
14
+ isBackValidate?: boolean;
15
+ }
16
+
17
+ export interface UseStepsFormResult<
18
+ TQueryFnData extends BaseRecord = BaseRecord,
19
+ TError = HttpError,
20
+ TVariables = Record<string, unknown>,
21
+ TData extends BaseRecord = TQueryFnData
22
+ > extends UseFormResult<TQueryFnData, TError, TVariables, TData> {
23
+ currentStep: number;
24
+ gotoStep: (step: number) => void;
25
+ next: () => void;
26
+ prev: () => void;
27
+ }
28
+
29
+ /**
30
+ * useStepsForm
31
+ * Extends `useForm` with step navigation state (currentStep, next, prev, gotoStep).
32
+ */
33
+ export function useStepsForm<
34
+ TQueryFnData extends BaseRecord = BaseRecord,
35
+ TError = HttpError,
36
+ TVariables = Record<string, unknown>,
37
+ TData extends BaseRecord = TQueryFnData
38
+ >(options: UseStepsFormOptions<TQueryFnData, TError, TVariables, TData> = {} as any): UseStepsFormResult<TQueryFnData, TError, TVariables, TData> {
39
+ const form = useForm<TQueryFnData, TError, TVariables, TData>(options);
40
+
41
+ let currentStep = $state(options.defaultStep ?? 0);
42
+
43
+ function gotoStep(step: number) {
44
+ currentStep = Math.max(0, step);
45
+ }
46
+
47
+ function next() {
48
+ gotoStep(currentStep + 1);
49
+ }
50
+
51
+ function prev() {
52
+ if (currentStep > 0) {
53
+ gotoStep(currentStep - 1);
54
+ }
55
+ }
56
+
57
+ // Inject stepping features into the returned useForm object while preserving its reactive getters.
58
+ const result = form as unknown as UseStepsFormResult<TQueryFnData, TError, TVariables, TData>;
59
+
60
+ Object.defineProperty(result, 'currentStep', {
61
+ get: () => currentStep,
62
+ enumerable: true,
63
+ configurable: true
64
+ });
65
+
66
+ result.gotoStep = gotoStep;
67
+ result.next = next;
68
+ result.prev = prev;
69
+
70
+ return result;
71
+ }