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.
- package/db/migrations/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +2 -1
- package/docs/guide/07-dynamic-actions.md +27 -0
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +85 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/components.ts +5 -4
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1627 -0
- package/src/builder/i18n/en.ts +1627 -0
- package/src/builder/i18n/es.ts +1627 -0
- package/src/builder/i18n/fr.ts +1627 -0
- package/src/builder/i18n/it.ts +1627 -0
- package/src/builder/i18n/ja.ts +1627 -0
- package/src/builder/i18n/ko.ts +1627 -0
- package/src/builder/i18n/nl.ts +1627 -0
- package/src/builder/i18n/pt.ts +1627 -0
- package/src/builder/i18n/zh-cn.ts +1627 -0
- package/src/builder/i18n/zh-tw.ts +1627 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cron-schedule.ts +47 -17
- package/src/metadata.ts +3 -1
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/render.ts +6 -1
- package/src/runtime/routes.ts +4 -0
- package/src/runtime/tree.ts +4 -3
package/src/builder/top-sql.ts
CHANGED
|
@@ -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
|
-
?
|
|
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
|
-
?
|
|
35
|
-
:
|
|
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"
|
|
76
|
-
<div class="table-wrap"><table class="report report-reflow top-sql"><thead><tr><th class="num"
|
|
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="
|
|
79
|
-
<td class="num" data-label="
|
|
80
|
-
<td data-label="
|
|
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="
|
|
83
|
-
: html`<p class="muted"
|
|
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"
|
|
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('
|
|
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,
|
|
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('
|
|
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, '
|
|
105
|
+
flash(s, tr(s)('topsql.reset_done'));
|
|
102
106
|
} catch (e) {
|
|
103
107
|
flash(s, (e as Error).message, 'error');
|
|
104
108
|
}
|
package/src/builder/unload.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
102
|
-
<div class="unload-formats" role="radiogroup" aria-label="
|
|
103
|
-
(f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: '
|
|
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"
|
|
108
|
-
<select id="f_delimiter" name="delimiter">${opt(CSV_DELIMITERS, 'delimiter', { comma: '
|
|
109
|
-
<div class="field"><label class="label" for="f_enclosure"
|
|
110
|
-
<select id="f_enclosure" name="enclosure">${opt(CSV_ENCLOSURES, 'enclosure', { double: '
|
|
111
|
-
<small class="help"
|
|
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')}>
|
|
114
|
-
<label class="check"><input type="checkbox" name="bom" value="1"${on('bom')}>
|
|
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"
|
|
118
|
-
<div class="field"><label class="label" for="f_row_tag"
|
|
119
|
-
<small class="help"
|
|
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="
|
|
125
|
-
<a class="ide-tab" href="${BASE}/sql/unload"${active === 'table' ? raw(' aria-current="page"') : ''}>${icon('table')}<span
|
|
126
|
-
<a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span
|
|
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
|
|
129
|
-
|
|
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
|
-
'
|
|
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"
|
|
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"
|
|
140
|
-
${formatFields(b)}
|
|
141
|
-
<div class="buttons"><button class="btn btn-hot">${icon('download')}
|
|
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"
|
|
148
|
-
<select id="f_table" name="table" required><option value=""
|
|
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' ?
|
|
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"
|
|
153
|
-
if (!rel) return html`${intro}${sourceTabs('table')}${region('
|
|
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
|
-
|
|
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
|
|
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"
|
|
165
|
-
<small class="help"
|
|
166
|
-
<div class="field"><label class="label" for="f_order"
|
|
167
|
-
<small class="help"
|
|
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')}
|
|
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('
|
|
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('
|
|
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(
|
|
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();
|
package/src/builder/worksheet.ts
CHANGED
|
@@ -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">
|
package/src/cron-schedule.ts
CHANGED
|
@@ -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 ?
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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) ? '
|
|
267
|
-
time =
|
|
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 ?
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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 (
|
|
280
|
-
if (!isAll(s.months, 1, 12)) parts.push(
|
|
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;
|
package/src/runtime/calendar.ts
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/runtime/context.ts
CHANGED
|
@@ -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;
|
package/src/runtime/grid.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/maps.ts
CHANGED
|
@@ -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 };
|
package/src/runtime/render.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/routes.ts
CHANGED
|
@@ -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
|
package/src/runtime/tree.ts
CHANGED
|
@@ -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
|
-
|
|
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>`;
|