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.
Files changed (91) hide show
  1. package/db/migrations/102_app_computations.sql +345 -0
  2. package/db/migrations/103_shortcuts.sql +341 -0
  3. package/db/migrations/104_report_queries.sql +413 -0
  4. package/db/migrations/105_component_settings_groups.sql +367 -0
  5. package/db/migrations/106_form_designer.sql +102 -0
  6. package/db/migrations/107_app_version.sql +9 -0
  7. package/docs/apex-feature-parity.md +15 -12
  8. package/docs/guide/02-concepts.md +24 -0
  9. package/docs/guide/03-builder.md +69 -13
  10. package/docs/guide/04-pages-and-regions.md +84 -3
  11. package/docs/guide/05-items.md +14 -2
  12. package/docs/guide/06-processing.md +22 -0
  13. package/docs/guide/09-reference.md +14 -1
  14. package/docs/guide/11-from-apex.md +2 -1
  15. package/docs/guide/12-development.md +8 -2
  16. package/docs/guide/16-files.md +23 -0
  17. package/docs/guide/18-cli.md +4 -0
  18. package/examples/hr/hr_61_form_designer.sql +80 -0
  19. package/package.json +1 -1
  20. package/public/app.css +79 -2
  21. package/public/app.js +307 -35
  22. package/public/builder.css +21 -1
  23. package/public/builder.js +67 -0
  24. package/public/sw.js +2 -1
  25. package/src/app.ts +9 -1
  26. package/src/appfiles.ts +18 -2
  27. package/src/builder/advisor.ts +7 -2
  28. package/src/builder/appwizard.ts +22 -6
  29. package/src/builder/arrange.ts +33 -12
  30. package/src/builder/code-editor.ts +7 -2
  31. package/src/builder/components.ts +86 -4
  32. package/src/builder/designer.ts +44 -17
  33. package/src/builder/errors.ts +65 -0
  34. package/src/builder/form-designer.ts +229 -0
  35. package/src/builder/forms.ts +1 -1
  36. package/src/builder/locks.ts +6 -0
  37. package/src/builder/newapp.ts +2 -1
  38. package/src/builder/object-browser.ts +81 -18
  39. package/src/builder/region-help.ts +2 -0
  40. package/src/builder/region-settings.ts +2 -0
  41. package/src/builder/reporter.ts +1 -1
  42. package/src/builder/routes.ts +7 -5
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +41 -8
  45. package/src/builder/subscriptions.ts +30 -4
  46. package/src/builder/ui.ts +2 -0
  47. package/src/cli/replace.ts +16 -2
  48. package/src/i18n/ar.ts +62 -0
  49. package/src/i18n/cs.ts +62 -0
  50. package/src/i18n/da.ts +62 -0
  51. package/src/i18n/de.ts +62 -0
  52. package/src/i18n/el.ts +62 -0
  53. package/src/i18n/es.ts +62 -0
  54. package/src/i18n/fi.ts +62 -0
  55. package/src/i18n/fr.ts +62 -0
  56. package/src/i18n/he.ts +62 -0
  57. package/src/i18n/it.ts +62 -0
  58. package/src/i18n/ja.ts +62 -0
  59. package/src/i18n/ko.ts +62 -0
  60. package/src/i18n/nb.ts +62 -0
  61. package/src/i18n/pl.ts +62 -0
  62. package/src/i18n/pt.ts +62 -0
  63. package/src/i18n/ru.ts +62 -0
  64. package/src/i18n/sv.ts +62 -0
  65. package/src/i18n/tr.ts +62 -0
  66. package/src/i18n/uk.ts +62 -0
  67. package/src/i18n/zh.ts +62 -0
  68. package/src/i18n.ts +124 -0
  69. package/src/metadata.ts +18 -3
  70. package/src/runtime/component-settings.ts +42 -0
  71. package/src/runtime/context.ts +4 -0
  72. package/src/runtime/devbar.ts +1 -1
  73. package/src/runtime/documents.ts +29 -3
  74. package/src/runtime/engine.ts +2 -2
  75. package/src/runtime/facet-state.ts +10 -5
  76. package/src/runtime/facets.ts +29 -5
  77. package/src/runtime/form-designer.ts +676 -0
  78. package/src/runtime/grid.ts +36 -11
  79. package/src/runtime/items.ts +47 -32
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/logic.ts +12 -7
  82. package/src/runtime/pdf.ts +2 -1
  83. package/src/runtime/regions.ts +8 -3
  84. package/src/runtime/render.ts +8 -3
  85. package/src/runtime/report.ts +4 -3
  86. package/src/runtime/routes.ts +7 -3
  87. package/src/runtime/shortcuts.ts +43 -0
  88. package/src/runtime/smart-filters.ts +2 -1
  89. package/src/runtime/tasks.ts +6 -3
  90. package/src/security.ts +2 -1
  91. package/src/subscriptions.ts +67 -0
