pgkiln 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/db/migrations/095_rest_catalogs.sql +59 -0
  2. package/db/migrations/096_developer_email.sql +15 -0
  3. package/docs/apex-feature-parity.md +12 -9
  4. package/docs/guide/03-builder.md +37 -1
  5. package/docs/guide/04-pages-and-regions.md +37 -4
  6. package/docs/guide/12-development.md +18 -1
  7. package/docs/guide/19-rest-data-sources.md +39 -1
  8. package/examples/hr/hr_59_gantt_editing.sql +21 -0
  9. package/examples/hr/hr_60_tree_charts.sql +46 -0
  10. package/package.json +1 -1
  11. package/public/app.css +44 -0
  12. package/public/app.js +126 -0
  13. package/public/builder.css +1 -1
  14. package/public/sw.js +5 -2
  15. package/src/app.ts +6 -0
  16. package/src/builder/find-workspace.ts +120 -0
  17. package/src/builder/home.ts +2 -0
  18. package/src/builder/mail.ts +39 -0
  19. package/src/builder/region-settings.ts +19 -1
  20. package/src/builder/rest-catalogs.ts +424 -0
  21. package/src/builder/routes.ts +32 -6
  22. package/src/builder/shared.ts +2 -0
  23. package/src/builder/workspace-transfer.ts +136 -0
  24. package/src/i18n/ar.ts +14 -0
  25. package/src/i18n/cs.ts +14 -0
  26. package/src/i18n/da.ts +14 -0
  27. package/src/i18n/de.ts +14 -0
  28. package/src/i18n/el.ts +14 -0
  29. package/src/i18n/es.ts +14 -0
  30. package/src/i18n/fi.ts +14 -0
  31. package/src/i18n/fr.ts +14 -0
  32. package/src/i18n/he.ts +14 -0
  33. package/src/i18n/it.ts +14 -0
  34. package/src/i18n/ja.ts +14 -0
  35. package/src/i18n/ko.ts +14 -0
  36. package/src/i18n/nb.ts +14 -0
  37. package/src/i18n/pl.ts +14 -0
  38. package/src/i18n/pt.ts +14 -0
  39. package/src/i18n/ru.ts +14 -0
  40. package/src/i18n/sv.ts +14 -0
  41. package/src/i18n/tr.ts +14 -0
  42. package/src/i18n/uk.ts +14 -0
  43. package/src/i18n/zh.ts +14 -0
  44. package/src/i18n.ts +28 -0
  45. package/src/restcatalog.ts +605 -0
  46. package/src/runtime/charts.ts +296 -12
  47. package/src/runtime/data-reporter.ts +48 -16
  48. package/src/runtime/gantt-move.ts +63 -0
  49. package/src/runtime/regions.ts +3 -0
  50. package/src/runtime/routes.ts +39 -0
  51. package/src/websources.ts +11 -3
  52. package/src/workspace-export.ts +240 -0
package/public/app.js CHANGED
@@ -972,6 +972,132 @@ const pgkilnUnsaved = (() => {
972
972
  }
973
973
  });
974
974
 
