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
@@ -11,6 +11,7 @@ import { heading } from '../runtime/items.ts';
11
11
  import { COLUMN_OPTIONS } from '../runtime/template-options.ts';
12
12
  import type { Session } from '../session.ts';
13
13
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
14
+ import { scopedTx as tx, tr, withTranslator } from './i18n.ts';
14
15
 
15
16
  // Page designer → a report region → Report settings: the most used keys of
16
17
  // the region's "config" JSON as a form (columns, link, paging, PDF, saved
@@ -84,7 +85,10 @@ const columnOption = (cfg: ReportConfig, name: string) => {
84
85
  };
85
86
 
86
87
  /** The Report settings form of a report region. */
87
- export async function reportSettingsForm(pageId: number, appId: number, r: { id: number; source: string | null; config: ReportConfig }, s: Session) {
88
+ /** (sprint 162) in the developer's builder language: the helpers below use the request's scoped translator */
89
+ export const reportSettingsForm = (pageId: number, appId: number, r: { id: number; source: string | null; config: ReportConfig }, s: Session) => withTranslator(tr(s), () => reportSettingsFormInner(pageId, appId, r, s));
90
+
91
+ async function reportSettingsFormInner(pageId: number, appId: number, r: { id: number; source: string | null; config: ReportConfig }, s: Session) {
88
92
  const cfg = r.config ?? {};
89
93
  const cols = await reportColumns(appId, await designSql(appId, r));
90
94
  const [layouts, schemes, pages, items] = await Promise.all([
@@ -107,85 +111,85 @@ export async function reportSettingsForm(pageId: number, appId: number, r: { id:
107
111
  const printed = cfg.pdf?.columns?.length ? new Set(cfg.pdf.columns.map((c) => c.toLowerCase())) : null;
108
112
 
109
113
  const columnRows = all.map((n, i) => html`<tr>
110
- <td data-label="Column"><code>${n}</code>${known.has(n) ? '' : html` <span class="tag tag-error">not in the query</span>`}<input type="hidden" name="col_${i}" value="${n}"></td>
111
- <td data-label="Heading"><input name="heading_${i}" value="${cfg.headings?.[n] ?? ''}" placeholder="${heading(n)}" aria-label="Heading of ${n}"></td>
112
- <td data-label="Format mask"><input name="fmt_${i}" value="${cfg.formats?.[n] ?? ''}" placeholder="e.g. 999G990D00" aria-label="Format mask of ${n}" class="u-mw10"></td>
113
- <td data-label="Display"><select name="opt_${i}" aria-label="Display of ${n}">${opt('', 'Normal', columnOption(cfg, n))}${COLUMN_OPTIONS.map((o) => opt(o.cls, o.label, columnOption(cfg, n)))}</select></td>
114
- <td data-label="Shown"><input type="checkbox" name="shown_${i}" value="true"${hidden.has(n.toLowerCase()) ? '' : raw(' checked')} aria-label="Show ${n}"></td>
115
- <td data-label="In PDF"><input type="checkbox" name="print_${i}" value="true"${(printed ? printed.has(n.toLowerCase()) : !hidden.has(n.toLowerCase())) ? raw(' checked') : ''} aria-label="Print ${n}"></td>
116
- <td data-label="PDF width (mm)"><input name="width_${i}" type="number" min="0" max="500" value="${cfg.pdf?.widths?.[n] ?? ''}" aria-label="PDF width of ${n}" class="u-mw6"></td>
114
+ <td data-label="${tx('rpt.column')}"><code>${n}</code>${known.has(n) ? '' : html` <span class="tag tag-error">${tx('rs.not_in_query')}</span>`}<input type="hidden" name="col_${i}" value="${n}"></td>
115
+ <td data-label="${tx('rpt.heading')}"><input name="heading_${i}" value="${cfg.headings?.[n] ?? ''}" placeholder="${heading(n)}" aria-label="${tx('rpt.aria_heading_of', { name: n })}"></td>
116
+ <td data-label="${tx('rpt.format_mask')}"><input name="fmt_${i}" value="${cfg.formats?.[n] ?? ''}" placeholder="e.g. 999G990D00" aria-label="${tx('rpt.aria_format_mask_of', { name: n })}" class="u-mw10"></td>
117
+ <td data-label="${tx('rpt.display')}"><select name="opt_${i}" aria-label="${tx('rpt.aria_display_of', { name: n })}">${opt('', tx('rpt.normal'), columnOption(cfg, n))}${COLUMN_OPTIONS.map((o) => opt(o.cls, o.label, columnOption(cfg, n)))}</select></td>
118
+ <td data-label="${tx('rpt.shown')}"><input type="checkbox" name="shown_${i}" value="true"${hidden.has(n.toLowerCase()) ? '' : raw(' checked')} aria-label="${tx('rpt.aria_show', { name: n })}"></td>
119
+ <td data-label="${tx('rpt.in_pdf')}"><input type="checkbox" name="print_${i}" value="true"${(printed ? printed.has(n.toLowerCase()) : !hidden.has(n.toLowerCase())) ? raw(' checked') : ''} aria-label="${tx('rpt.aria_print', { name: n })}"></td>
120
+ <td data-label="${tx('rpt.pdf_width_mm')}"><input name="width_${i}" type="number" min="0" max="500" value="${cfg.pdf?.widths?.[n] ?? ''}" aria-label="${tx('rpt.aria_pdf_width_of', { name: n })}" class="u-mw6"></td>
117
121
  </tr>`);
118
122
 
119
123
  const linkItems = linkItemsText(cfg.link?.items);
120
- return html`<h3 class="u-mt15">Report settings</h3>
121
- <p class="muted u-mt0">These fields write the region's settings JSON above (other keys are kept).</p>
124
+ return html`<h3 class="u-mt15">${tx('rpt.report_settings')}</h3>
125
+ <p class="muted u-mt0">${tx('rpt.these_fields_write_the_region')}</p>
122
126
  ${'error' in cols ? html`<div class="alert alert-error" role="alert">The columns could not be read: ${cols.error}</div>` : ''}
123
127
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/report-settings" class="component-form">${csrf(s)}
124
128
  <input type="hidden" name="n" value="${all.length}">
125
- <fieldset class="prop-group"><legend>Behaviour</legend><div class="form-grid">
126
- <div class="field"><label class="label" for="${id('page_size')}">Rows per page</label>
129
+ <fieldset class="prop-group"><legend>${tx('rpt.behaviour')}</legend><div class="form-grid">
130
+ <div class="field"><label class="label" for="${id('page_size')}">${tx('rpt.rows_per_page')}</label>
127
131
  <select id="${id('page_size')}" name="page_size">${PAGE_SIZES.map((n) => opt(n, n, cfg.page_size ?? 15))}</select></div>
128
- <div class="field"><label class="label" for="${id('mobile')}">On phones</label>
129
- <select id="${id('mobile')}" name="mobile">${opt('reflow', 'Cards (reflow)', cfg.mobile ?? 'reflow')}${opt('scroll', 'Scrolling table', cfg.mobile)}</select></div>
130
- <div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
131
- <input id="${id('empty')}" name="empty" value="${cfg.empty ?? ''}" placeholder="No data found"></div>
132
- ${check('searchable', 'Search box', cfg.searchable !== false)}
133
- ${check('interactive', 'Actions menu (filters, break, aggregates, downloads…)', cfg.interactive !== false)}
134
- ${check('sortable', 'Sortable', cfg.sortable !== false)}
135
- ${check('saved_reports', 'Users may save reports', cfg.saved_reports !== false)}
136
- <div class="field"><label class="label" for="${id('public')}">Who may save public reports</label>
137
- <select id="${id('public')}" name="public_reports">${opt('', '- nobody -', cfg.public_reports)}${schemes.rows.map((x) => opt(x.name, x.name, cfg.public_reports))}</select></div>
132
+ <div class="field"><label class="label" for="${id('mobile')}">${tx('rpt.on_phones')}</label>
133
+ <select id="${id('mobile')}" name="mobile">${opt('reflow', tx('rpt.cards_reflow'), cfg.mobile ?? 'reflow')}${opt('scroll', tx('rpt.scrolling_table'), cfg.mobile)}</select></div>
134
+ <div class="field" data-wide><label class="label" for="${id('empty')}">${tx('rpt.text_when_there_are_no')}</label>
135
+ <input id="${id('empty')}" name="empty" value="${cfg.empty ?? ''}" placeholder="${tx('rpt.no_data_found')}"></div>
136
+ ${check('searchable', tx('rpt.search_box'), cfg.searchable !== false)}
137
+ ${check('interactive', tx('rpt.actions_menu_filters_break_aggregates'), cfg.interactive !== false)}
138
+ ${check('sortable', tx('rpt.sortable'), cfg.sortable !== false)}
139
+ ${check('saved_reports', tx('rpt.users_may_save_reports'), cfg.saved_reports !== false)}
140
+ <div class="field"><label class="label" for="${id('public')}">${tx('rpt.who_may_save_public_reports')}</label>
141
+ <select id="${id('public')}" name="public_reports">${opt('', tx('rpt.nobody'), cfg.public_reports)}${schemes.rows.map((x) => opt(x.name, x.name, cfg.public_reports))}</select></div>
138
142
  </div></fieldset>
139
- <fieldset class="prop-group"><legend>Large tables</legend><div class="form-grid">
140
- <div class="field"><label class="label" for="${id('pagination')}">Pagination</label>
141
- <select id="${id('pagination')}" name="pagination">${opt('', 'Rows X–Y of Z (counts the total)', cfg.pagination)}${opt('range', 'Row ranges X–Y (no total)', cfg.pagination)}</select>
142
- <small class="help">Row ranges skip counting the rows: use them for large tables.</small></div>
143
- <div class="field"><label class="label" for="${id('keyset')}">Keyset columns</label>
143
+ <fieldset class="prop-group"><legend>${tx('rpt.large_tables')}</legend><div class="form-grid">
144
+ <div class="field"><label class="label" for="${id('pagination')}">${tx('rpt.pagination')}</label>
145
+ <select id="${id('pagination')}" name="pagination">${opt('', tx('rpt.rows_x_y_of_z'), cfg.pagination)}${opt('range', tx('rpt.row_ranges_x_y_no'), cfg.pagination)}</select>
146
+ <small class="help">${tx('rpt.row_ranges_skip_counting_the')}</small></div>
147
+ <div class="field"><label class="label" for="${id('keyset')}">${tx('rpt.keyset_columns')}</label>
144
148
  <input id="${id('keyset')}" name="keyset" value="${(cfg.keyset ?? []).join(', ')}" placeholder="e.g. id">
145
- <small class="help">With row ranges: columns that make a row unique (comma separated, indexed). Next and Previous then continue after the last row instead of skipping rows (fast deep pages).</small></div>
146
- <div class="field"><label class="label" for="${id('max_rows')}">Maximum row count</label>
149
+ <small class="help">${tx('rpt.with_row_ranges_columns_that')}</small></div>
150
+ <div class="field"><label class="label" for="${id('max_rows')}">${tx('rpt.maximum_row_count')}</label>
147
151
  <input id="${id('max_rows')}" name="max_rows" type="number" min="1" max="1000000" value="${cfg.max_rows ?? ''}" placeholder="no maximum">
148
- <small class="help">The report (and its downloads) reads at most this many rows.</small></div>
149
- ${check('lazy', 'Load after the page shows (lazy loading)', cfg.lazy === true)}
150
- <div class="field"><label class="label" for="${id('cache_scope')}">Cache</label>
151
- <select id="${id('cache_scope')}" name="cache_scope">${opt('', '- no cache -', cfg.cache?.scope)}${opt('user', 'Per user', cfg.cache?.scope)}${opt('session', 'Per session', cfg.cache?.scope)}${opt('all', 'For all users (with the same roles)', cfg.cache?.scope)}</select></div>
152
- <div class="field"><label class="label" for="${id('cache_seconds')}">Cache for (seconds)</label>
152
+ <small class="help">${tx('rpt.the_report_and_its_downloads')}</small></div>
153
+ ${check('lazy', tx('rpt.load_after_the_page_shows'), cfg.lazy === true)}
154
+ <div class="field"><label class="label" for="${id('cache_scope')}">${tx('rpt.cache')}</label>
155
+ <select id="${id('cache_scope')}" name="cache_scope">${opt('', tx('rpt.no_cache'), cfg.cache?.scope)}${opt('user', tx('rpt.per_user'), cfg.cache?.scope)}${opt('session', tx('rpt.per_session'), cfg.cache?.scope)}${opt('all', tx('rpt.for_all_users_with_the'), cfg.cache?.scope)}</select></div>
156
+ <div class="field"><label class="label" for="${id('cache_seconds')}">${tx('rpt.cache_for_seconds')}</label>
153
157
  <input id="${id('cache_seconds')}" name="cache_seconds" type="number" min="1" max="86400" value="${cfg.cache?.seconds ?? ''}" placeholder="300">
154
- <small class="help">A submit of the page empties its cache.</small></div>
158
+ <small class="help">${tx('rpt.a_submit_of_the_page')}</small></div>
155
159
  </div></fieldset>
156
- <fieldset class="prop-group"><legend>JSON path columns</legend><div class="form-grid">
157
- <div class="field" data-wide><label class="label" for="${id('json_columns')}">Columns read from a json or jsonb column</label>
160
+ <fieldset class="prop-group"><legend>${tx('rpt.json_path_columns')}</legend><div class="form-grid">
161
+ <div class="field" data-wide><label class="label" for="${id('json_columns')}">${tx('rpt.columns_read_from_a_json')}</label>
158
162
  <input id="${id('json_columns')}" name="json_columns" value="${jsonColumnsText(cfg.json_columns)}" placeholder="city=profile:address.city; first_skill=profile:skills.0.name; since=profile:since:date" class="code">
159
163
  <small class="help">name=column:path[:type]; separate them with ";". A path is keys separated by dots, a number picks an element of an array. Types: ${JSON_TYPES.join(', ')} (text when left out); a value that doesn't read as the type shows empty. They are columns like the others: search, filters, sorting, headings and downloads. To edit documents, use a JSON source (Shared Components).</small></div>
160
164
  </div></fieldset>
161
- <fieldset class="prop-group"><legend>Columns</legend>
165
+ <fieldset class="prop-group"><legend>${tx('rpt.columns')}</legend>
162
166
  ${all.length
163
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Heading</th><th>Format mask</th><th>Display</th><th>Shown</th><th>In PDF</th><th>PDF width (mm)</th></tr></thead><tbody>${columnRows}</tbody></table></div>`
164
- : html`<p class="muted">No columns yet.</p>`}
165
- <small class="help">Format masks: numbers like 999G999G990D00, FML999G990D00 (currency), 990D0% or 0000 (G and D are the language's separators); dates like DD-MON-YYYY. Empty: the application's formats. Excel and CSV downloads keep the raw values.</small>
167
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rpt.column')}</th><th>${tx('rpt.heading')}</th><th>${tx('rpt.format_mask')}</th><th>${tx('rpt.display')}</th><th>${tx('rpt.shown')}</th><th>${tx('rpt.in_pdf')}</th><th>${tx('rpt.pdf_width_mm')}</th></tr></thead><tbody>${columnRows}</tbody></table></div>`
168
+ : html`<p class="muted">${tx('rpt.no_columns_yet')}</p>`}
169
+ <small class="help">${tx('rpt.format_masks_numbers_like_999g999g990d00')}</small>
166
170
  </fieldset>
167
- <fieldset class="prop-group"><legend>Link</legend><div class="form-grid">
168
- <div class="field"><label class="label" for="${id('link_column')}">Link column</label>
169
- <select id="${id('link_column')}" name="link_column">${opt('', '- no link -', cfg.link?.column)}${all.map((n) => opt(n, n, cfg.link?.column))}</select></div>
170
- <div class="field"><label class="label" for="${id('link_page')}">To page</label>
171
- <select id="${id('link_page')}" name="link_page">${opt('', '- choose -', cfg.link?.page)}${pages.rows.map((p) => opt(String(p.page_no), `${p.page_no}. ${p.name}`, cfg.link?.page))}</select></div>
172
- <div class="field" data-wide><label class="label" for="${id('link_items')}">Set items</label>
171
+ <fieldset class="prop-group"><legend>${tx('rpt.link')}</legend><div class="form-grid">
172
+ <div class="field"><label class="label" for="${id('link_column')}">${tx('rpt.link_column')}</label>
173
+ <select id="${id('link_column')}" name="link_column">${opt('', tx('rpt.no_link'), cfg.link?.column)}${all.map((n) => opt(n, n, cfg.link?.column))}</select></div>
174
+ <div class="field"><label class="label" for="${id('link_page')}">${tx('rpt.to_page')}</label>
175
+ <select id="${id('link_page')}" name="link_page">${opt('', tx('rpt.choose'), cfg.link?.page)}${pages.rows.map((p) => opt(String(p.page_no), `${p.page_no}. ${p.name}`, cfg.link?.page))}</select></div>
176
+ <div class="field" data-wide><label class="label" for="${id('link_items')}">${tx('rpt.set_items')}</label>
173
177
  <input id="${id('link_items')}" name="link_items" value="${linkItems}" placeholder="P3_ID=#id#">
174
- <small class="help">ITEM=#column#, comma separated; #column# is replaced by the row's value.</small></div>
178
+ <small class="help">${tx('rpt.item_column_comma_separated_column')}</small></div>
175
179
  </div></fieldset>
176
- <fieldset class="prop-group"><legend>Row selection</legend><div class="form-grid">
177
- <div class="field"><label class="label" for="${id('sel_column')}">Value column</label>
178
- <select id="${id('sel_column')}" name="sel_column">${opt('', '- no row selection -', cfg.selection?.column)}${all.map((n) => opt(n, n, cfg.selection?.column))}</select></div>
179
- <div class="field"><label class="label" for="${id('sel_item')}">Into item</label>
180
- <select id="${id('sel_item')}" name="sel_item">${opt('', '- choose -', cfg.selection?.item)}${items.rows.map((x) => opt(x.name, `${x.name} (${x.type})`, cfg.selection?.item))}</select>
181
- <small class="help">A checkbox per row; on submit the checked rows' values reach the item, colon separated (e.g. 7839:7902). Usually a hidden item. Treat the values as user input in your process.</small></div>
180
+ <fieldset class="prop-group"><legend>${tx('rpt.row_selection')}</legend><div class="form-grid">
181
+ <div class="field"><label class="label" for="${id('sel_column')}">${tx('rpt.value_column')}</label>
182
+ <select id="${id('sel_column')}" name="sel_column">${opt('', tx('rpt.no_row_selection'), cfg.selection?.column)}${all.map((n) => opt(n, n, cfg.selection?.column))}</select></div>
183
+ <div class="field"><label class="label" for="${id('sel_item')}">${tx('rpt.into_item')}</label>
184
+ <select id="${id('sel_item')}" name="sel_item">${opt('', tx('rpt.choose'), cfg.selection?.item)}${items.rows.map((x) => opt(x.name, `${x.name} (${x.type})`, cfg.selection?.item))}</select>
185
+ <small class="help">${tx('rpt.a_checkbox_per_row_on')}</small></div>
182
186
  </div></fieldset>
183
- <fieldset class="prop-group"><legend>PDF</legend><div class="form-grid">
184
- <div class="field"><label class="label" for="${id('layout')}">Report layout</label>
187
+ <fieldset class="prop-group"><legend>${tx('rpt.pdf')}</legend><div class="form-grid">
188
+ <div class="field"><label class="label" for="${id('layout')}">${tx('rpt.report_layout')}</label>
185
189
  <select id="${id('layout')}" name="pdf_layout">${opt('', layouts.rows.some((l) => l.is_default) ? '- the default layout -' : '- built-in -', cfg.pdf?.layout)}${layouts.rows.map((l) => opt(l.name, l.name + (l.is_default ? ' (default)' : ''), cfg.pdf?.layout))}</select>
186
- <small class="help">Layouts are under Shared Components → Report layouts.</small></div>
190
+ <small class="help">${tx('rpt.layouts_are_under_shared_components')}</small></div>
187
191
  </div></fieldset>
188
- <div class="buttons"><button class="btn btn-hot">Save report settings</button></div>
192
+ <div class="buttons"><button class="btn btn-hot">${tx('rpt.save_report_settings')}</button></div>
189
193
  </form>`;
190
194
  }
191
195
 
@@ -295,8 +299,8 @@ export async function reportSettingsRoutes(app: FastifyInstance) {
295
299
  );
296
300
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
297
301
  const bad = formatProblems((req.body ?? {}) as Record<string, string | undefined>);
298
- if (bad.length) flash(s, `Report settings saved, without these format masks: ${bad.join('; ')}.`, 'error');
299
- else flash(s, 'Report settings saved.');
302
+ if (bad.length) flash(s, tr(s)('rpt.saved_without', { masks: bad.join('; ') }), 'error');
303
+ else flash(s, tr(s)('rpt.saved'));
300
304
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
301
305
  });
302
306
  }