pgkiln 0.44.0 → 0.46.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/102_app_computations.sql +345 -0
- package/db/migrations/103_shortcuts.sql +341 -0
- package/db/migrations/104_report_queries.sql +413 -0
- package/db/migrations/105_component_settings_groups.sql +367 -0
- package/db/migrations/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +69 -13
- package/docs/guide/04-pages-and-regions.md +84 -3
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +14 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +8 -2
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +79 -2
- package/public/app.js +307 -35
- package/public/builder.css +21 -1
- package/public/builder.js +67 -0
- package/public/sw.js +2 -1
- package/src/app.ts +9 -1
- package/src/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +7 -2
- package/src/builder/components.ts +86 -4
- package/src/builder/designer.ts +44 -17
- package/src/builder/errors.ts +65 -0
- package/src/builder/form-designer.ts +229 -0
- package/src/builder/forms.ts +1 -1
- package/src/builder/locks.ts +6 -0
- package/src/builder/newapp.ts +2 -1
- package/src/builder/object-browser.ts +81 -18
- package/src/builder/region-help.ts +2 -0
- package/src/builder/region-settings.ts +2 -0
- package/src/builder/reporter.ts +1 -1
- package/src/builder/routes.ts +7 -5
- package/src/builder/search.ts +3 -3
- package/src/builder/shared.ts +41 -8
- package/src/builder/subscriptions.ts +30 -4
- package/src/builder/ui.ts +2 -0
- package/src/cli/replace.ts +16 -2
- package/src/i18n/ar.ts +62 -0
- package/src/i18n/cs.ts +62 -0
- package/src/i18n/da.ts +62 -0
- package/src/i18n/de.ts +62 -0
- package/src/i18n/el.ts +62 -0
- package/src/i18n/es.ts +62 -0
- package/src/i18n/fi.ts +62 -0
- package/src/i18n/fr.ts +62 -0
- package/src/i18n/he.ts +62 -0
- package/src/i18n/it.ts +62 -0
- package/src/i18n/ja.ts +62 -0
- package/src/i18n/ko.ts +62 -0
- package/src/i18n/nb.ts +62 -0
- package/src/i18n/pl.ts +62 -0
- package/src/i18n/pt.ts +62 -0
- package/src/i18n/ru.ts +62 -0
- package/src/i18n/sv.ts +62 -0
- package/src/i18n/tr.ts +62 -0
- package/src/i18n/uk.ts +62 -0
- package/src/i18n/zh.ts +62 -0
- package/src/i18n.ts +124 -0
- package/src/metadata.ts +18 -3
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- package/src/runtime/facet-state.ts +10 -5
- package/src/runtime/facets.ts +29 -5
- package/src/runtime/form-designer.ts +676 -0
- package/src/runtime/grid.ts +36 -11
- package/src/runtime/items.ts +47 -32
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +8 -3
- package/src/runtime/render.ts +8 -3
- package/src/runtime/report.ts +4 -3
- package/src/runtime/routes.ts +7 -3
- package/src/runtime/shortcuts.ts +43 -0
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
- package/src/security.ts +2 -1
- package/src/subscriptions.ts +67 -0
package/src/builder/advisor.ts
CHANGED
|
@@ -57,6 +57,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
|
|
|
57
57
|
case 'button':
|
|
58
58
|
return [{ name: 'condition', shape: 'boolean' }, { name: 'badge_query', shape: 'select' }];
|
|
59
59
|
case 'computation':
|
|
60
|
+
case 'app_computation':
|
|
60
61
|
return [
|
|
61
62
|
...(row.type === 'sql_query' ? [{ name: 'expression', shape: 'select' as const }] : []),
|
|
62
63
|
...conditionSql(row),
|
|
@@ -83,6 +84,7 @@ function sqlFields(kind: string, row: any): { name: string; shape: SqlShape }[]
|
|
|
83
84
|
case 'automation_action':
|
|
84
85
|
return [{ name: 'code', shape: 'statements' }, { name: 'condition', shape: 'boolean' }];
|
|
85
86
|
case 'document_template':
|
|
87
|
+
case 'report_query':
|
|
86
88
|
return [{ name: 'query', shape: 'select' }];
|
|
87
89
|
case 'task_definition':
|
|
88
90
|
return [{ name: 'action_code', shape: 'statements' }];
|
|
@@ -286,7 +288,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
286
288
|
const buildOptions = new Set(all.filter((x) => x.kind === 'build_option').map((x) => x.row.name));
|
|
287
289
|
const layouts = new Set(all.filter((x) => x.kind === 'report_layout').map((x) => x.row.name.toUpperCase()));
|
|
288
290
|
const regionIds = new Set(all.filter((x) => x.kind === 'region').map((x) => x.row.id));
|
|
289
|
-
|
|
291
|
+
// (104) a document button names a document template or a report query
|
|
292
|
+
const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
|
|
290
293
|
const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
|
|
291
294
|
|
|
292
295
|
for (const e of entries) {
|
|
@@ -353,7 +356,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
353
356
|
&& !(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
|
|
354
357
|
missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
|
|
355
358
|
if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
|
|
356
|
-
missing(e, 'Document template', row.document ? `Document template ${row.document} doesn't exist.` : 'A document button needs a document template.');
|
|
359
|
+
missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
|
|
357
360
|
if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
|
|
358
361
|
if (kind === 'workflow_definition') {
|
|
359
362
|
const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
|
|
@@ -376,6 +379,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
376
379
|
for (const problem of [...devRefs.errors, ...devRefs.warnings]) missing(e, field, problem, 'warning');
|
|
377
380
|
}
|
|
378
381
|
}
|
|
382
|
+
if (kind === 'report_query' && row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
|
|
383
|
+
if (kind === 'app_computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
|
|
379
384
|
if (kind === 'document_template') {
|
|
380
385
|
const problem = templateProblem(row.template ?? '');
|
|
381
386
|
if (problem) missing(e, 'Template (HTML)', problem);
|
package/src/builder/appwizard.ts
CHANGED
|
@@ -50,6 +50,9 @@ interface Relation {
|
|
|
50
50
|
calendar: boolean;
|
|
51
51
|
/** (sprint 73) a table with a foreign key to this one, for master-detail */
|
|
52
52
|
detail: boolean;
|
|
53
|
+
/** (sprint 87) row level security is on, with this many policies */
|
|
54
|
+
rls: boolean;
|
|
55
|
+
policies: number;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
/** The tables, partitioned tables, views and materialized views of a schema (not partitions). */
|
|
@@ -59,7 +62,8 @@ export async function schemaRelations(schema: string): Promise<Relation[]> {
|
|
|
59
62
|
`select c.relname, meta.wizard_table_label(c.oid) as label, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
|
|
60
63
|
meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows,
|
|
61
64
|
meta.wizard_defaults('calendar', c.oid) ->> 'start' is not null as calendar,
|
|
62
|
-
meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail
|
|
65
|
+
meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail,
|
|
66
|
+
c.relrowsecurity as rls, (select count(*)::int from pg_policy p where p.polrelid = c.oid) as policies
|
|
63
67
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
64
68
|
where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
|
|
65
69
|
order by c.relkind in ('v', 'm'), c.relname`,
|
|
@@ -311,6 +315,16 @@ const SOURCES: [string, string, string, string][] = [
|
|
|
311
315
|
['gallery', 'grid', 'From the App Gallery', 'A starter application'],
|
|
312
316
|
];
|
|
313
317
|
|
|
318
|
+
/**
|
|
319
|
+
* (sprint 87) A table's row level security, before the application is created: on with its number
|
|
320
|
+
* of policies, or on without any (the application's role then sees no rows unless it owns the table).
|
|
321
|
+
*/
|
|
322
|
+
const rlsBadge = (r: Relation) =>
|
|
323
|
+
!r.rls ? ''
|
|
324
|
+
: r.policies > 0
|
|
325
|
+
? html` <span class="tag aw-rls" title="Row level security is on (${r.policies} ${r.policies === 1 ? 'policy' : 'policies'}): each user sees only the rows the policies allow">${icon('shield')} RLS · ${r.policies}</span>`
|
|
326
|
+
: html` <span class="tag tag-error aw-rls" title="Row level security is on without policies: the application's role sees no rows (unless it owns the table or bypasses row level security)">${icon('shield')} RLS, no policies</span>`;
|
|
327
|
+
|
|
314
328
|
async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
|
|
315
329
|
const schemas = await appSchemas();
|
|
316
330
|
const known = schemas.includes(schema);
|
|
@@ -334,8 +348,8 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
334
348
|
'The application\'s parsing schema. Choose a schema to get a page per table and view; a new schema is named after the alias.')}</div>
|
|
335
349
|
<div class="buttons"><button class="btn">${icon('search')} Show its tables</button></div></form>`)}
|
|
336
350
|
<form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
|
|
337
|
-
<input type="hidden" name="
|
|
338
|
-
${known ? html`<input type="hidden" name="tables" value="1">` : ''}
|
|
351
|
+
<input type="hidden" name="features" value="1">
|
|
352
|
+
<div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
|
|
339
353
|
${region('Application', html`<div class="form-grid">
|
|
340
354
|
<div class="field"><label class="label" for="f_name">Name</label>
|
|
341
355
|
<input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
|
|
@@ -354,14 +368,16 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
354
368
|
? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
|
|
355
369
|
<label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
|
|
356
370
|
<select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
|
|
357
|
-
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small
|
|
371
|
+
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small>${rlsBadge(r)}</li>`)
|
|
358
372
|
: html`<li class="muted">${schema} has no tables or views.</li>`
|
|
359
373
|
: html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
|
|
360
374
|
</ul>
|
|
361
|
-
${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}
|
|
375
|
+
${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
|
|
362
376
|
${region('Features', html`<div class="aw-features">${FEATURES.map(([k, label, help]) => check(`f_${k}`, label, features[k], help))}</div>`)}
|
|
363
377
|
${region('Settings', html`<div class="form-grid">
|
|
364
|
-
|
|
378
|
+
<div class="field"><label class="label" for="f_alias">Alias (URL)</label>
|
|
379
|
+
<input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
|
|
380
|
+
<small class="help">lowercase, e.g. inventory → /a/inventory; follows the name until you change it</small></div>
|
|
365
381
|
${select('authentication', 'Authentication', v('authentication', 'app_users'), [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
|
|
366
382
|
${input('admin_user', 'First user', v('admin_user', ''), { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
|
|
367
383
|
${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
|
package/src/builder/arrange.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { BASE, developer, flash, type Req } from './ui.ts';
|
|
|
17
17
|
export const LAYOUT_KINDS = ['region', 'item', 'button'] as const;
|
|
18
18
|
export type LayoutKind = (typeof LAYOUT_KINDS)[number];
|
|
19
19
|
const isKind = (k: unknown): k is LayoutKind => (LAYOUT_KINDS as readonly unknown[]).includes(k);
|
|
20
|
+
/** (sprint 90) The components with a build option, which can be commented out. */
|
|
21
|
+
const COMMENTABLE = new Set(['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch']);
|
|
20
22
|
|
|
21
23
|
const options = (kind: string, field: string) => COMPONENTS[kind].fields.find((f) => f.name === field)!.options!.filter(Boolean);
|
|
22
24
|
export const REGION_TYPES = () => options('region', 'type');
|
|
@@ -38,22 +40,22 @@ export const BOTTOM_SLOTS = ['previous', 'close', 'delete', 'next'];
|
|
|
38
40
|
|
|
39
41
|
/** Gallery names and icons. */
|
|
40
42
|
export const REGION_LABELS: Record<string, [string, string]> = {
|
|
41
|
-
report: ['Interactive
|
|
42
|
-
cards: ['Cards', 'layers'], calendar: ['Calendar', 'calendar'], facets: ['Faceted
|
|
43
|
-
workflows: ['Workflow
|
|
44
|
-
template_component: ['Template
|
|
45
|
-
ai_assistant: ['AI
|
|
43
|
+
report: ['Interactive Report', 'table'], grid: ['Interactive Grid', 'grid'], form: ['Form', 'file'], chart: ['Chart', 'chart'],
|
|
44
|
+
cards: ['Cards', 'layers'], calendar: ['Calendar', 'calendar'], facets: ['Faceted Search', 'filter'], smart_filters: ['Smart Filters', 'search'], display_selector: ['Region Display Selector', 'menu'], tasks: ['Task List', 'inbox'],
|
|
45
|
+
workflows: ['Workflow Console', 'activity'], map: ['Map', 'map'], tree: ['Tree', 'org'], static: ['Static Content', 'region'], dynamic: ['Dynamic Content', 'code'],
|
|
46
|
+
template_component: ['Template Component', 'layers'], list: ['List', 'list'], data_reporter: ['Data Reporter', 'table'], form_designer: ['Form Designer', 'file'],
|
|
47
|
+
ai_assistant: ['AI Assistant', 'bolt'], url: ['URL', 'link'], search: ['Search', 'search'], login: ['Login', 'lock'], plugin: ['Plug-in', 'layers'],
|
|
46
48
|
};
|
|
47
49
|
export const ITEM_LABELS: Record<string, [string, string]> = {
|
|
48
|
-
text: ['Text
|
|
49
|
-
datetime: ['Date and
|
|
50
|
-
checkbox: ['Checkbox', 'check'], switch: ['Switch', 'check'], checkbox_group: ['Checkbox
|
|
51
|
-
email: ['E-mail', 'inbox'], tel: ['Phone
|
|
52
|
-
location: ['Location', 'map'], richtext: ['Rich
|
|
53
|
-
combobox: ['Combobox (
|
|
50
|
+
text: ['Text Field', 'item'], textarea: ['Text Area', 'item'], number: ['Number Field', 'item'], date: ['Date Picker', 'calendar'],
|
|
51
|
+
datetime: ['Date and Time', 'clock'], select: ['Select List', 'list'], popup_lov: ['Popup LOV', 'search'], radio: ['Radio Group', 'check'],
|
|
52
|
+
checkbox: ['Checkbox', 'check'], switch: ['Switch', 'check'], checkbox_group: ['Checkbox Group', 'check'], multiselect: ['Multi Select', 'list'],
|
|
53
|
+
email: ['E-mail', 'inbox'], tel: ['Phone Number', 'item'], url: ['URL', 'item'], color: ['Color Picker', 'item'], file: ['File Upload', 'upload'],
|
|
54
|
+
location: ['Location', 'map'], richtext: ['Rich Text Editor', 'edit'], markdown: ['Markdown Editor', 'code'], rating: ['Star Rating', 'check'],
|
|
55
|
+
combobox: ['Combobox (Tags)', 'list'], daterange: ['Date Range', 'calendar'], qrcode: ['QR Code', 'scan'], image: ['Display Image', 'file'], address: ['Geocoded Address', 'map'], hidden: ['Hidden', 'item'], display: ['Display Only', 'file'], password: ['Password', 'key'],
|
|
54
56
|
};
|
|
55
57
|
export const BUTTON_LABELS: Record<string, [string, string]> = {
|
|
56
|
-
submit: ['Submit
|
|
58
|
+
submit: ['Submit Page', 'button'], redirect: ['Redirect to Page', 'button'], da: ['Dynamic Action', 'bolt'], document: ['Download Document', 'download'],
|
|
57
59
|
};
|
|
58
60
|
|
|
59
61
|
/** A working starting point per region type, so a new region renders straight away. */
|
|
@@ -351,6 +353,25 @@ export async function arrangeRoutes(app: FastifyInstance) {
|
|
|
351
353
|
});
|
|
352
354
|
|
|
353
355
|
// (sprint 77) the rendering tree's menu: Duplicate, Copy to other Page
|
|
356
|
+
// (sprint 90, PD-4; APEX: Comment Out) a component left out of the page by the build option COMMENTED_OUT (made with
|
|
357
|
+
// the status Exclude when first needed); Uncomment takes it off again. Not a layout change: the undo history stays.
|
|
358
|
+
route('comment', async (_s, pid, b) => {
|
|
359
|
+
const kind = b.kind ?? '';
|
|
360
|
+
const id = int(b.id);
|
|
361
|
+
if (!COMMENTABLE.has(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
|
|
362
|
+
const on = b.on !== 'false';
|
|
363
|
+
const done = await owner.tx(async (c) => {
|
|
364
|
+
if (on)
|
|
365
|
+
await c.query(
|
|
366
|
+
`insert into meta.build_option (app_id, name, status, description)
|
|
367
|
+
select app_id, 'COMMENTED_OUT', 'exclude', 'Components commented out in the page designer' from meta.page where id = $1
|
|
368
|
+
on conflict (app_id, name) do nothing`, [pid]);
|
|
369
|
+
return (await c.query(`update ${COMPONENTS[kind].table} set build_option = $3 where id = $1 and page_id = $2 and ${on ? 'true' : `build_option = 'COMMENTED_OUT'`}`, [id, pid, on ? 'COMMENTED_OUT' : null])).rowCount;
|
|
370
|
+
});
|
|
371
|
+
if (!done) throw new LayoutError(404, 'Unknown component.');
|
|
372
|
+
return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} ${on ? 'commented out: it stays in the page designer and is left out of the page (build option COMMENTED_OUT)' : 'back on the page'}.` };
|
|
373
|
+
});
|
|
374
|
+
|
|
354
375
|
route('duplicate', async (s, pid, b) => {
|
|
355
376
|
const kind = b.kind;
|
|
356
377
|
const id = int(b.id);
|
|
@@ -32,16 +32,18 @@ const CHECKS: Record<string, SqlShape> = {
|
|
|
32
32
|
'region.source': 'select', 'region.condition': 'boolean', 'item.lov': 'select', 'item.readonly_condition': 'boolean',
|
|
33
33
|
'button.condition': 'boolean', 'validation.expression': 'boolean', 'process.code': 'statements', 'authz_scheme.value': 'boolean',
|
|
34
34
|
'lov.query': 'select', 'app_process.code': 'statements', 'automation.query': 'select', 'automation_action.code': 'statements', 'automation_action.condition': 'boolean',
|
|
35
|
-
'document_template.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
|
|
35
|
+
'document_template.query': 'select', 'report_query.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
|
|
36
36
|
'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean',
|
|
37
37
|
};
|
|
38
38
|
/** Fields whose check follows the component's type select, like SWITCH ("none": no check). */
|
|
39
39
|
const CHECK_SWITCH: Record<string, string> = {
|
|
40
40
|
'dynamic_action.code': 'action:set_value=select,*=statements',
|
|
41
41
|
'process.code': 'type:sql=statements,*=none',
|
|
42
|
-
'region.source': 'type:static=none,form=none,facets=none,smart_filters=none,display_selector=none,tasks=none,workflows=none,list=none,data_reporter=none,ai_assistant=none,*=select',
|
|
42
|
+
'region.source': 'type:static=none,form=none,facets=none,smart_filters=none,display_selector=none,tasks=none,workflows=none,list=none,data_reporter=none,form_designer=none,ai_assistant=none,*=select',
|
|
43
43
|
'computation.expression': 'type:sql_query=select,*=none',
|
|
44
|
+
'app_computation.expression': 'type:sql_query=select,*=none',
|
|
44
45
|
'computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
|
|
46
|
+
'app_computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
|
|
45
47
|
'branch.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
|
|
46
48
|
};
|
|
47
49
|
/** Fields whose language follows the component's type select: "type:value=lang,…" (other values: SQL). */
|
|
@@ -51,7 +53,10 @@ const SWITCH: Record<string, string> = {
|
|
|
51
53
|
'authz_scheme.value': 'type:role=text',
|
|
52
54
|
'dynamic_action.code': 'action:set_value=sql',
|
|
53
55
|
'computation.expression': 'type:static=text,item=text,function_body=plpgsql',
|
|
56
|
+
'app_computation.expression': 'type:static=text,item=text,function_body=plpgsql',
|
|
57
|
+
'shortcut.value': 'type:text=text,html=html,function_body=plpgsql',
|
|
54
58
|
'computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
|
|
59
|
+
'app_computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
|
|
55
60
|
'branch.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
|
|
56
61
|
};
|
|
57
62
|
/** Language when no SWITCH value matches. */
|
|
@@ -20,6 +20,7 @@ import { aiProblems } from '../runtime/ai.ts';
|
|
|
20
20
|
import { DATA_LOAD_DEF_SPEC } from './dataload.ts';
|
|
21
21
|
import { processProblems } from '../runtime/processes.ts';
|
|
22
22
|
import { BUTTON_OPTIONS, ITEM_OPTIONS, REGION_OPTIONS } from '../runtime/template-options.ts';
|
|
23
|
+
import { checkGroupMembers } from '../subscriptions.ts';
|
|
23
24
|
|
|
24
25
|
export type FieldKind =
|
|
25
26
|
| 'text' | 'int' | 'bool' | 'code' | 'json' | 'select' | 'upper'
|
|
@@ -68,8 +69,8 @@ export interface ComponentSpec {
|
|
|
68
69
|
defaults?: Record<string, unknown>;
|
|
69
70
|
/** checks the parsed values before saving; returns a message when they're not valid */
|
|
70
71
|
validate?: (values: Record<string, any>) => string | null;
|
|
71
|
-
/** adjusts the parsed values before they are saved (cid: the row being edited); throws when they can't be */
|
|
72
|
-
beforeSave?: (values: Record<string, unknown>, cid: string | undefined) => Promise<void>;
|
|
72
|
+
/** adjusts the parsed values before they are saved (cid: the row being edited; parentId: its page or application); throws when they can't be */
|
|
73
|
+
beforeSave?: (values: Record<string, unknown>, cid: string | undefined, parentId: string) => Promise<void>;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
const AUTHZ_HELP = 'Authorization scheme; prefix with ! to negate. MUST_NOT_BE_PUBLIC_USER is built in.';
|
|
@@ -106,7 +107,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
106
107
|
{ name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
|
|
107
108
|
{ name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
|
|
108
109
|
help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
|
|
109
|
-
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
|
|
110
|
+
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
|
|
110
111
|
{ name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
|
|
111
112
|
help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
|
|
112
113
|
{ name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
|
|
@@ -169,7 +170,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
169
170
|
{ name: 'name', common: true, label: 'Name (request)', kind: 'upper', group: 'Identification' },
|
|
170
171
|
{ name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
|
|
171
172
|
{ name: 'action', common: true, label: 'Action', kind: 'select', options: ['submit', 'redirect', 'da', 'document', 'menu'], group: 'Behaviour', help: 'menu = a menu of links and submit requests (Menu entries) · da = "Defined by dynamic action"; document = download a document template (filled with the page\'s values as last loaded or saved)' },
|
|
172
|
-
{ name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template (Shared Components → Document templates).' },
|
|
173
|
+
{ name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template or a report query (Shared Components → Document templates, Report queries).' },
|
|
173
174
|
{ name: 'target_page', common: true, label: 'Target / branch page', kind: 'page', group: 'Behaviour' },
|
|
174
175
|
{ name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Behaviour', help: '{"P3_ID": "&P2_ID."}' },
|
|
175
176
|
{ name: 'confirm', label: 'Confirm message', kind: 'text', group: 'Behaviour' },
|
|
@@ -454,6 +455,66 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
454
455
|
buildOption(),
|
|
455
456
|
],
|
|
456
457
|
},
|
|
458
|
+
// (102) application computations (APEX: Shared Components → Application Computations)
|
|
459
|
+
app_computation: {
|
|
460
|
+
table: 'meta.app_computation',
|
|
461
|
+
scope: 'app',
|
|
462
|
+
label: 'Application computation',
|
|
463
|
+
plural: 'Application computations',
|
|
464
|
+
icon: 'activity',
|
|
465
|
+
summary: (c) => `${c.item_name} (${c.point}, ${c.type})`,
|
|
466
|
+
defaults: { point: 'before_header', type: 'static' },
|
|
467
|
+
fields: [
|
|
468
|
+
{ name: 'item_name', label: 'Item', kind: 'upper', group: 'Identification', help: 'An application item, e.g. AI_REGION (a page item is computed on its own page only)' },
|
|
469
|
+
{ name: 'point', label: 'Point', kind: 'select', options: ['after_authentication', 'before_header', 'after_submit'], group: 'Identification',
|
|
470
|
+
help: 'after_authentication: once, when a user signs in · before_header: every page shown, before the page\'s own computations · after_submit: every page submitted, before the page\'s own computations' },
|
|
471
|
+
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Identification' },
|
|
472
|
+
{ name: 'type', label: 'Type', kind: 'select', options: ['static', 'item', 'sql_query', 'sql_expression', 'function_body'], group: 'Computation' },
|
|
473
|
+
{ name: 'expression', label: 'Expression', kind: 'code', wide: true, group: 'Computation',
|
|
474
|
+
help: 'static: a value, &ITEM. allowed · item: an item name · sql_query: a SELECT (first column of the first row), e.g. select region from sales.rep where lower(username) = lower(:APP_USER) · sql_expression · function_body: PL/pgSQL. Runs as the application\'s role.' },
|
|
475
|
+
...conditionFields('Condition'),
|
|
476
|
+
{ name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
477
|
+
buildOption('Security'),
|
|
478
|
+
{ name: 'comments', label: 'Comments', kind: 'textarea', wide: true, group: 'Comments' },
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
// (105) component groups (APEX: Shared Components → Component Groups)
|
|
482
|
+
component_group: {
|
|
483
|
+
table: 'meta.component_group',
|
|
484
|
+
scope: 'app',
|
|
485
|
+
label: 'Component group',
|
|
486
|
+
plural: 'Component groups',
|
|
487
|
+
icon: 'layers',
|
|
488
|
+
summary: (g) => `${g.name} (${(g.members ?? []).length})`,
|
|
489
|
+
defaults: {},
|
|
490
|
+
beforeSave: async (v, _cid, appId) => {
|
|
491
|
+
const { members, problems } = await checkGroupMembers(Number(appId), (v.members as string[] | null) ?? []);
|
|
492
|
+
if (problems.length) throw new Error(problems.join(' '));
|
|
493
|
+
v.members = members;
|
|
494
|
+
},
|
|
495
|
+
fields: [
|
|
496
|
+
{ name: 'name', label: 'Name', kind: 'text', help: 'e.g. Sales lookups. Other applications subscribe to all of its components at once (Subscriptions), when this is a library or theme application.' },
|
|
497
|
+
{ name: 'description', label: 'Description', kind: 'text', wide: true },
|
|
498
|
+
{ name: 'members', label: 'Components', kind: 'list', wide: true,
|
|
499
|
+
help: 'Comma separated kind:name, e.g. lov:JOBS, authz_scheme:MANAGER, build_option:FEATURE_EXPORT, template_component:status_badge, list:SHORTCUTS.' },
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
// (103) shortcuts (APEX: Shared Components → Shortcuts)
|
|
503
|
+
shortcut: {
|
|
504
|
+
table: 'meta.shortcut',
|
|
505
|
+
scope: 'app',
|
|
506
|
+
label: 'Shortcut',
|
|
507
|
+
plural: 'Shortcuts',
|
|
508
|
+
icon: 'link',
|
|
509
|
+
summary: (s) => `"${s.name}" (${s.type})`,
|
|
510
|
+
defaults: { type: 'text' },
|
|
511
|
+
fields: [
|
|
512
|
+
{ name: 'name', label: 'Name', kind: 'upper', help: 'e.g. DELETE_CONFIRM_MSG: "DELETE_CONFIRM_MSG" (in double quotes) in a static region\'s content, an item\'s help text or a button\'s confirm message is replaced by the shortcut.' },
|
|
513
|
+
{ name: 'type', label: 'Type', kind: 'select', options: ['text', 'html', 'function_body'], help: 'text: the value (escaped in HTML) · html: the value as HTML · function_body: PL/pgSQL that returns the text, e.g. begin return \'Signed in as \' || :APP_USER; end. Runs as the application\'s role, once per request.' },
|
|
514
|
+
{ name: 'value', label: 'Value', kind: 'code', wide: true },
|
|
515
|
+
{ name: 'comments', label: 'Comments', kind: 'textarea', wide: true },
|
|
516
|
+
],
|
|
517
|
+
},
|
|
457
518
|
page_group: {
|
|
458
519
|
table: 'meta.page_group',
|
|
459
520
|
scope: 'app',
|
|
@@ -578,6 +639,27 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
578
639
|
web_credential: WEB_CREDENTIAL_SPEC,
|
|
579
640
|
rest_source: REST_SOURCE_SPEC,
|
|
580
641
|
data_load_def: DATA_LOAD_DEF_SPEC,
|
|
642
|
+
// (104) report queries (APEX: Shared Components → Report Queries)
|
|
643
|
+
report_query: {
|
|
644
|
+
table: 'meta.report_query',
|
|
645
|
+
scope: 'app',
|
|
646
|
+
label: 'Report query',
|
|
647
|
+
plural: 'Report queries',
|
|
648
|
+
icon: 'file',
|
|
649
|
+
summary: (q) => `${q.name} (${q.format})`,
|
|
650
|
+
defaults: { format: 'pdf', query: 'select empno, ename, job from sales.staff order by ename' },
|
|
651
|
+
fields: [
|
|
652
|
+
{ name: 'name', label: 'Name', kind: 'upper', group: 'Identification', help: 'Pages download it with ?doc=NAME (a document template of that name wins); buttons with action "document" name it.' },
|
|
653
|
+
{ name: 'description', label: 'Description', kind: 'text', wide: true, group: 'Identification' },
|
|
654
|
+
{ name: 'query', label: 'Query (SQL)', kind: 'code', wide: true, group: 'Source', help: 'A SELECT with :ITEM binds (the page\'s session state), run as the application\'s role. Its columns\' names are the headings.' },
|
|
655
|
+
{ name: 'format', label: 'Output format', kind: 'select', options: ['pdf', 'xlsx', 'csv', 'json', 'xml', 'html'], group: 'Output' },
|
|
656
|
+
{ name: 'layout', label: 'Report layout', kind: 'upper', group: 'Output', help: 'PDF: paper, margins, font size, colours, logo and footer (Shared Components → Report layouts). Empty: the default layout.' },
|
|
657
|
+
{ name: 'file_name', label: 'File name', kind: 'text', group: 'Output', help: 'Without extension, e.g. staff-&P3_DEPTNO. Empty: the name in lower case.' },
|
|
658
|
+
{ name: 'title', label: 'Title', kind: 'text', group: 'Output', help: 'Of a PDF or HTML file (&ITEM. allowed). Empty: the description or the name.' },
|
|
659
|
+
{ name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
660
|
+
{ name: 'comments', label: 'Comments', kind: 'textarea', wide: true, group: 'Comments' },
|
|
661
|
+
],
|
|
662
|
+
},
|
|
581
663
|
document_template: {
|
|
582
664
|
table: 'meta.document_template',
|
|
583
665
|
scope: 'app',
|
package/src/builder/designer.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { FastifyInstance } from 'fastify';
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
2
|
import { owner } from '../db.ts';
|
|
3
3
|
import { html, raw, type Raw } from '../html.ts';
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { COMPONENTS } from './components.ts';
|
|
6
|
-
import { back, BASE, bicon, csrf, developer, flash, input, select, send, shell, type Req } from './ui.ts';
|
|
6
|
+
import { back, BASE, bicon, csrf, designerUrl, developer, flash, input, select, send, shell, type Req } from './ui.ts';
|
|
7
|
+
import { wantsHtml } from './errors.ts';
|
|
8
|
+
import type { Session } from '../session.ts';
|
|
7
9
|
import { buildOptionChoices, componentForm, lookups, saveComponent } from './forms.ts';
|
|
8
10
|
import { regionConfigHelp } from './region-help.ts';
|
|
9
11
|
import { parseIncludes } from './static-files.ts';
|
|
@@ -47,16 +49,35 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
47
49
|
const appId = /^\d{1,9}$/.test(req.params.id) ? Number(req.params.id) : null;
|
|
48
50
|
const no = /^\d{1,9}$/.test(req.query.page ?? '') ? Number(req.query.page) : -1;
|
|
49
51
|
const p = appId === null ? null : await owner.one('select id from meta.page where app_id = $1 and page_no = $2', [appId, no]);
|
|
50
|
-
if (p) return reply.redirect(
|
|
52
|
+
if (p) return reply.redirect(designerUrl(appId!, no), 303);
|
|
51
53
|
flash(s, `There is no page ${req.query.page ?? ''}.`, 'error');
|
|
52
54
|
return back(reply, s, appId === null ? BASE : `${BASE}/apps/${appId}`);
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
// ---------------------------------------------------------------- page designer
|
|
58
|
+
// (sprint 86) its address is the application's id and the page's number; a browser that opens the
|
|
59
|
+
// internal id (an old bookmark, a redirect after saving) is sent there with the same query
|
|
60
|
+
app.get(`${BASE}/apps/:app/pages/:no`, async (req: Req, reply) => {
|
|
61
|
+
const s = await developer(req, reply);
|
|
62
|
+
if (!s) return;
|
|
63
|
+
const page = /^\d{1,9}$/.test(req.params.app) && /^\d{1,9}$/.test(req.params.no)
|
|
64
|
+
? await owner.one<{ id: number }>('select id from meta.page where app_id = $1 and page_no = $2', [req.params.app, req.params.no])
|
|
65
|
+
: undefined;
|
|
66
|
+
if (!page) return reply.code(404).send('Not found');
|
|
67
|
+
return pageDesigner(req, reply, s, page.id);
|
|
68
|
+
});
|
|
56
69
|
app.get(`${BASE}/pages/:pid`, async (req: Req, reply) => {
|
|
57
70
|
const s = await developer(req, reply);
|
|
58
71
|
if (!s) return;
|
|
59
|
-
|
|
72
|
+
if (wantsHtml(req) && /^\d{1,9}$/.test(req.params.pid)) {
|
|
73
|
+
const p = await owner.one<{ app_id: number; page_no: number }>('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
|
|
74
|
+
if (p) return reply.redirect(designerUrl(p.app_id, p.page_no, req.url.split('?')[1] ?? ''), 302);
|
|
75
|
+
}
|
|
76
|
+
return pageDesigner(req, reply, s, req.params.pid);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
async function pageDesigner(req: Req, reply: FastifyReply, s: Session, pid: number | string) {
|
|
80
|
+
const p = await owner.one('select p.*, a.alias, a.name as app_name from meta.page p join meta.app a on a.id = p.app_id where p.id = $1', [pid]);
|
|
60
81
|
if (!p) return reply.code(404).send('Not found');
|
|
61
82
|
const rows: Record<string, any[]> = {};
|
|
62
83
|
for (const kind of PAGE_KINDS) rows[kind] = (await owner.query(`select * from ${COMPONENTS[kind].table} where page_id = $1 order by seq, id`, [p.id])).rows;
|
|
@@ -73,7 +94,9 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
73
94
|
const sel = req.query.c ?? '';
|
|
74
95
|
const newKind = PAGE_KINDS.includes(req.query.new ?? '') ? req.query.new : '';
|
|
75
96
|
const [kind, cid] = sel.split('-');
|
|
76
|
-
const url = (q: string) =>
|
|
97
|
+
const url = (q: string) => designerUrl(p.app_id, p.page_no, q);
|
|
98
|
+
// the page's forms post to its internal id (a page number can change)
|
|
99
|
+
const post = `${BASE}/pages/${p.id}`;
|
|
77
100
|
const isSel = (key: string) => sel === key || (key === 'page' && !sel && !newKind);
|
|
78
101
|
const cur = (key: string) => (isSel(key) ? raw(' aria-current="true"') : '');
|
|
79
102
|
const badges = (r: any) => html`${r.authz ? html`<span class="pd-tag" title="Authorization: ${r.authz}">${icon('shield')}<span class="sr-only">Authorization ${r.authz}</span></span>` : ''}${r.condition || r.readonly_condition || r.condition_type ? html`<span class="pd-tag" title="Has a condition">${icon('filter')}<span class="sr-only">Has a condition</span></span>` : ''}${r.build_option ? html`<span class="pd-tag" title="Build option: ${r.build_option}">${icon('settings')}<span class="sr-only">Build option ${r.build_option}</span></span>` : ''}`;
|
|
@@ -81,8 +104,10 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
81
104
|
|
|
82
105
|
// ------------------------------------------------------------ left: component tree
|
|
83
106
|
// (sprint 77) data-kind and data-id: the node's context menu (Duplicate, Copy to other page, Delete…) and icon colour
|
|
107
|
+
// (sprint 90) a commented-out component: struck through in the tree, Uncomment in its menu
|
|
108
|
+
const commented = (r: any) => (r.build_option === 'COMMENTED_OUT' ? raw(' data-commented') : '');
|
|
84
109
|
const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
|
|
85
|
-
html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}">${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
|
|
110
|
+
html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
|
|
86
111
|
const folder = (label: string, children: Raw[] | Raw, open = true) =>
|
|
87
112
|
html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
|
|
88
113
|
const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
|
|
@@ -95,7 +120,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
95
120
|
const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
|
|
96
121
|
const kids = [...(items.length ? [folder('Items', items.map(itemLeaf))] : []),
|
|
97
122
|
...bySlot.map(([sl, list]) => folder(sl === 'region' ? 'Buttons' : `Buttons: ${SLOT_LABELS[sl]}`, list.map(buttonLeaf)))];
|
|
98
|
-
return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}">${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
|
|
123
|
+
return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
|
|
99
124
|
};
|
|
100
125
|
// (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
|
|
101
126
|
const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
|
|
@@ -129,7 +154,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
129
154
|
</ul></li>
|
|
130
155
|
</ul>
|
|
131
156
|
<dialog class="pd-dialog" id="pd-copy-dialog" aria-labelledby="pd-copy-title">
|
|
132
|
-
<form method="post" action="${
|
|
157
|
+
<form method="post" action="${post}/layout/copy">${csrf(s)}<input type="hidden" name="kind" value=""><input type="hidden" name="id" value="">
|
|
133
158
|
<h2 id="pd-copy-title">Copy to other page</h2>
|
|
134
159
|
<p class="muted" data-copy-what></p>
|
|
135
160
|
<div class="field"><label class="label" for="pd-copy-page">Page</label>
|
|
@@ -226,7 +251,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
226
251
|
${side === 'left' || extra('left') ? zone('left') : ''}${zone('body')}${side === 'right' || extra('right') ? zone('right') : ''}
|
|
227
252
|
</div>${zone('footer')}`;
|
|
228
253
|
const gallery = (k: 'region' | 'item' | 'button', types: string[], labels: Record<string, [string, string]>) =>
|
|
229
|
-
html`<ul class="pd-gallery-list" aria-label="${COMPONENTS[k].plural}">${types.map((t) => html`<li><a class="pd-gal" href="${url(`new=${k}&type=${t}`)}" draggable="true" data-new="${k}" data-type="${t}" title="
|
|
254
|
+
html`<ul class="pd-gallery-list" aria-label="${COMPONENTS[k].plural}">${types.map((t) => html`<li><a class="pd-gal" href="${url(`new=${k}&type=${t}`)}" draggable="true" data-new="${k}" data-type="${t}" title="${labels[t]?.[0] ?? t}: drag onto the layout, or open to create">${bicon(labels[t]?.[1] ?? k)}<span>${labels[t]?.[0] ?? t}</span></a></li>`)}</ul>`;
|
|
230
255
|
const layout = html`
|
|
231
256
|
<div class="pd-canvas-bar" role="toolbar" aria-label="Layout">
|
|
232
257
|
<button type="button" class="tb-btn" data-zoom="-1" title="Zoom out" hidden>${bicon('zoom-out')}<span class="sr-only">Zoom out</span></button>
|
|
@@ -294,7 +319,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
294
319
|
let hasForm = true;
|
|
295
320
|
const peTabs = (panels: Raw[]) => html`<div class="pd-tabs" data-tabs="pd-right" data-tabs-remember aria-label="Properties">${panels}</div>`;
|
|
296
321
|
const moveForm = (k: string, id: number, fields: Raw, button: Raw, title: string) =>
|
|
297
|
-
html`<form method="post" action="${
|
|
322
|
+
html`<form method="post" action="${post}/layout/move" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="kind" value="${k}"><input type="hidden" name="id" value="${id}">${fields}<button class="tb-btn" title="${title}">${button}</button></form>`;
|
|
298
323
|
if (newKind) {
|
|
299
324
|
const spec = COMPONENTS[newKind];
|
|
300
325
|
const lastSeq = Math.max(0, ...rows[newKind].map((r) => r.seq));
|
|
@@ -325,9 +350,9 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
325
350
|
${moveForm(kind, row.id, html`<input type="hidden" name="dir" value="up">`, html`${bicon('arrow-up')}<span class="sr-only">Move up</span>`, 'Move up (earlier in the sequence)')}
|
|
326
351
|
${moveForm(kind, row.id, html`<input type="hidden" name="dir" value="down">`, html`${bicon('arrow-down')}<span class="sr-only">Move down</span>`, 'Move down (later in the sequence)')}
|
|
327
352
|
${kind === 'region'
|
|
328
|
-
? html`<form method="post" action="${
|
|
353
|
+
? html`<form method="post" action="${post}/layout/span" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="id" value="${row.id}"><input type="hidden" name="delta" value="-1"><button class="tb-btn" title="Narrower (one column less)"${row.columns <= 1 ? raw(' disabled') : ''}>${bicon('narrow')}<span class="sr-only">Narrower</span></button></form>
|
|
329
354
|
<span class="pe-span" title="Column span">${row.columns}/12</span>
|
|
330
|
-
<form method="post" action="${
|
|
355
|
+
<form method="post" action="${post}/layout/span" class="pe-arrange-form">${csrf(s)}<input type="hidden" name="id" value="${row.id}"><input type="hidden" name="delta" value="1"><button class="tb-btn" title="Wider (one column more)"${row.columns >= 12 ? raw(' disabled') : ''}>${bicon('wide')}<span class="sr-only">Wider</span></button></form>`
|
|
331
356
|
: moveForm(kind, row.id, html`<label class="sr-only" for="pe-move-region">Move to region</label><select id="pe-move-region" name="region">${regionOptions}</select>`, html`<span>Move</span>`, 'Move to the end of this region')}
|
|
332
357
|
</div>`;
|
|
333
358
|
}
|
|
@@ -395,7 +420,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
395
420
|
const next = pages[idx + 1];
|
|
396
421
|
const h = undoState(s, p.id);
|
|
397
422
|
const tbForm = (op: string, ic: string, label: string, title: string | null) =>
|
|
398
|
-
html`<form method="post" action="${
|
|
423
|
+
html`<form method="post" action="${post}/layout/${op}" class="tb-form">${csrf(s)}<button class="tb-btn" title="${title ? `${label}: ${title}` : `Nothing to ${label.toLowerCase()}`}"${title ? '' : raw(' disabled')}>${bicon(ic)}<span class="sr-only">${label}</span></button></form>`;
|
|
399
424
|
const createMenu = html`<details class="menu tb-menu">
|
|
400
425
|
<summary class="tb-btn" title="Create">${icon('plus')}${bicon('down', 'icon tb-caret')}<span class="sr-only">Create</span></summary>
|
|
401
426
|
<div class="menu-panel align-right"><div class="menu-section menu-links">
|
|
@@ -415,7 +440,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
415
440
|
</div></div></details>`;
|
|
416
441
|
const toolbar = html`
|
|
417
442
|
<div class="tb-group pd-pagenav" role="group" aria-label="Page">
|
|
418
|
-
${prev ? html`<a class="tb-btn" href="${
|
|
443
|
+
${prev ? html`<a class="tb-btn" href="${designerUrl(p.app_id, prev.page_no)}" title="Previous page: ${prev.page_no}. ${prev.name}">${bicon('left')}<span class="sr-only">Previous page</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('left')}</span>`}
|
|
419
444
|
<form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto-no">
|
|
420
445
|
<label class="sr-only" for="pd-goto-no">Page number (Enter goes there)</label>
|
|
421
446
|
<input id="pd-goto-no" name="page" type="number" min="0" max="999999999" step="1" value="${p.page_no}" inputmode="numeric" title="Page number: type or step to a number, then Enter">
|
|
@@ -425,7 +450,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
425
450
|
<select id="pd-goto" name="page" data-autosubmit>${pages.map((x) => html`<option value="${x.page_no}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no} · ${x.name}</option>`)}</select>
|
|
426
451
|
<button class="tb-btn tb-text pd-goto-go">Go</button>
|
|
427
452
|
</form>
|
|
428
|
-
${next ? html`<a class="tb-btn" href="${
|
|
453
|
+
${next ? html`<a class="tb-btn" href="${designerUrl(p.app_id, next.page_no)}" title="Next page: ${next.page_no}. ${next.name}">${bicon('right')}<span class="sr-only">Next page</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('right')}</span>`}
|
|
429
454
|
</div>
|
|
430
455
|
<div class="tb-group tb-undo" role="group" aria-label="History">${tbForm('undo', 'undo', 'Undo', h.undo)}${tbForm('redo', 'redo', 'Redo', h.redo)}</div>
|
|
431
456
|
<div class="tb-group tb-menus">${createMenu}${utilMenu}</div>
|
|
@@ -442,7 +467,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
442
467
|
<section class="pd-pane pd-right tab-panel" id="pd-p-props" data-tab${pane === 'pd-p-props' ? raw(' data-tab-active') : ''}><h2 class="tab-title pd-pane-title"><span>Properties</span></h2>${right}</section>
|
|
443
468
|
</div>`;
|
|
444
469
|
return send(reply, s, shell(s, `Page ${p.page_no}`, [['App Builder', BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [`Page ${p.page_no}: ${p.name}`]], main, 'apps', { toolbar, full: true }));
|
|
445
|
-
}
|
|
470
|
+
}
|
|
446
471
|
|
|
447
472
|
// (sprint 59) Quick edit from the developer toolbar: an item or button of the page by its name
|
|
448
473
|
app.get(`${BASE}/pages/:pid/quick`, async (req: Req, reply) => {
|
|
@@ -452,7 +477,9 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
452
477
|
const q = req.query ?? {};
|
|
453
478
|
const [kind, table, name] = q.item ? ['item', 'meta.item', String(q.item).toUpperCase()] : q.button ? ['button', 'meta.button', String(q.button).toUpperCase()] : [null, null, null];
|
|
454
479
|
const row = kind ? await owner.one(`select id from ${table} where page_id = $1 and name = $2 order by id limit 1`, [req.params.pid, name]) : null;
|
|
455
|
-
|
|
480
|
+
const page = await owner.one<{ app_id: number; page_no: number }>('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
|
|
481
|
+
if (!page) return reply.code(404).send('Not found');
|
|
482
|
+
return reply.redirect(designerUrl(page.app_id, page.page_no, row ? `c=${kind}-${row.id}` : ''));
|
|
456
483
|
});
|
|
457
484
|
|
|
458
485
|
app.post(`${BASE}/pages/:pid`, async (req: Req, reply) => {
|