tina4ruby 3.13.148 → 3.13.149
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/lib/tina4/auto_crud.rb +49 -0
- data/lib/tina4/cli.rb +79 -20
- data/lib/tina4/crud.rb +411 -659
- data/lib/tina4/dev_admin.rb +53 -1
- data/lib/tina4/dispatch_pipeline.rb +3 -0
- data/lib/tina4/messenger.rb +52 -2
- data/lib/tina4/migration.rb +132 -16
- data/lib/tina4/templates/crud/form.twig +26 -0
- data/lib/tina4/templates/crud/modals.twig +31 -0
- data/lib/tina4/templates/crud/page.twig +401 -0
- data/lib/tina4/templates/crud/table.twig +69 -0
- data/lib/tina4/version.rb +1 -1
- metadata +6 -2
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
{#
|
|
2
|
+
crud/page.twig — the CRUD admin page shell.
|
|
3
|
+
|
|
4
|
+
Override: drop your own templates/crud/page.twig in your app to restyle it.
|
|
5
|
+
This file owns the page chrome (title, search box, info line, table slot,
|
|
6
|
+
pagination, modals) and ONE nonce'd <script> that drives the grid over fetch()
|
|
7
|
+
against the AutoCrud REST endpoints. Tina4::Crud registers no routes of its own
|
|
8
|
+
(ADR-0094) — the backend is entirely AutoCrud.
|
|
9
|
+
|
|
10
|
+
Values handed in by the controller:
|
|
11
|
+
title, search - page title + current search term (escaped)
|
|
12
|
+
request_path - this page's path (for no-JS form/sort fallbacks)
|
|
13
|
+
info_count/total/page/total_pages - the summary line numbers
|
|
14
|
+
table_html - the server-rendered crud/table.twig (first page)
|
|
15
|
+
modals_html - the server-rendered crud/modals.twig
|
|
16
|
+
show_pagination, controls - the pager links
|
|
17
|
+
config_json - the grid's state + columns for the script
|
|
18
|
+
|
|
19
|
+
Styling is tina4-css only (Bootstrap-compatible). The one <style> block below
|
|
20
|
+
carries the CSP nonce (ADR-0088); there are NO inline style= or on*= attributes
|
|
21
|
+
anywhere — every control binds through addEventListener in the script.
|
|
22
|
+
#}
|
|
23
|
+
<!DOCTYPE html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="UTF-8">
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
28
|
+
<title>{{ title }}</title>
|
|
29
|
+
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
30
|
+
<style nonce="{{ csp_nonce() }}">
|
|
31
|
+
.crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
|
|
32
|
+
.crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
|
33
|
+
.crud-search { flex: 0 1 300px; max-width: 300px; }
|
|
34
|
+
.crud-actions { display: flex; gap: 0.5rem; align-items: center; }
|
|
35
|
+
.crud-actions .btn { white-space: nowrap; flex-shrink: 0; }
|
|
36
|
+
.crud-info { color: var(--text-muted, #6c757d); font-size: 0.875rem; margin-bottom: 0.5rem; }
|
|
37
|
+
.crud-empty { text-align: center; padding: 2rem; }
|
|
38
|
+
.crud-errors { margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 0.25rem;
|
|
39
|
+
background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
|
40
|
+
.crud-errors[hidden] { display: none; }
|
|
41
|
+
.crud-pagination { display: flex; justify-content: center; gap: 0.25rem; margin-top: 1rem; }
|
|
42
|
+
.sort-link { text-decoration: none; color: inherit; cursor: pointer; }
|
|
43
|
+
.sort-link:hover { text-decoration: underline; }
|
|
44
|
+
.sort-indicator { font-size: 0.75rem; }
|
|
45
|
+
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
|
46
|
+
background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
|
|
47
|
+
.modal-overlay.active { display: flex; }
|
|
48
|
+
.modal-box { background: var(--bg, #fff); border-radius: 0.5rem; padding: 1.5rem;
|
|
49
|
+
width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto;
|
|
50
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
|
|
51
|
+
.modal-box h3 { margin-top: 0; }
|
|
52
|
+
.modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; padding-left: 0; padding-right: 0; }
|
|
53
|
+
.alert { padding: 0.75rem 1rem; border-radius: 0.25rem; margin-bottom: 1rem; display: none; }
|
|
54
|
+
.alert.active { display: block; }
|
|
55
|
+
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
|
56
|
+
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div class="crud-container">
|
|
61
|
+
<div id="crud-alert" class="alert"></div>
|
|
62
|
+
<div class="crud-header">
|
|
63
|
+
<h2>{{ title }}</h2>
|
|
64
|
+
<div class="crud-actions">
|
|
65
|
+
{# Live, as-you-type search (no Search button). Typing drives an AJAX
|
|
66
|
+
reload of the table; the term is preserved across sort + pagination. #}
|
|
67
|
+
<input type="text" data-crud-search value="{{ search }}" placeholder="Search..." class="form-control crud-search" aria-label="Search">
|
|
68
|
+
<button class="btn btn-primary" data-crud-action="create">+ New</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="crud-info" data-crud-info>
|
|
72
|
+
Showing {{ info_count }} of {{ info_total }} records (page {{ info_page }} of {{ info_total_pages }})
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{{ table_html|raw }}
|
|
76
|
+
|
|
77
|
+
<div class="crud-pagination" data-crud-pager>{% if show_pagination %}{% for c in controls %}{% if c.active %}<a class="btn btn-sm btn-primary" href="{{ c.url }}" data-crud-page="{{ c.page }}">{{ c.label }}</a>{% endif %}{% if c.inactive %}<a class="btn btn-sm btn-secondary" href="{{ c.url }}" data-crud-page="{{ c.page }}">{{ c.label }}</a>{% endif %}{% endfor %}{% endif %}</div>
|
|
78
|
+
|
|
79
|
+
{{ modals_html|raw }}
|
|
80
|
+
|
|
81
|
+
<script nonce="{{ csp_nonce() }}">
|
|
82
|
+
// The grid is AJAX-driven. CONFIG carries the current state and the column
|
|
83
|
+
// metadata; every change (search / sort / page) re-fetches the AutoCrud list
|
|
84
|
+
// endpoint and re-renders the table body + pager. CSP-clean: this element is
|
|
85
|
+
// nonce-authorised and every control binds through addEventListener — there
|
|
86
|
+
// is no inline on*= anywhere.
|
|
87
|
+
var CRUD = {{ config_json|raw }};
|
|
88
|
+
var CRUD_COLUMNS = CRUD.editable; // columns the create/edit modals fill
|
|
89
|
+
var state = {
|
|
90
|
+
search: CRUD.search || '',
|
|
91
|
+
sort: CRUD.sort || CRUD.pk,
|
|
92
|
+
sort_dir: CRUD.sort_dir || 'asc',
|
|
93
|
+
page: CRUD.page || 1
|
|
94
|
+
};
|
|
95
|
+
var controller = null; // in-flight fetch, so we can abort it
|
|
96
|
+
var searchTimer = null;
|
|
97
|
+
|
|
98
|
+
var elAlert = document.getElementById('crud-alert');
|
|
99
|
+
var elBody = document.querySelector('[data-crud-body]');
|
|
100
|
+
var elPager = document.querySelector('[data-crud-pager]');
|
|
101
|
+
var elInfo = document.querySelector('[data-crud-info]');
|
|
102
|
+
var elSearch = document.querySelector('[data-crud-search]');
|
|
103
|
+
|
|
104
|
+
function crudShowAlert(message, type) {
|
|
105
|
+
elAlert.className = 'alert alert-' + type + ' active';
|
|
106
|
+
elAlert.textContent = message;
|
|
107
|
+
setTimeout(function () { elAlert.className = 'alert'; }, 3000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Render the table body from a list-endpoint JSON page -----------------
|
|
111
|
+
// Values go in via textContent, so every cell is escaped by the DOM (no XSS).
|
|
112
|
+
function renderRows(records) {
|
|
113
|
+
elBody.textContent = '';
|
|
114
|
+
if (!records || records.length === 0) {
|
|
115
|
+
var tr = document.createElement('tr');
|
|
116
|
+
var td = document.createElement('td');
|
|
117
|
+
td.colSpan = CRUD.columns.length + 1;
|
|
118
|
+
td.className = 'crud-empty';
|
|
119
|
+
td.textContent = 'No records found.';
|
|
120
|
+
tr.appendChild(td);
|
|
121
|
+
elBody.appendChild(tr);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
records.forEach(function (record) {
|
|
125
|
+
var tr = document.createElement('tr');
|
|
126
|
+
tr.setAttribute('data-id', record[CRUD.pk]);
|
|
127
|
+
CRUD.columns.forEach(function (col) {
|
|
128
|
+
var td = document.createElement('td');
|
|
129
|
+
td.className = CRUD.aligns[col] || 'text-start';
|
|
130
|
+
var value = record[col];
|
|
131
|
+
td.textContent = (value === null || value === undefined) ? '' : value;
|
|
132
|
+
tr.appendChild(td);
|
|
133
|
+
});
|
|
134
|
+
var actions = document.createElement('td');
|
|
135
|
+
actions.className = 'text-end';
|
|
136
|
+
actions.appendChild(actionButton('edit', 'Edit', 'btn-primary me-1', record[CRUD.pk]));
|
|
137
|
+
actions.appendChild(actionButton('delete', 'Delete', 'btn-danger', record[CRUD.pk]));
|
|
138
|
+
tr.appendChild(actions);
|
|
139
|
+
elBody.appendChild(tr);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function actionButton(action, label, variant, id) {
|
|
144
|
+
var b = document.createElement('button');
|
|
145
|
+
b.className = 'btn btn-sm ' + variant;
|
|
146
|
+
b.setAttribute('data-crud-action', action);
|
|
147
|
+
b.setAttribute('data-id', id);
|
|
148
|
+
b.textContent = label;
|
|
149
|
+
return b;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderPager(data) {
|
|
153
|
+
elPager.textContent = '';
|
|
154
|
+
var totalPages = data.total_pages || 1;
|
|
155
|
+
if (totalPages <= 1) return;
|
|
156
|
+
if (data.page > 1) elPager.appendChild(pagerLink('Prev', data.page - 1, false));
|
|
157
|
+
var start = Math.max(data.page - 3, 1);
|
|
158
|
+
var end = Math.min(start + 6, totalPages);
|
|
159
|
+
start = Math.max(end - 6, 1);
|
|
160
|
+
for (var p = start; p <= end; p++) {
|
|
161
|
+
elPager.appendChild(pagerLink(String(p), p, p === data.page));
|
|
162
|
+
}
|
|
163
|
+
if (data.page < totalPages) elPager.appendChild(pagerLink('Next', data.page + 1, false));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pagerLink(label, page, active) {
|
|
167
|
+
var a = document.createElement('a');
|
|
168
|
+
a.className = 'btn btn-sm ' + (active ? 'btn-primary' : 'btn-secondary');
|
|
169
|
+
a.setAttribute('href', '#');
|
|
170
|
+
a.setAttribute('data-crud-page', page);
|
|
171
|
+
a.textContent = label;
|
|
172
|
+
return a;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function updateInfo(data) {
|
|
176
|
+
if (!elInfo) return;
|
|
177
|
+
elInfo.textContent = 'Showing ' + (data.records ? data.records.length : 0) +
|
|
178
|
+
' of ' + data.total + ' records (page ' + data.page + ' of ' + data.total_pages + ')';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Reflect the active sort column + direction on the header arrows.
|
|
182
|
+
function updateSortIndicators() {
|
|
183
|
+
document.querySelectorAll('[data-crud-sort]').forEach(function (link) {
|
|
184
|
+
var old = link.querySelector('.sort-indicator');
|
|
185
|
+
if (old) old.remove();
|
|
186
|
+
var col = link.getAttribute('data-crud-sort');
|
|
187
|
+
if (col === state.sort) {
|
|
188
|
+
var span = document.createElement('span');
|
|
189
|
+
span.className = 'sort-indicator';
|
|
190
|
+
span.textContent = state.sort_dir === 'asc' ? ' ▲' : ' ▼';
|
|
191
|
+
link.appendChild(span);
|
|
192
|
+
link.setAttribute('data-crud-dir', state.sort_dir === 'asc' ? 'desc' : 'asc');
|
|
193
|
+
} else {
|
|
194
|
+
link.setAttribute('data-crud-dir', 'asc');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Abort any in-flight request, then fetch the current state from AutoCrud.
|
|
200
|
+
function load() {
|
|
201
|
+
if (controller) controller.abort();
|
|
202
|
+
controller = new AbortController();
|
|
203
|
+
var params = new URLSearchParams();
|
|
204
|
+
params.set('search', state.search);
|
|
205
|
+
params.set('sort', state.sort);
|
|
206
|
+
params.set('sort_dir', state.sort_dir);
|
|
207
|
+
params.set('page', state.page);
|
|
208
|
+
params.set('per_page', CRUD.limit);
|
|
209
|
+
fetch(CRUD.api + '?' + params.toString(), { signal: controller.signal })
|
|
210
|
+
.then(function (r) { return r.json(); })
|
|
211
|
+
.then(function (data) {
|
|
212
|
+
renderRows(data.records || []);
|
|
213
|
+
renderPager(data);
|
|
214
|
+
updateInfo(data);
|
|
215
|
+
updateSortIndicators();
|
|
216
|
+
})
|
|
217
|
+
.catch(function (e) {
|
|
218
|
+
if (e.name !== 'AbortError') crudShowAlert('Failed to load: ' + e, 'danger');
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Wiring (all delegated, all CSP-clean) --------------------------------
|
|
223
|
+
if (elSearch) {
|
|
224
|
+
elSearch.addEventListener('input', function () {
|
|
225
|
+
clearTimeout(searchTimer);
|
|
226
|
+
var value = elSearch.value;
|
|
227
|
+
searchTimer = setTimeout(function () {
|
|
228
|
+
state.search = value;
|
|
229
|
+
state.page = 1;
|
|
230
|
+
load();
|
|
231
|
+
}, 300);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
document.addEventListener('click', function (event) {
|
|
236
|
+
var sortLink = event.target.closest('[data-crud-sort]');
|
|
237
|
+
if (sortLink) {
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
var col = sortLink.getAttribute('data-crud-sort');
|
|
240
|
+
if (state.sort === col) {
|
|
241
|
+
state.sort_dir = state.sort_dir === 'asc' ? 'desc' : 'asc';
|
|
242
|
+
} else {
|
|
243
|
+
state.sort = col;
|
|
244
|
+
state.sort_dir = 'asc';
|
|
245
|
+
}
|
|
246
|
+
state.page = 1;
|
|
247
|
+
load();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
var pageLink = event.target.closest('[data-crud-page]');
|
|
252
|
+
if (pageLink) {
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
state.page = parseInt(pageLink.getAttribute('data-crud-page'), 10) || 1;
|
|
255
|
+
load();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
var button = event.target.closest('[data-crud-action]');
|
|
260
|
+
if (!button) return;
|
|
261
|
+
var action = button.dataset.crudAction;
|
|
262
|
+
if (action === 'create') {
|
|
263
|
+
crudShowCreate();
|
|
264
|
+
} else if (action === 'edit') {
|
|
265
|
+
crudShowEdit(button.dataset.id);
|
|
266
|
+
} else if (action === 'delete') {
|
|
267
|
+
crudShowDelete(button.dataset.id);
|
|
268
|
+
} else if (action === 'close') {
|
|
269
|
+
crudCloseModal(button.dataset.crudModal);
|
|
270
|
+
} else if (action === 'confirm-delete') {
|
|
271
|
+
crudConfirmDelete();
|
|
272
|
+
} else if (action === 'save') {
|
|
273
|
+
saveRecord(button.dataset.crudMode === 'edit' ? 'edit' : 'create');
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// The modal forms post over fetch(), so stop their native submit here rather
|
|
278
|
+
// than with an onsubmit= attribute.
|
|
279
|
+
document.addEventListener('submit', function (event) {
|
|
280
|
+
if (event.target.closest('form[data-crud-form]')) {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- Create / edit / delete modals ----------------------------------------
|
|
286
|
+
function crudShowCreate() {
|
|
287
|
+
var form = document.getElementById('form-create');
|
|
288
|
+
if (form) form.reset();
|
|
289
|
+
clearErrors('create');
|
|
290
|
+
document.getElementById('modal-create').classList.add('active');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function crudShowEdit(id) {
|
|
294
|
+
clearErrors('edit');
|
|
295
|
+
fetch(CRUD.api + '/' + id)
|
|
296
|
+
.then(function (r) { return r.json(); })
|
|
297
|
+
.then(function (result) {
|
|
298
|
+
var data = result.data || result;
|
|
299
|
+
document.getElementById('edit-pk-value').value = id;
|
|
300
|
+
CRUD_COLUMNS.forEach(function (col) {
|
|
301
|
+
var input = document.getElementById('edit-' + col);
|
|
302
|
+
if (input) input.value = data[col] != null ? data[col] : '';
|
|
303
|
+
});
|
|
304
|
+
document.getElementById('modal-edit').classList.add('active');
|
|
305
|
+
})
|
|
306
|
+
.catch(function (e) { crudShowAlert('Failed to load record: ' + e, 'danger'); });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function crudShowDelete(id) {
|
|
310
|
+
document.getElementById('delete-pk-value').value = id;
|
|
311
|
+
document.getElementById('modal-delete').classList.add('active');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function crudCloseModal(name) {
|
|
315
|
+
document.getElementById('modal-' + name).classList.remove('active');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function collectForm(prefix) {
|
|
319
|
+
var data = {};
|
|
320
|
+
CRUD_COLUMNS.forEach(function (col) {
|
|
321
|
+
var input = document.getElementById(prefix + '-' + col);
|
|
322
|
+
if (input) data[col] = input.value;
|
|
323
|
+
});
|
|
324
|
+
return data;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Show / clear the in-modal validation error list (data-crud-errors).
|
|
328
|
+
function showErrors(mode, list) {
|
|
329
|
+
var box = document.querySelector('#modal-' + mode + ' [data-crud-errors]');
|
|
330
|
+
if (!box) return;
|
|
331
|
+
box.textContent = '';
|
|
332
|
+
(list || []).forEach(function (msg) {
|
|
333
|
+
var line = document.createElement('div');
|
|
334
|
+
line.textContent = msg;
|
|
335
|
+
box.appendChild(line);
|
|
336
|
+
});
|
|
337
|
+
box.hidden = !(list && list.length);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function clearErrors(mode) {
|
|
341
|
+
var box = document.querySelector('#modal-' + mode + ' [data-crud-errors]');
|
|
342
|
+
if (box) { box.textContent = ''; box.hidden = true; }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Create (POST) or edit (PUT) through AutoCrud. On a failed save AutoCrud
|
|
346
|
+
// answers non-2xx with {errors:[...]} (or {error:...}); keep the modal OPEN
|
|
347
|
+
// and show the messages inline instead of reporting a false success.
|
|
348
|
+
function saveRecord(mode) {
|
|
349
|
+
clearErrors(mode);
|
|
350
|
+
var id = (mode === 'edit') ? document.getElementById('edit-pk-value').value : null;
|
|
351
|
+
var url = (mode === 'edit') ? CRUD.api + '/' + encodeURIComponent(id) : CRUD.api;
|
|
352
|
+
var method = (mode === 'edit') ? 'PUT' : 'POST';
|
|
353
|
+
fetch(url, {
|
|
354
|
+
method: method,
|
|
355
|
+
headers: { 'Content-Type': 'application/json' },
|
|
356
|
+
body: JSON.stringify(collectForm(mode))
|
|
357
|
+
})
|
|
358
|
+
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); })
|
|
359
|
+
.then(function (res) {
|
|
360
|
+
var errors = res.body && (res.body.errors || (res.body.error ? [res.body.error] : null));
|
|
361
|
+
if (!res.ok || errors) {
|
|
362
|
+
showErrors(mode, errors || ['Save failed. Please check the fields and try again.']);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
crudCloseModal(mode);
|
|
366
|
+
crudShowAlert('Record ' + (mode === 'edit' ? 'updated' : 'created') + ' successfully', 'success');
|
|
367
|
+
load();
|
|
368
|
+
})
|
|
369
|
+
.catch(function (e) { showErrors(mode, ['Request failed: ' + e]); });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function crudConfirmDelete() {
|
|
373
|
+
var id = document.getElementById('delete-pk-value').value;
|
|
374
|
+
fetch(CRUD.api + '/' + id, { method: 'DELETE' })
|
|
375
|
+
.then(function (r) { return r.json(); })
|
|
376
|
+
.then(function (result) {
|
|
377
|
+
if (result.error) { crudShowAlert(result.error, 'danger'); return; }
|
|
378
|
+
crudCloseModal('delete');
|
|
379
|
+
crudShowAlert('Record deleted successfully', 'success');
|
|
380
|
+
load();
|
|
381
|
+
})
|
|
382
|
+
.catch(function (e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Close a modal on backdrop click or the Escape key.
|
|
386
|
+
document.querySelectorAll('.modal-overlay').forEach(function (overlay) {
|
|
387
|
+
overlay.addEventListener('click', function (e) {
|
|
388
|
+
if (e.target === overlay) overlay.classList.remove('active');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
document.addEventListener('keydown', function (e) {
|
|
392
|
+
if (e.key === 'Escape') {
|
|
393
|
+
document.querySelectorAll('.modal-overlay.active').forEach(function (m) {
|
|
394
|
+
m.classList.remove('active');
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
</script>
|
|
399
|
+
</div>
|
|
400
|
+
</body>
|
|
401
|
+
</html>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{#
|
|
2
|
+
crud/table.twig — the CRUD data table (headers + rows + row actions).
|
|
3
|
+
|
|
4
|
+
Override: drop your own templates/crud/table.twig in your app to restyle it.
|
|
5
|
+
It is rendered on its own by Tina4::Crud, so your copy wins over this one.
|
|
6
|
+
|
|
7
|
+
What the controller hands you:
|
|
8
|
+
headers - one per column: { label, align, sortable, col, next_dir, url,
|
|
9
|
+
indicator } (sortable) or { label, align, plain } (not sortable)
|
|
10
|
+
rows - one per record: { id, cells } (cells is the pre-built <td> run)
|
|
11
|
+
empty - true when there are no rows
|
|
12
|
+
colspan - column count + 1 (for the empty row)
|
|
13
|
+
editable - inline-edit table (Save/Delete); readonly - view table (Edit/Delete)
|
|
14
|
+
table_name, table_id_attr, inline_script
|
|
15
|
+
|
|
16
|
+
Alignment: each header/cell carries a tina4-css class (text-start for text,
|
|
17
|
+
text-end for numbers). Actions is always text-end. No inline styles (ADR-0088).
|
|
18
|
+
|
|
19
|
+
The initial page is server-rendered here; after it loads, the page's script
|
|
20
|
+
re-renders <tbody data-crud-body> over fetch() as the user searches/sorts/pages.
|
|
21
|
+
#}
|
|
22
|
+
<div class="table-responsive">
|
|
23
|
+
<table class="table table-striped table-hover"{{ table_id_attr|raw }}>
|
|
24
|
+
<thead class="table-dark"><tr>
|
|
25
|
+
{% for header in headers %}{% if header.sortable %}<th class="{{ header.align }}"><a class="sort-link" href="{{ header.url }}" data-crud-sort="{{ header.col }}" data-crud-dir="{{ header.next_dir }}">{{ header.label }}{{ header.indicator|raw }}</a></th>{% endif %}{% if header.plain %}<th class="{{ header.align }}">{{ header.label }}</th>{% endif %}{% endfor %}<th class="text-end">Actions</th>
|
|
26
|
+
</tr></thead>
|
|
27
|
+
<tbody data-crud-body>
|
|
28
|
+
{% if empty %}<tr><td colspan="{{ colspan }}" class="crud-empty">No records found.</td></tr>{% endif %}
|
|
29
|
+
{% for row in rows %}<tr data-id="{{ row.id }}">{{ row.cells|raw }}<td class="text-end">{% if editable %}<button class="btn btn-sm btn-primary me-1" data-crud-inline="save" data-table="{{ table_name }}" data-id="{{ row.id }}">Save</button><button class="btn btn-sm btn-danger" data-crud-inline="delete" data-table="{{ table_name }}" data-id="{{ row.id }}">Delete</button>{% endif %}{% if readonly %}<button class="btn btn-sm btn-primary me-1" data-crud-action="edit" data-id="{{ row.id }}">Edit</button><button class="btn btn-sm btn-danger" data-crud-action="delete" data-id="{{ row.id }}">Delete</button>{% endif %}</td></tr>
|
|
30
|
+
{% endfor %}
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
</div>
|
|
34
|
+
{% if inline_script %}
|
|
35
|
+
{# Inline-edit fragment (Tina4::Crud.generate_table). CSP-clean: a nonce'd
|
|
36
|
+
<script> plus data-crud-inline buttons bound through one delegated listener,
|
|
37
|
+
never an inline on*= attribute. #}
|
|
38
|
+
<script nonce="{{ csp_nonce() }}">
|
|
39
|
+
function crudSave(table, id) {
|
|
40
|
+
var row = document.querySelector('tr[data-id="' + id + '"]');
|
|
41
|
+
var cells = row.querySelectorAll('td[data-field]');
|
|
42
|
+
var data = {};
|
|
43
|
+
cells.forEach(function (cell) { data[cell.dataset.field] = cell.textContent; });
|
|
44
|
+
fetch('/api/' + table + '/' + id, {
|
|
45
|
+
method: 'PUT',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(data)
|
|
48
|
+
}).then(function (r) { return r.json(); }).then(function () { }).catch(function () { });
|
|
49
|
+
}
|
|
50
|
+
function crudDelete(table, id) {
|
|
51
|
+
fetch('/api/' + table + '/' + id, { method: 'DELETE' })
|
|
52
|
+
.then(function (r) { return r.json(); })
|
|
53
|
+
.then(function () {
|
|
54
|
+
var row = document.querySelector('tr[data-id="' + id + '"]');
|
|
55
|
+
if (row) row.remove();
|
|
56
|
+
})
|
|
57
|
+
.catch(function () { });
|
|
58
|
+
}
|
|
59
|
+
document.addEventListener('click', function (event) {
|
|
60
|
+
var button = event.target.closest('[data-crud-inline]');
|
|
61
|
+
if (!button) return;
|
|
62
|
+
if (button.dataset.crudInline === 'save') {
|
|
63
|
+
crudSave(button.dataset.table, button.dataset.id);
|
|
64
|
+
} else if (button.dataset.crudInline === 'delete') {
|
|
65
|
+
crudDelete(button.dataset.table, button.dataset.id);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
</script>
|
|
69
|
+
{% endif %}
|
data/lib/tina4/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.149
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: sqlite3
|
|
@@ -354,6 +354,10 @@ files:
|
|
|
354
354
|
- lib/tina4/swagger.rb
|
|
355
355
|
- lib/tina4/template.rb
|
|
356
356
|
- lib/tina4/templates/base.twig
|
|
357
|
+
- lib/tina4/templates/crud/form.twig
|
|
358
|
+
- lib/tina4/templates/crud/modals.twig
|
|
359
|
+
- lib/tina4/templates/crud/page.twig
|
|
360
|
+
- lib/tina4/templates/crud/table.twig
|
|
357
361
|
- lib/tina4/templates/errors/302.twig
|
|
358
362
|
- lib/tina4/templates/errors/401.twig
|
|
359
363
|
- lib/tina4/templates/errors/403.twig
|