tina4-nodejs 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.
- package/CLAUDE.md +4 -3
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +10875 -9847
- package/packages/cli/src/commands/generate.ts +184 -25
- package/packages/core/dist/index.js +10848 -9820
- package/packages/core/src/devAdmin.ts +72 -1
- package/packages/core/src/index.ts +3 -0
- package/packages/core/src/messenger.ts +57 -1
- package/packages/core/src/router.ts +11 -1
- package/packages/core/src/server.ts +5 -2
- package/packages/core/templates/crud/form.twig +26 -0
- package/packages/core/templates/crud/modals.twig +31 -0
- package/packages/core/templates/crud/page.twig +401 -0
- package/packages/core/templates/crud/table.twig +69 -0
- package/packages/frond/dist/index.js +9 -1
- package/packages/frond/src/engine.ts +9 -0
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1255 -217
- package/packages/orm/src/adapters/mongodb.ts +209 -34
- package/packages/orm/src/autoCrud.ts +9 -0
- package/packages/orm/src/crud.ts +273 -0
- package/packages/orm/src/crudHelpers.ts +194 -0
- package/packages/orm/src/crudRender.ts +202 -0
- package/packages/orm/src/crudTable.ts +171 -0
- package/packages/orm/src/index.ts +7 -1
- package/packages/orm/src/migration.ts +167 -4
- package/packages/orm/src/query.ts +99 -40
- package/packages/orm/src/sqlTranslator.ts +8 -1
- package/packages/orm/src/types.ts +19 -0
- package/types/core/src/devAdmin.d.ts +23 -0
- package/types/core/src/messenger.d.ts +4 -0
- package/types/core/src/router.d.ts +0 -5
- package/types/frond/src/engine.d.ts +6 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/crud.d.ts +82 -0
- package/types/orm/src/crudHelpers.d.ts +63 -0
- package/types/orm/src/crudRender.d.ts +38 -0
- package/types/orm/src/crudTable.d.ts +45 -0
- package/types/orm/src/index.d.ts +3 -1
- package/types/orm/src/migration.d.ts +0 -18
- package/types/orm/src/query.d.ts +0 -12
- package/types/orm/src/types.d.ts +19 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
{#
|
|
2
|
+
crud/page.twig — the CRUD admin page shell.
|
|
3
|
+
|
|
4
|
+
Override: drop your own src/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. Crud.toCrud 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 src/templates/crud/table.twig in your app to restyle it.
|
|
5
|
+
It is rendered on its own by Crud.toCrud, 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 (Crud.generateTable). CSP-clean: a nonce'd <script> plus
|
|
36
|
+
data-crud-inline buttons bound through one delegated listener, never an inline
|
|
37
|
+
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 %}
|
|
@@ -1068,6 +1068,9 @@ function splitArgs(raw) {
|
|
|
1068
1068
|
function htmlEscape(str) {
|
|
1069
1069
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1070
1070
|
}
|
|
1071
|
+
function escapeHtml(value) {
|
|
1072
|
+
return htmlEscape(String(value));
|
|
1073
|
+
}
|
|
1071
1074
|
var JS_SAFE_RE = /[A-Za-z0-9,._]/;
|
|
1072
1075
|
function jsEscape(value) {
|
|
1073
1076
|
let out = "";
|
|
@@ -1414,6 +1417,9 @@ function _b64url(data) {
|
|
|
1414
1417
|
}
|
|
1415
1418
|
var _formTokenSessionId = "";
|
|
1416
1419
|
var _cspNonceProvider = () => "";
|
|
1420
|
+
function setCspNonceProvider(fn) {
|
|
1421
|
+
_cspNonceProvider = fn;
|
|
1422
|
+
}
|
|
1417
1423
|
function _buildFormTokenJwt(descriptor = "") {
|
|
1418
1424
|
const secret = process.env.TINA4_SECRET ?? "";
|
|
1419
1425
|
const secretBytes = Buffer.byteLength(secret, "utf8");
|
|
@@ -2831,5 +2837,7 @@ var Frond = class _Frond {
|
|
|
2831
2837
|
}
|
|
2832
2838
|
};
|
|
2833
2839
|
export {
|
|
2834
|
-
Frond
|
|
2840
|
+
Frond,
|
|
2841
|
+
escapeHtml,
|
|
2842
|
+
setCspNonceProvider
|
|
2835
2843
|
};
|
|
@@ -1379,6 +1379,15 @@ function htmlEscape(str: string): string {
|
|
|
1379
1379
|
.replace(/'/g, "'");
|
|
1380
1380
|
}
|
|
1381
1381
|
|
|
1382
|
+
/**
|
|
1383
|
+
* The framework's ONE canonical HTML escaper (the five-character table `& < > " '`,
|
|
1384
|
+
* byte-identical across all four Tina4 frameworks). Exported so a component such as
|
|
1385
|
+
* Crud.toCrud never carries its own copy of the escape table (ADR-0094).
|
|
1386
|
+
*/
|
|
1387
|
+
export function escapeHtml(value: unknown): string {
|
|
1388
|
+
return htmlEscape(String(value));
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1382
1391
|
// -- Escape strategies (ADR-0077) -------------------------------------------
|
|
1383
1392
|
// Shared by js_escape and e(strategy); byte-identical to the Python master.
|
|
1384
1393
|
// Twig-compatible. Iterates code points so astral characters emit a surrogate
|
|
@@ -6,5 +6,5 @@ License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
6
6
|
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export { Frond } from "./engine.js";
|
|
9
|
+
export { Frond, escapeHtml, setCspNonceProvider } from "./engine.js";
|
|
10
10
|
export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";
|