pgkiln 0.40.0 → 0.42.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 (73) hide show
  1. package/db/migrations/095_rest_catalogs.sql +59 -0
  2. package/db/migrations/096_developer_email.sql +15 -0
  3. package/db/migrations/097_generated_app_quality.sql +911 -0
  4. package/db/migrations/098_app_activity.sql +37 -0
  5. package/docs/apex-feature-parity.md +15 -12
  6. package/docs/guide/03-builder.md +103 -19
  7. package/docs/guide/04-pages-and-regions.md +37 -4
  8. package/docs/guide/09-reference.md +6 -2
  9. package/docs/guide/11-from-apex.md +1 -1
  10. package/docs/guide/12-development.md +19 -2
  11. package/docs/guide/14-globalization.md +7 -3
  12. package/docs/guide/17-mobile.md +1 -1
  13. package/docs/guide/19-rest-data-sources.md +39 -1
  14. package/examples/hr/hr_59_gantt_editing.sql +21 -0
  15. package/examples/hr/hr_60_tree_charts.sql +46 -0
  16. package/package.json +1 -1
  17. package/public/app.css +219 -33
  18. package/public/app.js +206 -0
  19. package/public/builder.css +31 -3
  20. package/public/builder.js +31 -0
  21. package/public/sw.js +5 -2
  22. package/src/app.ts +6 -0
  23. package/src/builder/appfromfile.ts +5 -4
  24. package/src/builder/appsheets.ts +23 -9
  25. package/src/builder/appwizard.ts +215 -70
  26. package/src/builder/blueprints.ts +2 -1
  27. package/src/builder/components.ts +1 -1
  28. package/src/builder/find-workspace.ts +120 -0
  29. package/src/builder/home.ts +4 -32
  30. package/src/builder/mail.ts +39 -0
  31. package/src/builder/newapp.ts +182 -5
  32. package/src/builder/region-settings.ts +19 -1
  33. package/src/builder/rest-catalogs.ts +424 -0
  34. package/src/builder/routes.ts +36 -34
  35. package/src/builder/shared.ts +2 -0
  36. package/src/builder/workspace-transfer.ts +136 -0
  37. package/src/i18n/ar.ts +15 -0
  38. package/src/i18n/cs.ts +15 -0
  39. package/src/i18n/da.ts +15 -0
  40. package/src/i18n/de.ts +15 -0
  41. package/src/i18n/el.ts +15 -0
  42. package/src/i18n/es.ts +15 -0
  43. package/src/i18n/fi.ts +15 -0
  44. package/src/i18n/fr.ts +15 -0
  45. package/src/i18n/he.ts +15 -0
  46. package/src/i18n/it.ts +15 -0
  47. package/src/i18n/ja.ts +15 -0
  48. package/src/i18n/ko.ts +15 -0
  49. package/src/i18n/nb.ts +15 -0
  50. package/src/i18n/pl.ts +15 -0
  51. package/src/i18n/pt.ts +15 -0
  52. package/src/i18n/ru.ts +15 -0
  53. package/src/i18n/sv.ts +15 -0
  54. package/src/i18n/tr.ts +15 -0
  55. package/src/i18n/uk.ts +15 -0
  56. package/src/i18n/zh.ts +15 -0
  57. package/src/i18n.ts +30 -0
  58. package/src/icons.ts +2 -1
  59. package/src/layout.ts +2 -2
  60. package/src/restcatalog.ts +605 -0
  61. package/src/runtime/charts.ts +304 -15
  62. package/src/runtime/data-reporter.ts +48 -16
  63. package/src/runtime/devbar.ts +35 -14
  64. package/src/runtime/engine.ts +7 -4
  65. package/src/runtime/gantt-move.ts +63 -0
  66. package/src/runtime/locale.ts +2 -2
  67. package/src/runtime/regions.ts +28 -2
  68. package/src/runtime/render.ts +18 -4
  69. package/src/runtime/report.ts +15 -9
  70. package/src/runtime/routes.ts +39 -0
  71. package/src/runtime/styles.ts +5 -2
  72. package/src/websources.ts +11 -3
  73. package/src/workspace-export.ts +240 -0