975
+ // ------------------------------------------------------------ Gantt chart drag and drop (sprint 69)
976
+ // An editable Gantt chart (.gantt-editable, data-gantt = its region) has tasks with
977
+ // data-task, data-start and data-end (wall clock ms, read as UTC). Dragging a task moves
978
+ // it, dragging a bar's end handle changes its end, in whole days or hours (data-snap);
979
+ // from the keyboard the arrow keys do the same (Shift: the end), Enter saves, Escape
980
+ // cancels. The server checks and saves the new dates and sends the chart back.
981
+ const GANTT_STEP = { day: 86400000, hour: 3600000 };
982
+ const ganttWhen = (ms, snap) => new Date(ms).toISOString().slice(0, snap === 'hour' ? 16 : 10);
983
+ const ganttState = new WeakMap();
984
+ function ganttPreview(task, steps, mode) {
985
+ const chart = task.closest('.gantt-editable');
986
+ const plot = chart.querySelector('.gantt-plot');
987
+ const step = GANTT_STEP[chart.dataset.snap] || GANTT_STEP.day;
988
+ const span = Number(chart.dataset.hi) - Number(chart.dataset.lo);
989
+ const start = Number(task.dataset.start);
990
+ const end = Number(task.dataset.end);
991
+ const prev = ganttState.get(task);
992
+ // the bar's width before this edit (the preview changes it)
993
+ const orig = prev ? prev.orig : task.getBoundingClientRect().width;
994
+ // a bar's end never goes before its start; a milestone only moves
995
+ const resize = mode === 'end' && task.classList.contains('gantt-bar');
996
+ if (resize) steps = Math.max(steps, Math.round((start - end) / step));
997
+ const next = resize ? { start, end: end + steps * step } : { start: start + steps * step, end: end + steps * step };
998
+ const px = (ms) => (ms / span) * plot.getBoundingClientRect().width;
999
+ // (translate, not transform: a milestone is a rotated square)
1000
+ task.style.translate = !resize && steps ? `${px(steps * step)}px 0` : '';
1001
+ task.style.width = resize && steps ? `${Math.max(orig + px(steps * step), 2)}px` : '';
1002
+ ganttState.set(task, { steps, mode: resize ? 'end' : 'move', next, orig, keyboard: prev?.keyboard });
1003
+ task.classList.toggle('gantt-moving', !!steps);
1004
+ const status = chart.closest('.region')?.querySelector('.gantt-status');
1005
+ if (status) status.textContent = steps ? `${task.dataset.label}: ${ganttWhen(next.start, chart.dataset.snap).replace('T', ' ')} – ${ganttWhen(next.end, chart.dataset.snap).replace('T', ' ')}` : '';
1006
+ return next;
1007
+ }
1008
+ function ganttReset(task) {
1009
+ task.style.translate = '';
1010
+ task.style.width = '';
1011
+ const status = task.closest('.region')?.querySelector('.gantt-status');
1012
+ if (status) status.textContent = '';
1013
+ task.classList.remove('gantt-moving', 'gantt-dragging');
1014
+ ganttState.delete(task);
1015
+ }
1016
+ async function ganttSave(task) {
1017
+ const st = ganttState.get(task);
1018
+ if (!st || !st.steps) return ganttReset(task);
1019
+ const chart = task.closest('.gantt-editable');
1020
+ const region = chart.closest('.region');
1021
+ const id = task.dataset.task;
1022
+ const snap = chart.dataset.snap;
1023
+ chart.setAttribute('aria-busy', 'true');
1024
+ try {
1025
+ const res = await post(`/chart/${chart.dataset.gantt}/move`, { task: id, start: ganttWhen(st.next.start, snap), end: ganttWhen(st.next.end, snap) });
1026
+ const sheet = document.getElementById('pgkiln-css')?.sheet;
1027
+ if (sheet && res.css) for (const rule of res.css.split('\n')) if (rule) sheet.insertRule(rule, sheet.cssRules.length);
1028
+ const node = region ? replaceHtml(region, res.region) : null;
1029
+ const status = node?.querySelector('.gantt-status');
1030
+ if (status) status.textContent = res.message;
1031
+ // keyboard users keep their place
1032
+ if (st.keyboard) node?.querySelector(`.gantt-editable [data-task="${CSS.escape(id)}"]`)?.focus();
1033
+ } catch (err) {
1034
+ chart.removeAttribute('aria-busy');
1035
+ ganttReset(task);
1036
+ showError(err.message);
1037
+ }
1038
+ }
1039
+ let ganttDrag = null;
1040
+ let ganttSwallowClick = false;
1041
+ document.addEventListener('pointerdown', (e) => {
1042
+ const task = e.button === 0 && e.target.closest?.('.gantt-editable [data-task]');
1043
+ if (!task || task.closest('[aria-busy="true"]')) return;
1044
+ ganttDrag = { task, x: e.clientX, mode: e.target.closest('.gantt-handle') ? 'end' : 'move', id: e.pointerId, moved: false };
1045
+ task.setPointerCapture?.(e.pointerId);
1046
+ });
1047
+ document.addEventListener('pointermove', (e) => {
1048
+ if (!ganttDrag || e.pointerId !== ganttDrag.id) return;
1049
+ const { task } = ganttDrag;
1050
+ const chart = task.closest('.gantt-editable');
1051
+ const width = chart.querySelector('.gantt-plot').getBoundingClientRect().width || 1;
1052
+ const step = GANTT_STEP[chart.dataset.snap] || GANTT_STEP.day;
1053
+ const dx = e.clientX - ganttDrag.x;
1054
+ if (!ganttDrag.moved && Math.abs(dx) < 4) return;
1055
+ ganttDrag.moved = true;
1056
+ task.classList.add('gantt-dragging');
1057
+ const steps = Math.round(((dx / width) * (Number(chart.dataset.hi) - Number(chart.dataset.lo))) / step);
1058
+ if (steps !== ganttState.get(task)?.steps) ganttPreview(task, steps, ganttDrag.mode);
1059
+ });
1060
+ const ganttEnd = (e, cancel) => {
1061
+ if (!ganttDrag || e.pointerId !== ganttDrag.id) return;
1062
+ const { task, moved } = ganttDrag;
1063
+ ganttDrag = null;
1064
+ task.classList.remove('gantt-dragging');
1065
+ if (!moved) return;
1066
+ // a drag is not a click on the task's link
1067
+ ganttSwallowClick = true;
1068
+ setTimeout(() => (ganttSwallowClick = false), 0);
1069
+ if (cancel) ganttReset(task);
1070
+ else ganttSave(task);
1071
+ };
1072
+ document.addEventListener('pointerup', (e) => ganttEnd(e, false));
1073
+ document.addEventListener('pointercancel', (e) => ganttEnd(e, true));
1074
+ document.addEventListener('click', (e) => {
1075
+ if (ganttSwallowClick && e.target.closest?.('.gantt-editable [data-task]')) {
1076
+ e.preventDefault();
1077
+ e.stopPropagation();
1078
+ }
1079
+ }, true);
1080
+ document.addEventListener('keydown', (e) => {
1081
+ const task = e.target.closest?.('.gantt-editable [data-task]');
1082
+ if (!task || e.altKey || e.ctrlKey || e.metaKey) return;
1083
+ const st = ganttState.get(task);
1084
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
1085
+ e.preventDefault();
1086
+ const mode = e.shiftKey && task.classList.contains('gantt-bar') ? 'end' : 'move';
1087
+ // switching between moving and changing the end starts over
1088
+ if (st && st.mode !== mode) ganttReset(task);
1089
+ const same = ganttState.get(task);
1090
+ ganttPreview(task, (same ? same.steps : 0) + (e.key === 'ArrowRight' ? 1 : -1), mode);
1091
+ ganttState.get(task).keyboard = true;
1092
+ } else if (e.key === 'Enter' && st?.steps) {
1093
+ e.preventDefault();
1094
+ ganttSave(task);
1095
+ } else if (e.key === 'Escape' && st) {
1096
+ e.preventDefault();
1097
+ ganttReset(task);
1098
+ }
1099
+ });
1100
+
975
1101
  // ------------------------------------------------------------ lazy regions