@@ -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
+ }
@@ -179,7 +179,7 @@ export async function saveComponent(kind: string, parentCol: 'page_id' | 'app_id
179
179
  const values = parseFields(spec, body);
180
180
  const problem = spec.validate?.(values);
181
181
  if (problem) throw new Error(problem);
182
- await spec.beforeSave?.(values, cid);
182
+ await spec.beforeSave?.(values, cid, parentId);
183
183
  // region fields only take regions of the same page
184
184
  if (parentCol === 'page_id')
185
185
  for (const f of spec.fields)
@@ -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);
@@ -268,7 +268,8 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
268
268
  if (base !== 'standard') theme.base = base;
269
269
  if (a.topNav) theme.nav = 'top';
270
270
  if (a.accent) theme.accent = a.accent;
271
- const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme, language) values ($1, $2, $3, $4, $5, $6) returning id', [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
271
+ // (107) a new application starts at Release 1.0, like APEX's
272
+ const r = await c.query(`insert into meta.app (alias, name, authentication, db_role, theme, language, version) values ($1, $2, $3, $4, $5, $6, 'Release 1.0') returning id`, [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
272
273
  const appId = r.rows[0].id as number;
273
274
  await placeApp(c, appId, a.workspaceId);
274
275
  const p = await c.query(`insert into meta.page (app_id, page_no, name, title) values ($1, 1, 'Home', 'Home') returning id`, [appId]);
@@ -4,7 +4,9 @@ import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { readSchema } from '../ddl.ts';
6
6
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
7
- import { sessionSchema, workshopDb } from './workspaces.ts';
7
+ import { currentWorkspace, inWorkspaceSql, sessionSchema, workshopDb } from './workspaces.ts';
8
+ import { owner } from '../db.ts';
9
+ import type { Session } from '../session.ts';
8
10
 
9
11
  // (sprint 75) The SQL Workshop's Object Browser like APEX's: a schema in the header (the session's
10
12
  // schema by default), a tree with a folder per object type, a filter, a + menu that starts a CREATE
@@ -61,6 +63,23 @@ export function createTemplate(kind: string, schema: string): string | null {
61
63
  }
62
64
 
63
65
  const PAGE_SIZE = 25;
66
+
67
+ /**
68
+ * (sprint 89, SQL-4) The parsing schema of the application changed last in the developer's workspace: the
69
+ * schema its database role may use (not public, pgkiln's or the system's). APEX opens the Object Browser
70
+ * on the workspace's parsing schema.
71
+ */
72
+ async function parsingSchema(s: Session): Promise<string | null> {
73
+ const row = await owner.one<{ n: string }>(
74
+ `select n.nspname as n
75
+ from meta.app a join pg_roles r on r.rolname = a.db_role
76
+ join pg_namespace n on has_schema_privilege(r.oid, n.oid, 'USAGE')
77
+ where ${inWorkspaceSql('a', 1)} and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'public') and not (n.nspname = any ($2))
78
+ order by a.updated_at desc, a.id desc, n.nspname limit 1`,
79
+ [currentWorkspace(s), SYSTEM_SCHEMAS],
80
+ ).catch(() => undefined);
81
+ return row?.n ?? null;
82
+ }
64
83
  /** A column type in an action: a type name with an optional size and array brackets. */
65
84
  const TYPE_RE = /^[a-z][a-z0-9_ ]{0,40}(\(\d{1,4}(,\s?\d{1,4})?\))?(\[\])?$/i;
66
85
  const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
@@ -81,8 +100,15 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
81
100
  [system, SYSTEM_SCHEMAS],
82
101
  )
83
102
  ).rows.map((r) => r.n);
