pgkiln 0.82.0 → 0.83.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 (61) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/docs/apex-feature-parity.md +3 -3
  3. package/docs/guide/03-builder.md +8 -1
  4. package/docs/guide/04-pages-and-regions.md +2 -1
  5. package/docs/guide/07-dynamic-actions.md +27 -0
  6. package/docs/guide/11-from-apex.md +2 -1
  7. package/docs/guide/12-development.md +9 -1
  8. package/package.json +1 -1
  9. package/public/app.css +2 -0
  10. package/public/app.js +85 -4
  11. package/public/builder.css +25 -2
  12. package/public/data-modeler.js +8 -5
  13. package/public/sql-monitor.js +5 -2
  14. package/src/builder/ai-builder.ts +50 -46
  15. package/src/builder/api.ts +49 -49
  16. package/src/builder/autorest.ts +92 -87
  17. package/src/builder/changelog.ts +39 -37
  18. package/src/builder/components.ts +5 -4
  19. package/src/builder/data-modeler.ts +89 -84
  20. package/src/builder/data-security.ts +39 -37
  21. package/src/builder/dataload.ts +69 -65
  22. package/src/builder/datapump.ts +108 -97
  23. package/src/builder/ddl-tools.ts +38 -34
  24. package/src/builder/document-view.ts +11 -11
  25. package/src/builder/i18n/de.ts +1627 -0
  26. package/src/builder/i18n/en.ts +1627 -0
  27. package/src/builder/i18n/es.ts +1627 -0
  28. package/src/builder/i18n/fr.ts +1627 -0
  29. package/src/builder/i18n/it.ts +1627 -0
  30. package/src/builder/i18n/ja.ts +1627 -0
  31. package/src/builder/i18n/ko.ts +1627 -0
  32. package/src/builder/i18n/nl.ts +1627 -0
  33. package/src/builder/i18n/pt.ts +1627 -0
  34. package/src/builder/i18n/zh-cn.ts +1627 -0
  35. package/src/builder/i18n/zh-tw.ts +1627 -0
  36. package/src/builder/i18n.ts +18 -0
  37. package/src/builder/object-actions.ts +18 -16
  38. package/src/builder/object-browser.ts +2 -2
  39. package/src/builder/querybuilder.ts +42 -36
  40. package/src/builder/quicksql.ts +16 -18
  41. package/src/builder/region-settings.ts +201 -199
  42. package/src/builder/report-settings.ts +64 -60
  43. package/src/builder/rest-modules.ts +145 -143
  44. package/src/builder/sampledata.ts +59 -52
  45. package/src/builder/scheduling.ts +180 -159
  46. package/src/builder/scripts.ts +71 -65
  47. package/src/builder/sql-charts.ts +36 -30
  48. package/src/builder/sql-monitor.ts +95 -89
  49. package/src/builder/sql-utilities.ts +47 -40
  50. package/src/builder/top-sql.ts +21 -17
  51. package/src/builder/unload.ts +48 -44
  52. package/src/builder/worksheet.ts +3 -3
  53. package/src/cron-schedule.ts +47 -17
  54. package/src/metadata.ts +3 -1
  55. package/src/runtime/calendar.ts +3 -2
  56. package/src/runtime/context.ts +2 -0
  57. package/src/runtime/grid.ts +1 -0
  58. package/src/runtime/maps.ts +7 -4
  59. package/src/runtime/render.ts +6 -1
  60. package/src/runtime/routes.ts +4 -0
  61. package/src/runtime/tree.ts +4 -3
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
3
3
  import { html, raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
6
+ import { tr, type BuilderTranslate } from './i18n.ts';
6
7
 
7
8
  // App Builder → Activity → Top SQL: the statements the application's
8
9
  // database role ran, from pg_stat_statements (APEX: Top SQL in the
@@ -27,12 +28,14 @@ export async function statementsState(): Promise<{ ok: true } | { ok: false; rea
27
28
  }
28
29
 
29
30
  /** Why the statistics can't be shown, and how to turn them on. */
30
- export function statementsNote(reason: 'not_installed' | 'not_loaded' | 'error', message?: string) {
31
+ export function statementsNote(tx: BuilderTranslate, reason: 'not_installed' | 'not_loaded' | 'error', message?: string) {
32
+ const ext = html`<code>pg_stat_statements</code>`;
33
+ const setting = html`<code>shared_preload_libraries = 'pg_stat_statements'</code>`;
31
34
  return html`<div class="alert alert-error" role="alert">${reason === 'not_installed'
32
- ? html`The <code>pg_stat_statements</code> extension isn't installed in this database. To turn it on: set <code>shared_preload_libraries = 'pg_stat_statements'</code> (postgresql.conf, or <code>alter system set shared_preload_libraries = 'pg_stat_statements'</code>), restart PostgreSQL, then as a superuser: <code>create extension pg_stat_statements;</code>`
35
+ ? tx.rich('topsql.not_installed', { ext, setting, alter: html`<code>alter system set shared_preload_libraries = 'pg_stat_statements'</code>`, create: html`<code>create extension pg_stat_statements;</code>` })
33
36
  : reason === 'not_loaded'
34
- ? html`The server doesn't load <code>pg_stat_statements</code> yet. Set <code>shared_preload_libraries = 'pg_stat_statements'</code> (the development <code>docker-compose.yml</code> does) and restart PostgreSQL.`
35
- : html`The statistics could not be read: ${message ?? ''}`}</div>`;
37
+ ? tx.rich('topsql.not_loaded', { ext, setting, compose: html`<code>docker-compose.yml</code>` })
38
+ : tx('topsql.read_error', { message: message ?? '' })}</div>`;
36
39
  }
37
40
 
38
41
  /** The role's statements, slowest in total first (or by mean time, calls or rows). */
@@ -66,28 +69,29 @@ export async function topSqlRoutes(app: FastifyInstance) {
66
69
  if (!s) return;
67
70
  const a = await appOf(req.params.id);
68
71
  if (!a) return reply.code(404).send('Not found');
72
+ const tx = tr(s);
69
73
  const sort = SORTS[req.query.sort ?? ''] ? req.query.sort! : 'total';
70
74
  const top = await topSql(a.db_role, sort);
71
75
  const sortLink = (key: string, label: string) =>
72
76
  html`<a href="${BASE}/apps/${a.id}/top-sql?sort=${key}"${sort === key ? raw(' aria-current="true"') : ''}>${label}</a>`;
73
77
  const body = top.ok
74
78
  ? top.rows.length
75
- ? html`<div class="seg u-mb1">Sort by ${sortLink('total', 'total time')} ${sortLink('mean', 'time per call')} ${sortLink('calls', 'calls')} ${sortLink('rows', 'rows')}</div>
76
- <div class="table-wrap"><table class="report report-reflow top-sql"><thead><tr><th class="num">Calls</th><th class="num">Total ms</th><th class="num">Mean ms</th><th class="num">Rows</th><th class="num">Share</th><th>Statement</th></tr></thead>
79
+ ? html`<div class="seg u-mb1">${tx.rich('topsql.sort_by', { total: sortLink('total', tx('topsql.sort.total')), mean: sortLink('mean', tx('topsql.sort.mean')), calls: sortLink('calls', tx('topsql.sort.calls')), rows: sortLink('rows', tx('topsql.sort.rows')) })}</div>
80
+ <div class="table-wrap"><table class="report report-reflow top-sql"><thead><tr><th class="num">${tx('sm.col.calls')}</th><th class="num">${tx('sm.col.total_ms')}</th><th class="num">${tx('sm.col.mean_ms')}</th><th class="num">${tx('sm.col.rows')}</th><th class="num">${tx('topsql.share')}</th><th>${tx('sm.col.statement')}</th></tr></thead>
77
81
  <tbody>${top.rows.map((r) => html`<tr>
78
- <td class="num" data-label="Calls">${r.calls}</td><td class="num" data-label="Total ms">${r.total_ms}</td><td class="num" data-label="Mean ms">${r.mean_ms}</td>
79
- <td class="num" data-label="Rows">${r.rows}</td><td class="num" data-label="Share">${r.share ?? 0}%</td>
80
- <td data-label="Statement"><code class="sql-text">${r.query}</code></td></tr>`)}</tbody></table></div>
82
+ <td class="num" data-label="${tx('sm.col.calls')}">${r.calls}</td><td class="num" data-label="${tx('sm.col.total_ms')}">${r.total_ms}</td><td class="num" data-label="${tx('sm.col.mean_ms')}">${r.mean_ms}</td>
83
+ <td class="num" data-label="${tx('sm.col.rows')}">${r.rows}</td><td class="num" data-label="${tx('topsql.share')}">${r.share ?? 0}%</td>
84
+ <td data-label="${tx('sm.col.statement')}"><code class="sql-text">${r.query}</code></td></tr>`)}</tbody></table></div>
81
85
  <form method="post" action="${BASE}/apps/${a.id}/top-sql/reset" class="u-mt1">${csrf(s)}
82
- <button class="btn" data-confirm="Clear the statistics of role ${a.db_role}?">${icon('history')} Reset the statistics</button></form>`
83
- : html`<p class="muted">No statements recorded for <code>${a.db_role}</code> yet. Use the application, then come back.</p>`
86
+ <button class="btn" data-confirm="${tx('topsql.reset_confirm', { role: a.db_role })}">${icon('history')} ${tx('topsql.reset')}</button></form>`
87
+ : html`<p class="muted">${tx.rich('topsql.none_yet', { role: html`<code>${a.db_role}</code>` })}</p>`
84
88
  : top.reason === 'no_role'
85
- ? html`<div class="alert alert-error" role="alert">This application runs as the owner role, so its statements can't be told apart. Give it a database role in Settings.</div>`
86
- : statementsNote(top.reason, top.message);
89
+ ? html`<div class="alert alert-error" role="alert">${tx('topsql.no_role')}</div>`
90
+ : statementsNote(tx, top.reason, top.message);
87
91
  const main = html`${appHeader(a, 'activity', s)}
88
- ${region('Top SQL', html`<p class="muted u-mt0">Statements run as the role <code>${a.db_role ?? '(owner)'}</code> since the statistics were last reset, from <code>pg_stat_statements</code>. Literals and binds show as <code>$1</code>, <code>$2</code>…; applications that share a role share this list. <a href="${BASE}/apps/${a.id}/activity">Back to Activity</a></p>
92
+ ${region(tx('sql.tool.topsql'), html`<p class="muted u-mt0">${tx.rich('topsql.intro', { role: html`<code>${a.db_role ?? tx('topsql.owner')}</code>`, ext: html`<code>pg_stat_statements</code>`, p1: html`<code>$1</code>`, p2: html`<code>$2</code>` })} <a href="${BASE}/apps/${a.id}/activity">${tx('topsql.back_activity')}</a></p>
89
93
  ${body}`)}`;
90
- return send(reply, s, shell(s, `Top SQL · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Top SQL']], main));
94
+ return send(reply, s, shell(s, `${tx('sql.tool.topsql')} · ${a.name}`, [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('topsql.activity'), `${BASE}/apps/${a.id}/activity`], [tx('sql.tool.topsql')]], main));
91
95
  });