@@ -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
+ }
@@ -3,7 +3,146 @@ import { owner, type Client } from '../db.ts';
3
3
  import { passwordProblem } from '../accounts.ts';
4
4
  import { replaceApp } from '../cli/replace.ts';
5
5
  import { placeApp } from './workspaces.ts';
6
- import { NEW_APP_BASE } from '../runtime/styles.ts';
6
+ import { BASE_STYLES, NEW_APP_BASE } from '../runtime/styles.ts';
7
+ import { LANGUAGE_NAMES } from '../i18n.ts';
8
+ import { isIcon } from '../icons.ts';
9
+ import { secretKeyConfigured } from '../secrets.ts';
10
+
11
+ /** (097) The icon of a new application's home page when the developer chose none. */
12
+ export const DEFAULT_APP_ICON = 'layers';
13
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
14
+
15
+ /** (sprint 72) The page number of a generated application's About page. */
16
+ export const ABOUT_PAGE = 9000;
17
+
18
+ /**
19
+ * (sprint 72) The About page of a generated application (APEX: the About Page
20
+ * feature): the application's name, its description or the pages of its menu,
21
+ * and a Help menu with it in the header's navigation bar (a list NAVBAR, unless
22
+ * the application has a navigation bar list already). Nothing when the page
23
+ * number is taken.
24
+ */
25
+ export async function addAboutPage(c: Client, appId: number, description?: string | null) {
26
+ const app = (await c.query<{ name: string; navbar_list: string | null; home_page: number }>('select name, navbar_list, home_page from meta.app where id = $1', [appId])).rows[0];
27
+ if ((await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ABOUT_PAGE])).rowCount) return;
28
+ const pages = (
29
+ await c.query<{ label: string }>(
30
+ `select e.label from meta.nav_entry e where e.app_id = $1 and e.parent_id is null and e.target_page is not null and e.target_page <> $2 order by e.seq, e.id`,
31
+ [appId, app.home_page],
32
+ )
33
+ ).rows.map((r) => r.label);
34
+ const text = description
35
+ ? `<p>${escapeHtml(description)}</p>`
36
+ : pages.length
37
+ ? `<p>${escapeHtml(app.name)} has these pages:</p><ul>${pages.map((l) => `<li>${escapeHtml(l)}</li>`).join('')}</ul>`
38
+ : `<p>${escapeHtml(app.name)}</p>`;
39
+ const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page) values ($1, $2, 'About', $3, $4) returning id`, [appId, ABOUT_PAGE, `About ${app.name}`.slice(0, 200), app.home_page])).rows[0].id;
40
+ await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 10, $2, 'static', $3)`, [page, app.name, text]);
41
+ if (app.navbar_list) return;
42
+ await c.query(`insert into meta.list (app_id, name, description) values ($1, 'NAVBAR', 'The navigation bar: the Help menu') on conflict (app_id, name) do nothing`, [appId]);
43
+ const help = (await c.query(`insert into meta.list_entry (app_id, list_name, seq, label, icon) values ($1, 'NAVBAR', 10, 'Help', 'help') returning id`, [appId])).rows[0].id;
44
+ await c.query(`insert into meta.list_entry (app_id, list_name, parent_id, seq, label, icon, target_page) values ($1, 'NAVBAR', $2, 10, 'About', 'info', $3)`, [appId, help, ABOUT_PAGE]);
45
+ await c.query(`update meta.app set navbar_list = 'NAVBAR' where id = $1`, [appId]);
46
+ }
47
+
48
+ /**
49
+ * (sprint 73) The Create Application wizard's features (APEX: Features). Each maps to a setting or to pages:
50
+ * - about: an About page in a Help menu (addAboutPage)
51
+ * - access: authorization schemes CONTRIBUTOR and READER next to ADMIN (roles admin, contributor, reader)
52
+ * - activity: an Activity page for administrators (page 9010: page views per day and the latest activity)
53
+ * - feedback: users may send feedback (the navigation bar's Feedback entry)
54
+ * - theme: users may choose light or dark (theme.user_choice)
55
+ * - pwa, push: installable, with push notifications (push needs PGKILN_SECRET_KEY; its key is made after the commit)
56
+ */
57
+ export interface Features { about: boolean; access: boolean; activity: boolean; feedback: boolean; theme: boolean; pwa: boolean; push: boolean }
58
+ export const FEATURES: [keyof Features, string, string, boolean][] = [
59
+ ['about', 'About page', 'An About page in a Help menu of the header.', true],
60
+ ['access', 'Access control', 'Authorization schemes for the roles admin, contributor and reader, to put on pages, regions and buttons.', false],
61
+ ['activity', 'Activity reporting', 'A page for administrators with the page views per day and the latest activity.', false],
62
+ ['feedback', 'Feedback', 'Users send feedback from the header; you read it under Activity → Feedback.', false],
63
+ ['theme', 'Theme style selection', 'Users may choose the light or the dark theme.', true],
64
+ ['pwa', 'Install as an app (PWA)', 'Installable on phones and desktops, with offline pages.', false],
65
+ ['push', 'Push notifications', 'Notifications to the users\' devices (with Install as an app).', false],
66
+ ];
67
+ /** The features ticked on a form (a form without them: the defaults). */
68
+ export const featuresFrom = (b: Record<string, unknown>, posted: boolean): Features =>
69
+ Object.fromEntries(FEATURES.map(([k, , , on]) => [k, posted ? b[`f_${k}`] === 'true' : on])) as unknown as Features;
70
+
71
+ /** (sprint 73) The Activity page of a generated application. */
72
+ export const ACTIVITY_PAGE = 9010;
73
+
74
+ async function applyFeatures(c: Client, appId: number, f: Partial<Features>, notes: string[]) {
75
+ if (f.access) {
76
+ await c.query(
77
+ `insert into meta.authz_scheme (app_id, name, type, value, error_message) values
78
+ ($1, 'CONTRIBUTOR', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'')', 'Only contributors can do this.'),
79
+ ($1, 'READER', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'') or meta.has_role(''reader'')', 'Only readers can open this page.')
80
+ on conflict do nothing`,
81
+ [appId],
82
+ );
83
+ }
84
+ if (f.activity && !(await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ACTIVITY_PAGE])).rowCount) {
85
+ const home = (await c.query('select home_page from meta.app where id = $1', [appId])).rows[0].home_page;
86
+ const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page, authz) values ($1, $2, 'Activity', 'Activity', $3, 'ADMIN') returning id`, [appId, ACTIVITY_PAGE, home])).rows[0].id;
87
+ await c.query(
88
+ `insert into meta.region (page_id, seq, title, type, source, config) values
89
+ ($1, 10, 'Page views per day', 'chart', $2, '{"kind": "column"}'),
90
+ ($1, 20, 'Latest activity', 'report', $3, '{"headings": {"at": "Time", "username": "User", "event": "Event", "page_no": "Page", "elapsed_ms": "Time taken (ms)"}}')`,
91
+ [page,
92
+ `select to_char(date_trunc('day', at), 'YYYY-MM-DD') as "Day", count(*) as "Page views"
93
+ from meta.app_activity(30)
94
+ where event = 'page_view'
95
+ group by 1
96
+ order by 1`,
97
+ `select at, username, event, page_no, elapsed_ms
98
+ from meta.app_activity(30)`],
99
+ );
100
+ await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page, authz) values ($1, 9010, 'Activity', 'activity', $2, 'ADMIN')`, [appId, ACTIVITY_PAGE]);
101
+ }
102
+ if (f.feedback) await c.query('update meta.app set feedback = true where id = $1', [appId]);
103
+ if (f.theme === false) await c.query(`update meta.app set theme = theme || '{"user_choice": false}' where id = $1`, [appId]);
104
+ if (f.pwa || f.push) await c.query('update meta.app set pwa = true where id = $1', [appId]);
105
+ if (f.push) {
106
+ if (secretKeyConfigured()) await c.query('update meta.app set pwa_push = true where id = $1', [appId]);
107
+ else notes.push('Push notifications are off: they need PGKILN_SECRET_KEY in the server\'s environment.');
108
+ }
109
+ }
110
+
111
+ /** (sprint 72) After a wizard generated the pages: the home page's cards and, unless turned off, the About page; (sprint 73) the chosen features. */
112
+ export async function finishGeneratedApp(c: Client, appId: number, o: { about?: boolean; description?: string | null; features?: Partial<Features>; notes?: string[] } = {}) {
113
+ const f = { ...(o.features ?? {}) };
114
+ if (f.about === undefined) f.about = o.about !== false;
115
+ await applyFeatures(c, appId, f, o.notes ?? []);
116
+ await addHomeCards(c, appId);
117
+ if (f.about) await addAboutPage(c, appId, o.description);
118
+ }
119
+
120
+ /**
121
+ * (097) A card per page of the navigation menu on the home page (a static list
122
+ * HOME_PAGES shown by a list region with the cards template), after the pages
123
+ * of a new application were generated. Does nothing when the application has
124
+ * no other pages, or the list exists already.
125
+ */
126
+ export async function addHomeCards(c: Client, appId: number) {
127
+ const entries = (
128
+ await c.query<{ label: string; icon: string | null; target_page: number; authz: string | null }>(
129
+ `select e.label, e.icon, e.target_page, e.authz from meta.nav_entry e join meta.app a on a.id = e.app_id
130
+ where e.app_id = $1 and e.parent_id is null and e.target_page is not null and e.target_page <> a.home_page
131
+ order by e.seq, e.id`,
132
+ [appId],
133
+ )
134
+ ).rows;
135
+ if (!entries.length) return;
136
+ const made = await c.query(`insert into meta.list (app_id, name, description) values ($1, 'HOME_PAGES', 'The cards of the home page') on conflict (app_id, name) do nothing`, [appId]);
137
+ if (!made.rowCount) return;
138
+ for (const [n, e] of entries.entries())
139
+ await c.query('insert into meta.list_entry (app_id, list_name, seq, label, icon, target_page, authz) values ($1, $2, $3, $4, $5, $6, $7)', [appId, 'HOME_PAGES', (n + 1) * 10, e.label, e.icon, e.target_page, e.authz]);
140
+ await c.query(
141
+ `insert into meta.region (page_id, seq, title, type, template, config)
142
+ select p.id, 20, 'Pages', 'list', 'plain', '{"list": "HOME_PAGES", "template": "cards"}' from meta.page p join meta.app a on a.id = p.app_id and p.page_no = a.home_page where a.id = $1`,
143
+ [appId],
144
+ );
145
+ }
7
146
 
