pgkiln 0.83.0 → 0.85.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/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +3 -2
- package/docs/guide/03-builder.md +16 -5
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +13 -1
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +11 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3461 -0
- package/src/builder/i18n/en.ts +3461 -0
- package/src/builder/i18n/es.ts +3461 -0
- package/src/builder/i18n/fr.ts +3461 -0
- package/src/builder/i18n/it.ts +3461 -0
- package/src/builder/i18n/ja.ts +3461 -0
- package/src/builder/i18n/ko.ts +3461 -0
- package/src/builder/i18n/nl.ts +3461 -0
- package/src/builder/i18n/pt.ts +3461 -0
- package/src/builder/i18n/zh-cn.ts +3461 -0
- package/src/builder/i18n/zh-tw.ts +3461 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +30 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
package/src/builder/backups.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { icon } from '../icons.ts';
|
|
|
6
6
|
import { clientIp, logActivity } from '../session.ts';
|
|
7
7
|
import { appOr404 } from './forms.ts';
|
|
8
8
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
9
|
+
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
9
10
|
|
|
10
11
|
// Application → Backups (APEX: Application Backups, migration 082): the
|
|
11
12
|
// exports this installation keeps of an application (before every import
|
|
@@ -14,8 +15,8 @@ import { appHeader, back, BASE, csrf, developer, flash, input, region, send, she
|
|
|
14
15
|
// backup, so the version it replaces is backed up as well. Locks of the
|
|
15
16
|
// application are respected (developer() refuses a POST to a locked app).
|
|
16
17
|
|
|
17
|
-
const REASON: Record<string,
|
|
18
|
-
import: '
|
|
18
|
+
const REASON: Record<string, BuilderKey> = {
|
|
19
|
+
import: 'bkp.reason.import', merge: 'bkp.reason.merge', restore: 'bkp.reason.restore', daily: 'bkp.reason.daily', manual: 'bkp.reason.manual',
|
|
19
20
|
};
|
|
20
21
|
const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
|
|
21
22
|
const kb = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
@@ -39,35 +40,33 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
39
40
|
const rows = (await owner.query(
|
|
40
41
|
'select id, created_at, created_by, reason, note, size from meta.app_backup where app_id = $1 order by created_at desc, id desc', [a.id])).rows;
|
|
41
42
|
const body = html`
|
|
42
|
-
${region('
|
|
43
|
-
<p class="muted u-mt0"
|
|
44
|
-
|
|
45
|
-
The newest ${a.backups_keep} are kept. Backups are not exported.</p>
|
|
46
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Made (UTC)</th><th>Why</th><th>By</th><th>Note</th><th class="num">Size</th><th>Actions</th></tr></thead><tbody>
|
|
43
|
+
${region(tx('app.head.backups'), html`
|
|
44
|
+
<p class="muted u-mt0">${tx('bkp.intro', { n: a.backups_keep })}</p>
|
|
45
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('bkp.made_utc')}</th><th>${tx('bkp.why')}</th><th>${tx('bkp.by')}</th><th>${tx('bkp.note')}</th><th class="num">${tx('bkp.size')}</th><th>${tx('bkp.actions')}</th></tr></thead><tbody>
|
|
47
46
|
${rows.length ? rows.map((b) => html`<tr>
|
|
48
|
-
<td data-label="
|
|
49
|
-
<td data-label="
|
|
50
|
-
<td data-label="
|
|
51
|
-
<td data-label="
|
|
52
|
-
<td class="num" data-label="
|
|
53
|
-
<td data-label="
|
|
54
|
-
<a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')}
|
|
47
|
+
<td data-label="${tx('bkp.made')}">${when(b.created_at)}</td>
|
|
48
|
+
<td data-label="${tx('bkp.why')}">${REASON[b.reason] ? tx(REASON[b.reason]) : b.reason}</td>
|
|
49
|
+
<td data-label="${tx('bkp.by')}">${b.created_by ?? ''}</td>
|
|
50
|
+
<td data-label="${tx('bkp.note')}" class="u-break">${b.note ?? ''}</td>
|
|
51
|
+
<td class="num" data-label="${tx('bkp.size')}">${kb(b.size)}</td>
|
|
52
|
+
<td data-label="${tx('bkp.actions')}"><div class="buttons">
|
|
53
|
+
<a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')} ${tx('bkp.download')}</a>
|
|
55
54
|
<form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/restore" class="u-inline">${csrf(s)}
|
|
56
|
-
<button class="btn btn-sm" data-confirm="
|
|
55
|
+
<button class="btn btn-sm" data-confirm="${tx('bkp.restore_confirm', { when: when(b.created_at) })}">${icon('history')} ${tx('bkp.restore')}</button></form>
|
|
57
56
|
<form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/delete" class="u-inline">${csrf(s)}
|
|
58
|
-
<button class="btn btn-sm btn-danger" data-confirm="
|
|
57
|
+
<button class="btn btn-sm btn-danger" data-confirm="${tx('bkp.delete_confirm')}">${tx('bkp.delete')}</button></form>
|
|
59
58
|
</div></td></tr>`)
|
|
60
|
-
: html`<tr><td colspan="6" class="empty"
|
|
59
|
+
: html`<tr><td colspan="6" class="empty">${tx('bkp.none')}</td></tr>`}</tbody></table></div>`)}
|
|
61
60
|
<div class="columns">
|
|
62
|
-
${region('
|
|
63
|
-
<div class="form-grid">${input('note', '
|
|
64
|
-
<div class="buttons"><button class="btn btn-hot">${icon('save')}
|
|
65
|
-
${region('
|
|
66
|
-
<div class="form-grid">${input('keep', '
|
|
67
|
-
<div class="buttons"><button class="btn"
|
|
61
|
+
${region(tx('bkp.back_up_now'), html`<form method="post" action="${BASE}/apps/${a.id}/backups">${csrf(s)}
|
|
62
|
+
<div class="form-grid">${input('note', tx('bkp.note'), '', { placeholder: tx('bkp.note_placeholder') })}</div>
|
|
63
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} ${tx('bkp.back_up_now')}</button></div></form>`)}
|
|
64
|
+
${region(tx('bkp.keep'), html`<form method="post" action="${BASE}/apps/${a.id}/backups/settings">${csrf(s)}
|
|
65
|
+
<div class="form-grid">${input('keep', tx('bkp.to_keep'), a.backups_keep, { type: 'number', help: tx('bkp.to_keep_help') })}</div>
|
|
66
|
+
<div class="buttons"><button class="btn">${tx('common.save')}</button></div></form>`)}
|
|
68
67
|
</div>`;
|
|
69
68
|
const main = html`${appHeader(a, 'pages', s)}<div class="ide-body">${body}</div>`;
|
|
70
|
-
return send(reply, s, shell(s,
|
|
69
|
+
return send(reply, s, shell(s, tx('bkp.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.head.backups')]], main));
|
|
71
70
|
});
|
|
72
71
|
|
|
73
72
|
app.post(`${BASE}/apps/:id(^\\d+$)/backups`, async (req: Req, reply) => {
|
|
@@ -76,7 +75,7 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
76
75
|
const a = await appOr404(req.params.id);
|
|
77
76
|
if (!a) return reply.code(404).send('Not found');
|
|
78
77
|
const r = await owner.one(`select meta.backup_app($1, 'manual', $2, $3) as id`, [a.id, s.username, String(req.body?.note ?? '').trim().slice(0, 500) || null]);
|
|
79
|
-
flash(s, r.id ? '
|
|
78
|
+
flash(s, r.id ? tx('bkp.made_flash') : tx('bkp.none_kept'), r.id ? 'ok' : 'error');
|
|
80
79
|
return back(reply, s, `${BASE}/apps/${a.id}/backups`);
|
|
81
80
|
});
|
|
82
81
|
|
|
@@ -84,12 +83,12 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
84
83
|
const s = await developer(req, reply);
|
|
85
84
|
if (!s) return;
|
|
86
85
|
const keep = Number(req.body?.keep);
|
|
87
|
-
if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, '
|
|
86
|
+
if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, tx('bkp.keep_invalid'), 'error');
|
|
88
87
|
else {
|
|
89
88
|
await owner.query('update meta.app set backups_keep = $2, updated_at = now() where id = $1', [req.params.id, keep]);
|
|
90
89
|
// fewer to keep: the oldest go now
|
|
91
90
|
await owner.query(`delete from meta.app_backup where app_id = $1 and id not in (select id from meta.app_backup where app_id = $1 order by created_at desc, id desc limit $2)`, [req.params.id, keep]);
|
|
92
|
-
flash(s, '
|
|
91
|
+
flash(s, tx('bkp.saved'));
|
|
93
92
|
}
|
|
94
93
|
return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
|
|
95
94
|
});
|
|
@@ -112,14 +111,14 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
112
111
|
try {
|
|
113
112
|
const at = await owner.tx(async (c) => {
|
|
114
113
|
const b = (await c.query('select doc, created_at from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, a.id])).rows[0];
|
|
115
|
-
if (!b) throw new Error('
|
|
114
|
+
if (!b) throw new Error(tx('bkp.not_found'));
|
|
116
115
|
await replaceApp(c, b.doc, a.alias, { backup: { reason: 'restore', user: s.username, note: `before restoring the backup of ${when(b.created_at)}` } });
|
|
117
116
|
return when(b.created_at);
|
|
118
117
|
});
|
|
119
118
|
await logActivity({ appId: a.id, username: s.username, event: 'backup_restored', ip: clientIp(req), detail: `backup ${req.params.bid} of ${at}` });
|
|
120
|
-
flash(s,
|
|
119
|
+
flash(s, tx('bkp.restored', { when: at }));
|
|
121
120
|
} catch (e) {
|
|
122
|
-
flash(s,
|
|
121
|
+
flash(s, tx('bkp.restore_failed', { message: (e as Error).message }), 'error');
|
|
123
122
|
}
|
|
124
123
|
return back(reply, s, `${BASE}/apps/${a.id}/backups`);
|
|
125
124
|
});
|
|
@@ -128,7 +127,7 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
128
127
|
const s = await developer(req, reply);
|
|
129
128
|
if (!s) return;
|
|
130
129
|
const r = await owner.query('delete from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, req.params.id]);
|
|
131
|
-
flash(s, r.rowCount ? '
|
|
130
|
+
flash(s, r.rowCount ? tx('bkp.deleted') : tx('bkp.not_found_short'), r.rowCount ? 'ok' : 'error');
|
|
132
131
|
return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
|
|
133
132
|
});
|
|
134
133
|
}
|
|
@@ -16,6 +16,7 @@ import { devDbForApp } from '../workspace-db.ts';
|
|
|
16
16
|
import { isAdmin } from './locks.ts';
|
|
17
17
|
import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
|
|
18
18
|
import { WIZARD_KINDS } from './wizards.ts';
|
|
19
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
19
20
|
|
|
20
21
|
// Create → From a blueprint (migration 063, src/blueprint.ts): saved
|
|
21
22
|
// blueprints, an editor (JSON), an AI draft from a description (the App
|
|
@@ -68,38 +69,38 @@ const signed = (s: Session, spec: string, sig: unknown) => {
|
|
|
68
69
|
};
|
|
69
70
|
|
|
70
71
|
function parse(spec: string): { json: unknown; error: string | null } {
|
|
71
|
-
if (spec.length > MAX_SPEC_CHARS) return { json: null, error:
|
|
72
|
+
if (spec.length > MAX_SPEC_CHARS) return { json: null, error: tx('bp.too_long', { n: MAX_SPEC_CHARS }) };
|
|
72
73
|
try {
|
|
73
74
|
return { json: JSON.parse(spec), error: null };
|
|
74
75
|
} catch (e) {
|
|
75
|
-
return { json: null, error:
|
|
76
|
+
return { json: null, error: tx('bp.bad_json', { error: (e as Error).message }) };
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
const page = (s: Session, reply: FastifyReply, crumb: string, main: Raw) =>
|
|
80
|
-
send(reply, s, shell(s, '
|
|
81
|
+
send(reply, s, shell(s, tx('bp.blueprints'), [[tx('bp.app_builder'), BASE], [tx('bp.create'), `${BASE}/create`], [tx('bp.blueprints'), `${BASE}/blueprints`], ...(crumb ? [[crumb] as [string]] : [])], html`<div class="ab-narrow">${main}</div>`));
|
|
81
82
|
|
|
82
83
|
async function editor(s: Session, reply: FastifyReply, o: { id?: number | null; name: string; spec: string; description?: string; message?: Raw }) {
|
|
83
84
|
const svc = await builderService();
|
|
84
|
-
return page(s, reply, o.id ? o.name : '
|
|
85
|
-
<p class="muted"
|
|
85
|
+
return page(s, reply, o.id ? o.name : tx('bp.new_blueprint'), html`<h1>${o.id ? tx('bp.title', { name: o.name }) : tx('bp.new_blueprint')}</h1>
|
|
86
|
+
<p class="muted">${tx.rich('bp.intro', { id: html`<code>id</code>`, references: html`<code>"references"</code>`, pages: PAGE_TYPES.join(', '), review: html`<b>${tx('bp.review')}</b>` })}</p>
|
|
86
87
|
${o.message ?? ''}
|
|
87
|
-
${region('
|
|
88
|
+
${region(tx('bp.draft_with_ai'), svc
|
|
88
89
|
? html`<form method="post" action="${BASE}/blueprints/draft">${csrf(s)}
|
|
89
90
|
<input type="hidden" name="id" value="${o.id ?? ''}"><input type="hidden" name="name" value="${o.name}">
|
|
90
|
-
<div class="field"><label class="label" for="f_description"
|
|
91
|
-
<textarea id="f_description" name="description" rows="3" maxlength="4000" required placeholder="
|
|
92
|
-
<small class="help"
|
|
93
|
-
<div class="buttons"><button class="btn" data-busy="
|
|
91
|
+
<div class="field"><label class="label" for="f_description">${tx('bp.describe_the_application')}</label>
|
|
92
|
+
<textarea id="f_description" name="description" rows="3" maxlength="4000" required placeholder="${tx('bp.e_g_track_projects_with')}">${o.description ?? ''}</textarea>
|
|
93
|
+
<small class="help">${tx('bp.ai_help', { service: svc.name })}</small></div>
|
|
94
|
+
<div class="buttons"><button class="btn" data-busy="${tx('bp.thinking')}">${tx('bp.draft_a_blueprint')}</button></div>
|
|
94
95
|
</form>`
|
|
95
|
-
: html`<p class="muted"
|
|
96
|
-
${region('
|
|
96
|
+
: html`<p class="muted">${tx.rich('bp.no_service', { link: html`<a href="${BASE}/sql/ai">${tx('bp.sql_workshop_ai')}</a>` })}</p>`)}
|
|
97
|
+
${region(tx('bp.blueprint'), html`<form method="post" action="${BASE}/blueprints/review">${csrf(s)}
|
|
97
98
|
<input type="hidden" name="id" value="${o.id ?? ''}">
|
|
98
|
-
<div class="form-grid"><div class="field"><label class="label" for="f_name"
|
|
99
|
+
<div class="form-grid"><div class="field"><label class="label" for="f_name">${tx('bp.name')}</label><input id="f_name" name="name" maxlength="100" required value="${o.name}"></div></div>
|
|
99
100
|
<div class="field"><label class="label" for="f_spec">JSON</label>
|
|
100
101
|
<textarea id="f_spec" name="spec" class="code" rows="24" spellcheck="false" data-code="json">${o.spec}</textarea>
|
|
101
|
-
<small class="help"
|
|
102
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
102
|
+
<small class="help">${tx.rich('bp.spec_help', { types: Object.keys(COLUMN_TYPES).join(', '), required: html`<code>"required"</code>`, unique: html`<code>"unique"</code>`, values: html`<code>"values"</code>`, kinds: WIZARD_KINDS.map(([k]) => k).join(', '), text: html`<code>"text"</code>`, dashboard: html`<code>"dashboard": true</code>`, navigation: html`<code>"navigation": [{"label", "page", "icon"}]</code>`, sample: html`<code>"sample_data": [{"table", "columns", "rows"}]</code>` })}</small></div>
|
|
103
|
+
<div class="buttons"><button class="btn btn-hot">${tx('bp.review')}</button><button class="btn" formaction="${BASE}/blueprints/save">${tx('bp.save')}</button></div>
|
|
103
104
|
</form>`)}`);
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -107,28 +108,28 @@ function review(s: Session, bp: Blueprint, spec: string, id: number | null, name
|
|
|
107
108
|
const sql = blueprintSql(bp);
|
|
108
109
|
const rows = (t: string) => bp.sample_data.filter((d) => d.table === t).reduce((n, d) => n + d.rows.length, 0);
|
|
109
110
|
return html`<h1>Review: ${bp.name}</h1>
|
|
110
|
-
<p class="muted"
|
|
111
|
-
${region('
|
|
112
|
-
<dt
|
|
113
|
-
<dt
|
|
114
|
-
<dt
|
|
115
|
-
${region('
|
|
116
|
-
${bp.tables.map((t) => html`<tr><td data-label="
|
|
117
|
-
<td data-label="
|
|
118
|
-
<td data-label="
|
|
111
|
+
<p class="muted">${tx('bp.this_is_what_the_blueprint')}</p>
|
|
112
|
+
${region(tx('bp.application'), html`<dl class="bp-facts">
|
|
113
|
+
<dt>${tx('bp.name')}</dt><dd>${bp.name}</dd><dt>${tx('bp.alias')}</dt><dd><code>/a/${bp.alias}</code></dd>
|
|
114
|
+
<dt>${tx('bp.schema')}</dt><dd><code>${bp.schema}</code> ${tx.rich('bp.db_role', { role: html`<code>app_${bp.alias.replace(/-/g, '_')}</code>` })}</dd>
|
|
115
|
+
<dt>${tx('bp.sign_in')}</dt><dd>${bp.authentication === 'none' ? 'none: every page is public' : 'a login page (application users)'}</dd></dl>`)}
|
|
116
|
+
${region(tx('bp.tables'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('bp.table')}</th><th scope="col">${tx('bp.columns')}</th><th scope="col" class="num">${tx('bp.sample_rows')}</th></tr></thead><tbody>
|
|
117
|
+
${bp.tables.map((t) => html`<tr><td data-label="${tx('bp.table')}"><code>${t.name}</code>${t.label ? html` <span class="muted">${t.label}</span>` : ''}</td>
|
|
118
|
+
<td data-label="${tx('bp.columns')}">${t.columns.map((c, i) => html`${i ? ', ' : ''}<code>${c.name}</code> <span class="muted">${c.references ? `→ ${c.references}` : c.type}${c.required ? ', required' : ''}${c.unique ? ', unique' : ''}${c.values.length ? `, ${c.values.join(' / ')}` : ''}</span>`)}</td>
|
|
119
|
+
<td data-label="${tx('bp.sample_rows')}" class="num">${rows(t.name)}</td></tr>`)}
|
|
119
120
|
</tbody></table></div>
|
|
120
|
-
<details class="u-mt1"><summary
|
|
121
|
-
${region('
|
|
122
|
-
${bp.dashboard ? html`<li
|
|
123
|
-
${bp.navigation ? html`<p>Menu: ${bp.navigation.map((n) => n.label).join(' · ')}</p>` : html`<p class="muted"
|
|
124
|
-
${region('
|
|
121
|
+
<details class="u-mt1"><summary>${tx('bp.the_sql')}</summary><pre class="bp-sql">${sql.join(';\n\n')};</pre></details>`)}
|
|
122
|
+
${region(tx('bp.pages'), html`<ul>${bp.pages.map((p) => html`<li>Page ${p.page}${p.form_page ? html` and ${p.form_page}` : ''}: ${p.label} <span class="muted">(${p.type}${p.table ? ` on ${p.table}` : ''})</span></li>`)}
|
|
123
|
+
${bp.dashboard ? html`<li>${tx('bp.a_dashboard_with_charts_per')}</li>` : ''}</ul>
|
|
124
|
+
${bp.navigation ? html`<p>Menu: ${bp.navigation.map((n) => n.label).join(' · ')}</p>` : html`<p class="muted">${tx('bp.menu_home_and_the_pages')}</p>`}`)}
|
|
125
|
+
${region(tx('bp.create_the_application'), html`<form method="post" action="${BASE}/blueprints/create">${csrf(s)}
|
|
125
126
|
<input type="hidden" name="id" value="${id ?? ''}"><input type="hidden" name="name" value="${name}">
|
|
126
127
|
<input type="hidden" name="spec" value="${spec}"><input type="hidden" name="sig" value="${sign(s, spec)}">
|
|
127
128
|
${bp.authentication === 'none' ? '' : html`<div class="form-grid">
|
|
128
|
-
<div class="field"><label class="label" for="f_admin_user"
|
|
129
|
-
<div class="field"><label class="label" for="f_admin_password"
|
|
129
|
+
<div class="field"><label class="label" for="f_admin_user">${tx('bp.first_user_admin_role')}</label><input id="f_admin_user" name="admin_user" required value="${s.username}" autocomplete="off"></div>
|
|
130
|
+
<div class="field"><label class="label" for="f_admin_password">${tx('bp.password_for_a_new_account')}</label><input id="f_admin_password" name="admin_password" type="password" autocomplete="new-password"></div>
|
|
130
131
|
</div>`}
|
|
131
|
-
<div class="buttons"><button class="btn" formaction="${BASE}/blueprints/edit"
|
|
132
|
+
<div class="buttons"><button class="btn" formaction="${BASE}/blueprints/edit">${tx('bp.back_to_the_editor')}</button><button class="btn btn-hot">${icon('plus')} ${tx('bp.create_the_application')}</button></div>
|
|
132
133
|
</form>`)}`;
|
|
133
134
|
}
|
|
134
135
|
|
|
@@ -140,10 +141,10 @@ export async function openBlueprint(s: Session, reply: FastifyReply, name: strin
|
|
|
140
141
|
const { json, error } = parse(spec);
|
|
141
142
|
const { blueprint, problems } = error ? { blueprint: null, problems: [error] } : checkBlueprint(json);
|
|
142
143
|
const taken = blueprint ? await owner.one('select 1 from meta.app where alias = $1', [blueprint.alias]) : null;
|
|
143
|
-
if (taken) problems.push(
|
|
144
|
+
if (taken) problems.push(tx('bp.alias_taken', { alias: blueprint!.alias }));
|
|
144
145
|
if (!blueprint || problems.length)
|
|
145
|
-
return editor(s, reply, { name, spec, message: html`<div class="alert alert-error" role="alert"><p
|
|
146
|
-
return page(s, reply, '
|
|
146
|
+
return editor(s, reply, { name, spec, message: html`<div class="alert alert-error" role="alert"><p>${tx('bp.the_blueprint_can_t_be')}</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
|
|
147
|
+
return page(s, reply, tx('bp.review_title'), review(s, blueprint, spec, null, name));
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
export async function blueprintRoutes(app: FastifyInstance) {
|
|
@@ -152,23 +153,23 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
152
153
|
if (!s) return;
|
|
153
154
|
const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
|
|
154
155
|
const apps = (await owner.query(`select id, name, alias from meta.app where meta.app_workspace(id) = $1 order by lower(name), id`, [currentWorkspace(s)])).rows;
|
|
155
|
-
return page(s, reply, '', html`<h1
|
|
156
|
-
<p class="muted"
|
|
156
|
+
return page(s, reply, '', html`<h1>${tx('bp.blueprints')}</h1>
|
|
157
|
+
<p class="muted">${tx('bp.list_intro')} <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} ${tx('bp.new_blueprint')}</a></p>
|
|
157
158
|
${list.length
|
|
158
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col"
|
|
159
|
-
${list.map((x) => html`<tr><td data-label="
|
|
160
|
-
<td data-label="
|
|
161
|
-
<td><form method="post" action="${BASE}/blueprints/${x.id}/delete">${csrf(s)}<button class="btn btn-small"
|
|
159
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('bp.blueprint')}</th><th scope="col">${tx('bp.application_created')}</th><th scope="col">${tx('bp.changed')}</th><th scope="col"></th></tr></thead><tbody>
|
|
160
|
+
${list.map((x) => html`<tr><td data-label="${tx('bp.blueprint')}"><a href="${BASE}/blueprints/${x.id}">${x.name}</a> <span class="muted">${x.created_by ?? ''}</span></td>
|
|
161
|
+
<td data-label="${tx('bp.application_created')}">${x.app_id ? html`<a href="${BASE}/apps/${x.app_id}">${x.alias}</a>` : '—'}</td><td data-label="${tx('bp.changed')}">${String(x.updated).slice(0, 16)}</td>
|
|
162
|
+
<td><form method="post" action="${BASE}/blueprints/${x.id}/delete">${csrf(s)}<button class="btn btn-small">${tx('bp.delete')}</button></form></td></tr>`)}
|
|
162
163
|
</tbody></table></div>`
|
|
163
|
-
: html`<p class="muted"
|
|
164
|
-
${apps.length ? region('
|
|
165
|
-
<p class="muted u-mt0"
|
|
164
|
+
: html`<p class="muted">${tx('bp.no_saved_blueprints_yet')}</p>`}
|
|
165
|
+
${apps.length ? region(tx('bp.from_an_application'), html`<form method="post" action="${BASE}/blueprints/from-app">${csrf(s)}
|
|
166
|
+
<p class="muted u-mt0">${tx('bp.a_blueprint_of_an_application')}</p>
|
|
166
167
|
<div class="form-grid">
|
|
167
|
-
<div class="field"><label class="label" for="f_bp_app"
|
|
168
|
-
<div class="field"><label class="check"><input type="checkbox" name="rows" value="true">
|
|
169
|
-
<small class="help"
|
|
168
|
+
<div class="field"><label class="label" for="f_bp_app">${tx('bp.application')}</label><select id="f_bp_app" name="app">${apps.map((a) => html`<option value="${a.id}">${a.name} (${a.alias})</option>`)}</select></div>
|
|
169
|
+
<div class="field"><label class="check"><input type="checkbox" name="rows" value="true"> ${tx('bp.sample_rows_up_to_20')}</label>
|
|
170
|
+
<small class="help">${tx('bp.the_rows_are_copied_into')}</small></div>
|
|
170
171
|
</div>
|
|
171
|
-
<div class="buttons"><button class="btn">${icon('layers')}
|
|
172
|
+
<div class="buttons"><button class="btn">${icon('layers')} ${tx('bp.create_a_blueprint')}</button></div></form>`) : ''}`);
|
|
172
173
|
});
|
|
173
174
|
|
|
174
175
|
// (sprint 66) a blueprint from an application of the current workspace, opened in the editor
|
|
@@ -184,15 +185,15 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
184
185
|
await logActivity({ appId: a.id, username: s.username, event: 'blueprint', ip: clientIp(req), detail: `blueprint from the application${rows ? ', with sample rows' : ''}` });
|
|
185
186
|
return editor(s, reply, {
|
|
186
187
|
name: `${a.name} blueprint`.slice(0, 100), spec: JSON.stringify(spec, null, 2),
|
|
187
|
-
message: html`<div class="alert alert-info" role="status"><p
|
|
188
|
-
${notes.length ? html`<details><summary>${notes.length
|
|
188
|
+
message: html`<div class="alert alert-info" role="status"><p>${tx.rich('bp.of_app', { app: a.name, review: html`<b>${tx('bp.review')}</b>` })}</p>
|
|
189
|
+
${notes.length ? html`<details><summary>${notes.length === 1 ? tx('bp.notes_1') : tx('bp.notes', { n: notes.length })}</summary><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></details>` : ''}</div>`,
|
|
189
190
|
});
|
|
190
191
|
});
|
|
191
192
|
|
|
192
193
|
app.get(`${BASE}/blueprints/new`, async (req: Req, reply) => {
|
|
193
194
|
const s = await developer(req, reply);
|
|
194
195
|
if (!s) return;
|
|
195
|
-
return editor(s, reply, { name: '
|
|
196
|
+
return editor(s, reply, { name: tx('bp.projects'), spec: JSON.stringify(EXAMPLE, null, 2) });
|
|
196
197
|
});
|
|
197
198
|
|
|
198
199
|
app.get(`${BASE}/blueprints/:id(^\\d+$)`, async (req: Req, reply) => {
|
|
@@ -225,7 +226,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
225
226
|
? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4 returning id', [o.id, o.name, JSON.stringify(json), currentWorkspace(s)]))?.id
|
|
226
227
|
: (await owner.one('insert into meta.blueprint (name, spec, created_by, workspace_id) values ($1, $2, $3, $4) returning id', [o.name, JSON.stringify(json), s.username, currentWorkspace(s)])).id;
|
|
227
228
|
if (!id) return reply.code(404).send('Not found');
|
|
228
|
-
flash(s,
|
|
229
|
+
flash(s, tx('bp.saved', { name: o.name }));
|
|
229
230
|
return back(reply, s, `${BASE}/blueprints/${id}`);
|
|
230
231
|
});
|
|
231
232
|
|
|
@@ -236,8 +237,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
236
237
|
const description = String(req.body?.description ?? '').trim().slice(0, 4000);
|
|
237
238
|
try {
|
|
238
239
|
const svc = await builderService();
|
|
239
|
-
if (!svc) throw new AiError('config', '
|
|
240
|
-
if (!description) throw new AiError('config', '
|
|
240
|
+
if (!svc) throw new AiError('config', tx('bp.the_app_builder_has_no'));
|
|
241
|
+
if (!description) throw new AiError('config', tx('bp.describe_the_application_2'));
|
|
241
242
|
const res = await generate(svc.name, {
|
|
242
243
|
system: 'You design small business web applications on PostgreSQL as blueprints. Use singular lower-case table names, a few meaningful columns per table, '
|
|
243
244
|
+ '"references" for relations (never an id column: every table has one), "values" for short fixed lists, and pages of the given types (report_form for the main tables; '
|
|
@@ -248,7 +249,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
248
249
|
}, { appId: null, user: s.username, source: 'builder' });
|
|
249
250
|
const spec = JSON.stringify(fromDraft(res.json), null, 2);
|
|
250
251
|
return editor(s, reply, { ...o, name: o.id ? o.name : String((res.json as any)?.name ?? o.name).slice(0, 100), spec, description,
|
|
251
|
-
message: html`<div class="alert alert-info"
|
|
252
|
+
message: html`<div class="alert alert-info">${tx('bp.drafted_by_ai_review_it')}</div>` });
|
|
252
253
|
} catch (e) {
|
|
253
254
|
return editor(s, reply, { ...o, description, message: html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>` });
|
|
254
255
|
}
|
|
@@ -261,10 +262,10 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
261
262
|
const { json, error } = parse(o.spec);
|
|
262
263
|
const { blueprint, problems } = error ? { blueprint: null, problems: [error] } : checkBlueprint(json);
|
|
263
264
|
const taken = blueprint ? await owner.one('select 1 from meta.app where alias = $1', [blueprint.alias]) : null;
|
|
264
|
-
if (taken) problems.push(
|
|
265
|
+
if (taken) problems.push(tx('bp.alias_taken_short', { alias: blueprint!.alias }));
|
|
265
266
|
if (!blueprint || problems.length)
|
|
266
|
-
return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert"><p
|
|
267
|
-
return page(s, reply, '
|
|
267
|
+
return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert"><p>${tx('bp.the_blueprint_can_t_be')}</p><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` });
|
|
268
|
+
return page(s, reply, tx('bp.review_title'), review(s, blueprint, o.spec, o.id, o.name));
|
|
268
269
|
});
|
|
269
270
|
|
|
270
271
|
app.post(`${BASE}/blueprints/create`, async (req: Req, reply) => {
|
|
@@ -290,10 +291,10 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
290
291
|
return { ...r, existing: a.existingAccount };
|
|
291
292
|
});
|
|
292
293
|
await logActivity({ username: s.username, event: 'blueprint', ip: clientIp(req), detail: `created ${blueprint.alias} from blueprint ${o.name}` });
|
|
293
|
-
flash(s,
|
|
294
|
+
flash(s, `${tx('bp.created', { alias: blueprint.alias, tables: blueprint.tables.length, rows: built.rows })}${built.existing ? ` ${tx('bp.admin_role', { user: String(b.admin_user) })}` : ''}`);
|
|
294
295
|
return back(reply, s, `${BASE}/apps/${built.appId}`);
|
|
295
296
|
} catch (e) {
|
|
296
|
-
return page(s, reply, '
|
|
297
|
+
return page(s, reply, tx('bp.review_title'), html`<div class="alert alert-error" role="alert">${tx('bp.not_created', { error: (e as Error).message })}</div>${review(s, blueprint, o.spec, o.id, o.name)}`);
|
|
297
298
|
}
|
|
298
299
|
});
|
|
299
300
|
|
|
@@ -301,7 +302,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
301
302
|
const s = await developer(req, reply);
|
|
302
303
|
if (!s) return;
|
|
303
304
|
const r = await owner.query('delete from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
|
|
304
|
-
flash(s, r.rowCount ? '
|
|
305
|
+
flash(s, r.rowCount ? tx('bp.blueprint_deleted_applications_created_from') : tx('bp.there_is_no_such_blueprint'));
|
|
305
306
|
return back(reply, s, `${BASE}/blueprints`);
|
|
306
307
|
});
|
|
307
308
|
}
|
|
@@ -33,7 +33,7 @@ const CHECKS: Record<string, SqlShape> = {
|
|
|
33
33
|
'process.code': 'statements', 'authz_scheme.value': 'boolean',
|
|
34
34
|
'lov.query': 'select', 'app_process.code': 'statements', 'automation.query': 'select', 'automation_action.code': 'statements', 'automation_action.condition': 'boolean',
|
|
35
35
|
'document_template.query': 'select', 'report_query.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
|
|
36
|
-
'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean',
|
|
36
|
+
'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean', 'list_entry.badge_query': 'select',
|
|
37
37
|
};
|
|
38
38
|
/** (147) The expression fields of server-side conditions (their type select names how to read them). */
|
|
39
39
|
const CONDITION_EXPRS = ['computation', 'app_computation', 'branch', 'process', 'item', 'validation'].map((k) => `${k}.condition_expr`).concat('dynamic_action.server_condition_expr');
|
|
@@ -225,7 +225,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
225
225
|
summary: (d) => d.name,
|
|
226
226
|
defaults: { event: 'change', action: 'show' },
|
|
227
227
|
validate: (v) =>
|
|
228
|
-
v.
|
|
228
|
+
v.event === 'timer' && !/^\s*\d{1,4}\s*$/.test(String(v.trigger_element ?? '')) || (v.event === 'timer' && (Number(v.trigger_element) < 5 || Number(v.trigger_element) > 3600))
|
|
229
|
+
? 'Timer: the trigger is a number of seconds from 5 to 3600 (how often the actions run).'
|
|
230
|
+
: v.css_classes && !CLASS_LIST.test(String(v.css_classes))
|
|
229
231
|
? 'CSS classes: up to five names of lower case letters, digits, - and _, separated by spaces.'
|
|
230
232
|
: v.action === 'execute_javascript' && !/^[A-Za-z_$][\w$.-]{0,99}$/.test(String(v.code ?? '').trim())
|
|
231
233
|
? 'Execute JavaScript: Code is the name of a function a static file registers with pgkiln.actions.register(name, fn), e.g. highlightLate.'
|
|
@@ -236,8 +238,8 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
236
238
|
{ name: 'name', label: 'Name', kind: 'text', group: 'When' },
|
|
237
239
|
{ name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom', 'menu_entry',
|
|
238
240
|
'double_click', 'key_down', 'key_press', 'mouse_enter', 'mouse_leave', 'select', 'resize', 'scroll', 'page_unload', 'before_refresh', 'facets_change',
|
|
239
|
-
'grid_row_init', 'grid_save', 'calendar_date_select', 'calendar_view_change', 'map_object_click', 'map_change', 'tree_selection_change'], group: 'When',
|
|
240
|
-
help: '(sprint 161) Component events, trigger: the region. grid_row_init: a row was added to an editable grid (Add row, Duplicate). grid_save: the grid\'s changes were saved (runs on the page that comes back). calendar_date_select: an empty day or hour was chosen (the condition sees YYYY-MM-DD or YYYY-MM-DDTHH:MM; cancel_event keeps the Create link closed). calendar_view_change: another view or period (the condition sees month, week, day or list). map_object_click: a marker or shape was clicked (the condition sees its id column, else its title). map_change: the map was moved or zoomed (the condition sees the zoom level). tree_selection_change: a tree node was chosen (the condition sees its id; cancel_event keeps its link closed). (sprint 156) double_click, mouse_enter, mouse_leave: items, buttons or regions. key_down, key_press (a key that types a character), select (text selected): items. resize, scroll: the window (no trigger). page_unload: the page is left (only quick, local actions finish). before_refresh: a region is about to be refreshed by a Refresh action. facets_change: a faceted search or smart filters region\'s facets were applied (trigger: that region). ' + 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). menu_entry (Menu entry chosen): an entry {"label": …, "action": "NAME"} of a menu button was chosen. The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
|
|
241
|
+
'grid_row_init', 'grid_save', 'calendar_date_select', 'calendar_view_change', 'map_object_click', 'map_change', 'tree_selection_change', 'timer'], group: 'When',
|
|
242
|
+
help: '(sprint 165) timer: every so many seconds while the page is open and visible (the trigger: a number of seconds, 5 to 3600), e.g. refresh_region of a report. (sprint 161) Component events, trigger: the region. grid_row_init: a row was added to an editable grid (Add row, Duplicate). grid_save: the grid\'s changes were saved (runs on the page that comes back). calendar_date_select: an empty day or hour was chosen (the condition sees YYYY-MM-DD or YYYY-MM-DDTHH:MM; cancel_event keeps the Create link closed). calendar_view_change: another view or period (the condition sees month, week, day or list). map_object_click: a marker or shape was clicked (the condition sees its id column, else its title). map_change: the map was moved or zoomed (the condition sees the zoom level). tree_selection_change: a tree node was chosen (the condition sees its id; cancel_event keeps its link closed). (sprint 156) double_click, mouse_enter, mouse_leave: items, buttons or regions. key_down, key_press (a key that types a character), select (text selected): items. resize, scroll: the window (no trigger). page_unload: the page is left (only quick, local actions finish). before_refresh: a region is about to be refreshed by a Refresh action. facets_change: a faceted search or smart filters region\'s facets were applied (trigger: that region). ' + 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). menu_entry (Menu entry chosen): an entry {"label": …, "action": "NAME"} of a menu button was chosen. The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
|
|
241
243
|
{ name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change, before_refresh, facets_change and the component events grid_*, calendar_*, map_*, tree_selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom), menu entry action names or menu button names (menu_entry: any of its entries; a client-side condition tests the entry\'s name). Empty for load and before_submit.' },
|
|
242
244
|
{ name: 'condition_type', label: 'Client-side condition', kind: 'select', options: ['', 'equals', 'not_equals', 'greater', 'greater_equal', 'less', 'less_equal', 'in_list', 'not_in_list', 'is_null', 'is_not_null'], group: 'When',
|
|
243
245
|
help: 'On the (first) trigger item\'s value. greater / less: numbers as numbers, other values (ISO dates) as text; an empty value fails them. in_list / not_in_list: comma-separated values.' },
|
|
@@ -299,7 +301,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
299
301
|
},
|
|
300
302
|
fields: [
|
|
301
303
|
{ name: 'name', label: 'Name', kind: 'text' },
|
|
302
|
-
{ name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push', 'send_email',
|
|
304
|
+
{ name: 'type', label: 'Type', kind: 'select', options: ['sql', 'form_dml', 'grid_dml', 'data_load', 'invoke_api', 'download', 'chain', 'workflow', 'ai_generate', 'plugin', 'send_push', 'send_email', 'send_notification',
|
|
303
305
|
'close_dialog', 'clear_session_state', 'reset_pagination', 'user_preferences', 'server_geocoding', 'print_report', 'human_task_create', 'human_task_manage', 'form_init'],
|
|
304
306
|
help: '(sprint 157, settings in Attributes) close_dialog: {"items": ["P5_ID"]} closes the modal page, handing these items to the page below · clear_session_state: {"items": [...], "pages": [3], "all": false} · reset_pagination: reports and grids start on their first page · user_preferences: {"action": "set", "preference": "DEFAULT_DEPT", "item": "P1_DEPT"} or {"action": "reset", …} · server_geocoding: {"address_item": "P3_ADDRESS", "coordinates_item": "P3_LOCATION"} · print_report: {"document": "EMPLOYEE_SHEET"} (a document template or report query) · human_task_create: {"definition": "LEAVE_APPROVAL", "detail_pk": "&P7_ID.", "parameters": {"DAYS": "&P7_DAYS."}, "id_item": "P7_TASK_ID"} · human_task_manage: {"action": "approve", "task": "&P8_TASK_ID.", "comment": "&P8_COMMENT."} (approve, reject, complete with "outcome", claim, release, delegate with "to", cancel) · form_init: APEX\'s Form – Initialization (the form region fetches its row by itself) · download: a file from the query in Code · chain: runs the processes that name it as their chain, in sequence (optionally in the background) · workflow: start, terminate or retry a workflow · ai_generate: Generate text with AI (an AI service the application may use) · send_push: a push notification to a user\'s devices (Settings → Progressive Web App → push notifications)' },
|
|
305
307
|
{ name: 'point', label: 'Point', kind: 'select', options: ['submit', 'load', 'ajax'], help: 'A chain\'s processes run when the chain does (their own point is not used). ajax: an Ajax callback (APEX: On Demand) that JavaScript calls by name with pgkiln.server.process(\'NAME\', {items: [\'P1_X\'], x01: …}); SQL processes only; the last statement\'s rows come back as JSON, and the items it sets are set on the page.' },
|
|
@@ -307,8 +309,8 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
307
309
|
{ name: 'code', label: 'Code (SQL / PL/pgSQL call)', kind: 'code', wide: true,
|
|
308
310
|
help: 'e.g. select sales.ship_order(:P3_ID::int) as p3_status — returned columns named like items set them. RAISE EXCEPTION messages are shown to the user; USING COLUMN = \'sal\' puts it on that field. · download: a query returning the file\'s content (bytea or text), file name and MIME type, e.g. select content, filename, mime_type from docs.file where id = :P5_ID::int — several rows are sent as one zip file.' },
|
|
309
311
|
{ name: 'region_id', label: 'Form / grid region (form_dml, grid_dml)', kind: 'region' },
|
|
310
|
-
{ name: 'config', label: 'Configuration (data_load, invoke_api, download, chain, workflow, ai_generate, plugin, send_push, send_email)', kind: 'json', wide: true,
|
|
311
|
-
help: 'data_load: {"file_item":"P5_FILE","definition":"ORDERS_LOAD"} (a data load definition: table, format, mapping, transformations) or {"file_item":"P5_FILE","table":"sales.orders","mode":"append | merge | replace","skip_errors":false,"headers":true,"format":"auto | csv | xlsx | json | xml","row_tag":"order","columns":{"Heading in file":"column"}} — runs as the app\'s database role; columns match by name unless mapped. · invoke_api: {"source":"WEATHER","params":{"city":"&P5_CITY."},"items":{"P5_TEMP":"current.temp"},"status_item":"P5_STATUS"} or {"url":"https://api.example.com/orders/&P5_ID.","method":"POST","credential":"SHOP_API","body":"{\\"note\\": &P5_NOTE.}","items":{…}} — without "items", the first row\'s columns set the items named like them. · download: {"content_column":"content","filename_column":"filename","mime_column":"mime_type","zip_name":"files-&P5_ID..zip","disposition":"attachment | inline"} (all optional; default: the first three columns). Or {"export":"csv | json | xml | html | xlsx | pdf","file_name":"orders-&P5_ID.","title":"Orders","layout":"A4_LANDSCAPE","separator":";"}: the query\'s rows as one file (APEX_DATA_EXPORT; meta.data_export does csv, json, xml and html from SQL). · chain: {"background": true, "status_item": "P5_JOB_ID"} runs it after the submit, on the server (status in meta.process_jobs). · workflow: {"action":"start","definition":"APPROVAL","version":"2","detail_pk":"&P5_ID.","variables":{"AMOUNT":"&P5_AMOUNT."},"id_item":"P5_WORKFLOW_ID"} or {"action":"terminate | retry","instance":"&P5_WORKFLOW_ID.","comment":"…"} · send_push: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"tag":"order-&P5_ID.","urgency":"normal | high | low | very-low"} — to the user\'s devices that turned notifications on; the link is signed for that user · ai_generate: {"service":"CLAUDE","system":"You write short, friendly summaries.","prompt":"Summarise: &P5_NOTES.","output_item":"P5_SUMMARY"} (text), {"service":…,"prompt":"Extract the contact from: &P5_TEXT.","output_items":["P5_NAME","P5_EMAIL"]} (structured: a JSON schema built from the items, their labels describe the fields) or {"service":…,"prompt":…,"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"items":{"P5_NAME":"name"}} (your schema; items map to properties or paths); optional "max_tokens", "error_message". &ITEM. values are sent as delimited data, never as instructions. · plugin: {"plugin":"name","attributes":{"NAME":"value or &ITEM."}} (a process plug-in: its function returns the message) · send_email: {"to":"&P5_EMAIL.","cc":"…","bcc":"…","from":"…","reply_to":"…","subject":"Order &P5_ID.","body":"plain text","body_html":"<p>HTML (item values escaped)</p>"} or {"to":"&P5_EMAIL.","template":"ORDER_SHIPPED","placeholders":{"NAME":"&P5_NAME."}}; "attachments":"select content, filename, mime from sales.invoice where order_id = :P5_ID" — queued in the page\'s transaction, sent after the commit through the application\'s mail service' },
|
|
312
|
+
{ name: 'config', label: 'Configuration (data_load, invoke_api, download, chain, workflow, ai_generate, plugin, send_push, send_email, send_notification)', kind: 'json', wide: true,
|
|
313
|
+
help: 'data_load: {"file_item":"P5_FILE","definition":"ORDERS_LOAD"} (a data load definition: table, format, mapping, transformations) or {"file_item":"P5_FILE","table":"sales.orders","mode":"append | merge | replace","skip_errors":false,"headers":true,"format":"auto | csv | xlsx | json | xml","row_tag":"order","columns":{"Heading in file":"column"}} — runs as the app\'s database role; columns match by name unless mapped. · invoke_api: {"source":"WEATHER","params":{"city":"&P5_CITY."},"items":{"P5_TEMP":"current.temp"},"status_item":"P5_STATUS"} or {"url":"https://api.example.com/orders/&P5_ID.","method":"POST","credential":"SHOP_API","body":"{\\"note\\": &P5_NOTE.}","items":{…}} — without "items", the first row\'s columns set the items named like them. · download: {"content_column":"content","filename_column":"filename","mime_column":"mime_type","zip_name":"files-&P5_ID..zip","disposition":"attachment | inline"} (all optional; default: the first three columns). Or {"export":"csv | json | xml | html | xlsx | pdf","file_name":"orders-&P5_ID.","title":"Orders","layout":"A4_LANDSCAPE","separator":";"}: the query\'s rows as one file (APEX_DATA_EXPORT; meta.data_export does csv, json, xml and html from SQL). · chain: {"background": true, "status_item": "P5_JOB_ID"} runs it after the submit, on the server (status in meta.process_jobs). · workflow: {"action":"start","definition":"APPROVAL","version":"2","detail_pk":"&P5_ID.","variables":{"AMOUNT":"&P5_AMOUNT."},"id_item":"P5_WORKFLOW_ID"} or {"action":"terminate | retry","instance":"&P5_WORKFLOW_ID.","comment":"…"} · send_push: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"tag":"order-&P5_ID.","urgency":"normal | high | low | very-low"} — to the user\'s devices that turned notifications on; the link is signed for that user · send_notification: {"to":"&P5_OWNER.","title":"Order &P5_ID. needs approval","body":"…","page":5,"items":{"P5_ID":"&P5_ID."},"icon":"bell","push":false} — an in-app notification (the bell; Settings → User Interface → Notifications); push: also to the user\'s devices · ai_generate: {"service":"CLAUDE","system":"You write short, friendly summaries.","prompt":"Summarise: &P5_NOTES.","output_item":"P5_SUMMARY"} (text), {"service":…,"prompt":"Extract the contact from: &P5_TEXT.","output_items":["P5_NAME","P5_EMAIL"]} (structured: a JSON schema built from the items, their labels describe the fields) or {"service":…,"prompt":…,"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false},"items":{"P5_NAME":"name"}} (your schema; items map to properties or paths); optional "max_tokens", "error_message". &ITEM. values are sent as delimited data, never as instructions. · plugin: {"plugin":"name","attributes":{"NAME":"value or &ITEM."}} (a process plug-in: its function returns the message) · send_email: {"to":"&P5_EMAIL.","cc":"…","bcc":"…","from":"…","reply_to":"…","subject":"Order &P5_ID.","body":"plain text","body_html":"<p>HTML (item values escaped)</p>"} or {"to":"&P5_EMAIL.","template":"ORDER_SHIPPED","placeholders":{"NAME":"&P5_NAME."}}; "attachments":"select content, filename, mime from sales.invoice where order_id = :P5_ID" — queued in the page\'s transaction, sent after the commit through the application\'s mail service' },
|
|
312
314
|
{ name: 'when_button', label: 'When button pressed', kind: 'upper' },
|
|
313
315
|
...conditionFields('Condition'),
|
|
314
316
|
{ name: 'success_message', label: 'Success message', kind: 'text' },
|
|
@@ -413,6 +415,8 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
413
415
|
{ name: 'parent_id', label: 'Parent entry', kind: 'list_parent', group: 'Entry', help: 'An entry of the same list: this entry is shown below it (a sub menu).' },
|
|
414
416
|
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Entry' },
|
|
415
417
|
{ name: 'badge', label: 'Badge', kind: 'text', group: 'Entry', help: 'A short text or number, &ITEM. allowed (e.g. &P1_OPEN_COUNT.).' },
|
|
418
|
+
{ name: 'badge_query', label: 'Badge (SQL)', kind: 'code', group: 'Entry',
|
|
419
|
+
help: '(sprint 165) A SELECT whose first value is the badge, run as the application\'s role; wins over Badge. No row or an empty value: no badge. E.g. select nullif(count(*), 0) from sales.orders where status = \'NEW\' · select meta.unread_notifications(). Refreshed while the page is open with Settings → User Interface → Live badges.' },
|
|
416
420
|
{ name: 'description', label: 'Description', kind: 'textarea', group: 'Entry', help: 'Shown by the cards template.' },
|
|
417
421
|
{ name: 'target_page', label: 'Page', kind: 'page', group: 'Target', help: 'Empty with no URL: a heading for its child entries.' },
|
|
418
422
|
{ name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Target', help: '{"P3_ID": "&P1_ID."} — sent with a checksum.' },
|