pgkiln 0.70.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +6 -5
- package/docs/guide/03-builder.md +107 -4
- package/docs/guide/04-pages-and-regions.md +3 -1
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +15 -1
- package/docs/guide/12-development.md +22 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +45 -0
- package/src/app.ts +10 -0
- package/src/builder/advisor.ts +2 -2
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +3 -1
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +2 -0
- package/src/builder/i18n/de.ts +4 -0
- package/src/builder/i18n/en.ts +4 -0
- package/src/builder/i18n/es.ts +4 -0
- package/src/builder/i18n/fr.ts +4 -0
- package/src/builder/i18n/it.ts +4 -0
- package/src/builder/i18n/ja.ts +4 -0
- package/src/builder/i18n/ko.ts +4 -0
- package/src/builder/i18n/nl.ts +4 -0
- package/src/builder/i18n/pt.ts +4 -0
- package/src/builder/i18n/zh-cn.ts +4 -0
- package/src/builder/i18n/zh-tw.ts +4 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/routes.ts +13 -2
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +9 -1
- package/src/history.ts +155 -0
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
package/src/builder/wizards.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
|
|
|
3
3
|
import { html, raw, type Raw } from '../html.ts';
|
|
4
4
|
import { ICONS } from '../icons.ts';
|
|
5
5
|
import { appOr404 } from './forms.ts';
|
|
6
|
+
import { addDashboard } from './appsheets.ts';
|
|
6
7
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
7
8
|
|
|
8
9
|
// Create page wizards (APEX: Create Page). Step 1, on the app's Pages tab,
|
|
@@ -23,7 +24,32 @@ export const WIZARD_KINDS: [string, string, string][] = [
|
|
|
23
24
|
['facets', 'Faceted search', 'A report with a filter panel: values with counts, ranges and a search field.'],
|
|
24
25
|
['master_detail', 'Master detail', 'A grid of the table and, below it, an editable grid of the selected row\'s details.'],
|
|
25
26
|
];
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* (sprint 137, AP-3) The page types that reuse other regions (APEX: Create a Page → Component), made by the same
|
|
29
|
+
* meta.generate_page (migration 137), and the dashboard (addDashboard). WIZARD_KINDS above stay the types the
|
|
30
|
+
* AI path and blueprints propose.
|
|
31
|
+
*/
|
|
32
|
+
export const MORE_KINDS: [string, string, string][] = [
|
|
33
|
+
['report', 'Interactive report', 'An interactive report of the table on its own page, optionally with a modal form page.'],
|
|
34
|
+
['smart_filters', 'Smart filters', 'An interactive report with one search field above it that suggests filters (chips) from the columns.'],
|
|
35
|
+
['search', 'Search page', 'A search region over the application\'s search configurations, or a new one on a table.'],
|
|
36
|
+
['data_load', 'Data loading', 'A data load definition for the table and a page where users upload a CSV, Excel, JSON or XML file into it.'],
|
|
37
|
+
['tree', 'Tree', 'The rows as a tree: an id, a parent (from a foreign key to the table itself) and a label.'],
|
|
38
|
+
['timeline', 'Timeline', 'Rows as events on a vertical line: when, who, a title and a text (template component Timeline).'],
|
|
39
|
+
['media_list', 'Media list', 'Rows with initials, a title, a description and a badge (template component Media list).'],
|
|
40
|
+
['content_row', 'Content row', 'Rows with initials, a title and a description, and a secondary text and a badge on the right (template component Content row).'],
|
|
41
|
+
['comments', 'Comments', 'Rows as a conversation: who wrote what and when (template component Comments).'],
|
|
42
|
+
['dashboard', 'Dashboard', 'Charts of the application\'s tables, chosen from the data like a new application\'s dashboard.'],
|
|
43
|
+
['wizard', 'Wizard', 'A form over several steps, one page per step with a progress list, Previous and Next; Finish adds the row.'],
|
|
44
|
+
['tasks', 'Unified task list', 'The approval and action tasks of the signed-in user (a task list region).'],
|
|
45
|
+
['workflows', 'Workflow console', 'The workflows the user started or administers, with their state and history.'],
|
|
46
|
+
];
|
|
47
|
+
/** All the page types step 2 knows: [key, label, description]. */
|
|
48
|
+
export const PAGE_KINDS: [string, string, string][] = [...WIZARD_KINDS, ...MORE_KINDS];
|
|
49
|
+
const KINDS = new Set(PAGE_KINDS.map((k) => k[0]));
|
|
50
|
+
/** Page types that need no table (search takes one optionally, for a new search configuration). */
|
|
51
|
+
export const NO_TABLE = new Set(['search', 'tasks', 'workflows', 'dashboard']);
|
|
52
|
+
const TASK_CONTEXTS: [string, string][] = [['my', 'My tasks (to act on or claim)'], ['initiated', 'Tasks I requested'], ['admin', 'Tasks I administer']];
|
|
27
53
|
const CHART_KINDS = ['bar', 'column', 'line', 'area', 'donut', 'pie', 'funnel'];
|
|
28
54
|
const FUNCTIONS: [string, string][] = [['count', 'Count'], ['sum', 'Sum'], ['avg', 'Average'], ['min', 'Minimum'], ['max', 'Maximum']];
|
|
29
55
|
|
|
@@ -110,6 +136,55 @@ export function wizardOptions(kind: string, b: Record<string, unknown>) {
|
|
|
110
136
|
o.search = b.search === 'true';
|
|
111
137
|
o.form_page = formPage();
|
|
112
138
|
break;
|
|
139
|
+
// (137) the page types of migration 137
|
|
140
|
+
case 'report':
|
|
141
|
+
o.form_page = formPage();
|
|
142
|
+
break;
|
|
143
|
+
case 'smart_filters':
|
|
144
|
+
o.facets = list(b.facets);
|
|
145
|
+
o.form_page = formPage();
|
|
146
|
+
break;
|
|
147
|
+
case 'tree':
|
|
148
|
+
for (const k of ['id_column', 'parent_column', 'label_column']) o[k] = str(k) || null;
|
|
149
|
+
o.expanded = /^\d{1,2}$/.test(str('expanded')) ? Number(str('expanded')) : 1;
|
|
150
|
+
o.form_page = formPage();
|
|
151
|
+
break;
|
|
152
|
+
case 'timeline':
|
|
153
|
+
for (const k of ['title', 'when', 'who', 'body']) o[k] = str(k) || null;
|
|
154
|
+
o.form_page = formPage();
|
|
155
|
+
break;
|
|
156
|
+
case 'media_list':
|
|
157
|
+
case 'content_row':
|
|
158
|
+
for (const k of ['title', 'description', 'badge', ...(kind === 'content_row' ? ['misc'] : [])]) o[k] = str(k) || null;
|
|
159
|
+
o.form_page = formPage();
|
|
160
|
+
break;
|
|
161
|
+
case 'comments':
|
|
162
|
+
for (const k of ['user', 'date', 'comment']) o[k] = str(k) || null;
|
|
163
|
+
break;
|
|
164
|
+
case 'data_load':
|
|
165
|
+
o.definition = str('definition').toUpperCase() || null;
|
|
166
|
+
o.mode = ['append', 'merge', 'replace'].includes(str('mode')) ? str('mode') : 'append';
|
|
167
|
+
o.skip_errors = b.skip_errors === 'true';
|
|
168
|
+
break;
|
|
169
|
+
case 'wizard':
|
|
170
|
+
o.columns = list(b.columns);
|
|
171
|
+
o.steps = /^\d{1,2}$/.test(str('steps')) ? Number(str('steps')) : 2;
|
|
172
|
+
o.return_page = pageNo(b.return_page);
|
|
173
|
+
break;
|
|
174
|
+
case 'search':
|
|
175
|
+
o.configs = list(b.configs).map((c) => c.toUpperCase());
|
|
176
|
+
o.form_page = formPage();
|
|
177
|
+
break;
|
|
178
|
+
case 'tasks':
|
|
179
|
+
o.context = TASK_CONTEXTS.some(([c]) => c === str('context')) ? str('context') : 'my';
|
|
180
|
+
o.completed = b.completed === 'true';
|
|
181
|
+
break;
|
|
182
|
+
case 'workflows':
|
|
183
|
+
o.context = str('context') === 'admin' ? 'admin' : 'initiator';
|
|
184
|
+
break;
|
|
185
|
+
case 'dashboard':
|
|
186
|
+
o.tables = list(b.tables);
|
|
187
|
+
break;
|
|
113
188
|
case 'master_detail': {
|
|
114
189
|
const d = str('detail');
|
|
115
190
|
const at = d.lastIndexOf('|');
|
|
@@ -130,30 +205,31 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
130
205
|
if (!a) return reply.code(404).send('Not found');
|
|
131
206
|
const kind = String(req.query.kind ?? '');
|
|
132
207
|
const table = String(req.query.table ?? '');
|
|
133
|
-
const rel = KINDS.has(kind) && table
|
|
208
|
+
const rel = KINDS.has(kind) && table && kind !== 'dashboard'
|
|
134
209
|
? await owner.one<{ t: string; relkind: string }>(
|
|
135
210
|
`select meta.wizard_qname(c.oid) as t, c.relkind::text from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
136
211
|
where c.oid = to_regclass($1) and c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')`,
|
|
137
212
|
[table],
|
|
138
213
|
)
|
|
139
214
|
: null;
|
|
140
|
-
if (!rel) {
|
|
215
|
+
if (!rel && !(NO_TABLE.has(kind) && !table)) {
|
|
141
216
|
flash(s, KINDS.has(kind) ? 'Choose a table or view.' : 'Choose a page type.', 'error');
|
|
142
|
-
|
|
217
|
+
// (137) back to the type's table step on the Create page grid
|
|
218
|
+
return back(reply, s, KINDS.has(kind) ? `${BASE}/apps/${a.id}/create-page?type=${encodeURIComponent(kind)}` : `${BASE}/apps/${a.id}/create-page`);
|
|
143
219
|
}
|
|
144
220
|
const [cols, defaults] = await Promise.all([
|
|
145
|
-
owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows),
|
|
146
|
-
owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel
|
|
221
|
+
rel ? owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows) : Promise.resolve([] as Col[]),
|
|
222
|
+
owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel?.t ?? null]).then((r) => r?.d ?? {}),
|
|
147
223
|
]);
|
|
148
224
|
const nextPage = await freePage(a.id, Math.max(0, ...(await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((p) => p.page_no)) + 1);
|
|
149
225
|
const pk = cols.find((c) => c.is_pk)?.column_name ?? null;
|
|
150
226
|
const role = a.db_role as string | null;
|
|
151
|
-
const access = role
|
|
227
|
+
const access = role && rel
|
|
152
228
|
? await owner.one<{ s: boolean; i: boolean; u: boolean; d: boolean }>(
|
|
153
229
|
`select has_table_privilege($1, $2::regclass, 'select') as s, has_table_privilege($1, $2::regclass, 'insert') as i,
|
|
154
230
|
has_table_privilege($1, $2::regclass, 'update') as u, has_table_privilege($1, $2::regclass, 'delete') as d
|
|
155
231
|
where exists (select 1 from pg_roles where rolname = $1)`,
|
|
156
|
-
[role, rel
|
|
232
|
+
[role, rel!.t],
|
|
157
233
|
)
|
|
158
234
|
: null;
|
|
159
235
|
|
|
@@ -171,6 +247,8 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
171
247
|
)}</div></fieldset>`;
|
|
172
248
|
const formPage = (help: string) => input('form_page', 'Form page', '', { type: 'number', placeholder: `e.g. ${nextPage + 1} (empty: no form)`, help });
|
|
173
249
|
const needsPk = ['report_form', 'grid', 'form', 'master_detail'].includes(kind);
|
|
250
|
+
const allCols = (c: Col) => c.kind !== 'binary';
|
|
251
|
+
const dates = (c: Col) => c.kind === 'date' || c.kind === 'timestamp';
|
|
174
252
|
|
|
175
253
|
let fields: Raw | string = '';
|
|
176
254
|
switch (kind) {
|
|
@@ -228,43 +306,134 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
228
306
|
${check('search', 'A search field above the facets', defaults.search !== false)}
|
|
229
307
|
${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
|
|
230
308
|
break;
|
|
309
|
+
// (137) the page types of migration 137
|
|
310
|
+
case 'report':
|
|
311
|
+
fields = pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : '';
|
|
312
|
+
break;
|
|
313
|
+
case 'smart_filters':
|
|
314
|
+
fields = html`
|
|
315
|
+
${colChecks('facets', 'Filters (suggested in the search field)', defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? `: values of ${c.fk_table}.${c.fk_display ?? c.column_name}` : ['number', 'date', 'timestamp'].includes(c.kind) ? ': ranges' : ': values'))}
|
|
316
|
+
${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
|
|
317
|
+
break;
|
|
318
|
+
case 'tree':
|
|
319
|
+
fields = html`<div class="form-grid">
|
|
320
|
+
${select('id_column', 'Node id', defaults.id_column ?? '', colOpts(allCols, false), 'What identifies a node: usually the primary key.')}
|
|
321
|
+
${select('parent_column', 'Parent', defaults.parent_column ?? '', colOpts(allCols), 'The column with the parent node\'s id; proposed from a foreign key to the table itself. Rows whose parent is not in the table are the top level.')}
|
|
322
|
+
${select('label_column', 'Label', defaults.label_column ?? '', colOpts(notBinary, false))}
|
|
323
|
+
${input('expanded', 'Levels open at first', defaults.expanded ?? 1, { type: 'number' })}
|
|
324
|
+
${pk ? formPage('A modal form page, opened by clicking a node.') : ''}
|
|
325
|
+
</div>`;
|
|
326
|
+
break;
|
|
327
|
+
case 'timeline':
|
|
328
|
+
fields = html`<div class="form-grid">
|
|
329
|
+
${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false))}
|
|
330
|
+
${select('when', 'When', defaults.when ?? '', colOpts(dates), 'The events come newest first.')}
|
|
331
|
+
${select('who', 'Who', defaults.who ?? '', colOpts(notBinary))}
|
|
332
|
+
${select('body', 'Text', defaults.body ?? '', colOpts(notBinary))}
|
|
333
|
+
${pk ? formPage('A modal form page, linked from each title.') : ''}
|
|
334
|
+
</div>`;
|
|
335
|
+
break;
|
|
336
|
+
case 'media_list':
|
|
337
|
+
case 'content_row':
|
|
338
|
+
fields = html`<div class="form-grid">
|
|
339
|
+
${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false), 'Its first two letters are the initials.')}
|
|
340
|
+
${select('description', 'Description', defaults.description ?? '', colOpts(notBinary))}
|
|
341
|
+
${kind === 'content_row' ? select('misc', 'Secondary text (on the right)', defaults.misc ?? '', colOpts(notBinary)) : ''}
|
|
342
|
+
${select('badge', 'Badge', defaults.badge ?? '', colOpts(notBinary))}
|
|
343
|
+
${pk ? formPage('A modal form page, linked from each title.') : ''}
|
|
344
|
+
</div>`;
|
|
345
|
+
break;
|
|
346
|
+
case 'comments':
|
|
347
|
+
fields = html`<div class="form-grid">
|
|
348
|
+
${select('user', 'Who wrote it', defaults.user ?? '', colOpts(notBinary))}
|
|
349
|
+
${select('date', 'When', defaults.date ?? '', colOpts(dates), 'The newest first.')}
|
|
350
|
+
${select('comment', 'Comment', defaults.comment ?? '', colOpts(notBinary, false))}
|
|
351
|
+
</div>`;
|
|
352
|
+
break;
|
|
353
|
+
case 'data_load': {
|
|
354
|
+
const defs = (await owner.query<{ name: string; table_name: string }>('select name, table_name from meta.data_load_def where app_id = $1 order by name', [a.id])).rows;
|
|
355
|
+
fields = html`<div class="form-grid">
|
|
356
|
+
${input('definition', 'Data load definition', defaults.definition ?? '', { required: true, help: `Upper case, e.g. ORDERS_LOAD. A new definition for ${rel?.t ?? 'the table'}; an existing one of that name is used as it is${defs.length ? ` (this application has ${defs.map((d) => d.name).join(', ')})` : ''}.` })}
|
|
357
|
+
${select('mode', 'Mode', defaults.mode ?? 'append', [['append', 'Append: add the rows'], ['merge', 'Merge: update rows with the same key, add the others'], ['replace', 'Replace: delete all rows first']])}
|
|
358
|
+
</div>
|
|
359
|
+
${check('skip_errors', 'Skip rows with errors', !!defaults.skip_errors, 'Load the good rows and list the others; otherwise nothing is loaded when a row fails.')}`;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'wizard':
|
|
363
|
+
fields = html`<div class="form-grid">
|
|
364
|
+
${input('steps', 'Steps', defaults.steps ?? 2, { type: 'number', help: 'One page per step, numbered from the page number up; the columns are shared out in order.' })}
|
|
365
|
+
${input('return_page', 'After Finish or Cancel, go to page', defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? `empty: the home page (${a.home_page})` : 'empty: this page' })}
|
|
366
|
+
</div>
|
|
367
|
+
${colChecks('columns', 'Columns', defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary' && c.kind !== 'geometry', (c) => (c.not_null && !c.has_default ? ', required: always included' : c.fk_table ? `, a select list of ${c.fk_table}` : ''))}`;
|
|
368
|
+
break;
|
|
369
|
+
case 'search': {
|
|
370
|
+
const configs = (await owner.query<{ name: string; label: string; source: string }>('select name, label, source from meta.search_config where app_id = $1 order by seq, name', [a.id])).rows;
|
|
371
|
+
const tables = await wizardTables(role);
|
|
372
|
+
fields = html`
|
|
373
|
+
${configs.length
|
|
374
|
+
? html`<fieldset class="prop-group"><legend>Search configurations</legend><div class="qb-pick">${configs.map((c) => html`<label class="check"><input type="checkbox" name="configs" value="${c.name}" checked> ${c.label} <span class="muted small">${c.name}, ${c.source.slice(0, 60)}</span></label>`)}</div></fieldset>`
|
|
375
|
+
: html`<p class="muted">This application has no search configurations yet (<a href="${BASE}/apps/${a.id}/shared?type=search_config">Shared Components → Search configurations</a>): choose a table to search below.</p>`}
|
|
376
|
+
<div class="form-grid">
|
|
377
|
+
${select('table', 'And a new configuration on', rel?.t ?? '', [['', '- none -'], ...tables.filter((t) => t.access).map((t): [string, string] => [t.t, t.t])], 'Searches the table\'s text columns and shows its name (or first text) column as the result\'s title.')}
|
|
378
|
+
${formPage('With a table: a modal form page that each result links to.')}
|
|
379
|
+
</div>`;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case 'tasks':
|
|
383
|
+
fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'my', TASK_CONTEXTS)}</div>
|
|
384
|
+
${check('completed', 'Also completed tasks', !!defaults.completed)}
|
|
385
|
+
<p class="muted small">Tasks come from task definitions (Shared Components → Task definitions) and approvals started in processes or workflows.</p>`;
|
|
386
|
+
break;
|
|
387
|
+
case 'workflows':
|
|
388
|
+
fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'initiator', [['initiator', 'The workflows the user started'], ['admin', 'The workflows the user administers']])}</div>`;
|
|
389
|
+
break;
|
|
390
|
+
case 'dashboard': {
|
|
391
|
+
const tables = (await wizardTables(role)).filter((t) => t.access);
|
|
392
|
+
fields = tables.length
|
|
393
|
+
? html`<fieldset class="prop-group"><legend>Tables</legend><div class="qb-pick">${tables.map((t, n) => html`<label class="check"><input type="checkbox" name="tables" value="${t.t}"${n < 12 ? raw(' checked') : ''}> ${t.t}</label>`)}</div></fieldset>
|
|
394
|
+
<p class="muted small">A chart per table (at most six) of the rows per parent row or per repeating value, read from the data; tables with row level security are left out.</p>`
|
|
395
|
+
: html`<p class="alert alert-error" role="status">The application's database role can't read any table.</p>`;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
231
398
|
case 'master_detail': {
|
|
399
|
+
const t = rel!.t;
|
|
232
400
|
const details = (
|
|
233
401
|
await owner.query<{ t: string; col: string }>(
|
|
234
402
|
`select meta.wizard_qname(con.conrelid) as t, a.attname::text as col
|
|
235
403
|
from pg_constraint con join pg_attribute a on a.attrelid = con.conrelid and a.attnum = con.conkey[1]
|
|
236
404
|
where con.confrelid = $1::regclass and con.contype = 'f' and con.conrelid <> con.confrelid and array_length(con.conkey, 1) = 1
|
|
237
405
|
order by 1, 2`,
|
|
238
|
-
[
|
|
406
|
+
[t],
|
|
239
407
|
)
|
|
240
408
|
).rows;
|
|
241
409
|
fields = details.length
|
|
242
|
-
? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${
|
|
243
|
-
: html`<p class="alert alert-error" role="status">No table has a foreign key to ${
|
|
410
|
+
? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${t})`]), 'Tables with a foreign key to this table. Selecting a master row shows its details; new detail rows get the master\'s key.')
|
|
411
|
+
: html`<p class="alert alert-error" role="status">No table has a foreign key to ${t}: a master-detail page needs one.</p>`;
|
|
244
412
|
break;
|
|
245
413
|
}
|
|
246
414
|
}
|
|
247
415
|
|
|
248
|
-
const
|
|
416
|
+
const kindDef = PAGE_KINDS.find((k) => k[0] === kind)!;
|
|
417
|
+
const kindName = kindDef[1];
|
|
249
418
|
const main = html`${appHeader(a, 'pages', s)}
|
|
250
|
-
${region(`${kindName} on ${rel.t}
|
|
251
|
-
<p class="muted u-mt0">${
|
|
252
|
-
${needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
|
|
253
|
-
${role && access && !(access.s && (!needsPk || (access.i && access.u && access.d)))
|
|
254
|
-
? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : 'select'}).</p>`
|
|
419
|
+
${region(rel && kind !== 'search' ? `${kindName} on ${rel.t}` : kindName, html`
|
|
420
|
+
<p class="muted u-mt0">${kindDef[2]} ${rel ? 'The proposals come from the table\'s columns, key and foreign keys; change them below.' : ''} <a href="${BASE}/apps/${a.id}/create-page">Back to choose another type${rel ? ' or table' : ''}</a>.</p>
|
|
421
|
+
${rel && needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
|
|
422
|
+
${rel && role && access && !(access.s && (!(needsPk || kind === 'wizard' || kind === 'data_load') || (access.i && (kind === 'wizard' || (access.u && access.d)))))
|
|
423
|
+
? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : kind === 'wizard' ? 'select, insert' : kind === 'data_load' ? 'select, insert, update, delete for merge or replace' : 'select'}).</p>`
|
|
255
424
|
: ''}
|
|
256
425
|
<form method="post" action="${BASE}/apps/${a.id}/wizard">${csrf(s)}
|
|
257
|
-
<input type="hidden" name="kind" value="${kind}"
|
|
426
|
+
<input type="hidden" name="kind" value="${kind}">${rel && kind !== 'search' ? html`<input type="hidden" name="table" value="${rel.t}">` : ''}
|
|
258
427
|
<div class="form-grid">
|
|
259
428
|
${input('report_page', 'Page number', nextPage, { type: 'number', required: true })}
|
|
260
429
|
${input('label', 'Name', defaults.label ?? '', { help: 'The page title and the navigation entry.' })}
|
|
261
430
|
${select('icon', 'Menu icon', defaults.icon ?? 'table', [...ICONS])}
|
|
262
431
|
</div>
|
|
263
|
-
${['report_form', 'grid'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
|
|
432
|
+
${['report_form', 'grid', 'dashboard'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
|
|
264
433
|
${fields}
|
|
265
434
|
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}">Cancel</a><button class="btn btn-hot">Create page</button></div>
|
|
266
435
|
</form>`)}`;
|
|
267
|
-
return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], [
|
|
436
|
+
return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page`], [kindName]], main));
|
|
268
437
|
});
|
|
269
438
|
|
|
270
439
|
app.post(`${BASE}/apps/:id/wizard`, async (req: Req, reply) => {
|
|
@@ -272,26 +441,42 @@ export async function wizardRoutes(app: FastifyInstance) {
|
|
|
272
441
|
if (!s) return;
|
|
273
442
|
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
274
443
|
const kind = KINDS.has(String(b.kind)) ? String(b.kind) : 'report_form';
|
|
275
|
-
const table = String(b.table ?? '');
|
|
276
|
-
const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}
|
|
444
|
+
const table = kind === 'dashboard' || kind === 'tasks' || kind === 'workflows' ? '' : String(b.table ?? '');
|
|
445
|
+
const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}${table && kind !== 'search' ? `&table=${encodeURIComponent(table)}` : ''}`;
|
|
277
446
|
try {
|
|
278
447
|
const page = pageNo(b.report_page);
|
|
279
448
|
if (page === null) throw new Error('Enter a page number.');
|
|
280
449
|
const options = wizardOptions(kind, b);
|
|
450
|
+
if (!table && !NO_TABLE.has(kind)) throw new Error('Choose a table or view.');
|
|
281
451
|
const id = await owner.tx(async (c) => {
|
|
282
|
-
const a = await c.query('select alias from meta.app where id = $1', [req.params.id]);
|
|
452
|
+
const a = await c.query('select alias, db_role from meta.app where id = $1', [req.params.id]);
|
|
283
453
|
if (!a.rowCount) throw new Error('This application does not exist.');
|
|
284
454
|
// report and form and grid keep their own icons and need no step 2 (older forms post here directly)
|
|
285
455
|
if (kind === 'report_form' && options.form_page === null) throw new Error('Enter the form page number.');
|
|
286
456
|
if (['report_form', 'grid'].includes(kind) && !options.icon) options.icon = kind === 'grid' ? 'grid' : 'table';
|
|
287
|
-
|
|
457
|
+
if (kind === 'dashboard') {
|
|
458
|
+
// (137) only tables the application's role can read
|
|
459
|
+
const readable = new Set((await wizardTables(a.rows[0].db_role)).filter((t) => t.access).map((t) => t.t));
|
|
460
|
+
const tables = (options.tables as string[]).filter((t) => readable.has(t));
|
|
461
|
+
if (!tables.length) throw new Error('Choose at least one table the application can read.');
|
|
462
|
+
const made = await addDashboard(c, a.rows[0].alias, tables, page);
|
|
463
|
+
if (!made) throw new Error('None of these tables has a column worth a chart (a foreign key, or values that repeat).');
|
|
464
|
+
const pid = (await c.query('select id from meta.page where app_id = $1 and page_no = $2', [req.params.id, page])).rows[0].id as number;
|
|
465
|
+
if (options.label) {
|
|
466
|
+
await c.query('update meta.page set name = $2, title = $2 where id = $1', [pid, options.label]);
|
|
467
|
+
await c.query('update meta.nav_entry set label = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.label]);
|
|
468
|
+
}
|
|
469
|
+
if (options.icon) await c.query('update meta.nav_entry set icon = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.icon]);
|
|
470
|
+
return pid;
|
|
471
|
+
}
|
|
472
|
+
return (await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb) as id', [a.rows[0].alias, kind, table || null, page, JSON.stringify(options)])).rows[0].id as number;
|
|
288
473
|
});
|
|
289
474
|
const pages = (await owner.query('select page_no from meta.page where app_id = $1 and id >= $2 order by page_no', [req.params.id, id])).rows.map((r) => r.page_no);
|
|
290
|
-
flash(s, `${pages.length > 1 ? `Pages ${pages.join('
|
|
475
|
+
flash(s, `${pages.length > 1 ? `Pages ${pages.join(', ')}` : `Page ${page}`} created${table ? ` for ${table}. Make sure the app's database role has privileges on it` : ''}.`);
|
|
291
476
|
return back(reply, s, `${BASE}/pages/${id}`);
|
|
292
477
|
} catch (e) {
|
|
293
478
|
flash(s, (e as Error).message, 'error');
|
|
294
|
-
return back(reply, s, KINDS.has(String(b.kind)) && table ? retry : `${BASE}/apps/${req.params.id}`);
|
|
479
|
+
return back(reply, s, KINDS.has(String(b.kind)) && (table || NO_TABLE.has(kind)) ? retry : `${BASE}/apps/${req.params.id}`);
|
|
295
480
|
}
|
|
296
481
|
});
|
|
297
482
|
}
|
package/src/cli/replace.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { randomBytes } from 'node:crypto';
|
|
13
13
|
import type pg from 'pg';
|
|
14
14
|
import { regionKeys } from '../appfiles.ts';
|
|
15
|
+
import { ensureSnapshot, recordHistory } from '../history.ts';
|
|
15
16
|
|
|
16
17
|
/** Tables of an application that are its definition: replaced. */
|
|
17
18
|
export const REPLACED = [
|
|
@@ -86,7 +87,10 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
|
|
|
86
87
|
// (126) outgoing calls the network access lists refused (Administration → Network access; a log, 7 days)
|
|
87
88
|
'network_refusal',
|
|
88
89
|
// (136) rows queued for their embeddings (the vector providers travel in meta.app)
|
|
89
|
-
'embedding_queue'
|
|
90
|
+
'embedding_queue',
|
|
91
|
+
// (138) the change history and the snapshot it compares with (this installation's record)
|
|
92
|
+
'app_snapshot', 'component_change',
|
|
93
|
+
];
|
|
90
94
|
/** Children of pages (replaced with their page). */
|
|
91
95
|
const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
|
|
92
96
|
/** References into replaced tables from kept data, repointed below: "table.column". */
|
|
@@ -140,6 +144,9 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
|
|
|
140
144
|
const target = (await db.query<{ id: number }>('select id from meta.app where alias = $1 for update', [alias])).rows[0];
|
|
141
145
|
if (!target) throw new Error(`application ${alias} not found`);
|
|
142
146
|
const old = target.id;
|
|
147
|
+
// (sprint 138) the change history compares the application before and after (src/history.ts); not on a bare pool
|
|
148
|
+
const inTx = !('totalCount' in db);
|
|
149
|
+
if (inTx) await ensureSnapshot(old, db as pg.ClientBase);
|
|
143
150
|
const backup = opts.backup === undefined ? { reason: 'import' as const } : opts.backup;
|
|
144
151
|
if (backup) await db.query('select meta.backup_app($1, $2, $3, $4)', [old, backup.reason, backup.user ?? null, backup.note ?? null]);
|
|
145
152
|
const tmpAlias = `pgkiln-replace-${randomBytes(6).toString('hex')}`;
|
|
@@ -251,5 +258,6 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
|
|
|
251
258
|
[old, neu],
|
|
252
259
|
);
|
|
253
260
|
await db.query('delete from meta.app where id = $1', [neu]);
|
|
261
|
+
if (inTx) await recordHistory(old, opts.backup ? (opts.backup.user ?? null) : null, 'import', db as pg.ClientBase).catch(() => {});
|
|
254
262
|
return old;
|
|
255
263
|
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// (sprint 138, AP-5) Change history per component (APEX: Application Utilities ›
|
|
2
|
+
// Change History) and application checksums (APEX: Application Checksum).
|
|
3
|
+
//
|
|
4
|
+
// meta.app_snapshot keeps an application's export after its last recorded
|
|
5
|
+
// change. After a builder change (app-updates.ts, onSend) and inside an import
|
|
6
|
+
// --replace (cli/replace.ts: restores, merges, the command line and the MCP
|
|
7
|
+
// server), recordHistory() exports the application again, splits both exports
|
|
8
|
+
// into components the way working copies compare them (workingcopy.ts
|
|
9
|
+
// components(): a region with its SQL, a page's settings, a list of values, …)
|
|
10
|
+
// and stores each added, changed or deleted component with its files before and
|
|
11
|
+
// after in meta.component_change (migration 138). The first change of an
|
|
12
|
+
// application without a snapshot has nothing to compare with: ensureSnapshot()
|
|
13
|
+
// takes one before a builder POST runs (a preHandler hook).
|
|
14
|
+
//
|
|
15
|
+
// appChecksum(): a SHA-256 over the export's files (the text layout, without the
|
|
16
|
+
// format marker and with the alias blanked), so two installations, or an
|
|
17
|
+
// application and its working copy, compare at a glance; pageChecksums() per page.
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import type pg from 'pg';
|
|
20
|
+
import { docToFiles, MARKER, type Doc, type FileMap } from './appfiles.ts';
|
|
21
|
+
import { owner } from './db.ts';
|
|
22
|
+
import { components, describe } from './workingcopy.ts';
|
|
23
|
+
|
|
24
|
+
type Db = { query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult<any>> };
|
|
25
|
+
|
|
26
|
+
/** Days a change stays in the history (CHANGE_HISTORY_DAYS, default 365; 0 keeps everything). */
|
|
27
|
+
export const historyDays = () => {
|
|
28
|
+
const n = Number(process.env.CHANGE_HISTORY_DAYS ?? 365);
|
|
29
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 365;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The export as files of the text layout (SQL inline in YAML: readable diffs), the alias blanked. */
|
|
33
|
+
export function appFiles(doc: Doc): FileMap {
|
|
34
|
+
const d = { ...doc, app: { ...(doc.app ?? {}), alias: '' } };
|
|
35
|
+
const files = docToFiles(d, 'text');
|
|
36
|
+
files.delete(MARKER);
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isText = (b: Buffer) => !b.subarray(0, 8000).includes(0);
|
|
41
|
+
const asText = (files: Map<string, Buffer> | undefined) =>
|
|
42
|
+
files ? Object.fromEntries([...files].map(([p, b]) => [p, isText(b) ? b.toString('utf8') : `(binary, ${b.length} bytes, sha256 ${createHash('sha256').update(b).digest('hex').slice(0, 16)})`])) : null;
|
|
43
|
+
|
|
44
|
+
const sameFiles = (a?: Map<string, Buffer>, b?: Map<string, Buffer>) => {
|
|
45
|
+
if (!a || !b) return !a && !b;
|
|
46
|
+
if (a.size !== b.size) return false;
|
|
47
|
+
for (const [p, buf] of a) if (!b.get(p)?.equals(buf)) return false;
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface ComponentDiff {
|
|
52
|
+
component: string;
|
|
53
|
+
label: string;
|
|
54
|
+
page: number | null;
|
|
55
|
+
op: 'added' | 'changed' | 'deleted';
|
|
56
|
+
before: Record<string, string> | null;
|
|
57
|
+
after: Record<string, string> | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The components that differ between two exports. */
|
|
61
|
+
export function diffDocs(before: Doc, after: Doc): ComponentDiff[] {
|
|
62
|
+
const a = components(appFiles(before)), b = components(appFiles(after));
|
|
63
|
+
const out: ComponentDiff[] = [];
|
|
64
|
+
for (const id of [...new Set([...a.keys(), ...b.keys()])].sort()) {
|
|
65
|
+
const x = a.get(id), y = b.get(id);
|
|
66
|
+
if (sameFiles(x, y)) continue;
|
|
67
|
+
const { label, page } = describe(id);
|
|
68
|
+
out.push({ component: id, label, page, op: !x ? 'added' : !y ? 'deleted' : 'changed', before: asText(x), after: asText(y) });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const exportDoc = async (db: Db, appId: number): Promise<Doc | null> =>
|
|
74
|
+
(await db.query('select meta.export_app(alias) as d from meta.app where id = $1', [appId])).rows[0]?.d ?? null;
|
|
75
|
+
|
|
76
|
+
/** Take a first snapshot of an application that has none (before its first recorded change); with `db`, inside its transaction. */
|
|
77
|
+
export async function ensureSnapshot(appId: number, db?: pg.ClientBase) {
|
|
78
|
+
const sql = `insert into meta.app_snapshot (app_id, doc) select id, meta.export_app(alias) from meta.app
|
|
79
|
+
where id = $1 and not exists (select 1 from meta.app_snapshot where app_id = $1)
|
|
80
|
+
on conflict (app_id) do nothing`;
|
|
81
|
+
if (!db) return void (await owner.query(sql, [appId]));
|
|
82
|
+
await db.query('savepoint history_snapshot');
|
|
83
|
+
try {
|
|
84
|
+
await db.query(sql, [appId]);
|
|
85
|
+
await db.query('release savepoint history_snapshot');
|
|
86
|
+
} catch {
|
|
87
|
+
await db.query('rollback to savepoint history_snapshot');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compare the application with its snapshot, record what changed and keep the new snapshot.
|
|
93
|
+
* With `db` a client in the caller's transaction (an import --replace), else a transaction of its own.
|
|
94
|
+
*/
|
|
95
|
+
export async function recordHistory(appId: number, changedBy: string | null, source: 'builder' | 'import' | 'upgrade' = 'builder', db?: pg.ClientBase): Promise<number> {
|
|
96
|
+
const run = async (c: Db) => {
|
|
97
|
+
// one change at a time per application: the snapshot row is the lock
|
|
98
|
+
const snap = (await c.query('select doc from meta.app_snapshot where app_id = $1 for update', [appId])).rows[0];
|
|
99
|
+
const now = await exportDoc(c, appId);
|
|
100
|
+
if (!now) return 0;
|
|
101
|
+
if (!snap) {
|
|
102
|
+
await c.query('insert into meta.app_snapshot (app_id, doc) values ($1, $2) on conflict (app_id) do update set doc = excluded.doc, taken_at = now()', [appId, now]);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
const changes = diffDocs(snap.doc, now);
|
|
106
|
+
if (!changes.length) return 0;
|
|
107
|
+
await c.query(
|
|
108
|
+
`insert into meta.component_change (app_id, component, label, page_no, op, before, after, changed_by, source)
|
|
109
|
+
select $1, x.component, x.label, x.page, x.op, x.before, x.after, $2, $3
|
|
110
|
+
from jsonb_to_recordset($4::jsonb) as x(component text, label text, page int, op text, before jsonb, after jsonb)`,
|
|
111
|
+
[appId, changedBy?.slice(0, 200) ?? null, source, JSON.stringify(changes)],
|
|
112
|
+
);
|
|
113
|
+
await c.query('update meta.app_snapshot set doc = $2, taken_at = now() where app_id = $1', [appId, now]);
|
|
114
|
+
const days = historyDays();
|
|
115
|
+
if (days) await c.query(`delete from meta.component_change where app_id = $1 and changed_at < now() - make_interval(days => $2)`, [appId, days]);
|
|
116
|
+
return changes.length;
|
|
117
|
+
};
|
|
118
|
+
if (db) {
|
|
119
|
+
await db.query('savepoint history');
|
|
120
|
+
try {
|
|
121
|
+
const n = await run(db);
|
|
122
|
+
await db.query('release savepoint history');
|
|
123
|
+
return n;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
await db.query('rollback to savepoint history');
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return owner.tx(run);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** SHA-256 of a set of files (paths and contents, in path order). */
|
|
133
|
+
function hashFiles(files: [string, Buffer][]) {
|
|
134
|
+
const h = createHash('sha256');
|
|
135
|
+
for (const [p, b] of files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(p).update('\0').update(b).update('\0');
|
|
136
|
+
return h.digest('hex');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The application checksum of an export (the same definition gives the same checksum on any installation). */
|
|
140
|
+
export const appChecksum = (doc: Doc) => hashFiles([...appFiles(doc)]);
|
|
141
|
+
|
|
142
|
+
/** A checksum per page: page number → the hash of the page's files. */
|
|
143
|
+
export function pageChecksums(doc: Doc): Map<number, string> {
|
|
144
|
+
const byPage = new Map<number, [string, Buffer][]>();
|
|
145
|
+
for (const [id, files] of components(appFiles(doc))) {
|
|
146
|
+
const page = id.startsWith('pages/') ? describe(id).page : null;
|
|
147
|
+
if (page === null) continue;
|
|
148
|
+
if (!byPage.has(page)) byPage.set(page, []);
|
|
149
|
+
byPage.get(page)!.push(...files);
|
|
150
|
+
}
|
|
151
|
+
return new Map([...byPage].sort(([a], [b]) => a - b).map(([p, f]) => [p, hashFiles(f)]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A short form for display (the first 16 hex digits). */
|
|
155
|
+
export const shortSum = (sum: string) => sum.slice(0, 16);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { TemplateComponent } from './template-components.ts';
|
|
2
2
|
|
|
3
3
|
// Built-in template components (APEX: the Universal Theme's template
|
|
4
|
-
// components: Avatar, Badge, Comments,
|
|
5
|
-
// Metric Card). Every application can use them in a template_component
|
|
4
|
+
// components: Avatar, Badge, Comments, Content Row (137), Media List,
|
|
5
|
+
// Timeline, and 26.1's Metric Card). Every application can use them in a template_component
|
|
6
6
|
// region or as a report column template without importing anything; a
|
|
7
7
|
// component of the application with the same static id takes its place
|
|
8
8
|
// (Shared Components → Template components → Copy into this application).
|
|
@@ -53,6 +53,27 @@ export const BUILTIN_COMPONENTS: readonly TemplateComponent[] = [
|
|
|
53
53
|
css_classes: [],
|
|
54
54
|
attributes: [text('USER', 'User'), text('INITIALS', 'Initials'), text('DATE', 'Date'), text('COMMENT', 'Comment'), text('ACTIONS', 'Extra text', 'Optional, e.g. "edited".')],
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
// (137) APEX: Content Row: an avatar, a title and description, and on the right a secondary text and a badge
|
|
58
|
+
static_id: 'ut_content_row',
|
|
59
|
+
name: 'Content row',
|
|
60
|
+
description: 'Rows with initials or a picture, a title (linked when the region has a link) and a description, and on the right a secondary text and a badge. Show the region as "multiple".',
|
|
61
|
+
template:
|
|
62
|
+
'<li class="tc-content-row">{if ?IMAGE/}<img class="tc-avatar tc-avatar-md" src="#IMAGE#" alt="" loading="lazy">{else/}<span class="tc-avatar tc-avatar-md" aria-hidden="true">#INITIALS#</span>{endif/}' +
|
|
63
|
+
'<div class="tc-stack"><p class="tc-title">{if ?LINK/}<a href="#LINK#">#TITLE#</a>{else/}#TITLE#{endif/}</p>{if ?DESCRIPTION/}<p class="tc-meta">#DESCRIPTION#</p>{endif/}</div>' +
|
|
64
|
+
'<div class="tc-content-row-end">{if ?MISC/}<span class="tc-meta">#MISC#</span>{endif/}{if ?BADGE/}<span class="tc-badge tc-badge-' + STATE + '">#BADGE#</span>{endif/}</div></li>',
|
|
65
|
+
wrapper: '<ul class="tc-content-rows">#APEX$ROWS#</ul>',
|
|
66
|
+
css_classes: [],
|
|
67
|
+
attributes: [
|
|
68
|
+
text('TITLE', 'Title'),
|
|
69
|
+
text('DESCRIPTION', 'Description'),
|
|
70
|
+
text('MISC', 'Secondary text', 'Shown on the right, e.g. a date or an amount.'),
|
|
71
|
+
text('INITIALS', 'Initials', 'Shown without a picture.'),
|
|
72
|
+
text('IMAGE', 'Picture URL', 'Optional: a relative or https URL.'),
|
|
73
|
+
text('BADGE', 'Badge'),
|
|
74
|
+
text('STATE', 'Badge state', 'success, warning, danger, info or neutral.'),
|
|
75
|
+
],
|
|
76
|
+
},
|
|
56
77
|
{
|
|
57
78
|
static_id: 'ut_media_list',
|
|
58
79
|
name: 'Media list',
|
package/src/runtime/items.ts
CHANGED
|
@@ -309,9 +309,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
309
309
|
case 'checkbox_group': {
|
|
310
310
|
useLegend = true;
|
|
311
311
|
const selected = new Set(splitValues(value));
|
|
312
|
-
|
|
312
|
+
// (137) {"switches": true}: each value as a switch (the Configuration page's build options)
|
|
313
|
+
const switches = item.config?.switches === true;
|
|
314
|
+
control = html`<div class="radio-group${switches ? ' switch-group' : ''}">${options.map(
|
|
313
315
|
(o, i) =>
|
|
314
|
-
html`<label class="check"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
|
|
316
|
+
html`<label class="check${switches ? ' switch' : ''}"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${switches ? raw(' role="switch"') : ''}${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
|
|
315
317
|
)}</div>`;
|
|
316
318
|
break;
|
|
317
319
|
}
|