8
147
  // Creating an application (Create → blank application, and Create → from a
9
148
  // file): the parsing schema, a database role app_<alias> that can use only
@@ -18,6 +157,15 @@ export interface NewAppInput {
18
157
  authentication?: string;
19
158
  admin_user?: string;
20
159
  admin_password?: string;
160
+ /** (097) a line about the application, shown on the home page */
161
+ description?: string;
162
+ /** (097) the application's icon (a name from icons.svg) */
163
+ icon?: string;
164
+ /** (sprint 73) appearance: base style, menu position, accent colour; the primary language */
165
+ base?: string;
166
+ nav?: string;
167
+ accent?: string;
168
+ language?: string;
21
169
  }
22
170
 
23
171
  export interface CheckedApp {
@@ -31,6 +179,16 @@ export interface CheckedApp {
31
179
  adminPassword: string | null;
32
180
  /** the workspace that gets the application (the developer's current one) */
33
181
  workspaceId: number;
182
+ /** (097) shown under the name on the home page */
183
+ description?: string | null;
184
+ /** (097) the home page's icon */
185
+ icon?: string | null;
186
+ /** (sprint 73) the theme: base style (Iris when not given), menu at the top, own accent colour */
187
+ base?: 'iris' | 'lavender' | 'standard';
188
+ topNav?: boolean;
189
+ accent?: string | null;
190
+ /** (sprint 73) the primary language (default en) */
191
+ language?: string;
34
192
  }
35
193
 
36
194
  /** pgkiln's and the system's schemas never become an application's parsing schema. */
@@ -56,7 +214,17 @@ export async function checkNewApp(b: NewAppInput, workspaceId: number): Promise<
56
214
  if (problem) throw new Error(problem);
57
215
  adminPassword = known ? null : (b.admin_password ?? '');
58
216
  }