92
96
 
93
97
  app.post(`${BASE}/apps/:id/top-sql/reset`, async (req: Req, reply) => {
@@ -96,9 +100,9 @@ export async function topSqlRoutes(app: FastifyInstance) {
96
100
  const a = await appOf(req.params.id);
97
101
  if (!a) return reply.code(404).send('Not found');
98
102
  try {
99
- if (!a.db_role) throw new Error('The application has no database role.');
103
+ if (!a.db_role) throw new Error(tr(s)('topsql.no_db_role'));
100
104
  await owner.query(`select pg_stat_statements_reset((select oid from pg_roles where rolname = $1), (select oid from pg_database where datname = current_database()), 0)`, [a.db_role]);
101
- flash(s, 'Statistics reset.');
105
+ flash(s, tr(s)('topsql.reset_done'));
102
106
  } catch (e) {
103
107
  flash(s, (e as Error).message, 'error');
104
108
  }
@@ -22,6 +22,7 @@ import {
22
22
  } from '../unload.ts';
23
23
  import { BASE, csrf, developer, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
24
24
  import { sessionSchema, workshopDb } from './workspaces.ts';
25
+ import { tr, type BuilderTranslate } from './i18n.ts';
25
26
 
26
27
  // SQL Workshop → Unload Data (APEX: Data Workshop → Unload Data): a table or
27
28
  // view (chosen columns, an optional WHERE and ORDER BY) or a query, to CSV,
@@ -88,86 +89,89 @@ const list = (v: unknown) => (Array.isArray(v) ? v : v === undefined || v === nu
88
89
  export async function unloadRoutes(app: FastifyInstance) {
89
90
  const page = (s: Session, reply: FastifyReply, main: Raw, code = 200) => {
90
91
  reply.code(code);
91
- return send(reply, s, shell(s, 'Unload Data', [['SQL Workshop', `${BASE}/sql`], ['Unload Data']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('unload', s)}${main}`, 'sql'));
92
+ const tx = tr(s);
93
+ return send(reply, s, shell(s, tx('sql.tool.unload'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.unload')]], html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('unload', s)}${main}`, 'sql'));
92
94
  };
93
95
 
94
- const formatFields = (b: Body) => {
96
+ const formatFields = (tx: BuilderTranslate, b: Body) => {
95
97
  const fmt = (UNLOAD_FORMATS as string[]).includes(b.format ?? '') ? b.format : 'csv';
96
98
  const posted = b.format !== undefined; // unticked checkboxes post nothing
97
99
  const on = (name: string) => (!posted || b[name] === '1' ? raw(' checked') : '');
98
100
  const opt = (map: Record<string, string>, field: string, labels: Record<string, string>) =>
99
101
  Object.keys(map).map((k) => html`<option value="${k}"${(b[field] ?? Object.keys(map)[0]) === k ? raw(' selected') : ''}>${labels[k]}</option>`);
100
102
  return html`
101
- <fieldset class="prop-group u-mt125"><legend>Format</legend>
102
- <div class="unload-formats" role="radiogroup" aria-label="Format">${UNLOAD_FORMATS.map(
103
- (f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: 'Excel (.xlsx)', xml: 'XML', sql: 'SQL inserts' }[f]}</label>`,
103
+ <fieldset class="prop-group u-mt125"><legend>${tx('cl.format')}</legend>
104
+ <div class="unload-formats" role="radiogroup" aria-label="${tx('cl.format')}">${UNLOAD_FORMATS.map(
105
+ (f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: tx('ul.fmt.xlsx'), xml: 'XML', sql: tx('ul.fmt.sql') }[f]}</label>`,
104
106
  )}</div>
105
107
  </fieldset>
106
108
  <fieldset class="prop-group"><legend>CSV</legend><div class="form-grid">
107
- <div class="field"><label class="label" for="f_delimiter">Separator</label>
108
- <select id="f_delimiter" name="delimiter">${opt(CSV_DELIMITERS, 'delimiter', { comma: 'Comma ,', semicolon: 'Semicolon ;', tab: 'Tab', pipe: 'Pipe |' })}</select></div>
109
- <div class="field"><label class="label" for="f_enclosure">Enclosed by</label>
110
- <select id="f_enclosure" name="enclosure">${opt(CSV_ENCLOSURES, 'enclosure', { double: 'Double quote "', single: "Single quote '" })}</select>
111
- <small class="help">Only values that contain the separator, the enclosure or a line break are enclosed.</small></div>
109
+ <div class="field"><label class="label" for="f_delimiter">${tx('ul.separator')}</label>
110
+ <select id="f_delimiter" name="delimiter">${opt(CSV_DELIMITERS, 'delimiter', { comma: tx('ul.sep.comma'), semicolon: tx('ul.sep.semicolon'), tab: tx('ul.sep.tab'), pipe: tx('ul.sep.pipe') })}</select></div>
111
+ <div class="field"><label class="label" for="f_enclosure">${tx('ul.enclosed')}</label>
112
+ <select id="f_enclosure" name="enclosure">${opt(CSV_ENCLOSURES, 'enclosure', { double: tx('ul.enc.double'), single: tx('ul.enc.single') })}</select>
113
+ <small class="help">${tx('ul.enclosed_help')}</small></div>
112
114
  <div class="field"><span class="label" aria-hidden="true"></span>
113
- <label class="check"><input type="checkbox" name="header" value="1"${on('header')}> First row: column names</label>
114
- <label class="check"><input type="checkbox" name="bom" value="1"${on('bom')}> UTF-8 byte order mark (for Excel)</label></div>
115
+ <label class="check"><input type="checkbox" name="header" value="1"${on('header')}> ${tx('ul.header')}</label>
116
+ <label class="check"><input type="checkbox" name="bom" value="1"${on('bom')}> ${tx('ul.bom')}</label></div>
115
117
  </div></fieldset>
116
118
  <fieldset class="prop-group"><legend>XML</legend><div class="form-grid">
117
- <div class="field"><label class="label" for="f_root_tag">Root element</label><input id="f_root_tag" name="root_tag" value="${b.root_tag ?? DEFAULT_UNLOAD.rootTag}" maxlength="64"></div>
118
- <div class="field"><label class="label" for="f_row_tag">Row element</label><input id="f_row_tag" name="row_tag" value="${b.row_tag ?? DEFAULT_UNLOAD.rowTag}" maxlength="64">
119
- <small class="help">One child element per column; empty (null) values are left out.</small></div>
119
+ <div class="field"><label class="label" for="f_root_tag">${tx('ul.root')}</label><input id="f_root_tag" name="root_tag" value="${b.root_tag ?? DEFAULT_UNLOAD.rootTag}" maxlength="64"></div>
120
+ <div class="field"><label class="label" for="f_row_tag">${tx('ul.row')}</label><input id="f_row_tag" name="row_tag" value="${b.row_tag ?? DEFAULT_UNLOAD.rowTag}" maxlength="64">
121
+ <small class="help">${tx('ul.row_help')}</small></div>
120
122
  </div></fieldset>`;
121
123
  };
122
124
 
123
- const sourceTabs = (active: 'table' | 'query') =>
124
- html`<nav class="ide-tabs u-mb1" aria-label="Unload from">
125
- <a class="ide-tab" href="${BASE}/sql/unload"${active === 'table' ? raw(' aria-current="page"') : ''}>${icon('table')}<span>Table or view</span></a>
126
- <a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span>Query</span></a></nav>`;
125
+ const sourceTabs = (tx: BuilderTranslate, active: 'table' | 'query') =>
126
+ html`<nav class="ide-tabs u-mb1" aria-label="${tx('ul.from')}">
127
+ <a class="ide-tab" href="${BASE}/sql/unload"${active === 'table' ? raw(' aria-current="page"') : ''}>${icon('table')}<span>${tx('ul.table_or_view')}</span></a>
128
+ <a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span>${tx('sm.col.query')}</span></a></nav>`;
127
129
 
128
- const intro = html`<p class="muted">Download the rows of a table, a view or a query as CSV, JSON, Excel or XML (up to ${DOWNLOAD_MAX_ROWS.toLocaleString('en')} rows).
129
- Runs as the builder's owner connection (an isolated workspace's developer login for its developers) in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
130
+ const introOf = (tx: BuilderTranslate) => html`<p class="muted">${tx('ul.intro', { n: DOWNLOAD_MAX_ROWS.toLocaleString('en') })}
131
+ ${tx.rich('ul.intro2', { load: html`<a href="${BASE}/sql/load">${tx('sql.tool.load')}</a>` })}</p>`;
130
132
 
131
133
  const form = async (s: Session, b: Body, error?: string) => {
134
+ const tx = tr(s);
135
+ const intro = introOf(tx);
132
136
  const alert = error ? html`<div class="alert alert-error" role="alert">${error}</div>` : '';
133
137
  if (b.source === 'query')
134
- return html`${intro}${sourceTabs('query')}${region(
135
- 'Unload a query',
138
+ return html`${intro}${sourceTabs(tx, 'query')}${region(
139
+ tx('ul.unload_query'),
136
140
  html`${alert}<form method="post" action="${BASE}/sql/unload">${csrf(s)}<input type="hidden" name="source" value="query">
137
- <div class="field"><label class="label" for="f_query">Query</label>
141
+ <div class="field"><label class="label" for="f_query">${tx('sm.col.query')}</label>
138
142
  <textarea id="f_query" name="query" class="code sql-editor" rows="10" spellcheck="false" data-code="plpgsql" required>${b.query ?? ''}</textarea>
139
- <small class="help">One SELECT (or WITH … SELECT) statement.</small></div>
140
- ${formatFields(b)}
141
- <div class="buttons"><button class="btn btn-hot">${icon('download')} Download</button></div></form>`,
143
+ <small class="help">${tx('ul.one_select')}</small></div>
144
+ ${formatFields(tx, b)}
145
+ <div class="buttons"><button class="btn btn-hot">${icon('download')} ${tx('scr.download')}</button></div></form>`,
142
146
  )}`;
143
147
  const rels = await relations();
144
148
  const rel = rels.find((r) => r.qname === b.table);
145
149
  let schema = '';
146
150
  const chooser = html`<form method="get" action="${BASE}/sql/unload" class="unload-pick">
147
- <label class="sr-only" for="f_table">Table or view</label>
148
- <select id="f_table" name="table" required><option value="">- choose a table or view -</option>${rels.map((r) => {
151
+ <label class="sr-only" for="f_table">${tx('ul.table_or_view')}</label>
152
+ <select id="f_table" name="table" required><option value="">${tx('ul.choose_table')}</option>${rels.map((r) => {
149
153
  const group = r.schema !== schema ? html`${schema ? raw('</optgroup>') : ''}<optgroup label="${(schema = r.schema)}">` : '';
150
- return html`${group}<option value="${r.qname}"${r === rel ? raw(' selected') : ''}>${r.name}${r.kind === 'view' ? ' (view)' : ''}</option>`;
154
+ return html`${group}<option value="${r.qname}"${r === rel ? raw(' selected') : ''}>${r.name}${r.kind === 'view' ? ` ${tx('ul.view')}` : ''}</option>`;
151
155
  })}${schema ? raw('</optgroup>') : ''}</select>
152
- <button class="btn">Choose</button></form>`;
153
- if (!rel) return html`${intro}${sourceTabs('table')}${region('Unload a table or view', html`${alert}${chooser}`)}`;
156
+ <button class="btn">${tx('ul.choose')}</button></form>`;
157
+ if (!rel) return html`${intro}${sourceTabs(tx, 'table')}${region(tx('ul.unload_table'), html`${alert}${chooser}`)}`;
154
158
  const cols = await columns(rel.qname);
155
159
  const chosen = new Set(b.format !== undefined ? list(b.columns) : cols.map((c) => c.name));
156
- return html`${intro}${sourceTabs('table')}${region(
157
- `Unload ${rel.qname}`,
160
+ return html`${intro}${sourceTabs(tx, 'table')}${region(
161
+ tx('ul.unload_of', { name: rel.qname }),
158
162
  html`${alert}${chooser}
159
163
  <form method="post" action="${BASE}/sql/unload" class="u-mt1">${csrf(s)}<input type="hidden" name="source" value="table"><input type="hidden" name="table" value="${rel.qname}">
160
- <fieldset class="prop-group"><legend>Columns</legend><div class="qb-pick">${cols.map(
164
+ <fieldset class="prop-group"><legend>${tx('ul.columns')}</legend><div class="qb-pick">${cols.map(
161
165
  (c) => html`<label class="check"><input type="checkbox" name="columns" value="${c.name}"${chosen.has(c.name) ? raw(' checked') : ''}> ${c.name} <span class="muted small">${c.type}</span></label>`,
162
166
  )}</div></fieldset>
163
167
  <div class="form-grid">
164
- <div class="field"><label class="label" for="f_where">Where</label><input id="f_where" name="where" value="${b.where ?? ''}" placeholder="e.g. deptno = 10" class="code" spellcheck="false">
165
- <small class="help">Optional: a condition, without the word WHERE.</small></div>
166
- <div class="field"><label class="label" for="f_order">Order by</label><input id="f_order" name="order" value="${b.order ?? ''}" placeholder="e.g. 1, created desc" class="code" spellcheck="false">
167
- <small class="help">Optional: columns, without the words ORDER BY.</small></div>
168
+ <div class="field"><label class="label" for="f_where">${tx('ul.where')}</label><input id="f_where" name="where" value="${b.where ?? ''}" placeholder="${tx('ul.eg', { x: 'deptno = 10' })}" class="code" spellcheck="false">
169
+ <small class="help">${tx('ul.where_help')}</small></div>
170
+ <div class="field"><label class="label" for="f_order">${tx('ul.order')}</label><input id="f_order" name="order" value="${b.order ?? ''}" placeholder="${tx('ul.eg', { x: '1, created desc' })}" class="code" spellcheck="false">
171
+ <small class="help">${tx('ul.order_help')}</small></div>
168
172
  </div>
169
- ${formatFields(b)}
170
- <div class="buttons"><button class="btn btn-hot">${icon('download')} Download</button></div></form>`,
173
+ ${formatFields(tx, b)}
174
+ <div class="buttons"><button class="btn btn-hot">${icon('download')} ${tx('scr.download')}</button></div></form>`,
171
175
  )}`;
172
176
  };
173
177
 
@@ -192,10 +196,10 @@ export async function unloadRoutes(app: FastifyInstance) {
192
196
  name = 'query';
193
197
  } else {
194
198
  const rel = (await relations()).find((r) => r.qname === b.table);
195
- if (!rel) return refuse('Choose a table or view.');
199
+ if (!rel) return refuse(tr(s)('ul.err.table'));
196
200
  const known = new Set((await columns(rel.qname)).map((c) => c.name));
197
201
  const cols = list(b.columns).filter((c) => known.has(c));
198
- if (!cols.length) return refuse('Choose at least one column.');
202
+ if (!cols.length) return refuse(tr(s)('ul.err.column'));
199
203
  const where = (b.where ?? '').trim();
200
204
  const order = (b.order ?? '').trim();
201
205
  // the WHERE and ORDER BY text is the developer's SQL: the whole statement must still be one SELECT
@@ -226,7 +230,7 @@ export async function unloadRoutes(app: FastifyInstance) {
226
230
  } catch (e) {
227
231
  await c.query('rollback').catch(() => {});
228
232
  c.release(true);
229
- return refuse(`The query failed: ${(e as Error).message}`);
233
+ return refuse(tr(s)('ul.err.failed', { error: (e as Error).message }));
230
234
  }
231
235
  await logActivity({ username: s.username, event: 'sql_unload', ip: clientIp(req), detail: `${opts.format}: ${sql.length > 1900 ? `${sql.slice(0, 1900)}…` : sql}` });
232
236
  const out = new PassThrough();
@@ -121,9 +121,9 @@ async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema:
121
121
  return html`<ul class="ws-tree" id="ws-tree">${objects.map((o) => {
122
122
  const list = cols.get(o.ref);
123
123
  const obLink = `${BASE}/sql/objects?${new URLSearchParams(o.type === 'policy' ? { schema, k: 'table', o: o.ref, tab: 'rls' } : { schema, k: o.type, o: o.ref })}`;
124
- if (!list) return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}"><a href="${obLink}" title="${label(o.type)}">${icon(iconOf(o.type))}<span>${o.name}</span></a>${objectMenu(schema, o)}</li>`;
124
+ if (!list) return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}"><a href="${obLink}" title="${label(o.type)}">${icon(iconOf(o.type))}<span>${o.name}</span></a>${objectMenu(schema, o, tx)}</li>`;
125
125
  const st = objectStatements(schema, o.name, list, o.type);
126
- return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}">${objectMenu(schema, o)}<details>
126
+ return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}">${objectMenu(schema, o, tx)}<details>
127
127
  <summary draggable="true" data-ws-sql="${st.select}" title="${tx('ws.nav.drag', { type: tx.lang === 'en' ? label(o.type).replace(/s$/, '') : label(o.type) })}">${icon(iconOf(o.type))}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}</summary>
128
128
  <ul class="ws-cols">${list.map((c) => html`<li title="${c.type}${c.notnull ? ' not null' : ''}">${c.pk ? icon('key') : icon('minus')}<span>${c.name}</span><small class="muted">${c.type}</small></li>`)}</ul>
129
129
  <div class="ws-obj-actions">
@@ -525,7 +525,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
525
525
  else {
526
526
  const d = drawChart('', lastQuery.columns, lastQuery.data ?? [], spec);
527
527
  chartCss = d.css;
528
- chartPane = html`${chartControls(lastQuery.columns, spec)}
528
+ chartPane = html`${chartControls(lastQuery.columns, spec, 'ch', tx)}
529
529
  <div class="ch-actions"><button class="btn btn-sm" name="mode" value="chart">${icon('refresh')} ${tx('ws.chart.again')}</button>
530
530
  <input type="hidden" name="chart_sql" value="${lastQuery.sql}">
531
531
  <label class="sr-only" for="ws-chart-name">${tx('ws.chart.name')}</label><input id="ws-chart-name" name="chart_name" placeholder="${tx('ws.chart.name')}" maxlength="120">
@@ -231,12 +231,12 @@ const pad = (n: number) => String(n).padStart(2, '0');
231
231
  const sorted = (s: Set<number>) => [...s].sort((a, b) => a - b);
232
232
  const isAll = (s: Set<number>, min: number, max: number) => s.size === max - min + 1;
233
233
  /** a list of numbers with runs as ranges: 1-5, 7 */
234
- function compact(values: number[], name: (n: number) => string = String) {
234
+ function compact(values: number[], name: (n: number) => string = String, t: CronWords = EN_WORDS) {
235
235
  const out: string[] = [];
236
236
  for (let i = 0; i < values.length; i++) {
237
237
  let j = i;
238
238
  while (j + 1 < values.length && values[j + 1] === values[j] + 1) j++;
239
- out.push(j - i >= 2 ? `${name(values[i])} to ${name(values[j])}` : j > i ? `${name(values[i])}, ${name(values[j])}` : name(values[i]));
239
+ out.push(j - i >= 2 ? fillWords(t('cron.range'), { from: name(values[i]), to: name(values[j]) }) : j > i ? `${name(values[i])}, ${name(values[j])}` : name(values[i]));
240
240
  i = j;
241
241
  }
242
242
  return out.join(', ');
@@ -250,33 +250,63 @@ function stepOf(s: Set<number>, min: number, max: number) {
250
250
  }
251
251
 
252
252
  /** The schedule in words ("At 02:00, every day"). */
253
- export function describeSchedule(s: Schedule): string {
254
- if (s.kind === 'reboot') return 'When the database server starts';
255
- if (s.kind === 'seconds') return s.seconds === 1 ? 'Every second' : `Every ${s.seconds} seconds`;
253
+ /**
254
+ * (sprint 162, CD-8) The phrases of a schedule in words, by key: English here; the builder passes its translator
255
+ * (the same keys, cron.*, in src/builder/i18n/*.ts) and the language for the day and month names.
256
+ */
257
+ export const EN_WORDS_TEXT: Record<string, string> = {
258
+ 'cron.reboot': 'When the database server starts', 'cron.every_second': 'Every second', 'cron.every_seconds': 'Every {n} seconds',
259
+ 'cron.every_minute': 'Every minute', 'cron.every_minutes': 'Every {n} minutes', 'cron.every_hour': 'Every hour',
260
+ 'cron.minute_of_hour': 'At minute {m} of every hour', 'cron.minutes_of_hour': 'At minutes {list} of every hour', 'cron.at': 'At {times}',
261
+ 'cron.at_minute': 'At minute {m}', 'cron.at_minutes': 'At minutes {list}', 'cron.of_every_hours': '{mins} of every {n} hours',
262
+ 'cron.past_hours': '{mins} past hours {list}', 'cron.day_and_last': 'on day {list} and the last day of the month',
263
+ 'cron.last_day': 'on the last day of the month', 'cron.day_of_month': 'on day {list} of the month', 'cron.weekdays': 'on weekdays',
264
+ 'cron.on_days': 'on {list}', 'cron.a_days': 'a {list}', 'cron.either': '{dom} or {dow}', 'cron.if': '{dom} if it is {dow}',
265
+ 'cron.every_day': 'every day', 'cron.in_months': 'in {list}', 'cron.range': '{from} to {to}',
266
+ };
267
+ export type CronWords = (key: string) => string;
268
+ const EN_WORDS: CronWords = (k) => EN_WORDS_TEXT[k] ?? k;
269
+ const fillWords = (text: string, p: Record<string, string | number>) => text.replace(/\{(\w+)\}/g, (m, k: string) => (k in p ? String(p[k]) : m));
270
+
271
+ /** Day (0 = Sunday) and month (1 = January) names in a language (English: as before). */
272
+ const dayName = (lang: string, d: number) => (lang === 'en' ? DAY_NAMES[d] : new Intl.DateTimeFormat(lang, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + d))));
273
+ const monthName = (lang: string, m: number) => (lang === 'en' ? MONTH_NAMES[m - 1] : new Intl.DateTimeFormat(lang, { month: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, m - 1, 1))));
274
+
275
+ export function describeSchedule(s: Schedule, t: CronWords = EN_WORDS, lang = 'en'): string {
276
+ const w = (key: string, p: Record<string, string | number> = {}) => fillWords(t(key), p);
277
+ const list = (v: number[], name?: (n: number) => string) => compact(v, name, t);
278
+ if (s.kind === 'reboot') return w('cron.reboot');
279
+ if (s.kind === 'seconds') return s.seconds === 1 ? w('cron.every_second') : w('cron.every_seconds', { n: s.seconds });
256
280
  const minutes = sorted(s.minutes);
257
281
  const hours = sorted(s.hours);
258
282
  let time: string;
259
- if (isAll(s.minutes, 0, 59) && isAll(s.hours, 0, 23)) time = 'Every minute';
260
- else if (isAll(s.hours, 0, 23)) {
283
+ // whether the time already says how often (then no "every day")
284
+ let often = false;
285
+ if (isAll(s.minutes, 0, 59) && isAll(s.hours, 0, 23)) {
286
+ time = w('cron.every_minute');
287
+ often = true;
288
+ } else if (isAll(s.hours, 0, 23)) {
261
289
  const step = stepOf(s.minutes, 0, 59);
262
- time = step ? `Every ${step} minutes` : minutes.length === 1 ? (minutes[0] === 0 ? 'Every hour' : `At minute ${minutes[0]} of every hour`) : `At minutes ${compact(minutes)} of every hour`;
263
- } else if (hours.length * minutes.length <= 6) time = `At ${hours.flatMap((h) => minutes.map((m) => `${pad(h)}:${pad(m)}`)).join(', ')}`;
290
+ often = true;
291
+ time = step ? w('cron.every_minutes', { n: step }) : minutes.length === 1 ? (minutes[0] === 0 ? w('cron.every_hour') : w('cron.minute_of_hour', { m: minutes[0] })) : w('cron.minutes_of_hour', { list: list(minutes) });
292
+ } else if (hours.length * minutes.length <= 6) time = w('cron.at', { times: hours.flatMap((h) => minutes.map((m) => `${pad(h)}:${pad(m)}`)).join(', ') });
264
293
  else {
265
294
  const step = stepOf(s.hours, 0, 23);
266
- const mins = isAll(s.minutes, 0, 59) ? 'Every minute' : minutes.length === 1 ? `At minute ${minutes[0]}` : `At minutes ${compact(minutes)}`;
267
- time = `${mins} ${step ? `of every ${step} hours` : `past hours ${compact(hours)}`}`;
295
+ const mins = isAll(s.minutes, 0, 59) ? w('cron.every_minute') : minutes.length === 1 ? w('cron.at_minute', { m: minutes[0] }) : w('cron.at_minutes', { list: list(minutes) });
296
+ time = step ? w('cron.of_every_hours', { mins, n: step }) : w('cron.past_hours', { mins, list: list(hours) });
268
297
  }
269
298
  const parts = [time];
270
299
  const domAll = isAll(s.days, 1, 31) && !s.lastDay;
271
300
  const dowAll = isAll(s.weekdays, 0, 6);
272
- const dom = s.lastDay ? (s.days.size ? `on day ${compact(sorted(s.days))} and the last day of the month` : 'on the last day of the month') : `on day ${compact(sorted(s.days))} of the month`;
301
+ const dom = s.lastDay ? (s.days.size ? w('cron.day_and_last', { list: list(sorted(s.days)) }) : w('cron.last_day')) : w('cron.day_of_month', { list: list(sorted(s.days)) });
273
302
  const weekdays = sorted(s.weekdays);
274
- const dow = weekdays.join() === '1,2,3,4,5' ? 'on weekdays' : `on ${compact(weekdays, (d) => DAY_NAMES[d])}`;
275
- if (s.either) parts.push(`${dom} or ${dow}`);
276
- else if (!domAll && !dowAll) parts.push(`${dom} if it is ${dow.replace(/^on /, 'a ')}`);
303
+ const days = list(weekdays, (d) => dayName(lang, d));
304
+ const dow = weekdays.join() === '1,2,3,4,5' ? w('cron.weekdays') : w('cron.on_days', { list: days });
305
+ if (s.either) parts.push(w('cron.either', { dom, dow }));
306
+ else if (!domAll && !dowAll) parts.push(w('cron.if', { dom, dow: weekdays.join() === '1,2,3,4,5' ? w('cron.weekdays') : w('cron.a_days', { list: days }) }));
277
307
  else if (!domAll) parts.push(dom);
278
308
  else if (!dowAll) parts.push(dow);
279
- else if (time !== 'Every minute' && !/^Every \d+ minutes$|hour$/.test(time)) parts.push('every day');
280
- if (!isAll(s.months, 1, 12)) parts.push(`in ${compact(sorted(s.months), (m) => MONTH_NAMES[m - 1])}`);
309
+ else if (!often) parts.push(w('cron.every_day'));
310
+ if (!isAll(s.months, 1, 12)) parts.push(w('cron.in_months', { list: list(sorted(s.months), (m) => monthName(lang, m)) }));
281
311
  return parts.join(', ');
282
312
  }
package/src/metadata.ts CHANGED
@@ -320,7 +320,9 @@ export interface DynamicAction {
320
320
  /** (124) a menu button's entry {"action": NAME} was chosen (trigger: entry or menu button names) */
321
321
  | 'menu_entry'
322
322
  /** (149) */
323
- | 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change';
323
+ | 'double_click' | 'key_down' | 'key_press' | 'mouse_enter' | 'mouse_leave' | 'select' | 'resize' | 'scroll' | 'page_unload' | 'before_refresh' | 'facets_change'
324
+ /** (153) component events: trigger = the grid, calendar, map or tree region */
325
+ | 'grid_row_init' | 'grid_save' | 'calendar_date_select' | 'calendar_view_change' | 'map_object_click' | 'map_change' | 'tree_selection_change';
324
326
  trigger_element: string | null;
325
327
  condition_type: 'equals' | 'not_equals' | 'in_list' | 'is_null' | 'is_not_null' | 'greater' | 'greater_equal' | 'less' | 'less_equal' | 'not_in_list' | null;
326
328
  condition_value: string | null;
@@ -229,7 +229,8 @@ export async function renderCalendar(ctx: PageContext, r: Region): Promise<Raw>
229
229
  return html`<a class="cal-add" ${linkAttrs(ctx, create.page, items)} aria-label="${label}" title="${label}">+</a>`;
230
230
  };
231
231
  const cellAttrs = (day: Date, hour: number | null) =>
232
- raw(`${movable ? ` data-drop="${iso(day)}${hour === null ? '' : `T${pad(hour)}:00`}"` : ''}${createOk ? ' data-add' : ''}`);
232
+ // (161) data-date: what a calendar_date_select dynamic action's condition sees
233
+ raw(`${movable ? ` data-drop="${iso(day)}${hour === null ? '' : `T${pad(hour)}:00`}"` : ''}${createOk ? ' data-add' : ''} data-date="${iso(day)}${hour === null ? '' : `T${pad(hour)}:00`}"`);
233
234
  const on = (day: string) => events.filter((ev) => ev.start <= day && ev.end >= day);
234
235
  const go = (v: CalendarView, day: Date | null) =>
235
236
  regionUrl(ctx, r, (p) => {
@@ -342,7 +343,7 @@ export async function renderCalendar(ctx: PageContext, r: Region): Promise<Raw>
342
343
  (v) => html`<a class="btn${v === view ? ' active' : ''}" href="${go(v, anchor)}"${v === view ? raw(' aria-current="page"') : ''}>${t(`calendar.view_${v}`)}</a>`,
343
344
  )}</nav>`
344
345
  : '';
345
- return html`<div class="calendar cal-view-${view}"${movable ? raw(` data-calendar="${r.id}"`) : ''}>
346
+ return html`<div class="calendar cal-view-${view}" data-view="${view}"${movable ? raw(` data-calendar="${r.id}"`) : ''}>
346
347
  <div class="cal-toolbar">
347
348
  <div class="buttons">${nav('‹', prev, prevLabel)}${nav(t('calendar.today'), null)}${nav('›', next, nextLabel)}</div>
348
349
  <h3 class="cal-title" aria-live="polite">${title}</h3>
@@ -38,6 +38,8 @@ export interface PageContext {
38
38
  ip: string;
39
39
  errors: Errors;
40
40
  messages: string[];
41
+ /** (161) grid regions whose changes this request saved (POST), or the request before it saved (GET): grid_save dynamic actions */
42
+ savedGrids?: number[];
41
43
  /** rendered inside a modal dialog (iframe) */
42
44
  dialog: boolean;
43
45
  client?: Client;
@@ -831,5 +831,6 @@ export async function gridDml(ctx: PageContext, p: Process): Promise<string | nu
831
831
  if (errors.length) throw new Error(errors.join(' '));
832
832
  const t = ctx.locale.t;
833
833
  const parts = [inserted && t('grid.added', { n: inserted }), updated && t('grid.updated', { n: updated }), deleted && t('grid.deleted', { n: deleted })].filter(Boolean);
834
+ (ctx.savedGrids ??= []).push(r.id);
834
835
  return p.success_message ?? (parts.length ? t('grid.saved_parts', { parts: parts.join(', ') }) : t('grid.no_changes'));
835
836
  }
@@ -13,7 +13,7 @@ import { encodeTile, tileArea, type TileFeature } from '../mvt.ts';
13
13
 
14
14
  // Map region (APEX: Map region). The SELECT returns, per row, a position
15
15
  // (columns lat and lng, latitude and longitude, or location as "lat,lng")
16
- // and optionally title, body and geojson (a GeoJSON geometry or feature for
16
+ // and optionally id (what a map_object_click dynamic action sees), title, body and geojson (a GeoJSON geometry or feature for
17
17
  // lines and areas; a GeoJSON point is a place), and weight (for a heat map).
18
18
  // With PostGIS installed, a geometry or geography column (WGS 84) is turned
19
19
  // into GeoJSON on the server (ST_AsGeoJSON), so no geojson column is needed.
@@ -59,6 +59,8 @@ interface Point {
59
59
  body: string;
60
60
  href: string | null;
61
61
  weight: number;
62
+ /** (161) the row's id column: what a map_object_click dynamic action's condition sees */
63
+ id?: string;
62
64
  }
63
65
  /** (159, CD-6) a shape layer's look: fixed in the layer's settings, or per row from columns of the same names */
64
66
  export interface ShapeStyle {
@@ -164,6 +166,7 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
164
166
  if ((Number.isNaN(lat) || Number.isNaN(lng)) && loc) [lat, lng] = [Number(loc[1]), Number(loc[2])];
165
167
  const title = toState(col(row, 'title')) ?? '';
166
168
  const body = toState(col(row, 'body')) ?? '';
169
+ const id = toState(col(row, 'id')) ?? undefined;
167
170
  let href: string | null = null;
168
171
  if (linkOk && link) {
169
172
  const items: Record<string, string> = {};
@@ -184,14 +187,14 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
184
187
  ? { ...def.style, ...shapeStyle({ fill_color: col(row, 'fill_color'), stroke_color: col(row, 'stroke_color'), stroke_width: col(row, 'stroke_width'), opacity: col(row, 'opacity') }) }
185
188
  : undefined;
186
189
  const h = num(col(row, 'height'));
187
- shapes.push({ type: 'Feature', geometry, properties: { title, body, href, ...(style ? { style } : {}), ...(def.kind === 'extruded' && Number.isFinite(h) ? { height: h } : {}) } });
190
+ shapes.push({ type: 'Feature', geometry, properties: { title, body, href, ...(id !== undefined ? { id } : {}), ...(style ? { style } : {}), ...(def.kind === 'extruded' && Number.isFinite(h) ? { height: h } : {}) } });
188
191
  }
189
192
  }
190
193
  // a shape layer draws only shapes
191
194
  if (SHAPE_KINDS.has(def.kind)) continue;
192
195
  const w = num(col(row, 'weight'));
193
196
  const weight = Number.isFinite(w) && w > 0 ? w : Number.isNaN(w) ? 1 : 0;
194
- if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight });
197
+ if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight, ...(id !== undefined ? { id } : {}) });
195
198
  }
196
199
  // (159) extruded: each polygon shaded by its height (from light to strong; no 3D view)
197
200
  if (def.kind === 'extruded') {
@@ -296,7 +299,7 @@ export function parseTile(z: string, x: string, y: string) {
296
299
  export async function layerTile(ctx: PageContext, def: LayerDef, t: { z: number; x: number; y: number }) {
297
300
  const { points, shapes, truncated } = await loadLayer(ctx, def, await postgis(), { area: tileArea(t.z, t.x, t.y, 1 / 16), limit: MAX_TILE_ROWS });
298
301
  const features: TileFeature[] = [
299
- ...points.map((p) => ({ geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, properties: { title: p.title, body: p.body, href: p.href, weight: p.weight } })),
302
+ ...points.map((p) => ({ geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, properties: { title: p.title, body: p.body, href: p.href, weight: p.weight, ...(p.id !== undefined ? { id: p.id } : {}) } })),
300
303
  ...(shapes as { geometry: TileFeature['geometry']; properties: TileFeature['properties'] }[]).map((s) => ({ geometry: s.geometry, properties: s.properties })),
301
304
  ];
302
305
  return { tile: encodeTile('places', t.z, t.x, t.y, features), truncated };
@@ -30,6 +30,9 @@ import { sessionLimits } from '../session.ts';
30
30
 
31
31
  const list = (s: string | null) => (s ?? '').split(',').map((x) => x.trim().toUpperCase()).filter(Boolean);
32
32
 
33
+ /** (161) events of grid, calendar, map and tree regions (their value: see public/app.js, pgkiln:component) */
34
+ export const COMPONENT_EVENTS = new Set(['grid_row_init', 'grid_save', 'calendar_date_select', 'calendar_view_change', 'map_object_click', 'map_change', 'tree_selection_change']);
35
+
33
36
  /** Client-side definition of the page's dynamic actions (never includes SQL). */
34
37
  function dynamicActionsJson(ctx: PageContext) {
35
38
  return ctx.page.dynamic_actions
@@ -53,6 +56,8 @@ function dynamicActionsJson(ctx: PageContext) {
53
56
  ...(d.action === 'plugin' ? pluginAction(ctx, d.code, d.config) : {}),
54
57
  // (156) set_style: checked properties and values; open_report_dialog: the dialog's key (checked by the database)
55
58
  ...(d.action === 'set_style' ? { styles: safeStyles(d.config) } : {}),
59
+ // (161) set_value without SQL on a component event: the event's value (a date, node id, …) into the affected items
60
+ ...(d.action === 'set_value' && !d.code?.trim() && COMPONENT_EVENTS.has(d.event) ? { eventValue: true } : {}),
56
61
  ...(d.action === 'open_report_dialog' ? { dialog: /^[a-z]{3,10}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null } : {}),
57
62
  }));
58
63
 
@@ -422,7 +427,7 @@ export async function renderPage(ctx: PageContext) {
422
427
  ${ctx.detached}
423
428
  </div>
424
429
  <script type="application/json" id="pgkiln-meta">${raw(
425
- JSON.stringify({ csrf: ctx.session.csrf_token, das, texts: clientTexts(ctx), dialogs: dialogShapes(ctx), ...timeZoneMeta(ctx), session: sessionMeta(ctx) }).replace(/</g, '\\u003c'),
430
+ JSON.stringify({ csrf: ctx.session.csrf_token, das, ...(ctx.savedGrids?.length ? { savedGrids: ctx.savedGrids } : {}), texts: clientTexts(ctx), dialogs: dialogShapes(ctx), ...timeZoneMeta(ctx), session: sessionMeta(ctx) }).replace(/</g, '\\u003c'),
426
431
  )}</script>`;
427
432
  return chrome(ctx, main, title);
428
433
  }
@@ -443,6 +443,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
443
443
  if (flash) ctx.messages.push(flash);
444
444
  const flashError = takeFlash(ctx.session, '__FLASH_ERROR');
445
445
  if (flashError) ctx.errors.page.push(flashError);
446
+ const saved = takeFlash(ctx.session, '__GRID_SAVED');
447
+ if (typeof saved === 'string') ctx.savedGrids = saved.split(',').map(Number).filter(Number.isInteger);
446
448
  // Actions → Download CSV / Excel / PDF: r<region id>_csv=1, _xlsx=1 or _pdf=1
447
449
  const downloadKey = [...ctx.params.keys()].find((k) => /^r\d+_(csv|xlsx|pdf)$/.test(k));
448
450
  // a document template: ?doc=NAME (see documents.ts)
@@ -629,6 +631,8 @@ export async function runtimeRoutes(app: FastifyInstance) {
629
631
  return reply.headers(downloadHeaders(ctx.download)).send(ctx.download.content);
630
632
  }
631
633
  if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
634
+ // (161) the page that comes back runs the saved grids' grid_save dynamic actions
635
+ if (ctx.savedGrids?.length && !ctx.dialog) ctx.session.state.__GRID_SAVED = ctx.savedGrids.join(',');
632
636
  await saveState(ctx.session);
633
637
  if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx, ctx.closeDialog));
634
638
  // (sprint 54) a grid's Save comes back to the grid's search, page, sort and view
@@ -64,14 +64,15 @@ export async function renderTree(ctx: PageContext, r: Region): Promise<Raw> {
64
64
  });
65
65
  label = html`<a ${linkAttrs(ctx, link.page, items)}>${label}</a>`;
66
66
  }
67
- if (!n.children.length) return html`<li class="tree-leaf">${label}</li>`;
67
+ // (161) data-node: what a tree_selection_change dynamic action's condition sees
68
+ if (!n.children.length) return html`<li class="tree-leaf" data-node="${n.id}">${label}</li>`;
68
69
  const open = depth < expanded ? raw(' open') : '';
69
70
  const children = html`<ul>${n.children.map((ch) => draw(ch, depth + 1))}</ul>`;
70
71
  // a linked branch: the link is a row of its own and the disclosure only toggles (no link inside <summary>)
71
72
  if (linkOk && link)
72
- return html`<li class="tree-branch"><span class="tree-row">${label} <span class="tree-count" aria-hidden="true">${n.children.length}</span></span>
73
+ return html`<li class="tree-branch" data-node="${n.id}"><span class="tree-row">${label} <span class="tree-count" aria-hidden="true">${n.children.length}</span></span>
73
74
  <details${open}><summary><span class="sr-only">${n.label} (${n.children.length})</span></summary>${children}</details></li>`;
74
- return html`<li><details${open}><summary>${label} <span class="tree-count">${n.children.length}</span></summary>
75
+ return html`<li data-node="${n.id}"><details${open}><summary>${label} <span class="tree-count">${n.children.length}</span></summary>
75
76
  ${children}</details></li>`;
76
77
  };
77
78
  return html`<ul class="tree-view">${roots.map((n) => draw(n, 0))}</ul>`;