84
- const own = await sessionSchema(s, db);
85
- const schema = typeof q.schema === 'string' && schemas.includes(q.schema) ? q.schema : own && schemas.includes(own) ? own : (schemas[0] ?? '');
103
+ // (sprint 89, SQL-4) the schema asked for, else the last one shown (remembered per session), else the session's
104
+ // schema of SQL Commands, else the parsing schema of the application changed last, else the first
105
+ const usable = (v: unknown): v is string => typeof v === 'string' && schemas.includes(v);
106
+ const asked = usable(q.schema) ? q.schema : null;
107
+ const last = s.state.__OBSCHEMA;
108
+ const own = asked || usable(last) ? null : await sessionSchema(s, db);
109
+ const parsing = asked || usable(last) || usable(own) ? null : await parsingSchema(s);
110
+ const schema = asked ?? (usable(last) ? last : usable(own) ? own : usable(parsing) ? parsing : (schemas[0] ?? ''));
111
+ if (schema && schema !== last) s.state.__OBSCHEMA = schema;
86
112
  const filter = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
87
113
  const objects = schema ? await listObjects(db, schema, system) : [];
88
114
  const shown = objects.filter((o) => !filter || o.name.toLowerCase().includes(filter.toLowerCase()));
@@ -161,6 +187,24 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
161
187
  if (!cols.includes(col)) problem = 'Choose a column of the table.';
162
188
  else if (!NAME_RE.test(to) || cols.includes(to)) problem = 'The new name must be a valid name that the table doesn\'t have yet.';
163
189
  else sql = `alter table ${table.q} rename column ${ident(col)} to ${ident(to)};`;
