pgkiln 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/102_app_computations.sql +345 -0
- package/db/migrations/103_shortcuts.sql +341 -0
- package/db/migrations/104_report_queries.sql +413 -0
- package/db/migrations/105_component_settings_groups.sql +367 -0
- package/db/migrations/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +69 -13
- package/docs/guide/04-pages-and-regions.md +84 -3
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +14 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +8 -2
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +79 -2
- package/public/app.js +307 -35
- package/public/builder.css +21 -1
- package/public/builder.js +67 -0
- package/public/sw.js +2 -1
- package/src/app.ts +9 -1
- package/src/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +7 -2
- package/src/builder/components.ts +86 -4
- package/src/builder/designer.ts +44 -17
- package/src/builder/errors.ts +65 -0
- package/src/builder/form-designer.ts +229 -0
- package/src/builder/forms.ts +1 -1
- package/src/builder/locks.ts +6 -0
- package/src/builder/newapp.ts +2 -1
- package/src/builder/object-browser.ts +81 -18
- package/src/builder/region-help.ts +2 -0
- package/src/builder/region-settings.ts +2 -0
- package/src/builder/reporter.ts +1 -1
- package/src/builder/routes.ts +7 -5
- package/src/builder/search.ts +3 -3
- package/src/builder/shared.ts +41 -8
- package/src/builder/subscriptions.ts +30 -4
- package/src/builder/ui.ts +2 -0
- package/src/cli/replace.ts +16 -2
- package/src/i18n/ar.ts +62 -0
- package/src/i18n/cs.ts +62 -0
- package/src/i18n/da.ts +62 -0
- package/src/i18n/de.ts +62 -0
- package/src/i18n/el.ts +62 -0
- package/src/i18n/es.ts +62 -0
- package/src/i18n/fi.ts +62 -0
- package/src/i18n/fr.ts +62 -0
- package/src/i18n/he.ts +62 -0
- package/src/i18n/it.ts +62 -0
- package/src/i18n/ja.ts +62 -0
- package/src/i18n/ko.ts +62 -0
- package/src/i18n/nb.ts +62 -0
- package/src/i18n/pl.ts +62 -0
- package/src/i18n/pt.ts +62 -0
- package/src/i18n/ru.ts +62 -0
- package/src/i18n/sv.ts +62 -0
- package/src/i18n/tr.ts +62 -0
- package/src/i18n/uk.ts +62 -0
- package/src/i18n/zh.ts +62 -0
- package/src/i18n.ts +124 -0
- package/src/metadata.ts +18 -3
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- package/src/runtime/facet-state.ts +10 -5
- package/src/runtime/facets.ts +29 -5
- package/src/runtime/form-designer.ts +676 -0
- package/src/runtime/grid.ts +36 -11
- package/src/runtime/items.ts +47 -32
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +8 -3
- package/src/runtime/render.ts +8 -3
- package/src/runtime/report.ts +4 -3
- package/src/runtime/routes.ts +7 -3
- package/src/runtime/shortcuts.ts +43 -0
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
- package/src/security.ts +2 -1
- package/src/subscriptions.ts +67 -0
|
@@ -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/forms.ts
CHANGED
|
@@ -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)
|
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);
|
package/src/builder/newapp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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 ?
|
|
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
|
-
|
|
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="
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
<
|
|
275
|
-
<
|
|
276
|
-
|
|
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}`;
|
package/src/builder/reporter.ts
CHANGED
|
@@ -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,
|
package/src/builder/routes.ts
CHANGED
|
@@ -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="${
|
|
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="${
|
|
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);
|