59
- return { name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId };
217
+ const description = (b.description ?? '').trim().slice(0, 300) || null;
218
+ const appIcon = (b.icon ?? '').trim();
219
+ if (appIcon && !isIcon(appIcon)) throw new Error('Choose one of the icons in the list.');
220
+ const base = b.base === 'lavender' || b.base === 'standard' ? b.base : 'iris';
221
+ const accent = /^#[0-9a-f]{6}$/i.test(b.accent ?? '') && b.accent!.toLowerCase() !== BASE_STYLES[base].accent ? b.accent!.toLowerCase() : null;
222
+ const language = (b.language ?? '').trim() || 'en';
223
+ if (!(language in LANGUAGE_NAMES)) throw new Error('Choose one of the languages in the list.');
224
+ return {
225
+ name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId, description, icon: appIcon || null,
226
+ base, topNav: b.nav === 'top', accent, language,
227
+ };
60
228
  }
61
229
 
62
230
  /** Create the schema, the role and the application (inside the caller's owner transaction). */
@@ -93,12 +261,21 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
93
261
  await c.query(`alter default privileges in schema ${S} grant usage, select on sequences to ${R}`);
94
262
  await c.query(`alter default privileges in schema ${S} grant execute on functions to ${R}`);
95
263
 
96
- // new applications start with the newest base style (Iris); existing ones keep theirs
97
- const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme) values ($1, $2, $3, $4, $5) returning id', [a.alias, a.name, a.authentication, a.role, { base: NEW_APP_BASE }]);
264
+ // new applications start with the newest base style (Iris), light (users may switch to dark); existing ones keep theirs
265
+ // (sprint 73) the wizard's appearance: base style, menu at the top, an own accent colour
266
+ const theme: Record<string, string> = { mode: 'light' };
267
+ const base = a.base ?? NEW_APP_BASE;
268
+ if (base !== 'standard') theme.base = base;
269
+ if (a.topNav) theme.nav = 'top';
270
+ if (a.accent) theme.accent = a.accent;
271
+ const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme, language) values ($1, $2, $3, $4, $5, $6) returning id', [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
98
272
  const appId = r.rows[0].id as number;
99
273
  await placeApp(c, appId, a.workspaceId);
100
274
  const p = await c.query(`insert into meta.page (app_id, page_no, name, title) values ($1, 1, 'Home', 'Home') returning id`, [appId]);
101
- await c.query(`insert into meta.region (page_id, title, type, source) values ($1, 'Welcome', 'static', '<p>Hello, &APP_USER.! Edit this page in the builder.</p>')`, [p.rows[0].id]);
275
+ // (097) a hero with the application's icon, name and description (no developer wording for end users)
276
+ await c.query(`insert into meta.region (page_id, seq, title, type, source, template, config) values ($1, 10, $2, 'static', $3, 'hero', $4)`, [
277
+ p.rows[0].id, a.name, a.description ? `<p>${escapeHtml(a.description)}</p>` : '', { icon: a.icon ?? DEFAULT_APP_ICON },
278
+ ]);
102
279
  await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page) values ($1, 1, 'Home', 'home', 1)`, [appId]);
103
280
  await c.query(`insert into meta.authz_scheme (app_id, name, type, value, error_message) values ($1, 'ADMIN', 'role', 'admin', 'Only administrators can access this page.')`, [appId]);
104
281
  let existingAccount = false;
@@ -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';