190
+ } else if (b.action === 'modify') {
191
+ // (sprint 89, SQL-4) type, nulls and default in one ALTER TABLE; a default is one expression, never a statement
192
+ const type = String(b.type ?? '').trim();
193
+ const expr = String(b.default ?? '').trim();
194
+ const parts: string[] = [];
195
+ const c = ident(col);
196
+ if (!cols.includes(col)) problem = 'Choose a column of the table.';
197
+ else if (type && !TYPE_RE.test(type)) problem = 'A type is a name with an optional size, e.g. text, numeric(9,2), varchar(40), int[].';
198
+ else if (b.default_action === 'set' && (!expr || expr.length > 200 || /;|--|\/\*/.test(expr))) problem = 'A default is one expression (at most 200 characters, without ; or comments), e.g. now(), 0 or \'new\'.';
199
+ else {
200
+ if (type) parts.push(`alter column ${c} type ${type} using ${c}::${type}`);
201
+ if (b.nullable === 'not_null') parts.push(`alter column ${c} set not null`);
202
+ else if (b.nullable === 'null') parts.push(`alter column ${c} drop not null`);
203
+ if (b.default_action === 'set') parts.push(`alter column ${c} set default ${expr}`);
204
+ else if (b.default_action === 'drop') parts.push(`alter column ${c} drop default`);
205
+ if (!parts.length) problem = 'Nothing to change: choose a new type, nulls or a default.';
206
+ else sql = `alter table ${table.q}\n ${parts.join(',\n ')};`;
207
+ }
164
208
  } else if (b.action === 'drop') {
165
209
  if (!cols.includes(col)) problem = 'Choose a column of the table.';
166
210
  else sql = `alter table ${table.q} drop column ${ident(col)};`;
@@ -210,7 +254,7 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
210
254
  const table = (rows: Record<string, unknown>[], empty = 'None.') =>
211
255
  rows.length
212
256
  ? html`<div class="table-wrap"><table class="report"><thead><tr>${Object.keys(rows[0]).map((k) => html`<th scope="col">${k}</th>`)}</tr></thead>
213
- <tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ? html`<span class="null">null</span>` : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
257
+ <tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
214
258
  : html`<p class="muted">${empty}</p>`;
215
259
  const ddlOf = async (match: (x: { kind: string; name: string; table?: string }) => boolean) => {
216
260
  const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
@@ -247,7 +291,14 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
247
291
  const isTable = o.type === 'table';
248
292
  const tabs = TABLE_TABS.filter(([k]) => isTable || !['indexes', 'constraints', 'triggers', 'rls'].includes(k) || (o.type === 'matview' && k === 'indexes'));
249
293
  const tab = tabs.some(([k]) => k === tabAsked) ? tabAsked : 'columns';
250
- const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${tabs.map(([k, label]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`)}</nav>`;
294
+ // (sprint 89, SQL-4) the tabs on one row: the main ones and the current one, the rest under More
295
+ const MAIN = ['columns', 'data', 'indexes', 'constraints', 'ddl'];
296
+ const tabLink = ([k, label]: readonly [string, string]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`;
297
+ const shownTabs = tabs.filter(([k]) => MAIN.includes(k) || k === tab);
298
+ const moreTabs = tabs.filter(([k]) => !shownTabs.some(([x]) => x === k));
299
+ const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${shownTabs.map(tabLink)}${moreTabs.length
300
+ ? html`<details class="menu ob-more"><summary>More ${icon('chevron-down')}</summary><div class="menu-panel"><div class="menu-section menu-links">${moreTabs.map(tabLink)}</div></div></details>`
301
+ : ''}</nav>`;
251
302
  let body: Raw;
252
303
  switch (tab) {
253
304
  case 'columns': {
@@ -262,20 +313,32 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
262
313
  ).rows;
263
314
  const names = cols.map((c) => String(c.column));
264
315
  const hidden = html`${raw(`<input type="hidden" name="__csrf" value="${s.csrf_token}">`)}<input type="hidden" name="table" value="${o.ref}"><input type="hidden" name="schema" value="${schema}">`;
265
- const pick = (id: string) => html`<label class="sr-only" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
266
- body = html`${table(cols)}${isTable
267
- ? html`<div class="ob-actions">
268
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
269
- <b>Add column</b><label class="sr-only" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
270
- <label class="sr-only" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
271
- <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm">Show the SQL</button></form>
272
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
273
- <b>Rename column</b>${pick('ob-ren-col')}<label class="sr-only" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm">Show the SQL</button></form>
274
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
275
- <b>Drop column</b>${pick('ob-drop-col')}<button class="btn btn-sm">Show the SQL</button></form>
276
- <p class="muted u-m0">Each action writes the ALTER TABLE statement into SQL Commands: review it there and press Run.</p>
316
+ const pick = (id: string) => html`<label class="label" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
317
+ // (sprint 89, SQL-4; APEX: the Columns toolbar) Add, Modify, Rename and Drop above the columns, each a small form
318
+ body = html`${isTable
319
+ ? html`<div class="ob-toolbar" role="toolbar" aria-label="Columns">
320
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('plus')} Add column</summary><div class="menu-panel">
321
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
322
+ <label class="label" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
323
+ <label class="label" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
324
+ <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
325
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('edit')} Modify column</summary><div class="menu-panel">
326
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="modify">
327
+ ${pick('ob-mod-col')}
328
+ <label class="label" for="ob-mod-type">New type</label><input id="ob-mod-type" name="type" placeholder="the same">
329
+ <label class="label" for="ob-mod-null">Nulls</label><select id="ob-mod-null" name="nullable"><option value="">the same</option><option value="null">allowed</option><option value="not_null">not null</option></select>
330
+ <label class="label" for="ob-mod-def">Default</label><select id="ob-mod-def" name="default_action"><option value="">the same</option><option value="set">set to</option><option value="drop">none</option></select>
331
+ <label class="sr-only" for="ob-mod-expr">Default expression</label><input id="ob-mod-expr" name="default" placeholder="now(), 0, 'x'">
332
+ <button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
333
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('tag')} Rename column</summary><div class="menu-panel">
334
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
335
+ ${pick('ob-ren-col')}<label class="label" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
336
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('trash')} Drop column</summary><div class="menu-panel">
337
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
338
+ ${pick('ob-drop-col')}<button class="btn btn-sm btn-danger">Show the SQL</button></form></div></details>
339
+ <span class="muted ob-toolbar-note">Each writes the ALTER TABLE statement into SQL Commands: review it there and press Run.</span>
277
340
  </div>`
278
- : ''}`;
341
+ : ''}${table(cols)}`;
279
342
  break;
280
343
  }
