pgkiln 0.45.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/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/docs/apex-feature-parity.md +6 -3
- package/docs/guide/03-builder.md +36 -12
- package/docs/guide/04-pages-and-regions.md +74 -2
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/09-reference.md +1 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +6 -1
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +62 -2
- package/public/app.js +244 -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/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +1 -1
- 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/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 +2 -2
- package/src/builder/ui.ts +2 -0
- package/src/cli/replace.ts +10 -0
- 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 +4 -2
- package/src/runtime/devbar.ts +1 -1
- 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 +15 -8
- package/src/runtime/items.ts +33 -21
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +7 -3
- package/src/runtime/report.ts +2 -2
- package/src/runtime/routes.ts +3 -1
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
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);
|
|
@@ -39,7 +39,7 @@ const CHECKS: Record<string, SqlShape> = {
|
|
|
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
44
|
'app_computation.expression': 'type:sql_query=select,*=none',
|
|
45
45
|
'computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
|
|
@@ -107,7 +107,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
107
107
|
{ name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
|
|
108
108
|
{ name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
|
|
109
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.' },
|
|
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', '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' },
|
|
111
111
|
{ name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
|
|
112
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).' },
|
|
113
113
|
{ name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
|
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) => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import { html } from '../html.ts';
|
|
3
|
+
import { documentShell } from '../layout.ts';
|
|
4
|
+
import { BASE, THEME_COOKIE, validTheme } from './ui.ts';
|
|
5
|
+
|
|
6
|
+
// (sprint 86) The builder's error pages: a browser that opens an address under /builder that
|
|
7
|
+
// doesn't exist (or an application or page that doesn't, or not for this developer), or that
|
|
8
|
+
// meets a server error, gets an HTML page in the builder's theme with a way back, instead of
|
|
9
|
+
// JSON or plain text. Requests from scripts (fetch, the layout canvas) keep their plain answers.
|
|
10
|
+
|
|
11
|
+
/** A browser's navigation (it asks for HTML), not a script's request. */
|
|
12
|
+
export const wantsHtml = (req: FastifyRequest) =>
|
|
13
|
+
(req.method === 'GET' || req.method === 'HEAD') && /\btext\/html\b/.test(String(req.headers.accept ?? '')) && req.headers['x-requested-with'] === undefined;
|
|
14
|
+
|
|
15
|
+
const isBuilder = (url: string) => url === BASE || url.startsWith(`${BASE}/`) || url.startsWith(`${BASE}?`);
|
|
16
|
+
|
|
17
|
+
const TEXTS: Record<number, { title: string; text: string }> = {
|
|
18
|
+
404: { title: 'Page not found', text: 'There is nothing at this address in the App Builder. The application or page may have been deleted, or belongs to a workspace you are not a member of.' },
|
|
19
|
+
500: { title: 'Something went wrong', text: 'The App Builder could not show this page because of an error on the server. It has been logged; try again, or go back.' },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The error page for a status (404, or 500 for any server error). */
|
|
23
|
+
export function builderErrorPage(req: FastifyRequest, status: number) {
|
|
24
|
+
const { title, text } = TEXTS[status >= 500 ? 500 : 404];
|
|
25
|
+
const theme = validTheme((req.cookies as Record<string, string> | undefined)?.[THEME_COOKIE]) ?? 'auto';
|
|
26
|
+
return documentShell(
|
|
27
|
+
`${title} · App Builder`,
|
|
28
|
+
html`<main class="t-error" id="main"><div class="card"><h1>${title}</h1><p>${text}</p>
|
|
29
|
+
<p class="muted">${status >= 500 ? 'Error' : 'Not found'}: <code>${req.url.split('?')[0].slice(0, 200)}</code></p>
|
|
30
|
+
<p><a class="btn btn-hot" href="${BASE}">App Builder home</a></p></div></main>`,
|
|
31
|
+
'login-body',
|
|
32
|
+
{},
|
|
33
|
+
'',
|
|
34
|
+
{ theme },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sendPage = (req: FastifyRequest, reply: FastifyReply, status: number) =>
|
|
39
|
+
reply.code(status).type('text/html; charset=utf-8').header('cache-control', 'no-store').send(builderErrorPage(req, status));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The hooks (registered on the root instance): unknown builder addresses, the builder's own
|
|
43
|
+
* "Not found" answers and server errors become HTML pages for browsers.
|
|
44
|
+
*/
|
|
45
|
+
export function builderErrorPages(app: FastifyInstance) {
|
|
46
|
+
app.setNotFoundHandler((req, reply) => {
|
|
47
|
+
if (isBuilder(req.url) && wantsHtml(req)) return sendPage(req, reply, 404);
|
|
48
|
+
return reply.code(404).send({ message: `Route ${req.method}:${req.url} not found`, error: 'Not Found', statusCode: 404 });
|
|
49
|
+
});
|
|
50
|
+
// a route's own plain "Not found" (an id that doesn't exist, another workspace's application)
|
|
51
|
+
app.addHook('onSend', async (req, reply, payload) => {
|
|
52
|
+
if (reply.statusCode !== 404 || !isBuilder(req.url) || !wantsHtml(req)) return payload;
|
|
53
|
+
if (!/^text\/plain\b/.test(String(reply.getHeader('content-type') ?? ''))) return payload;
|
|
54
|
+
reply.type('text/html; charset=utf-8');
|
|
55
|
+
return builderErrorPage(req, 404);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** For the root error handler: a server error on a builder page a browser opened, as HTML (true: sent). */
|
|
60
|
+
export function builderServerError(req: FastifyRequest, reply: FastifyReply, err: Error & { statusCode?: number }) {
|
|
61
|
+
if (!isBuilder(req.url) || !wantsHtml(req) || (err.statusCode !== undefined && err.statusCode < 500)) return false;
|
|
62
|
+
req.log.error(err);
|
|
63
|
+
sendPage(req, reply, 500);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { heading } from '../runtime/items.ts';
|
|
5
|
+
import { RESERVED_SCHEMAS, SOURCE_ID } from '../runtime/data-reporter.ts';
|
|
6
|
+
import { fdSourcesOf, type FdColumn, type FdSource } from '../runtime/form-designer.ts';
|
|
7
|
+
import type { Session } from '../session.ts';
|
|
8
|
+
import { findObject, objectList, type DbObject } from './reporter.ts';
|
|
9
|
+
import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
|
|
10
|
+
|
|
11
|
+
// Page designer → a Form Designer region → Settings (sprint 85): the tables
|
|
12
|
+
// and views the region offers (which columns forms may show and change, their
|
|
13
|
+
// labels and lists of values, the key column, whether forms may add, change
|
|
14
|
+
// and delete rows) and who may design forms. Saving replaces only the keys
|
|
15
|
+
// this form knows (sources, design_authz, page_size, empty).
|
|
16
|
+
|
|
17
|
+
type Config = Record<string, any>;
|
|
18
|
+
type Body = Record<string, string | undefined>;
|
|
19
|
+
|
|
20
|
+
export const MAX_FD_SOURCES = 20;
|
|
21
|
+
|
|
22
|
+
/** The single-column primary key of a table (null: none, or a view). */
|
|
23
|
+
export async function primaryKeyOf(schema: string, table: string): Promise<string | null> {
|
|
24
|
+
const row = await owner.one<{ name: string }>(
|
|
25
|
+
`select a.attname as name from pg_index i join pg_class c on c.oid = i.indrelid join pg_namespace n on n.oid = c.relnamespace
|
|
26
|
+
join pg_attribute a on a.attrelid = c.oid and a.attnum = i.indkey[0]
|
|
27
|
+
where i.indisprimary and i.indnatts = 1 and n.nspname = $1 and c.relname = $2`, [schema, table]);
|
|
28
|
+
return row?.name ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const clip = (v: string | undefined, max: number) => (v ?? '').trim().slice(0, max);
|
|
32
|
+
const LOV_REF = /^(LOV:[A-Z0-9_]+|STATIC:.+)$/is;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The region's settings from the form. Columns and the key must exist in the
|
|
36
|
+
* source's table or view (looked up by `lookup`); a new source is a table or
|
|
37
|
+
* view outside pgkiln's and the system's schemas, with its columns offered
|
|
38
|
+
* except binary and JSON ones, and its primary key as the key. A list of
|
|
39
|
+
* values is a shared one of the application (`lovs`) or STATIC:….
|
|
40
|
+
*/
|
|
41
|
+
export async function mergeFormDesignerSettings(
|
|
42
|
+
config: Config, b: Body, a: { authz: Set<string>; lovs: Set<string> },
|
|
43
|
+
lookup: { byName: (schema: string, table: string) => Promise<DbObject | null>; byOid: (oid: number) => Promise<DbObject | null>; key: (schema: string, table: string) => Promise<string | null> } =
|
|
44
|
+
{ byName: findObject, byOid: objectByOid, key: primaryKeyOf },
|
|
45
|
+
): Promise<{ config: Config; errors: string[] }> {
|
|
46
|
+
const out: Config = { ...config };
|
|
47
|
+
const errors: string[] = [];
|
|
48
|
+
const size = Number(b.page_size);
|
|
49
|
+
if (b.page_size && Number.isInteger(size) && size >= 5 && size <= 200 && size !== 25) out.page_size = size;
|
|
50
|
+
else delete out.page_size;
|
|
51
|
+
const empty = clip(b.empty, 200);
|
|
52
|
+
if (empty) out.empty = empty;
|
|
53
|
+
else delete out.empty;
|
|
54
|
+
delete out.design_authz;
|
|
55
|
+
if (b.design_authz && a.authz.has(b.design_authz.toUpperCase())) out.design_authz = b.design_authz;
|
|
56
|
+
else if (b.design_authz) errors.push(`There is no authorization scheme ${b.design_authz}.`);
|
|
57
|
+
|
|
58
|
+
const lovOk = (v: string) => LOV_REF.test(v) && (!/^LOV:/i.test(v) || a.lovs.has(v.slice(4).toUpperCase()));
|
|
59
|
+
const sources: FdSource[] = [];
|
|
60
|
+
const existing = fdSourcesOf({ config });
|
|
61
|
+
for (let i = 0; i < existing.length; i++) {
|
|
62
|
+
const s = existing.find((x) => x.id === b[`s${i}_key`]);
|
|
63
|
+
if (!s || sources.some((x) => x.id === s.id)) continue;
|
|
64
|
+
if (b[`s${i}_remove`] === 'true') continue;
|
|
65
|
+
const obj = await lookup.byName(s.schema, s.table);
|
|
66
|
+
const names = new Set(obj?.columns.map((c) => c.name));
|
|
67
|
+
const columns: FdColumn[] = [];
|
|
68
|
+
for (let j = 0; j < 500 && b[`s${i}_col_${j}`] !== undefined; j++) {
|
|
69
|
+
const name = b[`s${i}_col_${j}`]!;
|
|
70
|
+
if (b[`s${i}_on_${j}`] !== 'true' || columns.some((c) => c.name === name)) continue;
|
|
71
|
+
// a column the table no longer has may stay offered (it is skipped at run time), but none can be added
|
|
72
|
+
if (!names.has(name) && !s.columns.some((c) => c.name === name)) continue;
|
|
73
|
+
const label = clip(b[`s${i}_label_${j}`], 80);
|
|
74
|
+
let lov = clip(b[`s${i}_lov_${j}`], 2000);
|
|
75
|
+
if (lov && !lovOk(lov)) {
|
|
76
|
+
errors.push(`${s.id}.${name}: "${lov.slice(0, 60)}" is not a shared list of values of this application (LOV:NAME) or STATIC:Display;value,….`);
|
|
77
|
+
lov = '';
|
|
78
|
+
}
|
|
79
|
+
columns.push({ name, ...(label ? { label } : {}), ...(lov ? { lov } : {}) });
|
|
80
|
+
}
|
|
81
|
+
if (!columns.length) errors.push(`Data source ${s.id} offers no columns.`);
|
|
82
|
+
const key = clip(b[`s${i}_keycol`], 63);
|
|
83
|
+
if (key && !names.has(key)) errors.push(`${s.id}: the key column ${key} is not in ${s.schema}.${s.table}.`);
|
|
84
|
+
const label = clip(b[`s${i}_label`], 80);
|
|
85
|
+
const description = clip(b[`s${i}_description`], 300);
|
|
86
|
+
const hasKey = !!key && names.has(key);
|
|
87
|
+
sources.push({
|
|
88
|
+
id: s.id, ...(label ? { label } : {}), ...(description ? { description } : {}), schema: s.schema, table: s.table,
|
|
89
|
+
...(hasKey ? { key } : {}), columns, insert: b[`s${i}_insert`] === 'true', update: hasKey && b[`s${i}_update`] === 'true', delete: hasKey && b[`s${i}_delete`] === 'true',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (b.new_object) {
|
|
94
|
+
const obj = /^\d{1,10}$/.test(b.new_object) ? await lookup.byOid(Number(b.new_object)) : null;
|
|
95
|
+
if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push('Choose a table or view for the new data source.');
|
|
96
|
+
else if (sources.length >= MAX_FD_SOURCES) errors.push(`A region offers at most ${MAX_FD_SOURCES} data sources.`);
|
|
97
|
+
else {
|
|
98
|
+
let id = clip(b.new_id, 40).toLowerCase();
|
|
99
|
+
if (id && !SOURCE_ID.test(id)) {
|
|
100
|
+
errors.push(`"${id}" is not a valid static id: lower case letters, digits and _, starting with a letter.`);
|
|
101
|
+
id = '';
|
|
102
|
+
}
|
|
103
|
+
if (id && sources.some((s) => s.id === id)) {
|
|
104
|
+
errors.push(`There is already a data source ${id}.`);
|
|
105
|
+
id = '';
|
|
106
|
+
}
|
|
107
|
+
if (!id) {
|
|
108
|
+
const base = (obj.table.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^[^a-z]+/, '') || 'source').slice(0, 36);
|
|
109
|
+
id = base;
|
|
110
|
+
for (let n = 2; sources.some((s) => s.id === id); n++) id = `${base}_${n}`;
|
|
111
|
+
}
|
|
112
|
+
const label = clip(b.new_label, 80) || heading(obj.table);
|
|
113
|
+
const key = await lookup.key(obj.schema, obj.table);
|
|
114
|
+
const columns = obj.columns.filter((c) => !/^(bytea|json|jsonb|xml)$/.test(c.type)).map((c) => ({ name: c.name, label: heading(c.name) }));
|
|
115
|
+
// adding rows at first; changing them when the table has a key; never deleting until the developer says so
|
|
116
|
+
sources.push({ id, label, schema: obj.schema, table: obj.table, ...(key ? { key } : {}), columns, insert: true, update: !!key, delete: false });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (sources.length) out.sources = sources;
|
|
120
|
+
else delete out.sources;
|
|
121
|
+
return { config: out, errors };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function objectByOid(oid: number): Promise<DbObject | null> {
|
|
125
|
+
const row = await owner.one<{ schema: string; table: string }>(`select n.nspname as schema, c.relname as table from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.oid = $1`, [oid]);
|
|
126
|
+
return row ? findObject(row.schema, row.table) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const opt = (value: string, label: string, current: string) => html`<option value="${value}"${value === current ? raw(' selected') : ''}>${label}</option>`;
|
|
130
|
+
|
|
131
|
+
/** The settings form under a Form Designer region in the page designer. */
|
|
132
|
+
export async function formDesignerSettingsForm(pageId: number, appId: number, r: { id: number; config: Config }, s: Session): Promise<Raw> {
|
|
133
|
+
const cfg = r.config ?? {};
|
|
134
|
+
const id = (n: string) => `fds_${r.id}_${n}`;
|
|
135
|
+
const app = await owner.one<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId]);
|
|
136
|
+
const schemes = (await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1 order by name', [appId])).rows.map((x) => x.name);
|
|
137
|
+
const lovs = (await owner.query<{ name: string }>('select name from meta.lov where app_id = $1 order by name', [appId])).rows.map((x) => x.name);
|
|
138
|
+
const objects = await objectList(app?.db_role ?? null);
|
|
139
|
+
const sources = fdSourcesOf({ config: cfg });
|
|
140
|
+
const designAuthz = typeof cfg.design_authz === 'string' ? cfg.design_authz : '';
|
|
141
|
+
const role = app?.db_role ?? null;
|
|
142
|
+
const sourceSets = await Promise.all(
|
|
143
|
+
sources.map(async (src, i) => {
|
|
144
|
+
const obj = await findObject(src.schema, src.table);
|
|
145
|
+
const offered = new Map(src.columns.map((c) => [c.name, c]));
|
|
146
|
+
const all = [...(obj?.columns ?? []).map((c) => ({ name: c.name, type: c.type, missing: false })),
|
|
147
|
+
...src.columns.filter((c) => !obj?.columns.some((x) => x.name === c.name)).map((c) => ({ name: c.name, type: '', missing: true }))];
|
|
148
|
+
const privileges = obj && role
|
|
149
|
+
? await owner.one<{ sel: boolean; ins: boolean; upd: boolean; del: boolean }>(
|
|
150
|
+
`select has_table_privilege($1, $2::oid, 'select') as sel, has_table_privilege($1, $2::oid, 'insert') as ins,
|
|
151
|
+
has_table_privilege($1, $2::oid, 'update') as upd, has_table_privilege($1, $2::oid, 'delete') as del`, [role, obj.oid])
|
|
152
|
+
.catch(() => undefined)
|
|
153
|
+
: undefined;
|
|
154
|
+
const missing = privileges ? (['sel', 'ins', 'upd', 'del'] as const).filter((k) => !privileges[k] && (k === 'sel' || src[({ ins: 'insert', upd: 'update', del: 'delete' } as const)[k as 'ins' | 'upd' | 'del']])) : [];
|
|
155
|
+
const grant = { sel: 'SELECT', ins: 'INSERT', upd: 'UPDATE', del: 'DELETE' };
|
|
156
|
+
const check = (k: 'insert' | 'update' | 'delete', label: string, needsKey: boolean) =>
|
|
157
|
+
html`<label class="check"><input type="checkbox" name="s${i}_${k}" value="true"${src[k] ? raw(' checked') : ''}> ${label}${needsKey && !src.key ? ' (needs a key column)' : ''}</label>`;
|
|
158
|
+
return html`<fieldset class="prop-group"><legend>${src.label || src.id} <span class="muted">(${src.schema}.${src.table})</span></legend>
|
|
159
|
+
<input type="hidden" name="s${i}_key" value="${src.id}">
|
|
160
|
+
${!obj ? html`<div class="alert alert-error" role="alert">The table or view ${src.schema}.${src.table} does not exist (any more).</div>` : ''}
|
|
161
|
+
${missing.length ? html`<div class="alert alert-error" role="alert">The application's database role lacks ${missing.map((k) => grant[k]).join(', ')} on ${src.schema}.${src.table}: grant it, or forms can't do that.</div>` : ''}
|
|
162
|
+
<div class="form-grid">
|
|
163
|
+
<div class="field"><label class="label" for="${id(`s${i}_id`)}">Static id</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
|
|
164
|
+
<div class="field"><label class="label" for="${id(`s${i}_label`)}">Label</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
|
|
165
|
+
<div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">Description (shown to designers)</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
|
|
166
|
+
<div class="field"><label class="label" for="${id(`s${i}_keycol`)}">Key column (identifies a row)</label>
|
|
167
|
+
<select id="${id(`s${i}_keycol`)}" name="s${i}_keycol"><option value="">- none: forms only add rows -</option>${(obj?.columns ?? []).map((c) => opt(c.name, c.name, src.key ?? ''))}</select></div>
|
|
168
|
+
<div class="field"><span class="label">Forms on it may</span>
|
|
169
|
+
${check('insert', 'add rows', false)} ${check('update', 'change rows', true)} ${check('delete', 'delete rows', true)}</div>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Offer</th><th scope="col">Column</th><th scope="col">Label</th><th scope="col">List of values</th></tr></thead><tbody>
|
|
172
|
+
${all.map((c, j) => {
|
|
173
|
+
const o = offered.get(c.name);
|
|
174
|
+
return html`<tr>
|
|
175
|
+
<td data-label="Offer"><input type="hidden" name="s${i}_col_${j}" value="${c.name}"><input type="checkbox" name="s${i}_on_${j}" value="true"${o ? raw(' checked') : ''} aria-label="Offer ${c.name}"></td>
|
|
176
|
+
<td data-label="Column"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">not in the table</span>` : ''}</td>
|
|
177
|
+
<td data-label="Label"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="Label of ${c.name}"></td>
|
|
178
|
+
<td data-label="List of values"><input name="s${i}_lov_${j}" maxlength="2000" value="${o?.lov ?? ''}" placeholder="LOV:NAME" list="${id('lovs')}" aria-label="List of values of ${c.name}"></td>
|
|
179
|
+
</tr>`;
|
|
180
|
+
})}
|
|
181
|
+
</tbody></table></div>
|
|
182
|
+
<div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> Remove this data source (forms on it stop working)</label></div>
|
|
183
|
+
</fieldset>`;
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
return html`<h3 class="u-mt15">Form Designer settings</h3>
|
|
187
|
+
<p class="muted u-mt0">Users that pass the designers' authorization scheme build data entry forms on the tables and views below: they pick offered columns, labels, help texts, required fields and defaults, and publish forms for everyone who can see this region. Forms read and write rows as the application's database role, so grants and row level security apply.</p>
|
|
188
|
+
<datalist id="${id('lovs')}">${lovs.map((n) => html`<option value="LOV:${n}">`)}</datalist>
|
|
189
|
+
<form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/form-designer" class="component-form">${csrf(s)}
|
|
190
|
+
<fieldset class="prop-group"><legend>Forms</legend><div class="form-grid">
|
|
191
|
+
<div class="field"><label class="label" for="${id('design_authz')}">Who may design forms</label>
|
|
192
|
+
<select id="${id('design_authz')}" name="design_authz">${opt('', 'Nobody (choose an authorization scheme)', designAuthz)}${schemes.map((n) => opt(n, `Authorization: ${n}`, designAuthz))}</select></div>
|
|
193
|
+
<div class="field"><label class="label" for="${id('page_size')}">Rows per page</label>
|
|
194
|
+
<input id="${id('page_size')}" name="page_size" type="number" min="5" max="200" value="${cfg.page_size ?? ''}" placeholder="25"></div>
|
|
195
|
+
<div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
|
|
196
|
+
<input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="No data found"></div>
|
|
197
|
+
</div></fieldset>
|
|
198
|
+
${sourceSets}
|
|
199
|
+
<fieldset class="prop-group"><legend>Add a data source</legend><div class="form-grid">
|
|
200
|
+
<div class="field" data-wide><label class="label" for="${id('new_object')}">Table or view</label>
|
|
201
|
+
<select id="${id('new_object')}" name="new_object"><option value="">- none -</option>${objects.map((o) => html`<option value="${o.oid}">${o.name}${o.access ? '' : ' (no access for the app role)'}</option>`)}</select></div>
|
|
202
|
+
<div class="field"><label class="label" for="${id('new_id')}">Static id</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="from the name"></div>
|
|
203
|
+
<div class="field"><label class="label" for="${id('new_label')}">Label</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
|
|
204
|
+
</div>
|
|
205
|
+
<small class="help">All columns are offered at first (except binary and JSON ones), the primary key is the key column, and forms may add and change rows, not delete them. Untick columns designers should not put on forms, such as generated keys and audit columns.</small></fieldset>
|
|
206
|
+
<div class="buttons"><button class="btn btn-hot">Save Form Designer settings</button></div>
|
|
207
|
+
</form>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function formDesignerBuilderRoutes(app: FastifyInstance) {
|
|
211
|
+
app.post(`${BASE}/pages/:pid/region/:rid/form-designer`, async (req: Req, reply) => {
|
|
212
|
+
const s = await developer(req, reply);
|
|
213
|
+
if (!s) return;
|
|
214
|
+
const { pid, rid } = req.params as { pid: string; rid: string };
|
|
215
|
+
const r = /^\d{1,9}$/.test(pid) && /^\d{1,9}$/.test(rid)
|
|
216
|
+
? await owner.one<{ id: number; config: Config; app_id: number }>(
|
|
217
|
+
`select r.id, r.config, p.app_id from meta.region r join meta.page p on p.id = r.page_id where r.id = $1 and r.page_id = $2 and r.type = 'form_designer'`, [rid, pid])
|
|
218
|
+
: undefined;
|
|
219
|
+
if (!r) return reply.code(404).send('Not found');
|
|
220
|
+
const schemes = (await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1', [r.app_id])).rows;
|
|
221
|
+
const lovs = (await owner.query<{ name: string }>('select name from meta.lov where app_id = $1', [r.app_id])).rows;
|
|
222
|
+
const { config, errors } = await mergeFormDesignerSettings(r.config ?? {}, (req.body ?? {}) as Body,
|
|
223
|
+
{ authz: new Set(schemes.map((x) => x.name.toUpperCase())), lovs: new Set(lovs.map((x) => x.name.toUpperCase())) });
|
|
224
|
+
await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
|
|
225
|
+
if (errors.length) flash(s, errors.join(' '), 'error');
|
|
226
|
+
else flash(s, 'Settings saved.');
|
|
227
|
+
return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
|
|
228
|
+
});
|
|
229
|
+
}
|
package/src/builder/locks.ts
CHANGED
|
@@ -41,6 +41,12 @@ const RUN_ONLY_EXEMPT = /^\/apps\/\d+\/(availability|backups|backups\/settings|l
|
|
|
41
41
|
/** The application (and page) a builder URL under /apps/:id or /pages/:pid is about, or null. */
|
|
42
42
|
export async function appOfPath(path: string): Promise<{ appId: number; pageNo: number | null; pageId: number | null } | null> {
|
|
43
43
|
const rel = path.split('?')[0].slice(BASE.length);
|
|
44
|
+
// (sprint 86) the page designer by the page's number
|
|
45
|
+
const byNo = /^\/apps\/(\d{1,9})\/pages\/(\d{1,9})(\/|$)/.exec(rel);
|
|
46
|
+
if (byNo) {
|
|
47
|
+
const p = await owner.one<{ id: number }>('select id from meta.page where app_id = $1 and page_no = $2', [Number(byNo[1]), Number(byNo[2])]);
|
|
48
|
+
return { appId: Number(byNo[1]), pageNo: Number(byNo[2]), pageId: p?.id ?? null };
|
|
49
|
+
}
|
|
44
50
|
const app = /^\/apps\/(\d{1,9})(\/|$)/.exec(rel);
|
|
45
51
|
if (app) return { appId: Number(app[1]), pageNo: null, pageId: null };
|
|
46
52
|
const page = /^\/pages\/(\d{1,9})(\/|$)/.exec(rel);
|