millas 0.2.2 β†’ 0.2.4

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.
@@ -47,8 +47,8 @@ class AdminResource {
47
47
  /** Singular label */
48
48
  static labelSingular = null;
49
49
 
50
- /** Emoji or icon string for the sidebar */
51
- static icon = 'πŸ“‹';
50
+ /** SVG icon id without the ic- prefix e.g. 'users', 'file', 'tag' */
51
+ static icon = 'table';
52
52
 
53
53
  /** Records per page */
54
54
  static perPage = 20;
@@ -68,6 +68,16 @@ class AdminResource {
68
68
  /** Whether to show Delete buttons */
69
69
  static canDelete = true;
70
70
 
71
+ /** Whether to show a detail/view page */
72
+ static canView = true;
73
+
74
+ /**
75
+ * Fields that appear on the form as read-only (shown as text, not input).
76
+ * Useful for system-managed fields like created_at, updated_at, uuid.
77
+ * @type {string[]}
78
+ */
79
+ static readonlyFields = [];
80
+
71
81
  /** URL-safe slug used in routes */
72
82
  static get slug() {
73
83
  return (this.label || this.model?.name || 'resource')
@@ -75,11 +85,11 @@ class AdminResource {
75
85
  }
76
86
 
77
87
  /**
78
- * Define the fields shown in list + detail views.
88
+ * Define the fields shown in list, detail, and form views.
89
+ * Use AdminField.tab('Tab Name') to group fields into tabs on the form.
79
90
  * Must return an array of AdminField instances.
80
91
  */
81
92
  static fields() {
82
- // Default: infer from model.fields if available
83
93
  if (!this.model?.fields) return [];
84
94
  return Object.entries(this.model.fields).map(([name, def]) =>
85
95
  AdminField.fromModelField(name, def)
@@ -87,7 +97,7 @@ class AdminResource {
87
97
  }
88
98
 
89
99
  /**
90
- * Define filter controls shown in the sidebar.
100
+ * Define filter controls shown in the filter panel.
91
101
  * Must return an array of AdminFilter instances.
92
102
  */
93
103
  static filters() {
@@ -101,39 +111,42 @@ class AdminResource {
101
111
  static async fetchList({ page = 1, perPage, search, sort = 'id', order = 'desc', filters = {} } = {}) {
102
112
  const limit = perPage || this.perPage;
103
113
  const offset = (page - 1) * limit;
104
- const db = this.model._db();
105
114
 
106
- let query = db.orderBy(sort, order);
115
+ // Start from the public query() entry point β€” works regardless of
116
+ // whether the ORM changes have been applied or not.
117
+ let qb = this.model.query().orderBy(sort, order);
107
118
 
108
- // Search
119
+ // Search β€” build OR conditions across all searchable columns
109
120
  if (search && this.searchable.length) {
110
- query = query.where(function () {
111
- for (const col of this.constructor.searchable || []) {
121
+ const searchable = this.searchable; // capture for closure
122
+ // Use raw knex where group via the underlying _query
123
+ qb._query = qb._query.where(function () {
124
+ for (const col of searchable) {
112
125
  this.orWhere(col, 'like', `%${search}%`);
113
126
  }
114
127
  });
115
128
  }
116
129
 
117
- // Filters
130
+ // Filters β€” support __ lookups (e.g. created_at__gte) as well as plain equality
118
131
  for (const [key, value] of Object.entries(filters)) {
119
132
  if (value !== '' && value !== null && value !== undefined) {
120
- query = query.where(key, value);
133
+ qb.where(key, value);
121
134
  }
122
135
  }
123
136
 
124
- const [rows, countResult] = await Promise.all([
125
- query.clone().limit(limit).offset(offset),
126
- query.clone().count('* as count').first(),
137
+ // Run count and data fetch in parallel
138
+ const [rows, total] = await Promise.all([
139
+ qb._query.clone().limit(limit).offset(offset),
140
+ qb._query.clone().clearSelect().count('* as count').first()
141
+ .then(r => Number(r?.count ?? 0)),
127
142
  ]);
128
143
 
129
- const total = Number(countResult?.count || 0);
130
-
131
144
  return {
132
145
  data: rows.map(r => this.model._hydrate(r)),
133
146
  total,
134
147
  page: Number(page),
135
148
  perPage: limit,
136
- lastPage: Math.ceil(total / limit),
149
+ lastPage: Math.ceil(total / limit) || 1,
137
150
  };
138
151
  }
139
152
 
@@ -190,47 +203,91 @@ class AdminResource {
190
203
 
191
204
  class AdminField {
192
205
  constructor(name, type) {
193
- this._name = name;
194
- this._type = type;
195
- this._label = name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
196
- this._sortable = false;
197
- this._hidden = false;
198
- this._listOnly = false;
199
- this._detailOnly = false;
200
- this._colors = {};
201
- this._format = null;
206
+ this._name = name;
207
+ this._type = type;
208
+ this._label = name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
209
+ this._sortable = false;
210
+ this._hidden = false;
211
+ this._listOnly = false;
212
+ this._detailOnly = false;
213
+ this._readonly = false;
214
+ this._colors = {};
215
+ this._format = null;
216
+ this._nullable = true; // defaults to optional; use .required() to enforce
217
+ this._tab = null; // tab name this field belongs to
218
+ this._span = null; // 'full' | 'third' | null (default half-width)
219
+ this._min = null;
220
+ this._max = null;
202
221
  }
203
222
 
204
- // ─── Field types ────────────────────────────────────────────────────────
205
-
206
- static id(name = 'id') { return new AdminField(name, 'id'); }
207
- static text(name) { return new AdminField(name, 'text'); }
208
- static email(name) { return new AdminField(name, 'email'); }
209
- static number(name) { return new AdminField(name, 'number'); }
210
- static boolean(name) { return new AdminField(name, 'boolean'); }
211
- static badge(name) { return new AdminField(name, 'badge'); }
212
- static datetime(name) { return new AdminField(name, 'datetime'); }
213
- static date(name) { return new AdminField(name, 'date'); }
214
- static image(name) { return new AdminField(name, 'image'); }
215
- static textarea(name) { return new AdminField(name, 'textarea'); }
216
- static select(name, options) { const f = new AdminField(name, 'select'); f._options = options; return f; }
217
- static password(name) { return new AdminField(name, 'password'); }
218
- static json(name) { return new AdminField(name, 'json'); }
219
-
220
- // ─── Fluent modifiers ───────────────────────────────────────────────────
221
-
222
- label(l) { this._label = l; return this; }
223
- sortable() { this._sortable = true; return this; }
224
- hidden() { this._hidden = true; return this; }
225
- listOnly() { this._listOnly = true; return this; }
226
- detailOnly() { this._detailOnly = true; return this; }
227
- colors(map) { this._colors = map; return this; }
228
- format(fn) { this._format = fn; return this; }
229
- placeholder(p) { this._placeholder = p; return this; }
230
- help(h) { this._help = h; return this; }
231
- readonly() { this._readonly = true; return this; }
232
-
233
- // ─── Serialise for rendering ─────────────────────────────────────────────
223
+ // ─── Field types ───────────────────────────────────────────────────────────
224
+
225
+ static id(name = 'id') { return new AdminField(name, 'id'); }
226
+ static text(name) { return new AdminField(name, 'text'); }
227
+ static email(name) { return new AdminField(name, 'email'); }
228
+ static number(name) { return new AdminField(name, 'number'); }
229
+ static boolean(name) { return new AdminField(name, 'boolean'); }
230
+ static badge(name) { return new AdminField(name, 'badge'); }
231
+ static datetime(name) { return new AdminField(name, 'datetime'); }
232
+ static date(name) { return new AdminField(name, 'date'); }
233
+ static image(name) { return new AdminField(name, 'image'); }
234
+ static textarea(name) { return new AdminField(name, 'textarea'); }
235
+ static password(name) { return new AdminField(name, 'password'); }
236
+ static json(name) { return new AdminField(name, 'json'); }
237
+ static url(name) { return new AdminField(name, 'url'); }
238
+ static phone(name) { return new AdminField(name, 'phone'); }
239
+ static color(name) { return new AdminField(name, 'color'); }
240
+ static richtext(name) { return new AdminField(name, 'richtext'); }
241
+
242
+ static select(name, options) {
243
+ const f = new AdminField(name, 'select');
244
+ f._options = Array.isArray(options)
245
+ ? options.map(o => typeof o === 'string' ? { value: o, label: o } : o)
246
+ : [];
247
+ return f;
248
+ }
249
+
250
+ /**
251
+ * Tab separator β€” splits the form into named tabs.
252
+ * All fields after this marker belong to this tab until the next one.
253
+ *
254
+ * static fields() {
255
+ * return [
256
+ * AdminField.tab('General'),
257
+ * AdminField.text('name'),
258
+ * AdminField.tab('Settings'),
259
+ * AdminField.boolean('active'),
260
+ * ];
261
+ * }
262
+ */
263
+ static tab(label) {
264
+ const f = new AdminField('__tab__', 'tab');
265
+ f._label = label;
266
+ return f;
267
+ }
268
+
269
+ // ─── Fluent modifiers ──────────────────────────────────────────────────────
270
+
271
+ label(l) { this._label = l; return this; }
272
+ sortable() { this._sortable = true; return this; }
273
+ hidden() { this._hidden = true; return this; }
274
+ listOnly() { this._listOnly = true; return this; }
275
+ detailOnly() { this._detailOnly = true; return this; }
276
+ readonly() { this._readonly = true; return this; }
277
+ nullable() { this._nullable = true; return this; }
278
+ required() { this._nullable = false; return this; }
279
+ full() { this._span = 'full'; return this; }
280
+ third() { this._span = 'third'; return this; }
281
+ colors(map) { this._colors = map; return this; }
282
+ format(fn) { this._format = fn; return this; }
283
+ placeholder(p) { this._placeholder = p; return this; }
284
+ help(h) { this._help = h; return this; }
285
+ min(n) { this._min = n; return this; }
286
+ max(n) { this._max = n; return this; }
287
+ /** Assign this field to a named tab (alternative to using tab() separators). */
288
+ inTab(name) { this._tab = name; return this; }
289
+
290
+ // ─── Serialise ─────────────────────────────────────────────────────────────
234
291
 
235
292
  toJSON() {
236
293
  return {
@@ -241,49 +298,50 @@ class AdminField {
241
298
  hidden: this._hidden,
242
299
  listOnly: this._listOnly,
243
300
  detailOnly: this._detailOnly,
301
+ readonly: this._readonly,
302
+ nullable: this._nullable,
244
303
  colors: this._colors,
245
304
  options: this._options,
246
305
  placeholder: this._placeholder,
247
306
  help: this._help,
248
- readonly: this._readonly,
307
+ tab: this._tab,
308
+ span: this._span,
309
+ min: this._min,
310
+ max: this._max,
249
311
  };
250
312
  }
251
313
 
252
- /**
253
- * Format a raw value for display.
254
- */
314
+ /** Format a raw value for display in the detail/list views. */
255
315
  display(value) {
256
316
  if (this._format) return this._format(value);
257
317
  if (value === null || value === undefined) return 'β€”';
258
- if (this._type === 'boolean') return value ? 'βœ“' : 'βœ—';
259
- if (this._type === 'datetime' && value) {
260
- return new Date(value).toLocaleString();
261
- }
262
- if (this._type === 'date' && value) {
263
- return new Date(value).toLocaleDateString();
264
- }
318
+ if (this._type === 'boolean') return value ? 'Yes' : 'No';
319
+ if (this._type === 'datetime' && value) return new Date(value).toLocaleString();
320
+ if (this._type === 'date' && value) return new Date(value).toLocaleDateString();
265
321
  if (this._type === 'password') return 'β€’β€’β€’β€’β€’β€’β€’β€’';
266
- if (this._type === 'json') return JSON.stringify(value);
322
+ if (this._type === 'json') return JSON.stringify(value, null, 2);
267
323
  return String(value);
268
324
  }
269
325
 
270
326
  static fromModelField(name, fieldDef) {
271
327
  const typeMap = {
272
- id: () => AdminField.id(name),
273
- string: () => AdminField.text(name),
274
- text: () => AdminField.textarea(name),
275
- integer: () => AdminField.number(name),
276
- bigInteger:() => AdminField.number(name),
277
- float: () => AdminField.number(name),
278
- decimal: () => AdminField.number(name),
279
- boolean: () => AdminField.boolean(name),
280
- timestamp: () => AdminField.datetime(name),
281
- date: () => AdminField.date(name),
282
- enum: () => AdminField.select(name, fieldDef.enumValues || []),
283
- json: () => AdminField.json(name),
328
+ id: () => AdminField.id(name),
329
+ string: () => AdminField.text(name),
330
+ text: () => AdminField.textarea(name),
331
+ integer: () => AdminField.number(name),
332
+ bigInteger: () => AdminField.number(name),
333
+ float: () => AdminField.number(name),
334
+ decimal: () => AdminField.number(name),
335
+ boolean: () => AdminField.boolean(name),
336
+ timestamp: () => AdminField.datetime(name),
337
+ date: () => AdminField.date(name),
338
+ enum: () => AdminField.select(name, fieldDef.enumValues || []),
339
+ json: () => AdminField.json(name),
284
340
  };
285
341
  const fn = typeMap[fieldDef.type] || (() => AdminField.text(name));
286
- return fn();
342
+ const field = fn();
343
+ if (fieldDef.nullable) field.nullable();
344
+ return field;
287
345
  }
288
346
  }
289
347
 
@@ -946,6 +946,27 @@
946
946
  <div id="main">
947
947
  <header id="topbar">
948
948
  <span class="topbar-title">{% block topbar_title %}{{ pageTitle }}{% endblock %}</span>
949
+
950
+ {# ── Global search ── #}
951
+ <form action="{{ adminPrefix }}/search" method="GET" id="global-search-form" style="flex:1;max-width:280px;margin:0 16px">
952
+ <div class="search-wrap">
953
+ <span class="search-icon-inner">
954
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
955
+ <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
956
+ </svg>
957
+ </span>
958
+ <input
959
+ type="text"
960
+ name="q"
961
+ id="global-search-input"
962
+ placeholder="Search everything… (/)"
963
+ class="form-control search-input"
964
+ value="{% if query is defined %}{{ query }}{% endif %}"
965
+ autocomplete="off"
966
+ style="width:100%;font-size:13px">
967
+ </div>
968
+ </form>
969
+
949
970
  <div class="topbar-actions">
950
971
  {% block topbar_actions %}{% endblock %}
951
972
  </div>
@@ -1074,7 +1095,81 @@
1074
1095
  form.submit();
1075
1096
  }
1076
1097
 
1077
- // ── Auto-dismiss flash after 5s ──────────────────────────────
1098
+ // ── Keyboard shortcuts ───────────────────────────────────────
1099
+ (function() {
1100
+ const prefix = '{{ adminPrefix }}';
1101
+ const resources = [
1102
+ {% for r in resources %}
1103
+ { slug: '{{ r.slug }}', index: {{ r.index }} }{% if not loop.last %},{% endif %}
1104
+ {% endfor %}
1105
+ ];
1106
+
1107
+ let gPressed = false;
1108
+ let gTimer = null;
1109
+
1110
+ document.addEventListener('keydown', function(e) {
1111
+ // Don't fire when typing in inputs
1112
+ const tag = document.activeElement?.tagName;
1113
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
1114
+ // Allow Escape to blur
1115
+ if (e.key === 'Escape') document.activeElement.blur();
1116
+ return;
1117
+ }
1118
+
1119
+ // / β†’ focus global search
1120
+ if (e.key === '/') {
1121
+ e.preventDefault();
1122
+ document.getElementById('global-search-input')?.focus();
1123
+ return;
1124
+ }
1125
+
1126
+ // N β†’ new record (only on list pages)
1127
+ if (e.key === 'n' || e.key === 'N') {
1128
+ const newBtn = document.querySelector('a[href*="/create"].btn-primary');
1129
+ if (newBtn) { e.preventDefault(); window.location.href = newBtn.href; }
1130
+ return;
1131
+ }
1132
+
1133
+ // Escape β†’ close modals (already handled), clear selection
1134
+ if (e.key === 'Escape') {
1135
+ document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
1136
+ return;
1137
+ }
1138
+
1139
+ // G β†’ start chord
1140
+ if (e.key === 'g' || e.key === 'G') {
1141
+ if (gPressed) return;
1142
+ gPressed = true;
1143
+ clearTimeout(gTimer);
1144
+ gTimer = setTimeout(() => { gPressed = false; }, 800);
1145
+ return;
1146
+ }
1147
+
1148
+ // G+D β†’ dashboard
1149
+ if (gPressed && (e.key === 'd' || e.key === 'D')) {
1150
+ gPressed = false;
1151
+ window.location.href = prefix + '/';
1152
+ return;
1153
+ }
1154
+
1155
+ // G+1…9 β†’ jump to resource
1156
+ if (gPressed && e.key >= '1' && e.key <= '9') {
1157
+ gPressed = false;
1158
+ const idx = parseInt(e.key);
1159
+ const r = resources.find(x => x.index === idx);
1160
+ if (r) window.location.href = `${prefix}/${r.slug}`;
1161
+ return;
1162
+ }
1163
+ });
1164
+ })();
1165
+
1166
+ // ── Shortcut hint tooltip ────────────────────────────────────
1167
+ document.getElementById('global-search-input')?.addEventListener('focus', function() {
1168
+ this.placeholder = 'Search everything…';
1169
+ });
1170
+ document.getElementById('global-search-input')?.addEventListener('blur', function() {
1171
+ this.placeholder = 'Search everything… (/)';
1172
+ });
1078
1173
  const flashAlert = document.getElementById('flash-alert');
1079
1174
  if (flashAlert) {
1080
1175
  setTimeout(() => {