webspresso 0.0.97 → 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -1,8 +1,28 @@
1
1
  // Login Form Component
2
2
  const LoginForm = {
3
+ oninit: () => {
4
+ state.loading = false;
5
+ state.error = null;
6
+ state.notice = null;
7
+ var reason = null;
8
+ try {
9
+ if (typeof m !== 'undefined' && m.route && typeof m.route.param === 'function') {
10
+ reason = m.route.param('reason');
11
+ }
12
+ if (!reason && typeof window !== 'undefined' && window.location && window.location.search) {
13
+ var match = window.location.search.match(/[?&]reason=([^&]+)/);
14
+ if (match && match[1]) reason = decodeURIComponent(match[1]);
15
+ }
16
+ } catch (e) {}
17
+ if (reason === 'expired') {
18
+ state.notice = 'Your session has expired. Please sign in again.';
19
+ }
20
+ },
3
21
  view: () => m('.min-h-screen.flex.items-center.justify-center.p-4.sm:p-6.bg-gradient-to-br.from-blue-600.via-indigo-600.to-purple-700', [
22
+ typeof ToastContainer !== 'undefined' ? m(ToastContainer) : null,
23
+ typeof ConfirmModalContainer !== 'undefined' ? m(ConfirmModalContainer) : null,
4
24
  m('.w-full.max-w-md', [
5
- m('.bg-white dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
25
+ m('.bg-white.dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
6
26
  m('div.text-center.mb-6', [
7
27
  m('h1.text-2xl.sm:text-3xl.font-bold.text-gray-900.dark:text-slate-50', 'Admin Login'),
8
28
  m('p.text-gray-500.dark:text-slate-400.text-sm.mt-1', 'Sign in to your account'),
@@ -12,22 +32,41 @@ const LoginForm = {
12
32
  e.preventDefault();
13
33
  state.loading = true;
14
34
  state.error = null;
35
+ m.redraw();
15
36
  try {
16
37
  const data = new FormData(e.target);
38
+ const email = (data.get('email') || '').trim();
39
+ const password = data.get('password') || '';
40
+ if (!email || !password) {
41
+ state.error = 'Please enter both email and password';
42
+ state.loading = false;
43
+ m.redraw();
44
+ return;
45
+ }
17
46
  const result = await api.post('/auth/login', {
18
- email: data.get('email'),
19
- password: data.get('password'),
47
+ email,
48
+ password,
20
49
  });
21
50
  state.user = result.user;
51
+ state.error = null;
52
+ state.notice = null;
22
53
  m.route.set(consumeIntendedRoute('/'));
23
54
  } catch (err) {
24
- state.error = err.message;
55
+ state.error = err.message || 'Invalid email or password';
25
56
  } finally {
26
57
  state.loading = false;
58
+ m.redraw();
27
59
  }
28
60
  }
29
61
  }, [
30
- state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm', state.error) : null,
62
+ state.notice ? m('.bg-amber-50.border.border-amber-200.text-amber-800.dark:bg-amber-950/50.dark:border-amber-800/80.dark:text-amber-200.px-4.py-3.rounded-lg.mb-4.text-sm.flex.items-center.gap-2', [
63
+ m('span.text-base.select-none', 'ℹ️'),
64
+ m('span.flex-1', state.notice),
65
+ ]) : null,
66
+ state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm.flex.items-center.gap-2', [
67
+ m('span.text-base.select-none', '⚠️'),
68
+ m('span.flex-1', state.error),
69
+ ]) : null,
31
70
  m('.mb-4', [
32
71
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'email' }, 'Email'),
33
72
  m('input#email.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.placeholder-gray-400.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100.dark:placeholder-slate-500', {
@@ -35,6 +74,12 @@ const LoginForm = {
35
74
  name: 'email',
36
75
  required: true,
37
76
  placeholder: 'admin@example.com',
77
+ oninput: () => {
78
+ if (state.error) {
79
+ state.error = null;
80
+ m.redraw();
81
+ }
82
+ },
38
83
  }),
39
84
  ]),
40
85
  m('.mb-6', [
@@ -43,6 +88,12 @@ const LoginForm = {
43
88
  type: 'password',
44
89
  name: 'password',
45
90
  required: true,
91
+ oninput: () => {
92
+ if (state.error) {
93
+ state.error = null;
94
+ m.redraw();
95
+ }
96
+ },
46
97
  }),
47
98
  ]),
48
99
  m('button.w-full.bg-blue-600.text-white.py-2.5.px-4.rounded-lg.font-medium.hover:bg-blue-700.focus:ring-2.focus:ring-blue-500.focus:ring-offset-2.dark:focus:ring-offset-slate-800.disabled:opacity-50.transition-colors', {
@@ -57,42 +108,70 @@ const LoginForm = {
57
108
 
58
109
  // Setup Form Component
59
110
  const SetupForm = {
111
+ oninit: () => {
112
+ state.loading = false;
113
+ state.error = null;
114
+ state.notice = null;
115
+ },
60
116
  view: () => m('.min-h-screen.flex.items-center.justify-center.p-4.sm:p-6.bg-gradient-to-br.from-blue-600.via-indigo-600.to-purple-700', [
117
+ typeof ToastContainer !== 'undefined' ? m(ToastContainer) : null,
118
+ typeof ConfirmModalContainer !== 'undefined' ? m(ConfirmModalContainer) : null,
61
119
  m('.w-full.max-w-md', [
62
- m('.bg-white dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
120
+ m('.bg-white.dark:bg-slate-800.rounded-2xl.shadow-2xl.p-6.sm:p-8', [
63
121
  m('div.text-center.mb-6', [
64
122
  m('h1.text-2xl.sm:text-3xl.font-bold.text-gray-900.dark:text-slate-50', 'Setup Admin Account'),
65
123
  m('p.text-gray-500.dark:text-slate-400.text-sm.mt-1', 'Create the first admin user account.'),
66
124
  ]),
67
125
  m('form', {
68
- onsubmit: async (e) => {
69
- e.preventDefault();
70
- state.loading = true;
71
- state.error = null;
72
- try {
73
- const data = new FormData(e.target);
74
- const result = await api.post('/auth/setup', {
75
- email: data.get('email'),
76
- password: data.get('password'),
77
- name: data.get('name'),
78
- });
79
- state.user = result.user;
80
- state.needsSetup = false;
81
- m.route.set(consumeIntendedRoute('/'));
82
- } catch (err) {
83
- state.error = err.message;
84
- } finally {
85
- state.loading = false;
86
- }
87
- }
88
- }, [
89
- state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm', state.error) : null,
126
+ onsubmit: async (e) => {
127
+ e.preventDefault();
128
+ state.loading = true;
129
+ state.error = null;
130
+ m.redraw();
131
+ try {
132
+ const data = new FormData(e.target);
133
+ const name = (data.get('name') || '').trim();
134
+ const email = (data.get('email') || '').trim();
135
+ const password = data.get('password') || '';
136
+ if (!name || !email || !password) {
137
+ state.error = 'Please fill in all fields';
138
+ state.loading = false;
139
+ m.redraw();
140
+ return;
141
+ }
142
+ const result = await api.post('/auth/setup', {
143
+ email,
144
+ password,
145
+ name,
146
+ });
147
+ state.user = result.user;
148
+ state.needsSetup = false;
149
+ state.error = null;
150
+ m.route.set(consumeIntendedRoute('/'));
151
+ } catch (err) {
152
+ state.error = err.message || 'Failed to setup admin account';
153
+ } finally {
154
+ state.loading = false;
155
+ m.redraw();
156
+ }
157
+ }
158
+ }, [
159
+ state.error ? m('.bg-red-50.border.border-red-200.text-red-700.dark:bg-red-950/50.dark:border-red-800/80.dark:text-red-200.px-4.py-3.rounded-lg.mb-4.text-sm.flex.items-center.gap-2', [
160
+ m('span.text-base.select-none', '⚠️'),
161
+ m('span.flex-1', state.error),
162
+ ]) : null,
90
163
  m('.mb-4', [
91
164
  m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-2', { for: 'name' }, 'Name'),
92
165
  m('input#name.w-full.px-3.py-2.5.bg-white.text-gray-900.border.border-gray-300.rounded-lg.focus:ring-2.focus:ring-blue-500.focus:border-blue-500.transition-colors.dark:bg-slate-900/80.dark:border-slate-500.dark:text-slate-100', {
93
166
  type: 'text',
94
167
  name: 'name',
95
168
  required: true,
169
+ oninput: () => {
170
+ if (state.error) {
171
+ state.error = null;
172
+ m.redraw();
173
+ }
174
+ },
96
175
  }),
97
176
  ]),
98
177
  m('.mb-4', [
@@ -102,6 +181,12 @@ const SetupForm = {
102
181
  name: 'email',
103
182
  required: true,
104
183
  placeholder: 'admin@example.com',
184
+ oninput: () => {
185
+ if (state.error) {
186
+ state.error = null;
187
+ m.redraw();
188
+ }
189
+ },
105
190
  }),
106
191
  ]),
107
192
  m('.mb-6', [
@@ -110,6 +195,12 @@ const SetupForm = {
110
195
  type: 'password',
111
196
  name: 'password',
112
197
  required: true,
198
+ oninput: () => {
199
+ if (state.error) {
200
+ state.error = null;
201
+ m.redraw();
202
+ }
203
+ },
113
204
  }),
114
205
  ]),
115
206
  m('button.w-full.bg-blue-600.text-white.py-2.5.px-4.rounded-lg.font-medium.hover:bg-blue-700.focus:ring-2.focus:ring-blue-500.focus:ring-offset-2.dark:focus:ring-offset-slate-800.disabled:opacity-50.transition-colors', {
@@ -84,7 +84,27 @@ function formatCellValue(value, col, record = null) {
84
84
  return String(value);
85
85
  }
86
86
  }
87
-
87
+
88
+ // Badge formatting for status / enum / mapped values
89
+ const badgeMap = (col && col.badge) || (col && col.customField && col.customField.badge);
90
+ if (badgeMap && typeof badgeMap === 'object') {
91
+ const valStr = String(value);
92
+ const color = badgeMap[valStr] || badgeMap['*'] || 'gray';
93
+ const colorClasses = {
94
+ green: 'bg-green-100 text-green-800 dark:bg-green-950/60 dark:text-green-300 border border-green-200 dark:border-green-800',
95
+ red: 'bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-300 border border-red-200 dark:border-red-800',
96
+ blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300 border border-blue-200 dark:border-blue-800',
97
+ amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-200 dark:border-amber-800',
98
+ yellow: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border border-amber-200 dark:border-amber-800',
99
+ purple: 'bg-purple-100 text-purple-800 dark:bg-purple-950/60 dark:text-purple-300 border border-purple-200 dark:border-purple-800',
100
+ violet: 'bg-purple-100 text-purple-800 dark:bg-purple-950/60 dark:text-purple-300 border border-purple-200 dark:border-purple-800',
101
+ indigo: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800',
102
+ gray: 'bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700',
103
+ }[color] || 'bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-slate-300 border border-gray-200 dark:border-slate-700';
104
+
105
+ return m('span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.' + colorClasses, valStr);
106
+ }
107
+
88
108
  switch (col?.type) {
89
109
  case 'boolean':
90
110
  return value
@@ -298,7 +318,7 @@ const BulkFieldUpdateDropdown = {
298
318
  vnode.state._bulkFieldName = null;
299
319
  if (onComplete) onComplete();
300
320
  } catch (err) {
301
- alert('Error: ' + err.message);
321
+ notifyError('Error: ' + err.message);
302
322
  } finally {
303
323
  state.bulkActionInProgress = false;
304
324
  m.redraw();
@@ -319,9 +339,9 @@ const BulkFieldUpdateDropdown = {
319
339
  const trimmed = vnode.state.bulkTemporalInput && String(vnode.state.bulkTemporalInput).trim();
320
340
  if (!trimmed) {
321
341
  if (state.selectedBulkField.nullable) {
322
- alert('Enter a date/time or use Clear (set null).');
342
+ notifyWarning('Enter a date/time or use Clear (set null).');
323
343
  } else {
324
- alert('Please enter a value.');
344
+ notifyWarning('Please enter a value.');
325
345
  }
326
346
  return;
327
347
  }
@@ -342,7 +362,7 @@ const BulkFieldUpdateDropdown = {
342
362
  vnode.state._bulkFieldName = null;
343
363
  if (onComplete) onComplete();
344
364
  } catch (err) {
345
- alert('Error: ' + err.message);
365
+ notifyError('Error: ' + err.message);
346
366
  } finally {
347
367
  state.bulkActionInProgress = false;
348
368
  m.redraw();
@@ -363,7 +383,7 @@ const BulkFieldUpdateDropdown = {
363
383
  state.selectedBulkField = null;
364
384
  if (onComplete) onComplete();
365
385
  } catch (err) {
366
- alert('Error: ' + err.message);
386
+ notifyError('Error: ' + err.message);
367
387
  } finally {
368
388
  state.bulkActionInProgress = false;
369
389
  m.redraw();
@@ -414,13 +434,27 @@ const BulkFieldUpdateDropdown = {
414
434
  },
415
435
  };
416
436
 
417
- // Get columns to display in table (limit to reasonable number)
418
- function getDisplayColumns(columns) {
437
+ // Get columns to display in table (respects listColumns if configured, else limits to reasonable number)
438
+ function getDisplayColumns(columns, modelMeta = null) {
419
439
  if (!columns || columns.length === 0) return [];
420
440
 
421
441
  // Filter out hidden columns (password_hash, api_token, etc.)
422
442
  const visible = [...columns].filter((col) => !col.hidden);
423
443
 
444
+ // If model defines listColumns, respect the explicit column list and ordering
445
+ if (modelMeta?.listColumns && Array.isArray(modelMeta.listColumns) && modelMeta.listColumns.length > 0) {
446
+ const colMap = new Map(visible.map((c) => [c.name, c]));
447
+ const customList = [];
448
+ for (const name of modelMeta.listColumns) {
449
+ if (colMap.has(name)) {
450
+ customList.push(colMap.get(name));
451
+ }
452
+ }
453
+ if (customList.length > 0) {
454
+ return customList;
455
+ }
456
+ }
457
+
424
458
  // Prioritize: id, name/title, then others (excluding long text/json fields)
425
459
  const priority = ['id', 'name', 'title', 'email', 'slug', 'status', 'published', 'created_at'];
426
460
  const exclude = ['password', 'content', 'body', 'description']; // Usually too long
@@ -633,6 +667,13 @@ function initializeModelView(modelName) {
633
667
  .then(modelMeta => {
634
668
  state.currentModelMeta = modelMeta;
635
669
  state.currentModel = modelMeta;
670
+ if (typeof setAdminPageTitle === 'function') {
671
+ setAdminPageTitle(modelMeta?.label || modelName);
672
+ }
673
+ if (!state.sortColumn && modelMeta?.defaultSort?.column) {
674
+ state.sortColumn = modelMeta.defaultSort.column;
675
+ state.sortDirection = (modelMeta.defaultSort.order || 'desc').toLowerCase();
676
+ }
636
677
  // Load bulk-updatable fields for this model
637
678
  loadBulkFields(modelName);
638
679
  return loadRecords(modelName, page, state.filters, state.sortColumn, state.sortDirection);