976
1102
  // A lazy region arrives as a placeholder with a "Show" link (which works
977
1103
  // without JavaScript); fetch the region now and put it in place, with its
@@ -168,7 +168,7 @@ body.ide { margin: 0; min-height: 100vh; min-height: 100dvh; padding-left: var(-
168
168
  .ide-page { max-width: 1680px; margin: 0 auto; padding: 14px 18px 48px; }
169
169
  .ide .region-standard, .ide .region-collapsible { border-radius: var(--radius); box-shadow: none; }
170
170
  .ide .region-header { min-height: 38px; padding: 0.35rem 0.85rem; background: var(--surface-2); border-radius: var(--radius) var(--radius) 0 0; }
171
- .ide .region-header h2 { font-size: 13px; font-weight: 650; }
171
+ .ide .region-header h2 { font-size: 13px; font-weight: 650; min-width: 0; overflow-wrap: anywhere; }
172
172
  .ide .region-body { padding: 0.85rem; }
173
173
  .ide .btn { font-size: 13px; padding: 0.34rem 0.8rem; border-radius: 4px; }
174
174
  .ide input:not([type=checkbox]):not([type=radio]), .ide select, .ide textarea { padding: 0.32rem 0.5rem; border-radius: 3px; font-size: 13px; }
package/public/sw.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // pgkiln service worker for one application (served as /a/<alias>/sw.js with PGKILN = {base, offlinePages, offlineSubmit, version}).
2
2
  // static files (app.css, app.js, icons) and the offline page: cached at install, served from the cache by their
3
3
  // exact URL; pages ask for them with ?v=<version> (static-version.ts), so a changed file is fetched anew
4
+ // (offline, the cached copy of another version)
4
5
  // pages (navigations): network first; offline, the cached copy (only when the app keeps pages) or the offline page
5
6
  // signing in or out empties the page cache, so the next person on the device doesn't see them
6
7
  // push notifications (migration 074): shown as they arrive, a click opens their page of the app;
@@ -40,8 +41,10 @@ self.addEventListener('fetch', (event) => {
40
41
  if (req.method !== 'GET') return;
41
42
 
42
43
  if (url.pathname.startsWith('/static/')) {
43
- // the exact URL: another ?v= (a changed file) is not the cached copy
44
- event.respondWith(caches.match(req, { cacheName: STATIC }).then((hit) => hit || fetch(req)));
44
+ // the exact URL: another ?v= (a changed file) is not the cached copy. Offline, the copy under another ?v= is
45
+ // better than none: a page kept on the device under an older version still gets its script and styles
46
+ const anyVersion = async () => (await caches.match(req, { cacheName: STATIC, ignoreSearch: true })) || Response.error();
47
+ event.respondWith(caches.match(req, { cacheName: STATIC }).then((hit) => hit || fetch(req).catch(anyVersion)));
45
48
  return;
46
49
  }
47
50
 
package/src/app.ts CHANGED
@@ -70,6 +70,9 @@ import { galleryRoutes } from './builder/gallery.ts';
70
70
  import { appGroupRoutes } from './builder/app-groups.ts';
71
71
  import { crossAppRoutes } from './builder/cross-app.ts';
72
72
  import { workspaceBackupRoutes } from './builder/workspace-backups.ts';
73
+ import { restCatalogRoutes } from './builder/rest-catalogs.ts';
74
+ import { workspaceTransferRoutes } from './builder/workspace-transfer.ts';
75
+ import { findWorkspaceRoutes } from './builder/find-workspace.ts';
73
76
  import { runtimeScope } from './db.ts';
74
77
 
75
78
  export async function buildApp(opts: { logger?: boolean } = {}) {
@@ -179,6 +182,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
179
182
  await app.register(appGroupRoutes);
180
183
  await app.register(crossAppRoutes);
181
184
  await app.register(workspaceBackupRoutes);
185
+ await app.register(restCatalogRoutes);
186
+ await app.register(workspaceTransferRoutes);
187
+ await app.register(findWorkspaceRoutes);
182
188
  await app.register(sharedRoutes);
183
189
  await app.register(designerRoutes);
184
190
  await app.register(lockRoutes);
@@ -0,0 +1,120 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { esc, html, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { documentShell } from '../layout.ts';
6
+ import { senderAllowed, sendWith, type MailService } from '../mail/mail.ts';
7
+ import { clientIp, getSession, logActivity, saveState, takeFlash, type Session } from '../session.ts';
8
+ import { publicUrl } from '../sso.ts';
9
+ import { back, BASE, builderHead, csrf, flash, THEME_COOKIE, validTheme, type Req } from './ui.ts';
10
+
11
+ // "Find my workspace" on the builder's sign-in page (migration 096; APEX: Find
12
+ // My Workspace): a developer who forgot which workspace to sign in to enters
13
+ // their e-mail address, and the developer accounts with that address get a
14
+ // mail with their user name and workspaces. The page says the same whatever
15
+ // the address, the mail goes out after the answer (no timing difference), and
16
+ // requests are limited per address and per IP address. It is offered only
17
+ // when administrators chose a mail service for the builder (Workspace
18
+ // utilities → Mail services → Builder mail).
19
+
20
+ const PER_IP_HOUR = 10;
21
+ const PER_ADDRESS_HOUR = 3;
22
+ export const EMAIL = /^[^@\s<>"(),;:\\]+@[^@\s<>"(),;:\\]+\.[^@\s<>"(),;:\\]+$/;
23
+
24
+ /** The builder's mail service and From address, when set up and usable; null otherwise. */
25
+ export async function builderMail(): Promise<{ service: MailService; from: string } | null> {
26
+ const rows = (await owner.query<{ name: string; value: string }>(`select name, value from meta.setting where name in ('builder_mail_service', 'builder_mail_from')`)).rows;
27
+ const get = (k: string) => rows.find((r) => r.name === k)?.value ?? '';
28
+ const service = get('builder_mail_service') ? await owner.one<MailService>('select * from meta.mail_service where name = $1 and enabled', [get('builder_mail_service')]) : undefined;
29
+ const from = get('builder_mail_from');
30
+ return service && from && senderAllowed(service.senders, from) ? { service, from } : null;
31
+ }
32
+
33
+ /** The mail for one address: each account with it, and its workspaces. */
34
+ export async function workspacesMail(email: string): Promise<{ to: string; subject: string; text: string; html: string } | null> {
35
+ const accounts = (await owner.query<{ username: string; admin: boolean; workspaces: string[] }>(
36
+ `select d.username, d.is_admin as admin,
37
+ coalesce((select array_agg(w.name order by w.id = 1 desc, lower(w.name)) from meta.workspace w
38
+ where d.is_admin or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = d.username)), '{}') as workspaces
39
+ from meta.developer d where lower(d.email) = lower($1) order by d.username`, [email])).rows;
40
+ if (!accounts.length) return null;
41
+ const signIn = `${publicUrl()}${BASE}/login`;
42
+ const lines = accounts.map((a) => `${a.username}${a.admin ? ' (administrator: every workspace)' : ''}\n${a.workspaces.length ? a.workspaces.map((w) => ` - ${w}`).join('\n') : ' (no workspace yet: ask an administrator)'}`);
43
+ const text = `You asked which pgkiln workspaces you can sign in to.\n\n${lines.join('\n\n')}\n\nSign in at ${signIn}\n\nIf you didn't ask, you can ignore this mail: nothing was changed.`;
44
+ const body = `<p>You asked which pgkiln workspaces you can sign in to.</p>${accounts.map((a) => `<p><b>${esc(a.username)}</b>${a.admin ? ' (administrator: every workspace)' : ''}</p><ul>${a.workspaces.length ? a.workspaces.map((w) => `<li>${esc(w)}</li>`).join('') : '<li>No workspace yet: ask an administrator.</li>'}</ul>`).join('')}<p>Sign in at <a href="${esc(signIn)}">${esc(signIn)}</a></p><p>If you didn't ask, you can ignore this mail: nothing was changed.</p>`;
45
+ return { to: email, subject: 'Your pgkiln workspaces', text, html: body };
46
+ }
47
+
48
+ const page = (s: Session, theme: ReturnType<typeof validTheme>, body: Raw) =>
49
+ documentShell(
50
+ 'Find my workspace · Builder',
51
+ html`<main class="signin">
52
+ <section class="signin-hero" aria-label="pgkiln">
53
+ <span class="signin-logo" aria-hidden="true">pg</span>
54
+ <h1 class="signin-title">pgkiln</h1>
55
+ <p class="signin-tagline">Build data applications on PostgreSQL, in SQL you can trust</p>
56
+ </section>
57
+ <section class="signin-panel"><div class="signin-form">${body}
58
+ <nav class="signin-actions" aria-label="Quick actions"><a href="${BASE}/login">${icon('arrow-left')} Back to sign in</a></nav></div></section>
59
+ </main>`,
60
+ 'login-body ide-login signin-body',
61
+ {},
62
+ builderHead(),
63
+ { theme: theme ?? 'dark' },
64
+ );
65
+
66
+ export async function findWorkspaceRoutes(app: FastifyInstance) {
67
+ app.get(`${BASE}/find-workspace`, async (req: Req, reply) => {
68
+ const s = await getSession(req, reply, null, BASE);
69
+ const done = takeFlash(s);
70
+ const error = takeFlash(s, '__ERROR');
71
+ await saveState(s);
72
+ const mail = await builderMail();
73
+ const theme = validTheme(req.cookies?.[THEME_COOKIE]);
74
+ const body = !mail
75
+ ? html`<h2>Find my workspace</h2><p class="muted u-mt0">This installation doesn't send mail to developers. Ask an administrator which workspaces you belong to; or sign in with your user name and password only: the workspace is optional.</p>`
76
+ : done
77
+ ? html`<h2>Check your mail</h2><div class="alert alert-success" role="status">${done}</div>`
78
+ : html`<h2>Find my workspace</h2>
79
+ <p class="muted u-mt0">Enter the e-mail address of your developer account: we send you your user name and the workspaces you can sign in to.</p>
80
+ ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
81
+ <form method="post" action="${BASE}/find-workspace">${csrf(s)}
82
+ <div class="field signin-field"><label class="sr-only" for="f_email">E-mail address</label>
83
+ <div class="signin-input">${icon('at')}<input id="f_email" name="email" type="email" placeholder="E-mail address" required autocomplete="email" maxlength="254"></div></div>
84
+ <button class="btn btn-hot signin-submit">Send</button>
85
+ </form>`;
86
+ return reply.type('text/html').send(page(s, theme, body));
87
+ });
88
+
89
+ app.post(`${BASE}/find-workspace`, async (req: Req, reply) => {
90
+ const s = await getSession(req, reply, null, BASE);
91
+ const to = `${BASE}/find-workspace`;
92
+ if (req.body?.__csrf !== s.csrf_token) {
93
+ flash(s, 'Your session expired. Please try again.', 'error');
94
+ return back(reply, s, to);
95
+ }
96
+ const email = String(req.body?.email ?? '').trim().slice(0, 254);
97
+ if (!EMAIL.test(email)) {
98
+ flash(s, 'Enter an e-mail address, e.g. ann@example.com.', 'error');
99
+ return back(reply, s, to);
100
+ }
101
+ const ip = clientIp(req);
102
+ const mail = await builderMail();
103
+ // the same limits and the same answer for every address
104
+ const recent = await owner.one<{ by_ip: number; by_address: number }>(
105
+ `select count(*) filter (where ip = $1)::int as by_ip, count(*) filter (where lower(detail) = lower($2))::int as by_address
106
+ from meta.activity_log where event = 'find_workspace' and app_id is null and at > now() - interval '1 hour'`, [ip, email]);
107
+ const limited = !!recent && (recent.by_ip >= PER_IP_HOUR || recent.by_address >= PER_ADDRESS_HOUR);
108
+ await logActivity({ appId: null, username: null, event: 'find_workspace', ip, detail: email.toLowerCase() });
109
+ if (mail && !limited)
110
+ // after the answer: the time it takes never tells whether an account has the address
111
+ setImmediate(() => {
112
+ workspacesMail(email)
113
+ .then((m) => (m ? sendWith(mail.service, { from: mail.from, to: [m.to], cc: [], bcc: [], replyTo: null, subject: m.subject, text: m.text, html: m.html, attachments: [] }) : null))
114
+ .catch((e) => console.error('find my workspace: the mail was not sent:', (e as Error).message));
115
+ });
116
+ flash(s, `If a developer account has the address ${email}, it gets a mail with its user name and workspaces in a few minutes. No mail? Ask an administrator.`);
117
+ return back(reply, s, to);
118
+ });
119
+ }
120
+
@@ -298,6 +298,8 @@ export async function homeRoutes(app: FastifyInstance) {
298
298
  ${card(`${BASE}/developers`, icon('users'), 'Developers', 'Who may use this builder.')}
299
299
  ${s.workspace?.sql_workshop === false ? '' : card(`${BASE}/sql`, icon('database'), 'SQL Workshop', 'Run SQL, browse objects and load data.')}
300
300
  ${card(`${BASE}/backups`, icon('archive'), 'Manage backups', 'The backups of every application of this workspace; back them all up at once.')}
301
+ ${card(`${BASE}/rest-catalogs`, icon('globe'), 'REST Source Catalogs', 'Web services described once for the workspace (e.g. from an OpenAPI document); applications make REST data sources from them.')}
302
+ ${s.workspace?.admin ? card(`${BASE}/workspace-export`, icon('download'), 'Export and import', 'This workspace\'s components as one file: settings, developers, groups, links, catalogs, scripts, blueprints (workspace administrators).') : ''}
301
303
  ${card(`${BASE}/dashboard`, icon('activity'), 'Dashboard', 'Usage and problems across the workspace.')}
302
304
  ${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), 'Instance settings', 'Session length, sign-in throttling and the server\'s configuration (administrators).') : ''}
303
305
  ${(await isAdmin(s.username))
@@ -207,10 +207,37 @@ export async function mailRoutes(app: FastifyInstance) {
207
207
  <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
208
208
  : html`<tr><td colspan="6" class="empty">No mail services yet.</td></tr>`}</tbody></table></div>`,
209
209
  html`<a class="btn btn-sm btn-hot" href="${BASE}/mail/new">${icon('plus')} Add service</a>`)}
210
+ ${await builderMailRegion(s, services)}
210
211
  ${region('Recent mail', mailTable(recent, true))}`;
211
212
  return send(reply, s, shell(s, 'Mail services', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Mail services']], main));
212
213
  });
213
214
 
215
+ // (096) the builder's own mail ("Find my workspace"): a service and a From address it may send as
216
+ app.post(`${BASE}/mail/builder`, async (req: Req, reply) => {
217
+ const s = await developer(req, reply);
218
+ if (!s || !(await adminOnly(s, reply, 'Mail services'))) return;
219
+ const name = String(req.body?.service ?? '');
220
+ const from = String(req.body?.from ?? '').trim();
221
+ try {
222
+ if (!name) await owner.query(`delete from meta.setting where name in ('builder_mail_service', 'builder_mail_from')`);
223
+ else {
224
+ const svc = await owner.one<{ senders: string[] }>('select senders from meta.mail_service where name = $1', [name]);
225
+ if (!svc) throw new Error('Choose one of the mail services.');
226
+ if (!/^[^@\s<>"]+@[^@\s<>"]+$/.test(from)) throw new Error('The From address is an e-mail address, e.g. pgkiln@example.com.');
227
+ if (!senderAllowed(svc.senders, from)) throw new Error(`The service ${name} may not send as ${from}: add it to its senders first.`);
228
+ await owner.tx(async (c) => {
229
+ for (const [k, v] of [['builder_mail_service', name], ['builder_mail_from', from]])
230
+ await c.query('insert into meta.setting (name, value) values ($1, $2) on conflict (name) do update set value = excluded.value', [k, v]);
231
+ });
232
+ }
233
+ await logActivity({ appId: null, username: s.username, event: 'mail_service', ip: clientIp(req), detail: name ? `builder mail: ${name} as ${from}` : 'builder mail: none' });
234
+ flash(s, name ? 'Builder mail saved: "Find my workspace" sends through it.' : 'The builder sends no mail now.');
235
+ } catch (e) {
236
+ flash(s, (e as Error).message, 'error');
237
+ }
238
+ return back(reply, s, `${BASE}/mail#builder-mail`);
239
+ });
240
+
214
241
  // a new service: choose the provider, then its settings only
215
242
  app.get(`${BASE}/mail/new`, async (req: Req, reply) => {
216
243
  const s = await developer(req, reply);
@@ -423,3 +450,15 @@ export async function mailRoutes(app: FastifyInstance) {
423
450
  app.post(`${BASE}/apps/:id(^\\d+$)/mail/:mid(^\\d+$)/retry`, change('queued', ['error', 'cancelled']));
424
451
  app.post(`${BASE}/apps/:id(^\\d+$)/mail/:mid(^\\d+$)/cancel`, change('cancelled', ['queued']));
425
452
  }
453
+
454
+ /** (096) Mail services → Builder mail: the service and From address of the builder's own mail. */
455
+ async function builderMailRegion(s: Session, services: { name: string; enabled: boolean }[]) {
456
+ const set = Object.fromEntries((await owner.query<{ name: string; value: string }>(`select name, value from meta.setting where name in ('builder_mail_service', 'builder_mail_from')`)).rows.map((r) => [r.name, r.value]));
457
+ return region('Builder mail', html`<form method="post" action="${BASE}/mail/builder">${csrf(s)}
458
+ <p class="muted u-mt0">The service the builder itself sends through: <b>Find my workspace</b> on the sign-in page mails developers their workspaces (to the address on their account). None: the builder sends no mail.</p>
459
+ <div class="form-grid">
460
+ ${select('service', 'Service', set.builder_mail_service ?? '', [['', 'None'], ...services.map((x) => [x.name, `${x.name}${x.enabled ? '' : ' (disabled)'}`] as [string, string])])}
461
+ ${input('from', 'From address', set.builder_mail_from ?? '', { type: 'email', placeholder: 'pgkiln@example.com', help: 'One of the service\'s senders.' })}
462
+ </div>
463
+ <div class="buttons"><button class="btn">Save</button></div></form>`, '', 'builder-mail');
464
+ }
@@ -53,6 +53,9 @@ const CHART_LABELS: Record<string, string> = {
53
53
  funnel: 'Funnel (stages in the query order)',
54
54
  radar: 'Radar (one axis per row)',
55
55
  gantt: 'Gantt (task, start and end columns)',
56
+ treemap: 'Treemap (label and value; node_id, parent_id for a hierarchy)',
57
+ sunburst: 'Sunburst (label and value; node_id, parent_id for a hierarchy)',
58
+ timeline: 'Timeline (label, start, end; a series column for lanes)',
56
59
  pyramid: 'Pyramid (one series; two series back to back)',
57
60
  polar: 'Polar area (one sector per row)',
58
61
  range: 'Range (low and high columns: floating columns)',
@@ -131,6 +134,11 @@ export function mergeChartSettings(config: Config, b: Body, a: Allowed): Config
131
134
  if (raw && Number.isFinite(v)) gauge[k] = v;
132
135
  }
133
136
  set('gauge', Object.keys(gauge).length ? gauge : undefined);
137
+ // (sprint 69) Gantt drag and drop
138
+ set('gantt_move', b.gantt_move?.trim() || undefined);
139
+ const authz = b.gantt_move_authz?.trim().toUpperCase();
140
+ set('gantt_move_authz', authz && (authz === 'MUST_NOT_BE_PUBLIC_USER' || a.authz?.has(authz)) ? authz : undefined);
141
+ set('gantt_snap', b.gantt_snap === 'day' || b.gantt_snap === 'hour' ? b.gantt_snap : undefined);
134
142
  return out;
135
143
  }
136
144
 
@@ -728,6 +736,7 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
728
736
  const cfg = r.config ?? {};
729
737
  const id = (n: string) => `rg_${r.id}_${n}`;
730
738
  const pages = async () => (await owner.query('select page_no, name from meta.page where app_id = $1 order by page_no', [appId])).rows;
739
+ const schemes = async () => (await owner.query('select name from meta.authz_scheme where app_id = $1 order by name', [appId])).rows.map((x) => x.name as string);
731
740
  let body: Raw;
732
741
  let title = 'Settings';
733
742
  switch (r.type as SettingsType) {
@@ -752,7 +761,16 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
752
761
  </div>
753
762
  <small class="help">Gauge charts only. A warning threshold above the critical one means low values are bad (e.g. warning 50, critical 20).</small></fieldset>
754
763
  ${linkFieldset(id, cfg.link, await pages(), 'Each data point')}
755
- <small class="help">Drill-down: <code>#column#</code> takes the value from the point's row, <code>#series#</code> the name of its series. Columns only the link refers to are not drawn.</small>`;
764
+ <small class="help">Drill-down: <code>#column#</code> takes the value from the point's row, <code>#series#</code> the name of its series. Columns only the link refers to are not drawn.</small>
765
+ <fieldset class="prop-group"><legend>Gantt: drag and drop</legend><div class="form-grid">
766
+ <div class="field" data-wide><label class="label" for="${id('gantt_move')}">SQL that moves a task</label>
767
+ <textarea id="${id('gantt_move')}" name="gantt_move" rows="3" data-code="sql" placeholder="update my_schema.task set starts = :NEW_START::date, ends = :NEW_END::date where id = :TASK_ID::int">${cfg.gantt_move ?? ''}</textarea>
768
+ <small class="help">Gantt charts with a <code>task_id</code> column. Runs as the application's database role (row level security applies) with <code>:TASK_ID</code>, <code>:NEW_START</code> and <code>:NEW_END</code> (<code>YYYY-MM-DD</code>, or <code>YYYY-MM-DDTHH:MM</code> by the hour; a milestone gets the same start and end). Empty: tasks can't be dragged.</small></div>
769
+ <div class="field"><label class="label" for="${id('gantt_move_authz')}">Who may drag</label>
770
+ <select id="${id('gantt_move_authz')}" name="gantt_move_authz">${opt('', 'Everyone who sees the chart', cfg.gantt_move_authz)}${opt('MUST_NOT_BE_PUBLIC_USER', 'Signed-in users', cfg.gantt_move_authz)}${(await schemes()).map((n) => opt(n, `Authorization: ${n}`, cfg.gantt_move_authz))}</select></div>
771
+ <div class="field"><label class="label" for="${id('gantt_snap')}">Steps</label>
772
+ <select id="${id('gantt_snap')}" name="gantt_snap">${opt('', 'Days, or hours when a task has a time', cfg.gantt_snap)}${opt('day', 'Days', cfg.gantt_snap)}${opt('hour', 'Hours', cfg.gantt_snap)}</select></div>
773
+ </div><small class="help">Only tasks the user sees in this chart can be moved; the chart is drawn again from its query afterwards.</small></fieldset>`;
756
774
  break;
757
775
  case 'cards':
758
776
  title = 'Cards settings';