281
344
  case 'data': {
@@ -21,6 +21,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
21
21
  [['*'], '{"display_selector":true} puts it in the page\'s display selector'],
22
22
  [['report', 'chart', 'cards', 'dynamic', 'tree', 'template_component'], '{"lazy":true} loads it after the page shows, {"cache":{"scope":"user"|"session"|"all","seconds":300}} keeps its HTML (a submit of the page empties it)'],
23
23
  [['data_reporter'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"order_v","columns":[{"name":"total","label":"Total","format":"FML999G990D00"}]}],"sharing":true,"share_authz":"SCHEME","page_size":25} (the Data Reporter settings below edit it)'],
24
+ [['form_designer'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"orders","key":"id","columns":[{"name":"customer","label":"Customer"},{"name":"status","lov":"LOV:ORDER_STATUS"}],"insert":true,"update":true,"delete":false}],"design_authz":"SCHEME","page_size":25} (the Form Designer region\'s Settings; users that pass design_authz design forms)'],
24
25
  [['ai_assistant'], '{"service":"NAME","system":"…","welcome":"…","context":[{"name":"policy","sql":"select … :AI_PROMPT …"}],"tools":[{"name":"my_leave","description":"…","sql":"select … where status = :STATUS","parameters":{"STATUS":{"type":"string","enum":["PENDING"]}}},{"name":"dept","type":"rest","source":"DEPARTMENT","description":"…"}],"max_rounds":5,"max_turns":20} (the AI assistant settings below edit it)'],
25
26
  [['report'], '{"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters)'],
26
27
  [['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
@@ -45,6 +46,7 @@ const ANCHORS: Record<string, string> = {
45
46
  tree: 'tree',
46
47
  list: 'list-lists',
47
48
  data_reporter: 'data-reporter-data-reporter',
49
+ form_designer: 'form_designer-form-designer',
48
50
  ai_assistant: 'ai-assistant-ai-assistant',
49
51
  template_component: 'template-components',
50
52
  static: 'static-and-dynamic-content',
@@ -16,6 +16,7 @@ import { LIST_TEMPLATES } from '../runtime/lists.ts';
16
16
  import { MAX_EXTRA_LAYERS } from '../runtime/maps.ts';
17
17
  import { postgis } from '../runtime/spatial.ts';
18
18
  import { reporterSettingsForm } from './reporter.ts';
19
+ import { formDesignerSettingsForm } from './form-designer.ts';
19
20
 
20
21
  // Page designer → a region → Settings: the region's "config" JSON as a form
21
22
  // for grid, chart, cards, calendar and faceted search regions (report
@@ -732,6 +733,7 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
732
733
  if (r.type === 'ai_assistant') return assistantSettingsForm(pageId, appId, r, s);
733
734
  if (r.type === 'template_component') return templateRegionForm(pageId, appId, r, s);
734
735
  if (r.type === 'data_reporter') return reporterSettingsForm(pageId, appId, r, s);
736
+ if (r.type === 'form_designer') return formDesignerSettingsForm(pageId, appId, r, s);
735
737
  if (!(SETTINGS_TYPES as readonly string[]).includes(r.type)) return '';
736
738
  const cfg = r.config ?? {};
737
739
  const id = (n: string) => `rg_${r.id}_${n}`;
@@ -43,7 +43,7 @@ async function objectByOid(oid: number): Promise<DbObject | null> {
43
43
  }
44
44
 
45
45
  /** The tables and views to choose from, with whether the application's role may read them. */
46
- async function objectList(dbRole: string | null) {
46
+ export async function objectList(dbRole: string | null) {
47
47
  return (
48
48
  await owner.query<{ oid: number; name: string; access: boolean }>(
49
49
  `select c.oid::int as oid, format('%I.%I', n.nspname, c.relname) as name,
@@ -13,7 +13,7 @@ import { documentShell } from '../layout.ts';
13
13
  import { passwordProblem } from '../accounts.ts';
14
14
  import { clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
15
15
  import { loginSettingsFields, saveLoginSettings } from './login-page.ts';
16
- import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
16
+ import { APP_COLORS, appHeader, back, BASE, designerUrl, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
17
17
  import { appOr404 } from './forms.ts';
18
18
  import { docToFiles, filesToZip } from '../appfiles.ts';
19
19
  import { homeRoutes, rememberApp } from './home.ts';
@@ -278,11 +278,11 @@ export async function builderRoutes(app: FastifyInstance) {
278
278
  <select id="f_group" name="group">${[['', 'All pages'], ...groups.map((g) => [g, g]), ['-', 'Without a group']].map(([v, l]) => html`<option value="${v}"${v === groupFilter ? raw(' selected') : ''}>${l}</option>`)}</select></div>
279
279
  <button class="btn u-selfend">Show</button></form>`
280
280
  : ''}${icons
281
- ? html`<ul class="page-icons" id="pages-rows">${shownPages.map((p) => html`<li><a href="${BASE}/pages/${p.id}" class="page-icon"><span class="page-icon-no">${p.page_no}</span><b>${p.name}</b><small class="muted">${p.mode}, ${p.regions} region${p.regions === 1 ? '' : 's'}</small></a></li>`)}</ul>`
281
+ ? html`<ul class="page-icons" id="pages-rows">${shownPages.map((p) => html`<li><a href="${designerUrl(a.id, p.page_no)}" class="page-icon"><span class="page-icon-no">${p.page_no}</span><b>${p.name}</b><small class="muted">${p.mode}, ${p.regions} region${p.regions === 1 ? '' : 's'}</small></a></li>`)}</ul>`
282
282
  : html`<div class="table-wrap"><table class="report">
283
283
  <thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
284
284
  <tbody id="pages-rows">${shownPages.map((p) => html`<tr>
285
- <td class="num">${p.page_no}</td><td><a href="${BASE}/pages/${p.id}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
285
+ <td class="num">${p.page_no}</td><td><a href="${designerUrl(a.id, p.page_no)}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
286
286
  <td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
287
287
  <td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
288
288
  <td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
@@ -401,6 +401,7 @@ export async function builderRoutes(app: FastifyInstance) {
401
401
  <div class="form-grid">
402
402
  ${input('name', 'Name', a.name, { required: true })}
403
403
  ${input('alias', 'Alias', a.alias, { required: true })}
404
+ ${input('version', 'Version', a.version ?? '', { placeholder: 'Release 1.0', help: 'Shown in the footer of the application\'s pages; empty: none.' })}
404
405
  ${input('home_page', 'Home page', a.home_page, { type: 'number' })}
405
406
  ${select('app_type', 'Application type', a.app_type ?? 'standard', APP_TYPES.map((t): [string, string] => [t, APP_TYPE_LABELS[t]]),
406
407
  'Theme and library applications offer their theme or shared components to other applications (Shared Components → Subscriptions); Create application can start from a boilerplate application.')}
@@ -546,7 +547,7 @@ export async function builderRoutes(app: FastifyInstance) {
546
547
  date_format = $14, timestamp_format = $15, remember_me_days = $16, ldap_directories = $17,
547
548
  header_name = $18, header_auto_create = $19, logout_url = $20, db_auth_roles = $21, db_auth_member_of = $22,
548
549
  custom_auth_function = $23, custom_auth_code = $24, custom_auth_post_code = $25, nav_list = $26, navbar_list = $27,
549
- app_type = $28, session_group = $29, updated_at = now() where id = $1`,
550
+ app_type = $28, session_group = $29, version = $30, updated_at = now() where id = $1`,
550
551
  [req.params.id, b.name?.trim(), b.alias?.trim().toLowerCase(), Number(b.home_page) || 1, b.authentication, b.db_role?.trim() || null, b.debug === 'true',
551
552
  JSON.stringify({
552
553
  base: baseStyleOf({ base: b.base }) === 'standard' ? undefined : baseStyleOf({ base: b.base }),
@@ -579,7 +580,8 @@ export async function builderRoutes(app: FastifyInstance) {
579
580
  b.nav_list?.trim().toUpperCase() || null,
580
581
  b.navbar_list?.trim().toUpperCase() || null,
581
582
  (APP_TYPES as readonly string[]).includes(b.app_type ?? '') ? b.app_type : 'standard',
582
- /^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null],
583
+ /^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null,
584
+ (b.version ?? '').trim().slice(0, 40) || null],
583
585
  );
584
586
  await saveTimeZoneSettings(req.params.id, b);
585
587
  await saveLoginSettings(req.params.id, b);